@spunto/design-system 0.27.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.
Files changed (32) hide show
  1. package/README.md +35 -1
  2. package/package.json +6 -1
  3. package/src/components/sheet.tsx +7 -1
  4. package/src/components/tasks/agent-markdown.tsx +322 -0
  5. package/src/components/tasks/attachments.ts +152 -0
  6. package/src/components/tasks/command-log.tsx +102 -0
  7. package/src/components/tasks/composer.tsx +252 -0
  8. package/src/components/tasks/conversation-list.tsx +238 -0
  9. package/src/components/tasks/diff-panel.tsx +534 -0
  10. package/src/components/tasks/event-stream.tsx +860 -0
  11. package/src/components/tasks/index.ts +111 -0
  12. package/src/components/tasks/machine.tsx +455 -0
  13. package/src/components/tasks/new-task-dialog.tsx +301 -0
  14. package/src/components/tasks/pull-request.tsx +179 -0
  15. package/src/components/tasks/session-usage.tsx +165 -0
  16. package/src/components/tasks/task-attachments.tsx +475 -0
  17. package/src/components/tasks/task-cockpit-mobile.tsx +272 -0
  18. package/src/components/tasks/task-cockpit.tsx +225 -0
  19. package/src/components/tasks/task-details.tsx +239 -0
  20. package/src/components/tasks/task-list.tsx +272 -0
  21. package/src/components/tasks/task-state.tsx +180 -0
  22. package/src/components/tasks/tasks.test.tsx +205 -0
  23. package/src/components/tasks/tool-stack.tsx +692 -0
  24. package/src/components/tasks/types.ts +220 -0
  25. package/src/components/tasks/worker-link.tsx +126 -0
  26. package/src/components/workers/index.ts +5 -2
  27. package/src/components/workers/types.ts +22 -0
  28. package/src/components/workers/worker-cockpit.tsx +17 -2
  29. package/src/components/workers/worker-status-history.test.ts +85 -0
  30. package/src/components/workers/worker-status-history.tsx +166 -0
  31. package/src/index.ts +1 -1
  32. package/src/utils.ts +23 -0
