@spunto/design-system 0.28.0 → 0.30.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.
@@ -0,0 +1,111 @@
1
+ // @spunto/design-system/tasks — delegated work (docs/tasks.md): the lists that name a task, the
2
+ // cockpit that follows one, and every panel inside it.
3
+ //
4
+ // A domain sub-export like `/workers`: nothing here fetches. Lists take their rows, panels take
5
+ // their data, actions go out as callbacks, and whatever reads an API is a slot or a
6
+ // promise-returning callback (`TaskDiffPanel.loadPatch`). The cockpit is a layout whose panels are
7
+ // the package's own components, assembled by the app.
8
+
9
+ // ── Vocabulary ──────────────────────────────────────────────────────────────────────────────
10
+ export {
11
+ TASK_STATE_ORDER,
12
+ TaskPendingBadge,
13
+ TaskStateBadge,
14
+ TaskStateDot,
15
+ TaskStateIcon,
16
+ isLiveTaskState,
17
+ taskPendingActionConfig,
18
+ taskSpan,
19
+ taskStateConfig,
20
+ taskStateStyle,
21
+ useElapsed,
22
+ } from "./task-state"
23
+ export type { TaskStateBadgeProps, TaskStateStyle } from "./task-state"
24
+
25
+ // ── Lists ───────────────────────────────────────────────────────────────────────────────────
26
+ export { TaskList } from "./task-list"
27
+ export type { TaskListProps } from "./task-list"
28
+ export { TaskConversationList, TaskConversationPlaceholder } from "./conversation-list"
29
+ export type { TaskConversationListProps } from "./conversation-list"
30
+ export { TaskWorkerChip, WorkerTaskChip, indexTasksByWorker } from "./worker-link"
31
+ export type { WorkerTask } from "./worker-link"
32
+ export { NewTaskDialog } from "./new-task-dialog"
33
+ export type { NewTaskDialogProps, NewTaskModelGroup, NewTaskValue } from "./new-task-dialog"
34
+
35
+ // ── The cockpit ─────────────────────────────────────────────────────────────────────────────
36
+ export { TaskCockpit, TaskPanelTabs } from "./task-cockpit"
37
+ export type { TaskCockpitProps, TaskCockpitView } from "./task-cockpit"
38
+ export { TaskCockpitMobile, useVisualViewport } from "./task-cockpit-mobile"
39
+ export type { TaskCockpitMobileProps, VisualViewportState } from "./task-cockpit-mobile"
40
+ export { TaskDetails } from "./task-details"
41
+ export type { TaskDetailsProps } from "./task-details"
42
+
43
+ // ── The conversation ────────────────────────────────────────────────────────────────────────
44
+ export { TaskEventStream } from "./event-stream"
45
+ export type { TaskEventStreamProps } from "./event-stream"
46
+ export { TaskComposer } from "./composer"
47
+ export type { TaskComposerProps } from "./composer"
48
+ export { AgentMarkdown, renderInlineMarkdown } from "./agent-markdown"
49
+ export {
50
+ TaskToolStackButton,
51
+ TaskToolStackSheet,
52
+ ToolLine,
53
+ ToolRunBody,
54
+ runCalls,
55
+ runDuration,
56
+ runFiles,
57
+ runStats,
58
+ toolSubject,
59
+ toolVerb,
60
+ } from "./tool-stack"
61
+ export type { RunItem, RunStats, StackedCall, ToolCall, ToolRun, ToolVerb } from "./tool-stack"
62
+
63
+ // ── Files (RFC 0022) ────────────────────────────────────────────────────────────────────────
64
+ export { AttachFileButton, AttachmentDraftStrip, FileDropOverlay, TaskFiles, fileRefs, useAttachmentDraft } from "./task-attachments"
65
+ export type { AttachmentDraft } from "./task-attachments"
66
+ export {
67
+ MAX_FILES_PER_TURN,
68
+ MAX_FILE_BYTES,
69
+ MAX_TURN_BYTES,
70
+ fileKind,
71
+ filesFrom,
72
+ formatBytes,
73
+ isPreviewable,
74
+ prepareFile,
75
+ } from "./attachments"
76
+
77
+ // ── Side panels ─────────────────────────────────────────────────────────────────────────────
78
+ export { TaskDiffPanel } from "./diff-panel"
79
+ export type { TaskDiffPanelProps } from "./diff-panel"
80
+ export { TaskPullRequest } from "./pull-request"
81
+ export { TaskSessionUsage, formatCost, formatTokens } from "./session-usage"
82
+ export { TaskCommandLog } from "./command-log"
83
+ export { TaskBoot, TaskMachine, bootSteps, workerLabel } from "./machine"
84
+ export type { TaskBootStep, TaskMachineProps, TaskMachineTask } from "./machine"
85
+
86
+ export type {
87
+ AttachmentUrl,
88
+ DraftFile,
89
+ PendingMessage,
90
+ TaskCommand,
91
+ TaskConversation,
92
+ TaskDiff,
93
+ TaskDiffFile,
94
+ TaskEvent,
95
+ TaskFilePatch,
96
+ TaskFileRef,
97
+ TaskItem,
98
+ TaskMachineNode,
99
+ TaskMachinePort,
100
+ TaskPatchHunk,
101
+ TaskPatchLine,
102
+ TaskPendingAction,
103
+ TaskPullRequestData,
104
+ TaskPullRequests,
105
+ TaskRepoDiff,
106
+ TaskSessionUsageData,
107
+ TaskState,
108
+ } from "./types"
109
+
110
+ export type { LinkRender } from "../workers/worker-card"
111
+ export { cn, formatRelativeTime } from "../../utils"
@@ -0,0 +1,455 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import {
5
+ Code2Icon,
6
+ ExternalLinkIcon,
7
+ GitBranchIcon,
8
+ MoonIcon,
9
+ NetworkIcon,
10
+ ServerIcon,
11
+ SparklesIcon,
12
+ WifiOffIcon,
13
+ } from "lucide-react"
14
+
15
+ import { cn, formatDuration } from "../../utils"
16
+ import { ResourceBars, type WorkerStats } from "../workers/resource-bars"
17
+ import { SetupWaterfall } from "../workers/setup-waterfall"
18
+ import { StepIndicator, phaseLabel, setupTotalMs, type StepState } from "../workers/setup-progress"
19
+ import type { WorkerCardWorker, WorkerSetupStatus } from "../workers/types"
20
+ import type { LinkRender } from "../workers/worker-card"
21
+ import { WorkerSetupTimeline } from "../workers/worker-setup-timeline"
22
+ import { WorkerStatusDot, WorkerStatusPill, resolveWorkerStatus } from "../workers/worker-status"
23
+ import { useElapsed } from "./task-state"
24
+ import type { TaskCommand, TaskItem, TaskMachineNode, TaskMachinePort } from "./types"
25
+
26
+ /**
27
+ * The machine behind a task.
28
+ *
29
+ * A task is a conversation, but it happens *somewhere* — a container, on a node, that had to be
30
+ * found or built before a single token was generated. The cockpit used to say so in three words
31
+ * ("Worker · open"), which is fine right up until the interesting thing is the machine itself:
32
+ *
33
+ * - a `queued` task is **entirely** a machine being prepared. "Waiting for the session's first
34
+ * event…" described none of it, while the platform knew the phase, the repositories being
35
+ * cloned and the lifecycle command running.
36
+ * - a task in review holds a **parked** container (`taskReviewMode: "stop"`) — stopped, disk
37
+ * intact, because releasing it would wipe what you opened the review to look at.
38
+ * - a running session is a container burning CPU and memory, which is the difference between an
39
+ * agent thinking and an agent stuck in a test suite.
40
+ *
41
+ * Everything here is observed, never guessed: the worker row, its container-side setup status,
42
+ * its live stats, and the task's own command history. Where the platform doesn't know (has the
43
+ * pool recycled this machine since the task ended?) the component says so rather than implying.
44
+ */
45
+
46
+ /** The task fields the machine block and the boot panel read. */
47
+ export type TaskMachineTask = Pick<TaskItem, "state" | "createdAt"> &
48
+ Partial<Pick<TaskItem, "startedAt" | "branch" | "baseBranch" | "error">>
49
+
50
+ /** A worker's display name — `name` when it has one, its index otherwise (same rule as the card). */
51
+ export function workerLabel(worker: WorkerCardWorker): string {
52
+ return worker.name?.trim() || (worker.index != null ? `Workspace #${worker.index}` : "Workspace")
53
+ }
54
+
55
+ /**
56
+ * Did this task get a machine that already existed?
57
+ *
58
+ * The pool is the whole point — the first task of a project pays for an image build and a clone,
59
+ * the tenth should not — and "recycled" vs "fresh" is exactly the difference between a boot of
60
+ * seconds and one of minutes. Read from the timestamps rather than recorded: a worker older than
61
+ * the task that took it was not created for it.
62
+ */
63
+ function recycled(task: TaskMachineTask, worker: WorkerCardWorker): boolean {
64
+ if (!worker.createdAt) return false
65
+ return new Date(worker.createdAt).getTime() < new Date(task.createdAt).getTime()
66
+ }
67
+
68
+ // ── The sidebar block ────────────────────────────────────────────────────────────────────────
69
+
70
+ export interface TaskMachineProps {
71
+ task: TaskMachineTask
72
+ /** The worker row, as the app reads it. `null` → the task has no machine (yet). */
73
+ worker: WorkerCardWorker | null | undefined
74
+ /** The node it lives on. `online: false` draws "offline". */
75
+ node?: TaskMachineNode | null
76
+ /** Live container stats — pass them only while the task is live and the container up. */
77
+ stats?: WorkerStats | null
78
+ /** Ports detected listening in the container (editor and SSH are filtered out). */
79
+ ports?: TaskMachinePort[]
80
+ /** The worker's own page (logs, terminal, files). */
81
+ workerHref?: string
82
+ /** The editor (code-server). Shown only while the container runs. */
83
+ editorHref?: string
84
+ portHref?: (port: number) => string | undefined
85
+ render?: { link?: LinkRender }
86
+ }
87
+
88
+ export function TaskMachine({ task, worker, node = null, stats = null, ports = [], workerHref, editorHref, portHref, render }: TaskMachineProps) {
89
+ const [showBreakdown, setShowBreakdown] = useState(false)
90
+ const Link: LinkRender =
91
+ render?.link ?? (({ href, className, children }) => <a href={href} className={className}>{children}</a>)
92
+
93
+ if (!worker) {
94
+ return (
95
+ <p className="text-[11px] leading-relaxed text-muted-foreground">
96
+ {task.state === "queued"
97
+ ? "Looking for a machine — a free worker from the project's pool, or a fresh one."
98
+ : "This task never got a machine."}
99
+ </p>
100
+ )
101
+ }
102
+
103
+ const status = resolveWorkerStatus(worker)
104
+ const setup = worker.setupStatus ?? null
105
+ const running = worker.dockerState === "running"
106
+ // 8080 is the editor, already its own link; 22 is SSH, not a browser tab.
107
+ const detectedPorts = ports.filter((p) => p.port !== 8080 && p.port !== 22)
108
+ const startup = setupTotalMs(setup)
109
+ // A terminal task no longer holds its worker: the next task may already be using it. Saying
110
+ // "Running" without saying whose is how a cockpit lies quietly.
111
+ const released = task.state === "done" || task.state === "failed"
112
+ const parked = task.state === "in-review" && !running
113
+ const label = workerLabel(worker)
114
+
115
+ return (
116
+ <div className="space-y-2.5">
117
+ <div className="flex items-center gap-2">
118
+ <WorkerStatusDot status={status} />
119
+ {workerHref ? (
120
+ <Link href={workerHref} className="min-w-0 flex-1 truncate text-xs font-medium hover:underline">
121
+ {label}
122
+ </Link>
123
+ ) : (
124
+ <span className="min-w-0 flex-1 truncate text-xs font-medium">{label}</span>
125
+ )}
126
+ <WorkerStatusPill status={status} label={status.settingUp ? phaseLabel(setup) : undefined} />
127
+ </div>
128
+
129
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
130
+ <span className="flex items-center gap-1">
131
+ <ServerIcon className="h-3 w-3 shrink-0" />
132
+ {node ? node.name : "node unknown"}
133
+ </span>
134
+ {node && !node.online && (
135
+ <span className="flex items-center gap-1 text-orange-600 dark:text-orange-400">
136
+ <WifiOffIcon className="h-3 w-3 shrink-0" />
137
+ offline
138
+ </span>
139
+ )}
140
+ <span aria-hidden>·</span>
141
+ <span className="flex items-center gap-1">
142
+ <SparklesIcon className="h-3 w-3 shrink-0" />
143
+ {recycled(task, worker) ? "recycled from the pool" : "spawned for this task"}
144
+ </span>
145
+ </div>
146
+
147
+ {/* Not while the task is `queued`: the boot panel in the centre column is showing this very
148
+ timeline, in full, and two copies of a cloning repository on one screen is one too many.
149
+ It stays here for the rest — a parked machine restarting for a follow-up, say. */}
150
+ {status.settingUp && task.state !== "queued" && <WorkerSetupTimeline status={setup} settingUp />}
151
+
152
+ {/* `@container`, deliberately: ResourceBars decides to go two-up from its *nearest container
153
+ ancestor*'s width, and outside a WorkerCard there is none — the bars would stay stacked
154
+ for ever (docs/design-system.md § "une brique réutilisée hors de sa carte"). */}
155
+ {stats && (
156
+ <div className="@container">
157
+ <ResourceBars stats={stats} />
158
+ </div>
159
+ )}
160
+
161
+ {parked && (
162
+ <p className="flex items-start gap-1.5 rounded-md border border-border/60 bg-muted/30 px-2.5 py-2 text-[11px] leading-relaxed text-muted-foreground">
163
+ <MoonIcon className="mt-0.5 h-3 w-3 shrink-0" />
164
+ <span>
165
+ Parked for review — stopped, disk kept as it was. Accepting or dropping the task
166
+ releases it to the pool.
167
+ </span>
168
+ </p>
169
+ )}
170
+
171
+ {released && (
172
+ <p className="text-[11px] leading-relaxed text-muted-foreground">
173
+ This task is over, so the machine is back in the pool — what it runs now may belong to
174
+ another task.
175
+ </p>
176
+ )}
177
+
178
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]">
179
+ {editorHref && running && (
180
+ <a
181
+ href={editorHref}
182
+ target="_blank"
183
+ rel="noreferrer"
184
+ className="flex items-center gap-1 text-primary hover:underline"
185
+ >
186
+ <Code2Icon className="h-3 w-3 shrink-0" />
187
+ Open the editor
188
+ <ExternalLinkIcon className="h-3 w-3 shrink-0" />
189
+ </a>
190
+ )}
191
+ {workerHref && (
192
+ <Link href={workerHref} className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground">
193
+ Logs, terminal, files →
194
+ </Link>
195
+ )}
196
+ </div>
197
+
198
+ {running && portHref && detectedPorts.length > 0 && (
199
+ <div className="flex flex-wrap items-center gap-1.5">
200
+ {detectedPorts.map((p) => (
201
+ <a
202
+ key={p.port}
203
+ href={portHref(p.port)}
204
+ target="_blank"
205
+ rel="noreferrer"
206
+ title={p.process ? `${p.process} · :${p.port}` : `Port ${p.port}`}
207
+ className="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 font-mono text-[11px] text-muted-foreground transition-colors hover:bg-primary/10 hover:text-primary"
208
+ >
209
+ <NetworkIcon className="h-3 w-3 shrink-0" />:{p.port}
210
+ </a>
211
+ ))}
212
+ </div>
213
+ )}
214
+
215
+ {startup !== null && (
216
+ <div>
217
+ <button
218
+ type="button"
219
+ onClick={() => setShowBreakdown((v) => !v)}
220
+ className="text-[11px] text-muted-foreground transition-colors hover:text-foreground"
221
+ >
222
+ Booted in <span className="font-mono tabular-nums">{formatDuration(startup)}</span> ·{" "}
223
+ {showBreakdown ? "hide" : "breakdown"}
224
+ </button>
225
+ {showBreakdown && <SetupWaterfall timings={setup?.timings} className="mt-2" />}
226
+ </div>
227
+ )}
228
+ </div>
229
+ )
230
+ }
231
+
232
+ // ── The boot panel, centre column ────────────────────────────────────────────────────────────
233
+
234
+ export type TaskBootStep = { key: string; label: string; state: StepState; detail?: string }
235
+
236
+ /**
237
+ * What the platform is doing before the first token — as steps, each one read from something it
238
+ * actually observed.
239
+ *
240
+ * Exported for the same reason it is a pure function: it is the part worth arguing with.
241
+ */
242
+ export function bootSteps({
243
+ task,
244
+ worker,
245
+ setup,
246
+ commands,
247
+ }: {
248
+ task: TaskMachineTask
249
+ worker: WorkerCardWorker | null | undefined
250
+ setup?: WorkerSetupStatus | null
251
+ commands: TaskCommand[]
252
+ }): TaskBootStep[] {
253
+ const machine: TaskBootStep = worker
254
+ ? {
255
+ key: "machine",
256
+ label: "Machine",
257
+ state: "done",
258
+ detail: recycled(task, worker)
259
+ ? `recycled from the pool · ${workerLabel(worker)}`
260
+ : `spawned for this task · ${workerLabel(worker)}`,
261
+ }
262
+ : {
263
+ key: "machine",
264
+ label: "Machine",
265
+ // A queued task with no worker is being allocated one; a task that stopped here never got
266
+ // one at all, and pretending it is still looking would be a lie with a spinner on it.
267
+ state: task.state === "queued" ? "running" : "error",
268
+ detail: task.state === "queued" ? "looking for a free worker, or spawning one…" : "never allocated",
269
+ }
270
+
271
+ // `ready` and nothing else: it is the platform's own definition of a usable machine (what
272
+ // `waitForWorkerReady` blocks on). A restarting pool worker reports `dockerState: running`
273
+ // seconds before its setup has caught up, and calling that "up" ticks the step while the box
274
+ // right underneath is still cloning.
275
+ const containerState: StepState = !worker
276
+ ? "pending"
277
+ : worker.state === "error"
278
+ ? "error"
279
+ : worker.state === "ready"
280
+ ? "done"
281
+ : "running"
282
+ const container: TaskBootStep = {
283
+ key: "container",
284
+ label: "Container",
285
+ state: containerState,
286
+ detail: worker
287
+ ? containerState === "done"
288
+ ? "up"
289
+ : resolveWorkerStatus(worker).label.toLowerCase().replace(/…$/, "…")
290
+ : undefined,
291
+ }
292
+
293
+ // A `ready` worker counts as set up even with no status to show for it: old workers predate the
294
+ // container-side reporting, and a step spinning for ever on a machine that is demonstrably up
295
+ // would be the component contradicting the platform.
296
+ const setupState: StepState = !worker
297
+ ? "pending"
298
+ : setup?.phase === "error"
299
+ ? "error"
300
+ : worker.state === "ready" || setup?.phase === "ready"
301
+ ? "done"
302
+ : "running"
303
+ const setupStep: TaskBootStep = {
304
+ key: "setup",
305
+ label: "Setup",
306
+ state: setupState,
307
+ detail: setup ? phaseLabel(setup) : worker ? "waiting for the container's first report…" : undefined,
308
+ }
309
+
310
+ // The platform creates the task's branch in every repository of the project before the session
311
+ // starts — one recorded command per repo, which is where this reads from.
312
+ const branchCommands = commands.filter((c) => (c.label ?? "").startsWith("Branch setup"))
313
+ const branchState: StepState =
314
+ branchCommands.length === 0
315
+ ? "pending"
316
+ : branchCommands.some((c) => c.status === "running")
317
+ ? "running"
318
+ : branchCommands.some((c) => c.status !== "succeeded")
319
+ ? "error"
320
+ : "done"
321
+ const branch: TaskBootStep = {
322
+ key: "branch",
323
+ label: "Branch",
324
+ state: branchState,
325
+ detail: `${task.branch} from ${task.baseBranch ?? "the project's default"}`,
326
+ }
327
+
328
+ // Only a recycled machine runs the project's reset command, so the step exists if and only if
329
+ // that command has a row — the same rule as everything else here: nothing ran, nothing to show.
330
+ const resetCommand = commands.find((c) => (c.label ?? "") === "Reset")
331
+ const reset: TaskBootStep | null = resetCommand
332
+ ? {
333
+ key: "reset",
334
+ label: "Reset",
335
+ state:
336
+ resetCommand.status === "running" ? "running" : resetCommand.status === "succeeded" ? "done" : "error",
337
+ detail: "putting the reused machine back in shape",
338
+ }
339
+ : null
340
+
341
+ const queued = task.state === "queued"
342
+ const ready = branchState === "done" && (!reset || reset.state === "done")
343
+ const session: TaskBootStep = {
344
+ key: "session",
345
+ label: "Agent session",
346
+ state: task.startedAt ? "done" : queued && ready ? "running" : "pending",
347
+ detail: task.startedAt
348
+ ? "started"
349
+ : queued
350
+ ? reset && reset.state !== "done"
351
+ ? "starts once the machine is back in shape"
352
+ : "starts as soon as the branch is ready"
353
+ : "never started",
354
+ }
355
+
356
+ // A post-mortem drops the container and setup rows on purpose. Their only source is the
357
+ // machine's state *now* — and a task that is over no longer owns its machine, so those two rows
358
+ // would describe someone else's boot. What is left is recorded: which worker it got, whether
359
+ // its branch commands ran, whether a session ever started.
360
+ const rest = reset ? [branch, reset, session] : [branch, session]
361
+ return queued ? [machine, container, setupStep, ...rest] : [machine, ...rest]
362
+ }
363
+
364
+ /**
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.
367
+ *
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.
370
+ */
371
+ export function TaskBoot({
372
+ task,
373
+ worker,
374
+ commands,
375
+ }: {
376
+ task: TaskMachineTask
377
+ worker: WorkerCardWorker | null | undefined
378
+ commands: TaskCommand[]
379
+ }) {
380
+ const setup = worker?.setupStatus ?? null
381
+ const steps = bootSteps({ task, worker, setup, commands })
382
+ const elapsed = useElapsed(task.state === "queued" ? task.createdAt : null)
383
+ const queued = task.state === "queued"
384
+
385
+ return (
386
+ // `min-h-full` + `my-auto`, not `h-full items-center`: centred while it fits, and growing (so
387
+ // the parent's scroll takes over) once the setup timeline unfolds — a box that is centred
388
+ // *and* capped overflows at both ends, cutting off the title and the footnote.
389
+ <div className="flex min-h-full flex-col p-4">
390
+ <div className="mx-auto my-auto w-full max-w-md">
391
+ <div className="mb-3 flex items-center gap-2">
392
+ <span className="relative flex h-2 w-2 shrink-0">
393
+ <span className={cn("h-2 w-2 rounded-full", queued ? "bg-yellow-400" : "bg-destructive")} />
394
+ {queued && (
395
+ <span className="absolute inset-0 h-2 w-2 animate-ping rounded-full bg-yellow-400 opacity-60 motion-reduce:animate-none" />
396
+ )}
397
+ </span>
398
+ <h3 className="text-sm font-medium">{queued ? "Preparing the machine" : "The session never started"}</h3>
399
+ {elapsed && <span className="ml-auto font-mono text-[11px] tabular-nums text-muted-foreground">{elapsed}</span>}
400
+ </div>
401
+
402
+ <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}
421
+ </p>
422
+ )}
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
+ )}
431
+ </div>
432
+ </div>
433
+ ))}
434
+ </div>
435
+
436
+ {/* The reason, where the eye already is. It is in the sidebar too, but a post-mortem whose
437
+ centre column says only "never started" makes you hunt for the one line that matters. */}
438
+ {!queued && task.error && (
439
+ <p className="mt-3 whitespace-pre-wrap break-words rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-2 text-[11px] leading-relaxed text-destructive">
440
+ {task.error}
441
+ </p>
442
+ )}
443
+
444
+ <p className="mt-3 flex items-start gap-1.5 text-[11px] leading-relaxed text-muted-foreground">
445
+ <GitBranchIcon className="mt-0.5 h-3 w-3 shrink-0" />
446
+ <span>
447
+ {queued
448
+ ? "The session appears here on its own as soon as the agent starts. Tasks of the same project share this pool of machines — the first one pays for the image build, the next ones reuse it."
449
+ : "Nothing was streamed for this task. The sidebar has every command the platform ran."}
450
+ </span>
451
+ </p>
452
+ </div>
453
+ </div>
454
+ )
455
+ }