@spunto/design-system 0.31.1 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.31.1",
3
+ "version": "0.32.0",
4
4
  "description": "Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,143 @@
1
+ "use client"
2
+
3
+ import { useEffect, useRef } from "react"
4
+
5
+ import { TerminalPanel, type TerminalPanelStatus } from "../terminal-panel"
6
+ import type { TerminalHandle } from "../terminal"
7
+ import type { TaskCommand } from "./types"
8
+
9
+ // Commands run without a TTY: bare `\n`, which xterm reads as "down one row, same column".
10
+ function normalizeLF(s: string): string {
11
+ return s.replace(/\r\n/g, "\n").replace(/\n/g, "\r\n")
12
+ }
13
+
14
+ const DIM = "\x1b[2m"
15
+ const RED = "\x1b[31m"
16
+ const RESET = "\x1b[0m"
17
+
18
+ /**
19
+ * What the terminal should hold, as keyed segments in display order. Keys are what make the
20
+ * writes incremental: a segment whose new text still starts with what was written only sends the
21
+ * difference.
22
+ */
23
+ function segments(commands: TaskCommand[]): { key: string; text: string; color?: string }[] {
24
+ return commands.flatMap((c) => [
25
+ // Several commands behind one step (one branch setup per repository) say which one is which;
26
+ // a single command needs no heading, whatever opened the terminal already named it.
27
+ ...(commands.length > 1 ? [{ key: `${c.id}:h`, text: `${DIM}── ${c.label ?? "Command"}${RESET}\r\n` }] : []),
28
+ { key: `${c.id}:out`, text: normalizeLF(c.stdout ?? "") },
29
+ // stderr in red, since the two files can't be interleaved after the fact: each update appends
30
+ // what each stream gained since the last one, which is as close to the real order as a tail
31
+ // read can get. The colour is applied per write, not baked into the text, so the segment stays
32
+ // a prefix of its next version.
33
+ { key: `${c.id}:err`, text: normalizeLF(c.stderr ?? ""), color: RED },
34
+ ])
35
+ }
36
+
37
+ /** What has been written so far: each segment's text, and where the last write left the line. */
38
+ export interface CommandTerminalState {
39
+ written: Map<string, string>
40
+ last: { key: string; endedLine: boolean } | null
41
+ }
42
+
43
+ export const emptyCommandTerminalState = (): CommandTerminalState => ({ written: new Map(), last: null })
44
+
45
+ /**
46
+ * From what is on screen to what `commands` says should be: the bytes to write, and whether to
47
+ * clear first. Pure, so the three cases that matter are testable without an xterm:
48
+ *
49
+ * - a segment that grew → only its new bytes;
50
+ * - a tail that *slid* (the platform keeps the last 64 KiB per stream) no longer extends what is
51
+ * on screen → clear, and write everything again;
52
+ * - two streams take turns on one screen, so a stderr warning without its newline would have the
53
+ * next stdout line glued onto it → break the line when the stream changes mid-line. The same
54
+ * partial line *continuing* on its own stream is left alone.
55
+ */
56
+ export function planCommandTerminalWrites(
57
+ state: CommandTerminalState,
58
+ commands: TaskCommand[],
59
+ ): { clear: boolean; writes: string[]; state: CommandTerminalState } {
60
+ const next = segments(commands)
61
+ const clear = !next.every(({ key, text }) => text.startsWith(state.written.get(key) ?? ""))
62
+ const written = clear ? new Map<string, string>() : new Map(state.written)
63
+ let last = clear ? null : state.last
64
+ const writes: string[] = []
65
+ for (const { key, text, color } of next) {
66
+ const delta = text.slice((written.get(key) ?? "").length)
67
+ if (delta) {
68
+ const breakLine = last && last.key !== key && !last.endedLine
69
+ writes.push((breakLine ? "\r\n" : "") + (color ? `${color}${delta}${RESET}` : delta))
70
+ last = { key, endedLine: delta.endsWith("\n") }
71
+ }
72
+ written.set(key, text)
73
+ }
74
+ return { clear, writes, state: { written, last } }
75
+ }
76
+
77
+ function panelStatus(commands: TaskCommand[]): { status: TerminalPanelStatus; label: string } {
78
+ if (commands.some((c) => c.status === "running")) return { status: "running", label: "running" }
79
+ const failed = commands.find((c) => c.status !== "succeeded")
80
+ if (failed) {
81
+ return {
82
+ status: "failed",
83
+ label: failed.exitCode != null ? `${failed.status} · exit ${failed.exitCode}` : failed.status,
84
+ }
85
+ }
86
+ return { status: "success", label: "done" }
87
+ }
88
+
89
+ export interface TaskCommandTerminalProps {
90
+ /** The commands to show, in order — their captured stdout/stderr tails, as the app polls them. */
91
+ commands: TaskCommand[]
92
+ /** Bar title — the step or the command the output belongs to. */
93
+ title: string
94
+ className?: string
95
+ }
96
+
97
+ /**
98
+ * The output of commands the platform ran for a task, as a read-only terminal that follows them
99
+ * while they run.
100
+ *
101
+ * Fed by props only: the app re-reads the task's commands on its own cadence and hands the new
102
+ * rows down — each still-running command's tail a little longer than last time — and this writes
103
+ * the difference. A tail that *slid* (the platform keeps the last 64 KiB per stream) no longer
104
+ * extends what is on screen, and is redrawn from scratch rather than appended to.
105
+ *
106
+ * `TaskCommandLog` shows the same rows folded into a list, for after the fact. This is for
107
+ * watching one happen — a reseed or a reinstall is minutes with nothing else moving.
108
+ */
109
+ export function TaskCommandTerminal({ commands, title, className }: TaskCommandTerminalProps) {
110
+ const termRef = useRef<TerminalHandle>(null)
111
+ const screen = useRef<CommandTerminalState>(emptyCommandTerminalState())
112
+
113
+ useEffect(() => {
114
+ const term = termRef.current
115
+ if (!term) return
116
+ const plan = planCommandTerminalWrites(screen.current, commands)
117
+ if (plan.clear) {
118
+ // `clear()` keeps the cursor's row; erase it too, or the old tail's last line leads the new one.
119
+ term.clear()
120
+ term.write("\x1b[2K\r")
121
+ }
122
+ for (const bytes of plan.writes) term.write(bytes)
123
+ screen.current = plan.state
124
+ }, [commands])
125
+
126
+ const { status, label } = panelStatus(commands)
127
+ const empty = commands.every((c) => !c.stdout && !c.stderr)
128
+
129
+ return (
130
+ <TerminalPanel
131
+ ref={termRef}
132
+ title={title}
133
+ subtitle={empty ? (status === "running" ? "no output yet" : "no output") : "stdout / stderr"}
134
+ status={status}
135
+ statusLabel={label}
136
+ followTail
137
+ copy
138
+ download
139
+ downloadFileName={`${title.toLowerCase().replace(/\W+/g, "-")}.log`}
140
+ className={className}
141
+ />
142
+ )
143
+ }
@@ -712,7 +712,10 @@ export function TaskEventStream({
712
712
  const empty = protocol === "none" || count === 0
713
713
  // Only while there is something to come back to: on a finished session the reader scrolling up
714
714
  // is just reading, not falling behind.
715
- const showJumpToLatest = streaming && rows.length > 0 && !virtualizer.isAtEnd(SCROLL_END_THRESHOLD)
715
+ // Never over the empty state either: that is not a transcript (a queued task's boot panel
716
+ // scrolls once its setup timeline unfolds), and a virtualizer measuring nothing always reads as
717
+ // "not at the end".
718
+ const showJumpToLatest = !empty && streaming && rows.length > 0 && !virtualizer.isAtEnd(SCROLL_END_THRESHOLD)
716
719
 
717
720
  return (
718
721
  <div className={cn("flex min-h-0 flex-1 flex-col", className)}>
@@ -83,7 +83,9 @@ export { TaskPullRequest } from "./pull-request"
83
83
  export { TaskSessionUsage, formatCost, formatTokens } from "./session-usage"
84
84
  export { TaskCommandLog } from "./command-log"
85
85
  export { TaskBoot, TaskMachine, bootSteps, workerLabel } from "./machine"
86
- export type { TaskBootStep, TaskMachineProps, TaskMachineTask } from "./machine"
86
+ export { TaskCommandTerminal, emptyCommandTerminalState, planCommandTerminalWrites } from "./command-terminal"
87
+ export type { CommandTerminalState, TaskCommandTerminalProps } from "./command-terminal"
88
+ export type { TaskBootLogs, TaskBootProps, TaskBootStep, TaskMachineProps, TaskMachineTask } from "./machine"
87
89
 
88
90
  export type {
89
91
  AttachmentUrl,
@@ -1,7 +1,8 @@
1
1
  "use client"
2
2
 
3
- import { useState } from "react"
3
+ import { useState, type ReactNode } from "react"
4
4
  import {
5
+ ChevronRightIcon,
5
6
  Code2Icon,
6
7
  ExternalLinkIcon,
7
8
  GitBranchIcon,
@@ -9,10 +10,12 @@ import {
9
10
  NetworkIcon,
10
11
  ServerIcon,
11
12
  SparklesIcon,
13
+ TerminalIcon,
12
14
  WifiOffIcon,
13
15
  } from "lucide-react"
14
16
 
15
17
  import { cn, formatDuration } from "../../utils"
18
+ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "../sheet"
16
19
  import { ResourceBars, type WorkerStats } from "../workers/resource-bars"
17
20
  import { SetupWaterfall } from "../workers/setup-waterfall"
18
21
  import { StepIndicator, phaseLabel, setupTotalMs, type StepState } from "../workers/setup-progress"
@@ -20,6 +23,7 @@ import type { WorkerCardWorker, WorkerSetupStatus } from "../workers/types"
20
23
  import type { LinkRender } from "../workers/worker-card"
21
24
  import { WorkerSetupTimeline } from "../workers/worker-setup-timeline"
22
25
  import { WorkerStatusDot, WorkerStatusPill, resolveWorkerStatus } from "../workers/worker-status"
26
+ import { TaskCommandTerminal } from "./command-terminal"
23
27
  import { useElapsed } from "./task-state"
24
28
  import type { TaskCommand, TaskItem, TaskMachineNode, TaskMachinePort } from "./types"
25
29
 
@@ -231,7 +235,26 @@ export function TaskMachine({ task, worker, node = null, stats = null, ports = [
231
235
 
232
236
  // ── The boot panel, centre column ────────────────────────────────────────────────────────────
233
237
 
234
- export type TaskBootStep = { key: string; label: string; state: StepState; detail?: string }
238
+ export type TaskBootStep = {
239
+ /** `machine` | `container` | `setup` | `branch` | `reset` | `session`. */
240
+ key: string
241
+ label: string
242
+ state: StepState
243
+ detail?: string
244
+ /** The platform's commands behind the step (Branch, Reset) — what its log is drawn from. */
245
+ commands?: TaskCommand[]
246
+ }
247
+
248
+ /**
249
+ * A log per boot step, keyed by `TaskBootStep.key`, shown in a sheet when its row is clicked.
250
+ *
251
+ * Branch and Reset need nothing here: their output is already in `commands`, so `TaskBoot` draws
252
+ * it itself (`TaskCommandTerminal`). The others live where the package cannot reach — the image
253
+ * build's log, the container's stdout (where every setup phase prints) — so the app passes them
254
+ * in, typically its own socket-backed panels. An element is only a description: nothing is
255
+ * mounted, and no socket opened, until that step's sheet is. `null` turns a built-in log off.
256
+ */
257
+ export type TaskBootLogs = Partial<Record<string, ReactNode | null>>
235
258
 
236
259
  /**
237
260
  * What the platform is doing before the first token — as steps, each one read from something it
@@ -323,6 +346,7 @@ export function bootSteps({
323
346
  label: "Branch",
324
347
  state: branchState,
325
348
  detail: `${task.branch} from ${task.baseBranch ?? "the project's default"}`,
349
+ commands: branchCommands.length > 0 ? branchCommands : undefined,
326
350
  }
327
351
 
328
352
  // Only a recycled machine runs the project's reset command, so the step exists if and only if
@@ -335,6 +359,7 @@ export function bootSteps({
335
359
  state:
336
360
  resetCommand.status === "running" ? "running" : resetCommand.status === "succeeded" ? "done" : "error",
337
361
  detail: "putting the reused machine back in shape",
362
+ commands: [resetCommand],
338
363
  }
339
364
  : null
340
365
 
@@ -361,32 +386,124 @@ export function bootSteps({
361
386
  return queued ? [machine, container, setupStep, ...rest] : [machine, ...rest]
362
387
  }
363
388
 
389
+ /** The log a step opens onto, if any: what the app passed, else the step's own commands. */
390
+ function stepLog(step: TaskBootStep, logs: TaskBootLogs | undefined): ReactNode | null {
391
+ const passed = logs?.[step.key]
392
+ if (passed !== undefined) return passed
393
+ return step.commands?.length ? <TaskCommandTerminal commands={step.commands} title={step.label} /> : null
394
+ }
395
+
364
396
  /**
365
- * The centre column of a task that has no session yet — which is every `queued` task, and any
366
- * task that died before one started.
397
+ * A step's log, in a sheet.
367
398
  *
368
- * The steps are the ones the platform actually goes through, so a failed boot shows *where* it
369
- * stopped instead of an empty transcript — including the reset, on a machine that needed one.
399
+ * Folding the log under its row was tried first and lost on both axes: the boot panel is a centred
400
+ * column that a cockpit next to a conversation list squeezes to a few hundred pixels, and a log
401
+ * folded into it got a fixed height that was always either too short to read or too tall for the
402
+ * page. A sheet has the window's full height and all the width a log wants — and its tabs walk
403
+ * from the build to the setup to the reset without closing it, which is how a slow boot is read.
370
404
  */
371
- export function TaskBoot({
372
- task,
373
- worker,
374
- commands,
405
+ function StepLogSheet({
406
+ steps,
407
+ logs,
408
+ openKey,
409
+ onOpenKeyChange,
375
410
  }: {
411
+ steps: TaskBootStep[]
412
+ logs: TaskBootLogs | undefined
413
+ openKey: string | null
414
+ onOpenKeyChange: (key: string | null) => void
415
+ }) {
416
+ const withLogs = steps.filter((s) => stepLog(s, logs) != null)
417
+ const step = withLogs.find((s) => s.key === openKey) ?? null
418
+ const log = step ? stepLog(step, logs) : null
419
+
420
+ return (
421
+ <Sheet open={!!step} onOpenChange={(open) => !open && onOpenKeyChange(null)}>
422
+ <SheetContent
423
+ side="right"
424
+ size="xl"
425
+ // Above `z-[120]`, like the tool stack: on a phone the boot panel sits inside
426
+ // `TaskCockpitMobile`, itself a layer at that height.
427
+ className="z-[130] gap-0 p-0"
428
+ backdropClassName="z-[130]"
429
+ >
430
+ {step && (
431
+ <>
432
+ <SheetHeader className="shrink-0 gap-2 border-b border-border py-3 pr-12">
433
+ <SheetTitle className="flex items-center gap-2 text-sm font-medium">
434
+ <StepIndicator state={step.state} />
435
+ {step.label}
436
+ </SheetTitle>
437
+ {step.detail && (
438
+ <SheetDescription className="break-words font-mono text-[11px]">{step.detail}</SheetDescription>
439
+ )}
440
+ {withLogs.length > 1 && (
441
+ <div className="flex flex-wrap items-center gap-1 pt-1">
442
+ {withLogs.map((s) => (
443
+ <button
444
+ key={s.key}
445
+ type="button"
446
+ onClick={() => onOpenKeyChange(s.key)}
447
+ aria-pressed={s.key === step.key}
448
+ className={cn(
449
+ "flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] transition-colors",
450
+ s.key === step.key
451
+ ? "bg-muted font-medium text-foreground"
452
+ : "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
453
+ )}
454
+ >
455
+ <StepIndicator state={s.state} />
456
+ {s.label}
457
+ </button>
458
+ ))}
459
+ </div>
460
+ )}
461
+ </SheetHeader>
462
+ {/* Keyed on the step: each source owns its socket or its write cursor, and switching
463
+ tabs must start the next one clean rather than append to the previous buffer. */}
464
+ <div key={step.key} className="min-h-0 flex-1">
465
+ {log}
466
+ </div>
467
+ </>
468
+ )}
469
+ </SheetContent>
470
+ </Sheet>
471
+ )
472
+ }
473
+
474
+ export interface TaskBootProps {
376
475
  task: TaskMachineTask
377
476
  worker: WorkerCardWorker | null | undefined
378
477
  commands: TaskCommand[]
379
- }) {
478
+ /**
479
+ * The logs of the steps whose output the package cannot read — see `TaskBootLogs`. A step with
480
+ * a log gets a "Logs" affordance and opens it in a sheet; Branch and Reset have one on their own.
481
+ */
482
+ logs?: TaskBootLogs
483
+ }
484
+
485
+ /**
486
+ * The centre column of a task that has no session yet — which is every `queued` task, and any
487
+ * task that died before one started.
488
+ *
489
+ * The steps are the ones the platform actually goes through, so a failed boot shows *where* it
490
+ * stopped instead of an empty transcript — including the reset, on a machine that needed one. A
491
+ * step with output opens it in a sheet: nothing opens on its own, the reader decides what is
492
+ * worth watching.
493
+ */
494
+ export function TaskBoot({ task, worker, commands, logs }: TaskBootProps) {
380
495
  const setup = worker?.setupStatus ?? null
381
496
  const steps = bootSteps({ task, worker, setup, commands })
382
497
  const elapsed = useElapsed(task.state === "queued" ? task.createdAt : null)
383
498
  const queued = task.state === "queued"
499
+ const [logKey, setLogKey] = useState<string | null>(null)
384
500
 
385
501
  return (
386
502
  // `min-h-full` + `my-auto`, not `h-full items-center`: centred while it fits, and growing (so
387
503
  // the parent's scroll takes over) once the setup timeline unfolds — a box that is centred
388
504
  // *and* capped overflows at both ends, cutting off the title and the footnote.
389
505
  <div className="flex min-h-full flex-col p-4">
506
+ <StepLogSheet steps={steps} logs={logs} openKey={logKey} onOpenKeyChange={setLogKey} />
390
507
  <div className="mx-auto my-auto w-full max-w-md">
391
508
  <div className="mb-3 flex items-center gap-2">
392
509
  <span className="relative flex h-2 w-2 shrink-0">
@@ -400,37 +517,62 @@ export function TaskBoot({
400
517
  </div>
401
518
 
402
519
  <div className="overflow-hidden rounded-lg border bg-card/60">
403
- {steps.map((step) => (
404
- <div key={step.key} className="flex items-start gap-2.5 border-b border-border/50 px-3 py-2.5 last:border-b-0">
405
- <span className="mt-0.5">
406
- <StepIndicator state={step.state} />
407
- </span>
408
- <div className="min-w-0 flex-1">
409
- <p
410
- className={cn(
411
- "text-xs",
412
- step.state === "pending" ? "text-muted-foreground/50" : "font-medium",
413
- step.state === "error" && "text-destructive",
414
- )}
415
- >
416
- {step.label}
417
- </p>
418
- {step.detail && (
419
- <p className="mt-0.5 break-words font-mono text-[11px] leading-relaxed text-muted-foreground">
420
- {step.detail}
520
+ {steps.map((step) => {
521
+ const hasLog = stepLog(step, logs) != null
522
+ const row = (
523
+ <>
524
+ <span className="mt-0.5">
525
+ <StepIndicator state={step.state} />
526
+ </span>
527
+ <div className="min-w-0 flex-1">
528
+ <p
529
+ className={cn(
530
+ "text-xs",
531
+ step.state === "pending" ? "text-muted-foreground/50" : "font-medium",
532
+ step.state === "error" && "text-destructive",
533
+ )}
534
+ >
535
+ {step.label}
421
536
  </p>
537
+ {step.detail && (
538
+ <p className="mt-0.5 break-words font-mono text-[11px] leading-relaxed text-muted-foreground">
539
+ {step.detail}
540
+ </p>
541
+ )}
542
+ {/* The setup is the long one, and the only step with sub-steps worth watching:
543
+ which repository is cloning, which lifecycle command is running. Tied to the
544
+ step's own state rather than the worker's: a recycled machine carries the
545
+ setup status of its previous boot — which is the whole point of recycling —
546
+ and unfolding a finished timeline under a ticked step is just noise. */}
547
+ {step.key === "setup" && step.state === "running" && (
548
+ <WorkerSetupTimeline status={setup} settingUp className="mt-2" />
549
+ )}
550
+ </div>
551
+ {hasLog && (
552
+ <span className="mt-0.5 flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground transition-colors group-hover:text-foreground">
553
+ <TerminalIcon className="h-3 w-3" />
554
+ Logs
555
+ <ChevronRightIcon className="h-3.5 w-3.5" />
556
+ </span>
422
557
  )}
423
- {/* The setup is the long one, and the only step with sub-steps worth watching:
424
- which repository is cloning, which lifecycle command is running. Tied to the
425
- step's own state rather than the worker's: a recycled machine carries the
426
- setup status of its previous boot — which is the whole point of recycling —
427
- and unfolding a finished timeline under a ticked step is just noise. */}
428
- {step.key === "setup" && step.state === "running" && (
429
- <WorkerSetupTimeline status={setup} settingUp className="mt-2" />
430
- )}
558
+ </>
559
+ )
560
+ return hasLog ? (
561
+ <button
562
+ key={step.key}
563
+ type="button"
564
+ onClick={() => setLogKey(step.key)}
565
+ aria-haspopup="dialog"
566
+ className="group flex w-full items-start gap-2.5 border-b border-border/50 px-3 py-2.5 text-left transition-colors last:border-b-0 hover:bg-muted/30"
567
+ >
568
+ {row}
569
+ </button>
570
+ ) : (
571
+ <div key={step.key} className="flex items-start gap-2.5 border-b border-border/50 px-3 py-2.5 last:border-b-0">
572
+ {row}
431
573
  </div>
432
- </div>
433
- ))}
574
+ )
575
+ })}
434
576
  </div>
435
577
 
436
578
  {/* The reason, where the eye already is. It is in the sidebar too, but a post-mortem whose
@@ -9,8 +9,9 @@ import { describe, it, expect, vi } from "vitest"
9
9
  import { render, screen, fireEvent } from "@testing-library/react"
10
10
 
11
11
  import { renderInlineMarkdown } from "./agent-markdown"
12
+ import { emptyCommandTerminalState, planCommandTerminalWrites } from "./command-terminal"
12
13
  import { TaskComposer } from "./composer"
13
- import { bootSteps } from "./machine"
14
+ import { TaskBoot, bootSteps } from "./machine"
14
15
  import { formatCost, formatTokens } from "./session-usage"
15
16
  import { TaskCockpit } from "./task-cockpit"
16
17
  import { TaskList } from "./task-list"
@@ -103,6 +104,78 @@ describe("boot steps", () => {
103
104
  expect(steps.find((s) => s.key === "branch")?.state).toBe("done")
104
105
  expect(steps.find((s) => s.key === "session")?.state).toBe("running")
105
106
  })
107
+
108
+ it("hangs its own commands on the branch and reset steps — their log", () => {
109
+ const reset: TaskCommand = { ...branchSetup("running"), id: "c2", label: "Reset" }
110
+ const steps = bootSteps({ task: task({ state: "queued" }), worker: null, commands: [branchSetup("succeeded"), reset] })
111
+ expect(steps.find((s) => s.key === "branch")?.commands?.map((c) => c.id)).toEqual(["c1"])
112
+ expect(steps.find((s) => s.key === "reset")?.commands?.map((c) => c.id)).toEqual(["c2"])
113
+ expect(steps.find((s) => s.key === "machine")?.commands).toBeUndefined()
114
+ })
115
+ })
116
+
117
+ describe("boot panel logs", () => {
118
+ const worker = { id: "w1", name: "fresh", state: "setup", dockerState: "running", createdAt: "2026-09-24T10:00:01Z" }
119
+ const branch: TaskCommand = { id: "c1", command: "git checkout -B …", status: "succeeded", label: "Branch setup", createdAt: "2026-09-24T10:01:00Z" }
120
+
121
+ it("offers a log only on a step that has one", () => {
122
+ render(
123
+ <TaskBoot task={task({ state: "queued" })} worker={worker} commands={[branch]} logs={{ setup: <p>container stdout</p> }} />,
124
+ )
125
+ const openers = screen.getAllByRole("button", { name: /Logs/ }).map((b) => b.textContent)
126
+ // Setup from the app, Branch from its own command; Machine and Container have none here.
127
+ expect(openers).toHaveLength(2)
128
+ expect(openers.some((t) => t?.startsWith("Setup"))).toBe(true)
129
+ expect(openers.some((t) => t?.startsWith("Branch"))).toBe(true)
130
+ })
131
+
132
+ it("opens a step's log in a sheet, with the other steps' logs one tab away", () => {
133
+ render(
134
+ <TaskBoot task={task({ state: "queued" })} worker={worker} commands={[branch]} logs={{ setup: <p>container stdout</p> }} />,
135
+ )
136
+ // Nothing is mounted before the click: an app's log is a socket, and it must not open for a
137
+ // row nobody looked at.
138
+ expect(screen.queryByText("container stdout")).toBeNull()
139
+ fireEvent.click(screen.getAllByRole("button", { name: /Logs/ }).find((b) => b.textContent?.startsWith("Setup"))!)
140
+ const sheet = screen.getByRole("dialog")
141
+ expect(sheet.textContent).toContain("container stdout")
142
+ expect(screen.getByRole("button", { name: "Branch", pressed: false })).toBeTruthy()
143
+ expect(screen.getByRole("button", { name: "Setup", pressed: true })).toBeTruthy()
144
+ })
145
+
146
+ it("lets the app turn a built-in log off with null", () => {
147
+ render(<TaskBoot task={task({ state: "queued" })} worker={worker} commands={[branch]} logs={{ branch: null }} />)
148
+ expect(screen.queryByRole("button", { name: /Logs/ })).toBeNull()
149
+ })
150
+ })
151
+
152
+ describe("command terminal writes", () => {
153
+ const cmd = (stdout: string, stderr = ""): TaskCommand => ({ id: "r", command: "seed", status: "running", stdout, stderr, createdAt: "2026-09-24T10:00:00Z" })
154
+ // What the terminal ends up showing, colour codes and all, over successive reads of one command.
155
+ const replay = (...reads: TaskCommand[][]) => {
156
+ let state = emptyCommandTerminalState()
157
+ const log: string[] = []
158
+ for (const commands of reads) {
159
+ const plan = planCommandTerminalWrites(state, commands)
160
+ if (plan.clear) log.push("<clear>")
161
+ log.push(...plan.writes)
162
+ state = plan.state
163
+ }
164
+ return log
165
+ }
166
+
167
+ it("writes only what a growing tail gained", () => {
168
+ expect(replay([cmd("a\n")], [cmd("a\nb\n")], [cmd("a\nb\n")])).toEqual(["a\r\n", "b\r\n"])
169
+ })
170
+
171
+ it("starts over when the tail slid past what is on screen", () => {
172
+ expect(replay([cmd("a\nb\n")], [cmd("b\nc\n")])).toEqual(["a\r\nb\r\n", "<clear>", "b\r\nc\r\n"])
173
+ })
174
+
175
+ it("breaks the line when the other stream takes over mid-line, not when the same one continues", () => {
176
+ const out = replay([cmd("step 1\n", "warning")], [cmd("step 1\nstep 2\n", "warning")], [cmd("step 1\nstep 2\nhalf", "warning")], [cmd("step 1\nstep 2\nhalf line\n", "warning")])
177
+ expect(out).toEqual(["step 1\r\n", "\x1b[31mwarning\x1b[0m", "\r\nstep 2\r\n", "half", " line\r\n"])
178
+ })
106
179
  })
107
180
 
108
181
  describe("worker ↔ task", () => {