@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.
- package/README.md +35 -1
- package/package.json +6 -1
- package/src/components/sheet.tsx +7 -1
- package/src/components/tasks/agent-markdown.tsx +322 -0
- package/src/components/tasks/attachments.ts +152 -0
- package/src/components/tasks/command-log.tsx +102 -0
- package/src/components/tasks/composer.tsx +252 -0
- package/src/components/tasks/conversation-list.tsx +238 -0
- package/src/components/tasks/diff-panel.tsx +534 -0
- package/src/components/tasks/event-stream.tsx +860 -0
- package/src/components/tasks/index.ts +111 -0
- package/src/components/tasks/machine.tsx +455 -0
- package/src/components/tasks/new-task-dialog.tsx +301 -0
- package/src/components/tasks/pull-request.tsx +179 -0
- package/src/components/tasks/session-usage.tsx +165 -0
- package/src/components/tasks/task-attachments.tsx +475 -0
- package/src/components/tasks/task-cockpit-mobile.tsx +272 -0
- package/src/components/tasks/task-cockpit.tsx +225 -0
- package/src/components/tasks/task-details.tsx +239 -0
- package/src/components/tasks/task-list.tsx +272 -0
- package/src/components/tasks/task-state.tsx +180 -0
- package/src/components/tasks/tasks.test.tsx +205 -0
- package/src/components/tasks/tool-stack.tsx +692 -0
- package/src/components/tasks/types.ts +220 -0
- package/src/components/tasks/worker-link.tsx +126 -0
|
@@ -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'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
|
+
}
|