@@ -0,0 +1,860 @@
1
+ "use client"
2
+
3
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"
4
+ import { useVirtualizer } from "@tanstack/react-virtual"
5
+ import {
6
+ ArrowUpIcon,
7
+ BrainIcon,
8
+ CheckIcon,
9
+ ChevronRightIcon,
10
+ CircleDotIcon,
11
+ CircleIcon,
12
+ FlagIcon,
13
+ LayersIcon,
14
+ ListChecksIcon,
15
+ Loader2Icon,
16
+ PlayIcon,
17
+ SparklesIcon,
18
+ UserIcon,
19
+ WrenchIcon,
20
+ } from "lucide-react"
21
+
22
+ import { cn } from "../../utils"
23
+ import { AgentMarkdown, renderInlineMarkdown } from "./agent-markdown"
24
+ import { TaskFiles, fileRefs } from "./task-attachments"
25
+ import { useElapsed } from "./task-state"
26
+ import {
27
+ type RunItem,
28
+ type StackedCall,
29
+ type ToolCall,
30
+ type ToolRun,
31
+ TaskToolStackButton,
32
+ TaskToolStackSheet,
33
+ ToolLine,
34
+ ToolRunBody,
35
+ runCalls,
36
+ runDuration,
37
+ runFiles,
38
+ runStats,
39
+ toolSubject,
40
+ } from "./tool-stack"
41
+ import type { AttachmentUrl, PendingMessage, TaskEvent } from "./types"
42
+
43
+ /**
44
+ * The agent session, as it happens (RFC 0020) — the central panel of the task cockpit.
45
+ *
46
+ * The shape is a **timeline on a rail**, not a table of rows, and the two things on it are
47
+ * deliberately not equal:
48
+ *
49
+ * - what the agent *said* is prose, rendered as markdown, at reading size. It is the only thing
50
+ * here written for a human, so it gets the width and the weight.
51
+ * - what it *did* — tools, thinking — is secondary, and a *run* of it is one row: a run of
52
+ * twelve file reads scans as one movement ("read 12 files") instead of burying the sentence
53
+ * that follows it. Unfolding a run gives back the dense per-call lines, each with the subject
54
+ * of the call on it (the path, the command) rather than a JSON blob; `tool-stack.tsx`
55
+ * holds all of that, plus the session-wide stack a reader can open at any time.
56
+ *
57
+ * Facts that are not events in time (cost, token counts) are not on the timeline at all; they
58
+ * belong to the sidebar, which is what a recap is for.
59
+ */
60
+
61
+ const str = (v: unknown): string => (typeof v === "string" ? v : "")
62
+ const bool = (v: unknown): boolean => v === true
63
+
64
+ type Item =
65
+ /** `hideSummary` — see `useTimeline`: the end-of-session result is often the last message again. */
66
+ | { kind: "event"; event: TaskEvent; hideSummary?: boolean }
67
+ | { kind: "tool"; entry: ToolCall }
68
+ /** A chain of machinery, folded — see `tool-stack.tsx`. */
69
+ | { kind: "run"; run: ToolRun }
70
+
71
+ /** A marker on the rail. `tone` is what carries meaning at a glance, so it stays a small set. */
72
+ function Marker({
73
+ icon,
74
+ tone = "muted",
75
+ }: {
76
+ icon: ReactNode
77
+ tone?: "muted" | "primary" | "success" | "danger" | "pending"
78
+ }) {
79
+ return (
80
+ <span
81
+ className={cn(
82
+ "absolute left-0 top-0.5 flex h-6 w-6 -translate-x-1/2 items-center justify-center rounded-full border bg-background",
83
+ tone === "primary" && "border-primary/40 bg-primary/10 text-primary",
84
+ tone === "success" && "border-green-500/40 bg-green-500/10 text-green-600 dark:text-green-500",
85
+ tone === "danger" && "border-destructive/40 bg-destructive/10 text-destructive",
86
+ tone === "pending" && "border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-500",
87
+ tone === "muted" && "text-muted-foreground",
88
+ )}
89
+ >
90
+ {icon}
91
+ </span>
92
+ )
93
+ }
94
+
95
+ function Entry({ children, className }: { children: ReactNode; className?: string }) {
96
+ return <div className={cn("relative pb-4 pl-6", className)}>{children}</div>
97
+ }
98
+
99
+ /** A lone call, on the rail exactly as before: one of anything never needs a disclosure over it. */
100
+ function ToolEntry({
101
+ entry,
102
+ attachmentUrl,
103
+ onOpenStack,
104
+ }: {
105
+ entry: ToolCall
106
+ attachmentUrl: AttachmentUrl
107
+ onOpenStack: (seq: number) => void
108
+ }) {
109
+ const result = entry.result
110
+ return (
111
+ <Entry className="pb-2">
112
+ <Marker
113
+ icon={<WrenchIcon className="h-3 w-3" />}
114
+ tone={bool(result?.payload.isError) ? "danger" : result ? "muted" : "pending"}
115
+ />
116
+ <ToolLine entry={entry} attachmentUrl={attachmentUrl} onOpenStack={onOpenStack} />
117
+ </Entry>
118
+ )
119
+ }
120
+
121
+ /**
122
+ * A chain of machinery as a single row — the answer to "twelve reads buried the sentence".
123
+ *
124
+ * Three things are on it, and the order is the order of the questions a reader has:
125
+ *
126
+ * 1. **what it amounts to** — verbs and counts, not "14 tool calls", because "read 8 files ·
127
+ * ran 3 commands" is the same length and actually tells you what the agent was doing;
128
+ * 2. **what went wrong**, if anything — never folded away. A failure hidden behind a disclosure
129
+ * is a failure the reader finds out about from the diff instead;
130
+ * 3. **what is happening right now**, while it is still going. A live run shows the call in
131
+ * flight with its own clock rather than its summary: the summary of an unfinished chain is
132
+ * the least interesting thing about it.
133
+ *
134
+ * Unfolding is local state and is never undone for the reader — a run that collapsed itself when
135
+ * the next tool landed would be a row that moves while you read it.
136
+ */
137
+ function ToolRunEntry({
138
+ run,
139
+ attachmentUrl,
140
+ onOpenStack,
141
+ }: {
142
+ run: ToolRun
143
+ attachmentUrl: AttachmentUrl
144
+ onOpenStack: (seq: number) => void
145
+ }) {
146
+ const [open, setOpen] = useState(false)
147
+ const stats = runStats(run)
148
+ const took = runDuration(run)
149
+ const running = stats.running
150
+ const elapsed = useElapsed(running ? running.call.ts : null)
151
+ const files = runFiles(run)
152
+
153
+ return (
154
+ <Entry className="pb-2">
155
+ <Marker
156
+ icon={<LayersIcon className="h-3 w-3" />}
157
+ tone={running ? "pending" : stats.failed > 0 ? "danger" : "muted"}
158
+ />
159
+ <div className="group/run flex items-center gap-1">
160
+ <button
161
+ type="button"
162
+ onClick={() => setOpen((v) => !v)}
163
+ className="-ml-1 flex min-w-0 flex-1 items-center gap-1.5 rounded-md px-1 py-1 text-left transition-colors hover:bg-muted/50"
164
+ >
165
+ <ChevronRightIcon
166
+ className={cn(
167
+ "h-3 w-3 shrink-0 text-muted-foreground/50 transition-transform group-hover/run:text-muted-foreground",
168
+ open && "rotate-90",
169
+ )}
170
+ />
171
+ <span className="shrink-0 text-xs font-medium">{run.items.length} steps</span>
172
+ {running ? (
173
+ <>
174
+ <span className="shrink-0 text-[11px] font-medium text-amber-600 dark:text-amber-500">
175
+ {str(running.call.payload.name) || "tool"}
176
+ </span>
177
+ <span className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground">
178
+ {toolSubject(str(running.call.payload.input))}
179
+ </span>
180
+ </>
181
+ ) : (
182
+ <span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground">{stats.label}</span>
183
+ )}
184
+ {stats.failed > 0 && (
185
+ <span className="shrink-0 rounded-full border border-destructive/30 bg-destructive/10 px-1.5 text-[10px] font-medium text-destructive">
186
+ {stats.failed} failed
187
+ </span>
188
+ )}
189
+ {running ? (
190
+ <span className="shrink-0 font-mono text-[10px] font-medium text-amber-600 tabular-nums dark:text-amber-500">
191
+ {elapsed ?? "running"}
192
+ </span>
193
+ ) : (
194
+ took && (
195
+ <span className="shrink-0 font-mono text-[10px] text-muted-foreground/70 tabular-nums">{took}</span>
196
+ )
197
+ )}
198
+ </button>
199
+ <button
200
+ type="button"
201
+ onClick={() => onOpenStack(stats.calls[0].call.seq)}
202
+ title="Open the tool stack"
203
+ className="shrink-0 rounded-md p-1 text-muted-foreground/0 transition-colors max-sm:text-muted-foreground/60 group-hover/run:text-muted-foreground/60 hover:!text-foreground hover:bg-muted/60"
204
+ >
205
+ <LayersIcon className="h-3 w-3" />
206
+ </button>
207
+ </div>
208
+
209
+ {/* Only while folded: unfolded, each call draws its own, and both would be the same
210
+ thumbnails twice. */}
211
+ {!open && files.length > 0 && <TaskFiles files={files} attachmentUrl={attachmentUrl} className="pl-5" />}
212
+
213
+ {open && <ToolRunBody run={run} attachmentUrl={attachmentUrl} onOpenStack={onOpenStack} />}
214
+ </Entry>
215
+ )
216
+ }
217
+
218
+ function ThinkingEntry({ event }: { event: TaskEvent }) {
219
+ const [open, setOpen] = useState(false)
220
+ const text = str(event.payload.text)
221
+ return (
222
+ <Entry className="pb-2">
223
+ <Marker icon={<BrainIcon className="h-3 w-3" />} />
224
+ <button
225
+ type="button"
226
+ onClick={() => setOpen((v) => !v)}
227
+ className="-ml-1 block w-full rounded-md px-1 py-1 text-left text-[11px] italic leading-relaxed text-muted-foreground/80 transition-colors hover:bg-muted/50 hover:text-muted-foreground"
228
+ >
229
+ {open ? text : `${text.slice(0, 140)}${text.length > 140 ? "…" : ""}`}
230
+ </button>
231
+ </Entry>
232
+ )
233
+ }
234
+
235
+ const STEP_ICON: Record<string, ReactNode> = {
236
+ completed: <CheckIcon className="h-3 w-3 text-green-600 dark:text-green-500" />,
237
+ in_progress: <CircleDotIcon className="h-3 w-3 text-primary" />,
238
+ }
239
+
240
+ function PlanEntry({ event }: { event: TaskEvent }) {
241
+ const steps = Array.isArray(event.payload.steps) ? (event.payload.steps as Record<string, unknown>[]) : []
242
+ return (
243
+ <Entry>
244
+ <Marker icon={<ListChecksIcon className="h-3 w-3" />} tone="primary" />
245
+ <div className="rounded-lg border bg-card px-3 py-2">
246
+ <p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">Plan</p>
247
+ <ul className="mt-1.5 space-y-1">
248
+ {steps.map((step, i) => (
249
+ <li key={i} className="flex items-start gap-2 text-xs leading-5">
250
+ <span className="mt-0.5 shrink-0">
251
+ {STEP_ICON[str(step.status)] ?? <CircleIcon className="h-3 w-3 text-muted-foreground/40" />}
252
+ </span>
253
+ <span className={cn(str(step.status) === "completed" && "text-muted-foreground line-through")}>
254
+ {str(step.text)}
255
+ </span>
256
+ </li>
257
+ ))}
258
+ </ul>
259
+ </div>
260
+ </Entry>
261
+ )
262
+ }
263
+
264
+ function MessageEntry({ event, attachmentUrl }: { event: TaskEvent; attachmentUrl: AttachmentUrl }) {
265
+ // What *you* said, set apart from what the agent said. Without this the timeline reads as one
266
+ // voice — and a harness does not echo its own stdin, so these lines exist only because the
267
+ // platform wrote them down (see `appendPlatformEvent`).
268
+ const mine = str(event.payload.role) === "user"
269
+ const files = fileRefs(event.payload.files)
270
+ return (
271
+ <Entry>
272
+ <Marker icon={mine ? <UserIcon className="h-3 w-3" /> : <SparklesIcon className="h-3 w-3" />} tone="primary" />
273
+ {mine ? (
274
+ <div className="rounded-lg border border-primary/25 bg-primary/[0.06] px-3 py-2">
275
+ <p className="whitespace-pre-wrap break-words text-sm leading-6">
276
+ {renderInlineMarkdown(str(event.payload.text), `m${event.seq}`)}
277
+ </p>
278
+ {/* Inside the bubble: they were part of what you said, not an annex to it. */}
279
+ <TaskFiles files={files} attachmentUrl={attachmentUrl} />
280
+ </div>
281
+ ) : (
282
+ <>
283
+ <AgentMarkdown text={str(event.payload.text)} />
284
+ <TaskFiles files={files} attachmentUrl={attachmentUrl} />
285
+ </>
286
+ )}
287
+ {bool(event.payload.truncated) && (
288
+ <p className="mt-1 text-[10px] text-muted-foreground">Clipped to the 8 KiB kept per field.</p>
289
+ )}
290
+ </Entry>
291
+ )
292
+ }
293
+
294
+ function BoundaryEntry({ event, hideSummary = false }: { event: TaskEvent; hideSummary?: boolean }) {
295
+ const started = event.type === "session.started"
296
+ const ok = str(event.payload.status) === "succeeded"
297
+ const detail = started
298
+ ? [str(event.payload.harness), str(event.payload.model), str(event.payload.cwd)].filter(Boolean).join(" · ")
299
+ : str(event.payload.stopReason)
300
+
301
+ return (
302
+ <Entry>
303
+ <Marker
304
+ icon={started ? <PlayIcon className="h-3 w-3" /> : <FlagIcon className="h-3 w-3" />}
305
+ tone={started ? "primary" : ok ? "success" : "danger"}
306
+ />
307
+ <p className="text-xs font-medium">
308
+ {started ? "Session started" : ok ? "Session finished" : "Session failed"}
309
+ </p>
310
+ {detail && <p className="mt-0.5 font-mono text-[11px] text-muted-foreground">{detail}</p>}
311
+ {!started && !hideSummary && str(event.payload.summary) && (
312
+ <div className="mt-2 rounded-lg border border-primary/20 bg-primary/[0.04] px-3 py-2">
313
+ <p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">Result</p>
314
+ <AgentMarkdown text={str(event.payload.summary)} />
315
+ </div>
316
+ )}
317
+ </Entry>
318
+ )
319
+ }
320
+
321
+ function PendingEntry({ text, previews = [] }: { text: string; previews?: string[] }) {
322
+ return (
323
+ <Entry>
324
+ <Marker icon={<UserIcon className="h-3 w-3" />} tone="muted" />
325
+ <div className="rounded-lg border border-dashed bg-muted/30 px-3 py-2">
326
+ <p className="whitespace-pre-wrap break-words text-sm leading-6 text-muted-foreground">
327
+ {renderInlineMarkdown(text, "pending")}
328
+ </p>
329
+ {previews.length > 0 && (
330
+ <div className="mt-2 flex flex-wrap gap-2">
331
+ {previews.map((src) => (
332
+ <img key={src} src={src} alt="" className="max-h-24 rounded-md border opacity-70" />
333
+ ))}
334
+ </div>
335
+ )}
336
+ <p className="mt-1 flex items-center gap-1.5 text-[10px] text-muted-foreground">
337
+ <span className="h-1 w-1 animate-pulse rounded-full bg-muted-foreground" />
338
+ sending…
339
+ </p>
340
+ </div>
341
+ </Entry>
342
+ )
343
+ }
344
+
345
+ function WorkingEntry() {
346
+ return (
347
+ <Entry className="pb-0">
348
+ <Marker icon={<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary" />} tone="primary" />
349
+ <p className="pt-1 text-[11px] text-muted-foreground">working…</p>
350
+ </Entry>
351
+ )
352
+ }
353
+
354
+ /**
355
+ * How many tool calls in a row it takes before folding them is worth a disclosure.
356
+ *
357
+ * Two, because the row a fold replaces says strictly more than one call's line did (it counts,
358
+ * it totals, it keeps a failure visible) — but for a *single* call it would say less, behind one
359
+ * more click. Below the threshold nothing changes from what the timeline always drew.
360
+ */
361
+ const RUN_THRESHOLD = 2
362
+
363
+ /**
364
+ * Fold the flat event list into what the timeline draws: tool results merged into their call,
365
+ * consecutive machinery gathered into runs, and `raw` set aside. A rendering decision, so it
366
+ * happens here — the stored stream stays flat.
367
+ */
368
+ function useTimeline(events: TaskEvent[]) {
369
+ return useMemo(() => {
370
+ const flat: Item[] = []
371
+ const byCallId = new Map<string, ToolCall>()
372
+ const raw: TaskEvent[] = []
373
+ let lastMessage = ""
374
+
375
+ for (const event of events) {
376
+ if (event.type === "raw" || event.type === "usage" || event.type === "session.title") {
377
+ // `usage` is a fact about the whole session, not a moment in it — the sidebar shows it.
378
+ // `session.title` is the same kind of fact: the name the session gave itself, already
379
+ // read at the top of the page as the task's own title.
380
+ if (event.type === "raw") raw.push(event)
381
+ continue
382
+ }
383
+ if (event.type === "tool.call") {
384
+ const entry: ToolCall = { call: event }
385
+ const id = str(event.payload.callId)
386
+ if (id) byCallId.set(id, entry)
387
+ flat.push({ kind: "tool", entry })
388
+ continue
389
+ }
390
+ if (event.type === "tool.result") {
391
+ const entry = byCallId.get(str(event.payload.callId))
392
+ // A result whose call we never saw (ingestion that started mid-session) still gets a
393
+ // line: dropping it would hide a tool failure.
394
+ if (entry) entry.result = event
395
+ else flat.push({ kind: "tool", entry: { call: event, result: event } })
396
+ continue
397
+ }
398
+ // Only the agent's own words: the end-of-session summary can duplicate what it just said,
399
+ // never what you said.
400
+ if (event.type === "message" && str(event.payload.role) !== "user") {
401
+ lastMessage = str(event.payload.text).trim()
402
+ }
403
+ if (event.type === "session.ended") {
404
+ // Claude Code's `result` is its final assistant turn verbatim, so the end block would
405
+ // reprint the message right above it — twice the same paragraphs, which reads as a bug.
406
+ // Suppressed only when they actually match: a harness whose summary says something else
407
+ // still gets its own block.
408
+ const summary = str(event.payload.summary).trim()
409
+ flat.push({ kind: "event", event, hideSummary: summary !== "" && summary === lastMessage })
410
+ continue
411
+ }
412
+ flat.push({ kind: "event", event })
413
+ }
414
+
415
+ // ── Second pass: gather consecutive machinery ─────────────────────────────────────────
416
+ //
417
+ // A run ends at anything a human wrote or the agent said — a message, a plan, a session
418
+ // boundary. Which is the right seam by construction: those are the moments the agent stopped
419
+ // working to report, so what sits between two of them *is* one stretch of work.
420
+ const items: Item[] = []
421
+ let buffer: RunItem[] = []
422
+
423
+ const flush = () => {
424
+ if (buffer.length === 0) return
425
+ const tools = buffer.filter((item) => item.kind === "tool").length
426
+ if (tools >= RUN_THRESHOLD) items.push({ kind: "run", run: { items: buffer } })
427
+ else
428
+ for (const item of buffer)
429
+ items.push(item.kind === "tool" ? { kind: "tool", entry: item.entry } : { kind: "event", event: item.event })
430
+ buffer = []
431
+ }
432
+
433
+ for (const item of flat) {
434
+ if (item.kind === "tool") {
435
+ buffer.push({ kind: "tool", entry: item.entry })
436
+ continue
437
+ }
438
+ if (item.kind === "event" && item.event.type === "thinking") {
439
+ buffer.push({ kind: "thinking", event: item.event })
440
+ continue
441
+ }
442
+ flush()
443
+ items.push(item)
444
+ }
445
+ flush()
446
+
447
+ // Every call of the session, in order, tagged with the stretch it came from — what the stack
448
+ // sheet reads. Built here because this is where the stretches are known; deriving it a second
449
+ // time from the events would be a second grouping rule to keep in step with this one.
450
+ const calls: StackedCall[] = []
451
+ let runIndex = 0
452
+ for (const item of items) {
453
+ if (item.kind === "tool") calls.push({ ...item.entry, runIndex: runIndex++ })
454
+ else if (item.kind === "run") {
455
+ for (const entry of runCalls(item.run)) calls.push({ ...entry, runIndex })
456
+ runIndex += 1
457
+ }
458
+ }
459
+
460
+ return { items, raw, calls }
461
+ }, [events])
462
+ }
463
+
464
+
465
+
466
+ /** What the virtualizer actually renders — the timeline's items, plus the two rows that are not
467
+ * events at all: the reader's own message before it lands, and the "still working" pulse. */
468
+ type Row = Item | { kind: "pending"; text: string; previews?: string[] } | { kind: "working" }
469
+
470
+ /** Content-derived, never the row's index — the index shifts every time older history is
471
+ * prepended, and the virtualizer needs a stable key to keep the reader's scroll position across
472
+ * that prepend (see the tanstack-virtual chat guide). `seq` is dense and unique per task, so a
473
+ * tool entry's own call (or the orphaned result standing in for it) is already enough. */
474
+ function rowKey(row: Row): string | number {
475
+ if (row.kind === "tool") return row.entry.call.seq
476
+ if (row.kind === "run") return row.run.items[0].kind === "tool" ? row.run.items[0].entry.call.seq : row.run.items[0].event.seq
477
+ if (row.kind === "event") return row.event.seq
478
+ return row.kind
479
+ }
480
+
481
+ function Row({
482
+ row,
483
+ attachmentUrl,
484
+ onOpenStack,
485
+ }: {
486
+ row: Row
487
+ attachmentUrl: AttachmentUrl
488
+ onOpenStack: (seq: number) => void
489
+ }) {
490
+ if (row.kind === "pending") return <PendingEntry text={row.text} previews={row.previews} />
491
+ if (row.kind === "working") return <WorkingEntry />
492
+ if (row.kind === "run")
493
+ return <ToolRunEntry run={row.run} attachmentUrl={attachmentUrl} onOpenStack={onOpenStack} />
494
+ if (row.kind === "tool")
495
+ return <ToolEntry entry={row.entry} attachmentUrl={attachmentUrl} onOpenStack={onOpenStack} />
496
+ if (row.event.type === "thinking") return <ThinkingEntry event={row.event} />
497
+ if (row.event.type === "plan") return <PlanEntry event={row.event} />
498
+ if (row.event.type === "message") return <MessageEntry event={row.event} attachmentUrl={attachmentUrl} />
499
+ return <BoundaryEntry event={row.event} hideSummary={row.hideSummary} />
500
+ }
501
+
502
+ /** How close to the end still counts as "following" — both for the virtualizer's own
503
+ * follow-on-append and for the "Jump to latest" button's visibility. */
504
+ const SCROLL_END_THRESHOLD = 48
505
+
506
+ /**
507
+ * How far above the top of the loaded history the next page starts loading, as a multiple of the
508
+ * viewport — two screens of runway, floored so a short panel (a phone, the cockpit's sidebar)
509
+ * still gets a useful head start.
510
+ *
511
+ * The number is the whole trick behind "it just keeps going": at two screens, a reader flicking
512
+ * upward at ~1400 px/s (measured against a 700-event fixture) reaches the top of what they had
513
+ * about a second after the next page was asked for — long enough for it to have landed, on a
514
+ * connection far worse than this one. Firing at the top itself would be correct and would still
515
+ * feel like a stall, which is what the button it replaces felt like, minus the click.
516
+ */
517
+ const PREFETCH_SCREENS = 2
518
+ const MIN_PREFETCH_DISTANCE = 400
519
+
520
+ export interface TaskEventStreamProps {
521
+ /** The loaded part of the session, oldest first — the tail on open, older pages prepended. */
522
+ events: TaskEvent[]
523
+ /** The project's `taskAgentProtocol`. `none` → the session isn't captured, say so. */
524
+ protocol: string
525
+ /** How to reach the bytes of a file an event references (RFC 0022). */
526
+ attachmentUrl: AttachmentUrl
527
+ /** The task can still move (queued, running, in review) — drives "live" and "working…". */
528
+ live: boolean
529
+ /** Whether older history exists before what is loaded. */
530
+ hasMore?: boolean
531
+ loadingOlder?: boolean
532
+ /** The last attempt at older history failed. Turns the automatic load back into a manual retry. */
533
+ olderError?: boolean
534
+ /** Asked for by the stream itself as the reader scrolls toward the top. Must be idempotent while in flight. */
535
+ onLoadOlder?: () => void
536
+ /** The reader's own message, drawn until its event lands. */
537
+ pending?: PendingMessage | null
538
+ /**
539
+ * Drawn instead of the default sentence when there is nothing to show yet. A task that has no
540
+ * session *because its machine is still being built* has plenty to say — it just isn't the
541
+ * stream's to say, so the cockpit hands it in.
542
+ */
543
+ emptyState?: ReactNode
544
+ /**
545
+ * Rendered where the panel's own title would be. The cockpit puts the Session/Diff switch there
546
+ * rather than stacking a second bar on top: two strips of chrome above a transcript is one too
547
+ * many, and the title was only ever saying which panel you are on.
548
+ */
549
+ tabs?: ReactNode
550
+ className?: string
551
+ }
552
+
553
+ export function TaskEventStream({
554
+ events,
555
+ protocol,
556
+ attachmentUrl,
557
+ live,
558
+ hasMore = false,
559
+ loadingOlder = false,
560
+ olderError = false,
561
+ onLoadOlder = () => {},
562
+ pending = null,
563
+ emptyState,
564
+ tabs,
565
+ className,
566
+ }: TaskEventStreamProps) {
567
+ const { items, raw, calls } = useTimeline(events)
568
+ const [showRaw, setShowRaw] = useState(false)
569
+ const parentRef = useRef<HTMLDivElement>(null)
570
+ const count = events.length
571
+
572
+ // The stack sheet is owned here rather than by the row it is opened from: the list is
573
+ // virtualized, so that row is unmounted the moment the reader scrolls, taking its sheet with
574
+ // it. `seq` is only where it lands — what it shows is the whole session either way.
575
+ const [stack, setStack] = useState<{ open: boolean; seq: number | null }>({ open: false, seq: null })
576
+ const openStack = useCallback((seq: number | null) => setStack({ open: true, seq }), [])
577
+
578
+ // An interactive session does not exit between turns, so the task can still read `running`
579
+ // after the agent has handed back — until something reads the stream. The stream itself knows
580
+ // about its own end, and says so without waiting for the task row to catch up.
581
+ const streaming = live && !events.some((e) => e.type === "session.ended")
582
+
583
+ // Derived, never cleared: the optimistic bubble disappears exactly when its own event lands.
584
+ // Storing "is it still pending?" as state would mean an effect to clear it — a cascade of
585
+ // renders per poll, and a second source of truth to keep in step with the first.
586
+ const stillPending =
587
+ pending !== null &&
588
+ !events.some(
589
+ (e) =>
590
+ e.type === "message" &&
591
+ str(e.payload.role) === "user" &&
592
+ str(e.payload.text) === pending.text &&
593
+ e.seq > pending.sinceSeq,
594
+ )
595
+
596
+ // The two rows that are not events at all, appended so they scroll and anchor like every other
597
+ // row instead of living outside the virtualizer's own height accounting.
598
+ const rows: Row[] = useMemo(() => {
599
+ const list: Row[] = items
600
+ const extra: Row[] = []
601
+ if (stillPending) extra.push({ kind: "pending", text: pending!.text, previews: pending!.previews })
602
+ if (streaming) extra.push({ kind: "working" })
603
+ return extra.length > 0 ? [...list, ...extra] : list
604
+ }, [items, stillPending, pending, streaming])
605
+
606
+ // ── Scrolling, the way a terminal does ────────────────────────────────────────────────
607
+ //
608
+ // Two rules, and the second is the one that makes it feel right rather than merely automatic:
609
+ //
610
+ // 1. **Open at the bottom.** A conversation's newest turn is the one you came for — which is
611
+ // also why the cockpit opens this stream on its *tail* rather than its start (see
612
+ // the app's events hook). Jumping there instantly (no smooth scroll) on the first paint is what a
613
+ // terminal does; animating it would just be a long distraction on a session with hundreds
614
+ // of events.
615
+ // 2. **Follow only while stuck to the bottom.** The moment the reader scrolls up — to re-read
616
+ // a tool result — following would yank the view away every two seconds. `anchorTo: "end"`
617
+ // plus `followOnAppend` is tanstack-virtual's own answer to exactly this (its chat guide is
618
+ // written for this shape of list): it keeps pinning new rows to the bottom while the reader
619
+ // is there, and leaves the scroll position alone — even across a prepend from `onLoadOlder`
620
+ // — the moment they are not.
621
+ const virtualizer = useVirtualizer({
622
+ count: rows.length,
623
+ getScrollElement: () => parentRef.current,
624
+ estimateSize: () => 64,
625
+ getItemKey: (index) => rowKey(rows[index]),
626
+ anchorTo: "end",
627
+ followOnAppend: true,
628
+ scrollEndThreshold: SCROLL_END_THRESHOLD,
629
+ overscan: 8,
630
+ })
631
+
632
+ const firstPaint = useRef(true)
633
+ useLayoutEffect(() => {
634
+ if (rows.length === 0 || !firstPaint.current) return
635
+ firstPaint.current = false
636
+ virtualizer.scrollToEnd({ behavior: "instant" })
637
+ }, [rows.length, virtualizer])
638
+
639
+ /**
640
+ * Has the opening jump *landed*? Until it has, the container is still at offset 0 — which is to
641
+ * say sitting on the top sentinel — and arming the observer there reads "the reader is at the
642
+ * start of the history" off a view that has not been positioned yet. Measured: it pages
643
+ * backwards once, immediately, on every open.
644
+ *
645
+ * Asking the DOM rather than trusting the call above, because the call above is not the moment:
646
+ * the virtualizer lands `scrollToEnd` across the following frames, re-aligning as rows measure
647
+ * for real. `scrollTop > 0` is that landing, observed. A stream short enough not to scroll at
648
+ * all never gets one — and needs no gate either: its sentinel is honestly on screen, and
649
+ * filling the viewport is the right answer.
650
+ *
651
+ * In dev only, Strict Mode's second mount hands the container back at offset 0 before the
652
+ * virtualizer re-lands it, so one extra page is read on open. Nothing to gate: at that instant
653
+ * the view really is at the top of what is loaded. It does not happen in a production build.
654
+ */
655
+ const [opened, setOpened] = useState(false)
656
+ useEffect(() => {
657
+ const root = parentRef.current
658
+ if (opened || !root || rows.length === 0) return
659
+ const landed = () => {
660
+ if (root.scrollTop > 0 || root.scrollHeight <= root.clientHeight + 1) setOpened(true)
661
+ }
662
+ landed()
663
+ root.addEventListener("scroll", landed, { passive: true })
664
+ return () => root.removeEventListener("scroll", landed)
665
+ }, [opened, rows.length])
666
+
667
+ // ── Paging backwards, without a button ───────────────────────────────────────────────
668
+ //
669
+ // A sentinel at the top of the loaded history plus an IntersectionObserver, which is what the
670
+ // shape asks for: the alternative is a scroll handler doing the same arithmetic on every frame
671
+ // of a fast flick, and the observer already knows the answer. `rootMargin` is what turns "the
672
+ // reader hit the top" into "the reader is about to" — see `PREFETCH_SCREENS`.
673
+ //
674
+ // Three details keep it from being either a dead end or a request loop, and all three are
675
+ // load-bearing:
676
+ //
677
+ // * `loadingOlder` is a **dependency**, not a guard. An observer only reports *changes* in
678
+ // intersection, so a sentinel that never left the margin would fire once and go quiet —
679
+ // stranding a reader who is still scrolling up. Re-arming after each page delivers a fresh
680
+ // initial reading instead: still in the margin, load the next one; out of it, wait.
681
+ // * `onLoadOlder` is read through a ref, so re-rendering (twice a second, while live) does not
682
+ // tear the observer down and re-arm it with it.
683
+ // * `hasMore` and `olderError` stop it — at the start of the session, or on a page that failed.
684
+ const topRef = useRef<HTMLDivElement>(null)
685
+ const loadOlderRef = useRef(onLoadOlder)
686
+ useEffect(() => {
687
+ loadOlderRef.current = onLoadOlder
688
+ })
689
+ useEffect(() => {
690
+ const root = parentRef.current
691
+ const sentinel = topRef.current
692
+ if (!root || !sentinel || !opened || !hasMore || olderError || loadingOlder) return
693
+ const runway = Math.max(MIN_PREFETCH_DISTANCE, Math.round(root.clientHeight * PREFETCH_SCREENS))
694
+ const observer = new IntersectionObserver(
695
+ (entries) => {
696
+ if (entries.some((entry) => entry.isIntersecting)) loadOlderRef.current()
697
+ },
698
+ { root, rootMargin: `${runway}px 0px 0px 0px` },
699
+ )
700
+ observer.observe(sentinel)
701
+ return () => observer.disconnect()
702
+ }, [opened, hasMore, olderError, loadingOlder])
703
+
704
+ const empty = protocol === "none" || count === 0
705
+ // Only while there is something to come back to: on a finished session the reader scrolling up
706
+ // is just reading, not falling behind.
707
+ const showJumpToLatest = streaming && rows.length > 0 && !virtualizer.isAtEnd(SCROLL_END_THRESHOLD)
708
+
709
+ return (
710
+ <div className={cn("flex min-h-0 flex-1 flex-col", className)}>
711
+ <div className="flex shrink-0 items-center gap-2 border-b border-border bg-card px-4 py-2">
712
+ {tabs ?? <h2 className="text-xs font-medium">Session</h2>}
713
+ {streaming ? (
714
+ <span className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
715
+ <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary" />
716
+ live
717
+ </span>
718
+ ) : (
719
+ count > 0 && (
720
+ <span className="text-[11px] text-muted-foreground">
721
+ {hasMore ? `last ${count} events` : `${count} events`}
722
+ </span>
723
+ )
724
+ )}
725
+ {/* The session's machinery, from anywhere in it — including from a spot where the run
726
+ that made the call you are chasing is a thousand rows up. */}
727
+ {calls.length > 0 && (
728
+ <div className="ml-auto">
729
+ <TaskToolStackButton count={calls.length} onClick={() => openStack(null)} />
730
+ </div>
731
+ )}
732
+ {raw.length > 0 && (
733
+ <button
734
+ type="button"
735
+ onClick={() => setShowRaw((v) => !v)}
736
+ className={cn(
737
+ "text-[11px] text-muted-foreground transition-colors hover:text-foreground",
738
+ calls.length === 0 && "ml-auto",
739
+ )}
740
+ >
741
+ {showRaw ? "Hide" : "Show"} {raw.length} unmapped
742
+ </button>
743
+ )}
744
+ </div>
745
+
746
+ <div ref={parentRef} className="relative min-h-0 flex-1 overflow-y-auto px-5 py-4 md:px-8">
747
+ {empty && emptyState ? (
748
+ emptyState
749
+ ) : empty ? (
750
+ <div className="flex h-full items-center justify-center">
751
+ <p className="max-w-sm text-center text-xs leading-relaxed text-muted-foreground">
752
+ {protocol === "none" ? (
753
+ <>
754
+ This project doesn&apos;t capture its agent session. Set{" "}
755
+ <span className="font-mono">Session stream</span> in its delegated-work settings to watch the work as
756
+ it happens.
757
+ </>
758
+ ) : live ? (
759
+ "Waiting for the session's first event…"
760
+ ) : (
761
+ "The session produced no readable events."
762
+ )}
763
+ </p>
764
+ </div>
765
+ ) : (
766
+ <>
767
+ {/* The head of the loaded history, and the observer's sentinel.
768
+ Always drawn, and always the **same height**, whichever of its three things it is
769
+ saying. A block that appears or disappears up here changes the distance between
770
+ the top of the scroll content and every row below it, which is exactly the
771
+ measurement the virtualizer anchors a prepend against — so a zone that collapsed
772
+ when history ran out would pay for its own tidiness with the one visible jump this
773
+ whole mechanism exists to avoid. */}
774
+ <div ref={topRef} className="mx-auto mb-3 flex h-7 max-w-3xl items-center justify-center">
775
+ {!hasMore ? (
776
+ <span className="text-[11px] text-muted-foreground/60">Beginning of the session</span>
777
+ ) : olderError ? (
778
+ <button
779
+ type="button"
780
+ onClick={onLoadOlder}
781
+ className="rounded-full border border-dashed px-3 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-muted/50"
782
+ >
783
+ Couldn&apos;t load earlier messages — retry
784
+ </button>
785
+ ) : (
786
+ // Reached on its own in the normal case, so this is mostly a status line. It stays
787
+ // a button for the reader the observer cannot serve: no pointer, a scroll that
788
+ // never fires it, or simply a wish to go faster than the runway.
789
+ <button
790
+ type="button"
791
+ onClick={onLoadOlder}
792
+ disabled={loadingOlder}
793
+ className="flex items-center gap-1.5 rounded-full px-3 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-muted/50 disabled:hover:bg-transparent"
794
+ >
795
+ {loadingOlder ? (
796
+ <>
797
+ <Loader2Icon className="h-3 w-3 animate-spin" />
798
+ Loading earlier messages…
799
+ </>
800
+ ) : (
801
+ <>
802
+ <ArrowUpIcon className="h-3 w-3" />
803
+ Load earlier messages
804
+ </>
805
+ )}
806
+ </button>
807
+ )}
808
+ </div>
809
+
810
+ <div className="relative mx-auto max-w-3xl" style={{ height: virtualizer.getTotalSize() }}>
811
+ {/* The rail. Stops at the last marker rather than the container's edge, so a
812
+ finished session doesn't trail a line into nothing. */}
813
+ <span className="absolute bottom-4 left-0 top-2 w-px bg-border" aria-hidden />
814
+ {virtualizer.getVirtualItems().map((virtualRow) => (
815
+ <div
816
+ key={virtualRow.key}
817
+ data-index={virtualRow.index}
818
+ ref={virtualizer.measureElement}
819
+ className="absolute left-0 top-0 w-full"
820
+ style={{ transform: `translateY(${virtualRow.start}px)` }}
821
+ >
822
+ <Row row={rows[virtualRow.index]} attachmentUrl={attachmentUrl} onOpenStack={openStack} />
823
+ </div>
824
+ ))}
825
+ </div>
826
+ </>
827
+ )}
828
+
829
+ {showJumpToLatest && (
830
+ <button
831
+ type="button"
832
+ onClick={() => virtualizer.scrollToEnd({ behavior: "smooth" })}
833
+ className="sticky bottom-2 left-1/2 z-10 -translate-x-1/2 rounded-full border bg-background/95 px-3 py-1 text-[11px] shadow-sm backdrop-blur transition-colors hover:bg-muted"
834
+ >
835
+ Jump to latest ↓
836
+ </button>
837
+ )}
838
+
839
+ {showRaw && raw.length > 0 && (
840
+ <div className="mx-auto mt-4 max-w-3xl">
841
+ <p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
842
+ Unmapped events
843
+ </p>
844
+ <pre className="max-h-64 overflow-auto rounded-md border bg-muted/40 px-2.5 py-1.5 font-mono text-[10px] leading-relaxed whitespace-pre-wrap break-words">
845
+ {raw.map((e) => JSON.stringify(e.payload)).join("\n")}
846
+ </pre>
847
+ </div>
848
+ )}
849
+ </div>
850
+
851
+ <TaskToolStackSheet
852
+ open={stack.open}
853
+ onOpenChange={(open) => setStack((prev) => ({ ...prev, open }))}
854
+ calls={calls}
855
+ initialSeq={stack.seq}
856
+ attachmentUrl={attachmentUrl}
857
+ />
858
+ </div>
859
+ )
860
+ }