@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
|
@@ -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
|
-
|
|
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
|
|
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 = {
|
|
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
|
-
*
|
|
366
|
-
* task that died before one started.
|
|
397
|
+
* A step's log, in a sheet.
|
|
367
398
|
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
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", () => {
|