@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,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'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'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
|
+
}
|