@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,692 @@
1
+ "use client"
2
+
3
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"
4
+ import {
5
+ ArrowLeftIcon,
6
+ BrainIcon,
7
+ ChevronRightIcon,
8
+ CircleSlashIcon,
9
+ FileTextIcon,
10
+ GlobeIcon,
11
+ LayersIcon,
12
+ ListTreeIcon,
13
+ PencilIcon,
14
+ SearchIcon,
15
+ TerminalIcon,
16
+ UsersIcon,
17
+ WrenchIcon,
18
+ } from "lucide-react"
19
+
20
+ import { cn } from "../../utils"
21
+ import { Kbd } from "../kbd"
22
+ import { Sheet, SheetContent, SheetHeader, SheetTitle } from "../sheet"
23
+ import { TaskFiles, fileRefs } from "./task-attachments"
24
+ import { taskSpan, useElapsed } from "./task-state"
25
+ import type { AttachmentUrl, TaskEvent } from "./types"
26
+
27
+ /**
28
+ * What the agent *did*, as opposed to what it said — the call stack of a session.
29
+ *
30
+ * The timeline next door (`task-event-stream.tsx`) is built on one hierarchy: prose is the thing
31
+ * a human came to read, and tools are the machinery under it. That holds right up until the
32
+ * machinery arrives in a chain — twenty reads, a grep, six edits, a test run — at which point a
33
+ * folded line each is still twenty-seven lines, and the sentence that follows them is off screen.
34
+ * A reader scrolling that is not reading a conversation, they are scrolling past one.
35
+ *
36
+ * So a chain is **one row**, and there are two ways into it, because they answer two different
37
+ * questions:
38
+ *
39
+ * - **the row itself** says what the chain amounts to — "read 8 files · ran 3 commands" — and,
40
+ * while it is still going, what is happening right now. Unfolding it in place is for "wait,
41
+ * what did it edit?", asked in the middle of reading.
42
+ * - **the stack** (`TaskToolStackSheet`) is the other question entirely: not "what happened in
43
+ * this turn" but "what has this session actually done", every call in one list, searchable,
44
+ * with the full input and output of the selected one. That is a working surface, not a line on
45
+ * a timeline, so it gets a sheet of its own.
46
+ *
47
+ * The same shape the rest of the field landed on: T3 Code and Ducktape both fold consecutive
48
+ * tool/reasoning runs behind one disclosure with a counted summary, and Amp's transcript makes a
49
+ * tool call a card with collapsible input/output. What is ours is the *summary* — verbs and
50
+ * counts rather than "15 tool calls" — and the stack as a first-class surface.
51
+ */
52
+
53
+ const str = (v: unknown): string => (typeof v === "string" ? v : "")
54
+ const bool = (v: unknown): boolean => v === true
55
+
56
+ /** A call and, once it comes back, its result. Paired by `callId` in the timeline. */
57
+ export type ToolCall = { call: TaskEvent; result?: TaskEvent }
58
+
59
+ // ── Reading a call ────────────────────────────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * The one-line gist of a tool's arguments.
63
+ *
64
+ * Adapters store the input verbatim because that is the evidence; a folded row wants the
65
+ * *subject* instead. The keys below are the common ones across harnesses, and the fallback is the
66
+ * raw string — an unknown tool degrades to what we had, not to nothing.
67
+ */
68
+ const SUBJECT_KEYS = ["command", "file_path", "path", "pattern", "query", "url", "description", "prompt"]
69
+
70
+ export function toolSubject(input: string): string {
71
+ if (!input.startsWith("{")) return input
72
+ try {
73
+ const parsed = JSON.parse(input) as Record<string, unknown>
74
+ for (const key of SUBJECT_KEYS) {
75
+ const value = parsed[key]
76
+ if (typeof value === "string" && value.trim()) return value.replace(/\s+/g, " ").trim()
77
+ }
78
+ } catch {
79
+ // Clipped at 8 KiB, or simply not JSON — the raw text is still the best we have.
80
+ }
81
+ return input
82
+ }
83
+
84
+ /** JSON arguments, laid out, for the one place with the room to read them: the stack's detail. */
85
+ function pretty(text: string): string {
86
+ const trimmed = text.trim()
87
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return text
88
+ try {
89
+ return JSON.stringify(JSON.parse(trimmed), null, 2)
90
+ } catch {
91
+ return text
92
+ }
93
+ }
94
+
95
+ /**
96
+ * What kind of move a tool name is.
97
+ *
98
+ * Matched on a prefix rather than an exact list because the list is not ours to keep: every
99
+ * harness names its own tools, and MCP servers add more at runtime. A name we cannot place is
100
+ * `other`, which still counts — the summary says "3 other calls" rather than pretending the
101
+ * chain was shorter than it was.
102
+ */
103
+ export type ToolVerb = "read" | "edit" | "search" | "run" | "web" | "delegate" | "other"
104
+
105
+ const VERB_RULES: [RegExp, ToolVerb][] = [
106
+ [/^(read|view|open|cat|notebook_?read)/, "read"],
107
+ [/^(edit|write|create|update|multi_?edit|notebook_?edit|apply_?patch|patch|str_?replace)/, "edit"],
108
+ [/^(grep|glob|search|find|list|ls|codebase)/, "search"],
109
+ [/^(bash|sh|shell|exec|run|terminal|command|process)/, "run"],
110
+ [/^(web|fetch|browser|playwright|curl|http)/, "web"],
111
+ [/^(task|agent|dispatch|subagent)/, "delegate"],
112
+ ]
113
+
114
+ export function toolVerb(name: string): ToolVerb {
115
+ const lower = name.toLowerCase()
116
+ for (const [rule, verb] of VERB_RULES) if (rule.test(lower)) return verb
117
+ return "other"
118
+ }
119
+
120
+ export const VERB_ICON: Record<ToolVerb, ReactNode> = {
121
+ read: <FileTextIcon className="h-3 w-3" />,
122
+ edit: <PencilIcon className="h-3 w-3" />,
123
+ search: <SearchIcon className="h-3 w-3" />,
124
+ run: <TerminalIcon className="h-3 w-3" />,
125
+ web: <GlobeIcon className="h-3 w-3" />,
126
+ delegate: <UsersIcon className="h-3 w-3" />,
127
+ other: <WrenchIcon className="h-3 w-3" />,
128
+ }
129
+
130
+ const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
131
+
132
+ const VERB_LABEL: Record<ToolVerb, (n: number) => string> = {
133
+ read: (n) => `read ${plural(n, "file")}`,
134
+ edit: (n) => `edited ${plural(n, "file")}`,
135
+ search: (n) => plural(n, "search", "searches"),
136
+ run: (n) => `ran ${plural(n, "command")}`,
137
+ web: (n) => plural(n, "web call"),
138
+ delegate: (n) => plural(n, "subagent"),
139
+ other: (n) => plural(n, "other call"),
140
+ }
141
+
142
+ // ── A run of them ─────────────────────────────────────────────────────────────────────────────
143
+
144
+ /**
145
+ * Consecutive machinery: the tool calls of one stretch of work, and the thinking in between.
146
+ *
147
+ * Thinking is in here rather than beside it because it is the same kind of thing — the agent
148
+ * working, not the agent talking — and a chain read as "tool, thought, tool, thought" is exactly
149
+ * the alternation this row exists to stop. It stays visible inside the unfolded run.
150
+ */
151
+ export type RunItem = { kind: "tool"; entry: ToolCall } | { kind: "thinking"; event: TaskEvent }
152
+ export type ToolRun = { items: RunItem[] }
153
+
154
+ export function runCalls(run: ToolRun): ToolCall[] {
155
+ return run.items.flatMap((item) => (item.kind === "tool" ? [item.entry] : []))
156
+ }
157
+
158
+ /**
159
+ * The files a run's calls answered with — a screenshot, a PDF the agent read.
160
+ *
161
+ * Lifted to the run row for the same reason a single call keeps them outside its fold: the file
162
+ * *is* the answer. Folding a chain must not turn "here is what the page looks like now" into a
163
+ * line of text about a tool having been called.
164
+ */
165
+ export function runFiles(run: ToolRun) {
166
+ return runCalls(run).flatMap((entry) => fileRefs(entry.result?.payload.files))
167
+ }
168
+
169
+ export type RunStats = {
170
+ calls: ToolCall[]
171
+ thoughts: number
172
+ failed: number
173
+ /** The call still in flight, if any — there is at most one, and it is what a live row shows. */
174
+ running: ToolCall | null
175
+ /** Verb clauses, most frequent first, capped — plus what the cap left out. */
176
+ label: string
177
+ }
178
+
179
+ export function runStats(run: ToolRun): RunStats {
180
+ const calls = runCalls(run)
181
+ const thoughts = run.items.length - calls.length
182
+ const counts = new Map<ToolVerb, number>()
183
+ let failed = 0
184
+
185
+ for (const entry of calls) {
186
+ const verb = toolVerb(str(entry.call.payload.name))
187
+ counts.set(verb, (counts.get(verb) ?? 0) + 1)
188
+ if (bool(entry.result?.payload.isError)) failed += 1
189
+ }
190
+
191
+ // Only the *last* call counts as in flight. An earlier one missing its result means ingestion
192
+ // started mid-session and never saw it — a fact about the capture, not a tool that has been
193
+ // running for three hours, and showing a clock on it would say the second thing.
194
+ const lastCall = calls[calls.length - 1]
195
+ const running = lastCall && !lastCall.result ? lastCall : null
196
+
197
+ // Three clauses is what fits on one line at this size without the row becoming the paragraph it
198
+ // is replacing; the rest is counted, never dropped.
199
+ const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1])
200
+ const shown = ranked.slice(0, 3)
201
+ const rest = ranked.slice(3).reduce((n, [, count]) => n + count, 0)
202
+ const clauses = shown.map(([verb, count]) => VERB_LABEL[verb](count))
203
+ if (rest > 0) clauses.push(`+${rest} more`)
204
+ if (thoughts > 0) clauses.push(plural(thoughts, "thought"))
205
+
206
+ return { calls, thoughts, failed, running, label: clauses.join(" · ") }
207
+ }
208
+
209
+ /** How long the whole run took. `null` while it is still going — that is `useElapsed`'s job. */
210
+ export function runDuration(run: ToolRun): string | null {
211
+ const items = run.items
212
+ if (items.length === 0) return null
213
+ const first = items[0].kind === "tool" ? items[0].entry.call : items[0].event
214
+ const last = items[items.length - 1]
215
+ const lastEvent = last.kind === "tool" ? (last.entry.result ?? last.entry.call) : last.event
216
+ if (last.kind === "tool" && !last.entry.result) return null
217
+ return taskSpan(first.ts, lastEvent.ts)
218
+ }
219
+
220
+ // ── One call, folded ──────────────────────────────────────────────────────────────────────────
221
+
222
+ function Block({
223
+ label,
224
+ text,
225
+ danger = false,
226
+ className,
227
+ }: {
228
+ label: string
229
+ text: string
230
+ danger?: boolean
231
+ className?: string
232
+ }) {
233
+ return (
234
+ <div className={cn("min-w-0", className)}>
235
+ <p className="mb-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">{label}</p>
236
+ <pre
237
+ className={cn(
238
+ "max-h-64 overflow-auto rounded-md border px-2.5 py-1.5 font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-words",
239
+ danger ? "border-destructive/30 bg-destructive/5" : "bg-muted/50",
240
+ )}
241
+ >
242
+ {text}
243
+ </pre>
244
+ </div>
245
+ )
246
+ }
247
+
248
+ function TruncationNote({ entry }: { entry: ToolCall }) {
249
+ if (!bool(entry.call.payload.truncated) && !bool(entry.result?.payload.truncated)) return null
250
+ return <p className="text-[10px] text-muted-foreground">Clipped to the 8 KiB kept per field.</p>
251
+ }
252
+
253
+ /**
254
+ * `useElapsed` (see `task-state.tsx`) is what a running tool gets instead of output.
255
+ *
256
+ * Measured against a real session: a harness reports a tool's result **once, when it finishes** —
257
+ * during a 13-second `Bash` nothing at all comes out of the stream. So the honest thing a
258
+ * timeline can offer while a tool runs is not its output (there is none to have) but the one fact
259
+ * it does know: how long it has been at it. Without this a slow command is indistinguishable
260
+ * from a stuck one.
261
+ */
262
+ export function ToolLine({
263
+ entry,
264
+ attachmentUrl,
265
+ onOpenStack,
266
+ }: {
267
+ entry: ToolCall
268
+ attachmentUrl: AttachmentUrl
269
+ /** Hands this call off to the stack sheet. Absent inside the sheet itself. */
270
+ onOpenStack?: (seq: number) => void
271
+ }) {
272
+ const [open, setOpen] = useState(false)
273
+ const name = str(entry.call.payload.name) || "tool"
274
+ const input = str(entry.call.payload.input)
275
+ const result = entry.result
276
+ const isError = bool(result?.payload.isError)
277
+ const output = str(result?.payload.output)
278
+ const elapsed = useElapsed(result ? null : entry.call.ts)
279
+ const took = result ? taskSpan(entry.call.ts, result.ts) : null
280
+ // A tool that answers with a file — a screenshot, a PDF the agent read. Drawn outside the fold,
281
+ // unlike the rest of the result: the file *is* the answer, and a reader who has to expand a row
282
+ // to find out that a screenshot was taken has been told nothing by the row.
283
+ const files = fileRefs(result?.payload.files)
284
+
285
+ return (
286
+ <div className="min-w-0">
287
+ <div className="group/tool flex items-center gap-1">
288
+ <button
289
+ type="button"
290
+ onClick={() => setOpen((v) => !v)}
291
+ 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"
292
+ >
293
+ <ChevronRightIcon
294
+ className={cn(
295
+ "h-3 w-3 shrink-0 text-muted-foreground/50 transition-transform group-hover/tool:text-muted-foreground",
296
+ open && "rotate-90",
297
+ )}
298
+ />
299
+ <span className={cn("shrink-0 text-xs font-medium", isError && "text-destructive")}>{name}</span>
300
+ <span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground">{toolSubject(input)}</span>
301
+ {!result ? (
302
+ <span className="ml-auto shrink-0 font-mono text-[10px] font-medium text-amber-600 tabular-nums dark:text-amber-500">
303
+ {elapsed ?? "running"}
304
+ </span>
305
+ ) : (
306
+ took && (
307
+ <span className="ml-auto shrink-0 font-mono text-[10px] text-muted-foreground/70 tabular-nums">
308
+ {took}
309
+ </span>
310
+ )
311
+ )}
312
+ </button>
313
+ {onOpenStack && (
314
+ // Only on hover, and only on a pointer that has one: this is the third affordance on a
315
+ // row whose whole point is to be quiet.
316
+ <button
317
+ type="button"
318
+ onClick={() => onOpenStack(entry.call.seq)}
319
+ title="Open in the tool stack"
320
+ className="shrink-0 rounded-md p-1 text-muted-foreground/0 transition-colors max-sm:text-muted-foreground/60 group-hover/tool:text-muted-foreground/60 hover:!text-foreground hover:bg-muted/60"
321
+ >
322
+ <LayersIcon className="h-3 w-3" />
323
+ </button>
324
+ )}
325
+ </div>
326
+
327
+ {files.length > 0 && <TaskFiles files={files} attachmentUrl={attachmentUrl} className="pl-5" />}
328
+
329
+ {open && (
330
+ <div className="mt-1.5 space-y-1.5 pl-5">
331
+ <Block label="input" text={input || "(no input)"} />
332
+ {result && <Block label={isError ? "error" : "output"} text={output || "(no output)"} danger={isError} />}
333
+ <TruncationNote entry={entry} />
334
+ </div>
335
+ )}
336
+ </div>
337
+ )
338
+ }
339
+
340
+ function ThinkingLine({ event }: { event: TaskEvent }) {
341
+ const [open, setOpen] = useState(false)
342
+ const text = str(event.payload.text)
343
+ return (
344
+ <button
345
+ type="button"
346
+ onClick={() => setOpen((v) => !v)}
347
+ className="-ml-1 flex w-full items-start gap-1.5 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"
348
+ >
349
+ <BrainIcon className="mt-0.5 h-3 w-3 shrink-0" />
350
+ <span className="min-w-0">{open ? text : `${text.slice(0, 140)}${text.length > 140 ? "…" : ""}`}</span>
351
+ </button>
352
+ )
353
+ }
354
+
355
+ /** A run, unfolded in place: the same lines as before, indented under the row that summarised them. */
356
+ export function ToolRunBody({
357
+ run,
358
+ attachmentUrl,
359
+ onOpenStack,
360
+ }: {
361
+ run: ToolRun
362
+ attachmentUrl: AttachmentUrl
363
+ onOpenStack?: (seq: number) => void
364
+ }) {
365
+ return (
366
+ <div className="mt-1 space-y-0.5 border-l border-border/60 pl-3">
367
+ {run.items.map((item) =>
368
+ item.kind === "tool" ? (
369
+ <ToolLine
370
+ key={item.entry.call.seq}
371
+ entry={item.entry}
372
+ attachmentUrl={attachmentUrl}
373
+ onOpenStack={onOpenStack}
374
+ />
375
+ ) : (
376
+ <ThinkingLine key={item.event.seq} event={item.event} />
377
+ ),
378
+ )}
379
+ </div>
380
+ )
381
+ }
382
+
383
+ // ── The stack ─────────────────────────────────────────────────────────────────────────────────
384
+
385
+ /** A call in the stack, carrying the run it belonged to so the list can keep the turns apart. */
386
+ export type StackedCall = ToolCall & { runIndex: number }
387
+
388
+ function callStatus(entry: ToolCall): "running" | "error" | "done" {
389
+ if (!entry.result) return "running"
390
+ return bool(entry.result.payload.isError) ? "error" : "done"
391
+ }
392
+
393
+ const STATUS_DOT: Record<"running" | "error" | "done", string> = {
394
+ running: "bg-amber-500 animate-pulse",
395
+ error: "bg-destructive",
396
+ done: "bg-muted-foreground/40",
397
+ }
398
+
399
+ function StackRow({
400
+ entry,
401
+ selected,
402
+ onSelect,
403
+ }: {
404
+ entry: StackedCall
405
+ selected: boolean
406
+ onSelect: () => void
407
+ }) {
408
+ const name = str(entry.call.payload.name) || "tool"
409
+ const status = callStatus(entry)
410
+ const took = entry.result ? taskSpan(entry.call.ts, entry.result.ts) : null
411
+ const ref = useRef<HTMLButtonElement>(null)
412
+
413
+ // Keyboard navigation moves the selection, not the scroll — so the selection brings the scroll
414
+ // with it. `nearest` keeps the list still when the row is already visible, which is what makes
415
+ // arrowing through it feel like a list rather than a slideshow.
416
+ useEffect(() => {
417
+ if (selected) ref.current?.scrollIntoView({ block: "nearest" })
418
+ }, [selected])
419
+
420
+ return (
421
+ <button
422
+ ref={ref}
423
+ type="button"
424
+ onClick={onSelect}
425
+ className={cn(
426
+ "flex w-full items-center gap-2 border-l-2 px-3 py-1.5 text-left transition-colors",
427
+ selected ? "border-primary bg-primary/[0.07]" : "border-transparent hover:bg-muted/50",
428
+ )}
429
+ >
430
+ <span className={cn("h-1.5 w-1.5 shrink-0 rounded-full", STATUS_DOT[status])} />
431
+ <span className="shrink-0 text-muted-foreground">{VERB_ICON[toolVerb(name)]}</span>
432
+ <span className="min-w-0 flex-1">
433
+ <span className={cn("block truncate text-xs font-medium", status === "error" && "text-destructive")}>
434
+ {name}
435
+ </span>
436
+ <span className="block truncate font-mono text-[10px] text-muted-foreground">
437
+ {toolSubject(str(entry.call.payload.input))}
438
+ </span>
439
+ </span>
440
+ {took && <span className="shrink-0 font-mono text-[10px] text-muted-foreground/70 tabular-nums">{took}</span>}
441
+ </button>
442
+ )
443
+ }
444
+
445
+ function StackDetail({ entry, attachmentUrl }: { entry: StackedCall; attachmentUrl: AttachmentUrl }) {
446
+ const name = str(entry.call.payload.name) || "tool"
447
+ const input = str(entry.call.payload.input)
448
+ const result = entry.result
449
+ const isError = bool(result?.payload.isError)
450
+ const elapsed = useElapsed(result ? null : entry.call.ts)
451
+ const took = result ? taskSpan(entry.call.ts, result.ts) : null
452
+ const files = fileRefs(result?.payload.files)
453
+
454
+ return (
455
+ <div className="flex min-h-0 flex-1 flex-col">
456
+ <div className="shrink-0 border-b border-border px-4 py-2.5">
457
+ <div className="flex items-center gap-2">
458
+ <span className="text-muted-foreground">{VERB_ICON[toolVerb(name)]}</span>
459
+ <span className={cn("text-sm font-medium", isError && "text-destructive")}>{name}</span>
460
+ <span className="ml-auto shrink-0 font-mono text-[10px] text-muted-foreground tabular-nums">
461
+ {took ?? elapsed ?? "running"}
462
+ </span>
463
+ </div>
464
+ <p className="mt-0.5 break-all font-mono text-[11px] text-muted-foreground">
465
+ {toolSubject(input) || "(no arguments)"}
466
+ </p>
467
+ </div>
468
+
469
+ <div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-4">
470
+ {/* Full height, not the timeline's 16rem clamp: the whole reason to be here is that the
471
+ folded row was not enough. */}
472
+ <Block label="input" text={pretty(input) || "(no input)"} className="[&_pre]:max-h-none" />
473
+ {result ? (
474
+ <Block
475
+ label={isError ? "error" : "output"}
476
+ text={str(result.payload.output) || "(no output)"}
477
+ danger={isError}
478
+ className="[&_pre]:max-h-none"
479
+ />
480
+ ) : (
481
+ <p className="text-[11px] text-muted-foreground">
482
+ Still running — a harness reports a tool&apos;s result once, when it finishes.
483
+ </p>
484
+ )}
485
+ {files.length > 0 && <TaskFiles files={files} attachmentUrl={attachmentUrl} />}
486
+ <TruncationNote entry={entry} />
487
+ </div>
488
+ </div>
489
+ )
490
+ }
491
+
492
+ /**
493
+ * Every tool call of the session, in one surface.
494
+ *
495
+ * Owned by the stream rather than by a row, on purpose: the timeline is virtualized, so the row
496
+ * you opened this from is unmounted the moment you scroll — a sheet living inside it would close
497
+ * itself. It is also the honest place for it, since what this shows is the *session's* stack, not
498
+ * one run's: opening it from a row only decides where it starts.
499
+ *
500
+ * Two panes on a screen that has the width, one at a time on a phone. `selected` survives the
501
+ * filter narrowing under it: a search that would hide the row you are reading leaves the detail
502
+ * alone rather than jumping you somewhere else.
503
+ */
504
+ export function TaskToolStackSheet({
505
+ open,
506
+ onOpenChange,
507
+ calls,
508
+ initialSeq,
509
+ attachmentUrl,
510
+ }: {
511
+ open: boolean
512
+ onOpenChange: (open: boolean) => void
513
+ calls: StackedCall[]
514
+ /** Which call to land on. `null` opens on the last one — the newest thing that happened. */
515
+ initialSeq: number | null
516
+ attachmentUrl: AttachmentUrl
517
+ }) {
518
+ const [query, setQuery] = useState("")
519
+ const [errorsOnly, setErrorsOnly] = useState(false)
520
+ const [selectedSeq, setSelectedSeq] = useState<number | null>(null)
521
+ const [detailOnPhone, setDetailOnPhone] = useState(false)
522
+
523
+ // Re-aimed on every open, so reopening from a different row lands where the reader clicked
524
+ // rather than where they last were.
525
+ useEffect(() => {
526
+ if (!open) return
527
+ setSelectedSeq(initialSeq ?? (calls.length > 0 ? calls[calls.length - 1].call.seq : null))
528
+ setDetailOnPhone(initialSeq !== null)
529
+ setQuery("")
530
+ setErrorsOnly(false)
531
+ // `calls` grows while a live session runs; re-aiming on that would fight the reader.
532
+ // eslint-disable-next-line react-hooks/exhaustive-deps
533
+ }, [open, initialSeq])
534
+
535
+ const failed = useMemo(() => calls.filter((c) => callStatus(c) === "error").length, [calls])
536
+
537
+ const shown = useMemo(() => {
538
+ const needle = query.trim().toLowerCase()
539
+ return calls.filter((entry) => {
540
+ if (errorsOnly && callStatus(entry) !== "error") return false
541
+ if (!needle) return true
542
+ const name = str(entry.call.payload.name).toLowerCase()
543
+ const subject = toolSubject(str(entry.call.payload.input)).toLowerCase()
544
+ return name.includes(needle) || subject.includes(needle)
545
+ })
546
+ }, [calls, query, errorsOnly])
547
+
548
+ const selected = calls.find((entry) => entry.call.seq === selectedSeq) ?? null
549
+
550
+ const move = (delta: number) => {
551
+ if (shown.length === 0) return
552
+ const at = shown.findIndex((entry) => entry.call.seq === selectedSeq)
553
+ // A selection that the filter hid: the arrow keys bring you back into what is on screen.
554
+ const next = at === -1 ? (delta > 0 ? 0 : shown.length - 1) : Math.min(shown.length - 1, Math.max(0, at + delta))
555
+ setSelectedSeq(shown[next].call.seq)
556
+ }
557
+
558
+ return (
559
+ <Sheet open={open} onOpenChange={onOpenChange}>
560
+ <SheetContent
561
+ side="right"
562
+ size="xl"
563
+ // Above `z-[120]`, because on a phone the conversation this opens from is itself a layer
564
+ // at that height (`TaskCockpitMobile`): at the sheet's default `z-50` the stack opened
565
+ // *behind* the timeline. Harmless on desktop, where nothing else is up there.
566
+ className="z-[130] gap-0 p-0"
567
+ backdropClassName="z-[130]"
568
+ onKeyDown={(event) => {
569
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
570
+ event.preventDefault()
571
+ move(event.key === "ArrowDown" ? 1 : -1)
572
+ }}
573
+ >
574
+ <SheetHeader className="shrink-0 border-b border-border py-3 pr-12">
575
+ <SheetTitle className="flex items-center gap-2 text-sm font-medium">
576
+ <LayersIcon className="h-4 w-4 text-muted-foreground" />
577
+ Tool stack
578
+ </SheetTitle>
579
+ <p className="text-[11px] text-muted-foreground">
580
+ {plural(calls.length, "call")} in this session
581
+ {failed > 0 && <span className="text-destructive"> · {failed} failed</span>}
582
+ {/* Not on a phone, which has no arrow keys to offer. */}
583
+ <span className="hidden sm:inline">
584
+ {" · "}
585
+ <Kbd>↑</Kbd> <Kbd>↓</Kbd> to step through
586
+ </span>
587
+ </p>
588
+ </SheetHeader>
589
+
590
+ <div className="flex min-h-0 flex-1">
591
+ <div
592
+ className={cn(
593
+ "min-h-0 w-full shrink-0 flex-col border-border sm:flex sm:w-72 sm:border-r lg:w-80",
594
+ detailOnPhone ? "hidden" : "flex",
595
+ )}
596
+ >
597
+ <div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2">
598
+ <SearchIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
599
+ <input
600
+ value={query}
601
+ onChange={(event) => setQuery(event.target.value)}
602
+ placeholder="Filter by tool or subject"
603
+ className="min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
604
+ />
605
+ <button
606
+ type="button"
607
+ onClick={() => {
608
+ const next = !errorsOnly
609
+ setErrorsOnly(next)
610
+ // Asking for the failures *is* asking to read one, so it lands on the first —
611
+ // unlike typing in the filter, which narrows the list under a call the reader
612
+ // is in the middle of reading and must leave it alone.
613
+ if (next) {
614
+ const firstError = calls.find((entry) => callStatus(entry) === "error")
615
+ if (firstError) setSelectedSeq(firstError.call.seq)
616
+ }
617
+ }}
618
+ title="Only the calls that failed"
619
+ className={cn(
620
+ "shrink-0 rounded-md p-1 transition-colors",
621
+ errorsOnly ? "bg-destructive/10 text-destructive" : "text-muted-foreground hover:bg-muted",
622
+ )}
623
+ >
624
+ <CircleSlashIcon className="h-3.5 w-3.5" />
625
+ </button>
626
+ </div>
627
+
628
+ <div className="min-h-0 flex-1 overflow-y-auto py-1">
629
+ {shown.length === 0 ? (
630
+ <p className="px-3 py-4 text-[11px] text-muted-foreground">Nothing matches.</p>
631
+ ) : (
632
+ shown.map((entry, index) => (
633
+ <div key={entry.call.seq}>
634
+ {/* The turn boundary, kept: a list of 120 calls with no seams reads as one
635
+ undifferentiated blob, and the seam is exactly where the agent stopped to
636
+ say something. */}
637
+ {index > 0 && shown[index - 1].runIndex !== entry.runIndex && (
638
+ <div className="my-1 border-t border-dashed border-border/70" />
639
+ )}
640
+ <StackRow
641
+ entry={entry}
642
+ selected={entry.call.seq === selectedSeq}
643
+ onSelect={() => {
644
+ setSelectedSeq(entry.call.seq)
645
+ setDetailOnPhone(true)
646
+ }}
647
+ />
648
+ </div>
649
+ ))
650
+ )}
651
+ </div>
652
+ </div>
653
+
654
+ <div className={cn("min-h-0 min-w-0 flex-1 flex-col sm:flex", detailOnPhone ? "flex" : "hidden")}>
655
+ {selected && (
656
+ <button
657
+ type="button"
658
+ onClick={() => setDetailOnPhone(false)}
659
+ className="flex shrink-0 items-center gap-1.5 border-b border-border px-3 py-2 text-[11px] text-muted-foreground sm:hidden"
660
+ >
661
+ <ArrowLeftIcon className="h-3 w-3" /> All calls
662
+ </button>
663
+ )}
664
+ {selected ? (
665
+ <StackDetail entry={selected} attachmentUrl={attachmentUrl} />
666
+ ) : (
667
+ <div className="flex flex-1 items-center justify-center p-6">
668
+ <p className="max-w-xs text-center text-xs leading-relaxed text-muted-foreground">
669
+ This session has not called a tool yet — everything it does will land here.
670
+ </p>
671
+ </div>
672
+ )}
673
+ </div>
674
+ </div>
675
+ </SheetContent>
676
+ </Sheet>
677
+ )
678
+ }
679
+
680
+ /** The way into the stack from the timeline's own chrome — the whole session, from anywhere in it. */
681
+ export function TaskToolStackButton({ count, onClick }: { count: number; onClick: () => void }) {
682
+ return (
683
+ <button
684
+ type="button"
685
+ onClick={onClick}
686
+ className="flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
687
+ >
688
+ <ListTreeIcon className="h-3 w-3" />
689
+ {plural(count, "call")}
690
+ </button>
691
+ )
692
+ }