@workerdeck/ui 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +60 -0
  2. package/build/{SessionPanel-J2U8v88q.d.mts → SessionPanel-CnNYEX80.d.mts} +170 -21
  3. package/build/{SessionPanel-DI1NO4l8.mjs → SessionPanel-DMPhsNlW.mjs} +4759 -1483
  4. package/build/SessionPanel-DMPhsNlW.mjs.map +1 -0
  5. package/build/{format-ljc3lKpA.d.mts → format-DfI_je9S.d.mts} +1 -1
  6. package/build/format.d.mts +39 -4
  7. package/build/format.mjs +2 -118
  8. package/build/index.d.mts +493 -45
  9. package/build/index.mjs +343 -17
  10. package/build/index.mjs.map +1 -1
  11. package/build/status-Ydzi7n6j.mjs +143 -0
  12. package/build/status-Ydzi7n6j.mjs.map +1 -0
  13. package/build/workspace.d.mts +9 -1
  14. package/build/workspace.mjs +108 -5
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +16 -7
  17. package/src/components/agent/Composer.tsx +189 -89
  18. package/src/components/agent/Conversation.tsx +12 -12
  19. package/src/components/agent/FileCard.tsx +0 -26
  20. package/src/components/agent/FileTree.tsx +9 -8
  21. package/src/components/agent/Loader.tsx +22 -72
  22. package/src/components/agent/Message.tsx +11 -46
  23. package/src/components/agent/PermissionPrompt.tsx +0 -92
  24. package/src/components/agent/ProjectIcon.tsx +119 -0
  25. package/src/components/agent/QuestionPrompt.tsx +0 -122
  26. package/src/components/agent/Reasoning.tsx +5 -19
  27. package/src/components/agent/Response.tsx +1 -132
  28. package/src/components/agent/SessionBrowser.tsx +84 -3
  29. package/src/components/agent/SessionPanel.tsx +249 -28
  30. package/src/components/agent/SessionWorkspace.tsx +29 -0
  31. package/src/components/agent/StatusBar.tsx +20 -4
  32. package/src/components/agent/ToolCallCard.tsx +85 -112
  33. package/src/components/agent/Transcript.tsx +780 -203
  34. package/src/components/agent/UsageDialog.tsx +20 -106
  35. package/src/components/agent/UsageMeters.tsx +133 -0
  36. package/src/components/agent/pulse.tsx +3 -2
  37. package/src/components/agent/tool-result-fetch.tsx +36 -0
  38. package/src/components/agent/tool-result-image.tsx +209 -0
  39. package/src/components/agent/transcript-rows.ts +173 -0
  40. package/src/components/agent/transcript-variant.tsx +29 -51
  41. package/src/components/agent/use-height-epoch.ts +60 -0
  42. package/src/components/agent/use-path-links.ts +147 -0
  43. package/src/components/agent/use-transcript-jumps.ts +190 -0
  44. package/src/components/prompt-area/cursor-helpers.ts +65 -0
  45. package/src/components/prompt-area/use-prompt-area.ts +16 -10
  46. package/src/components/terminal/PermissionPrompt.tsx +119 -0
  47. package/src/components/terminal/QuestionPrompt.tsx +322 -0
  48. package/src/components/terminal/StatusLine.tsx +159 -0
  49. package/src/components/terminal/TerminalTranscript.tsx +225 -0
  50. package/src/components/terminal/affordances.tsx +118 -0
  51. package/src/components/terminal/blocks.ts +232 -0
  52. package/src/components/terminal/diff.tsx +130 -0
  53. package/src/components/terminal/height.ts +770 -0
  54. package/src/components/terminal/image-box.ts +53 -0
  55. package/src/components/terminal/items.tsx +486 -0
  56. package/src/components/terminal/markdown.tsx +191 -0
  57. package/src/components/terminal/press.tsx +120 -0
  58. package/src/components/terminal/prompt.tsx +343 -0
  59. package/src/components/terminal/result-preview.ts +86 -0
  60. package/src/components/terminal/row.tsx +132 -0
  61. package/src/components/terminal/scrubber.tsx +784 -0
  62. package/src/components/terminal/surface.tsx +80 -0
  63. package/src/components/terminal/tool-run.ts +224 -0
  64. package/src/index.ts +36 -1
  65. package/src/lib/status.ts +59 -3
  66. package/src/lib/tool-icon.ts +14 -0
  67. package/src/styles/terminal.css +1081 -0
  68. package/src/styles/theme.css +41 -0
  69. package/build/SessionPanel-DI1NO4l8.mjs.map +0 -1
  70. package/build/format.mjs.map +0 -1
  71. package/src/components/agent/line-prompt.tsx +0 -249
@@ -0,0 +1,784 @@
1
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
+ import { useStickToBottomContext } from 'use-stick-to-bottom'
3
+ import type { PermissionRequest } from '@workerdeck/protocol'
4
+ import type { TranscriptItem } from '@workerdeck/react'
5
+ import { formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
6
+ import { parentOf } from './blocks.ts'
7
+ import { TerminalSurface } from './surface.tsx'
8
+
9
+ /**
10
+ * The overview ruler — VS Code's strip beside the minimap, with this
11
+ * transcript's own semantics.
12
+ *
13
+ * A 12px rail over the scroller's right edge, two lanes of 6px: **left** is
14
+ * what you typed (blue), **right** is each turn's final response and its
15
+ * `turn_result` as *one* merged mark (white; red when the turn failed — the
16
+ * turn boundary is the response's address, so two ticks would answer no
17
+ * question the peek doesn't). Those two are the conversation, and they are what
18
+ * you navigate by.
19
+ *
20
+ * Everything else spans the **full 12px** and is an annotation rather than a
21
+ * step: an error, the pending approval (pinned at the foot, pulsing), a
22
+ * bookmark (magenta — paint only; the store is the client's, the way watermarks
23
+ * are) and the catch-up seam (dashed). A mark is its row's extent at rail
24
+ * scale, floored at 2px, drawn as a solid 2px head with a 25% tail; marks
25
+ * merge when closer than a pixel, loudest colour winning.
26
+ *
27
+ * Positions are **pixel space**, not index space: mark y = the row's
28
+ * virtualizer offset over `getTotalSize()`. That is only honest because the
29
+ * height calculator (`height.ts`) feeds `estimateSize`, so an unmounted row's
30
+ * offset is computed rather than guessed — the rail is the calculator's
31
+ * payoff, and it is why the rail can be a *real scrollbar*: drag scrubs
32
+ * `scrollTop` directly, and the native scrollbar is hidden while an
33
+ * interactive rail is mounted.
34
+ *
35
+ * The peek renders from `items`, never the DOM — the row it describes is
36
+ * usually unmounted, so there is nothing to clone. A click is a jump through
37
+ * the transcript's re-aim closure (`onJumpToRow`), which starts with
38
+ * `stopScroll()`: the rail is a third writer of `scrollTop` beside the follow
39
+ * spring and the virtualizer's corrections, and that call is the library's
40
+ * own "the user is leaving the bottom" switch.
41
+ *
42
+ * Under `affordances={false}` the rail is passive paint: `pointer-events:
43
+ * none`, no peek, no drag, no click — and the native scrollbar stays, working
44
+ * straight through the paint.
45
+ */
46
+
47
+ type Lane = 'l' | 'r' | 'f'
48
+ type MarkKind =
49
+ | 'user'
50
+ | 'subagent'
51
+ | 'turn'
52
+ | 'turnFailed'
53
+ | 'toolFailed'
54
+ | 'error'
55
+ | 'approval'
56
+ | 'recap'
57
+ | 'bookmark'
58
+
59
+ type Mark = {
60
+ kind: MarkKind
61
+ /** The jump anchor (for a turn mark: the paired response). −1 for recap. */
62
+ itemIndex: number
63
+ rowIndex: number
64
+ /** The `turn_result` behind a center mark — the peek shows its done-line. */
65
+ turnIndex?: number
66
+ }
67
+
68
+ /** Members keep their own y: a dense transcript chain-merges a lane into one
69
+ * tall bar (600 prompts over a 300px rail IS a solid stripe, exactly as VS
70
+ * Code draws dense decorations), and the bar answers the pointer by its
71
+ * *nearest member* — a click or peek at the middle of the bar must not act on
72
+ * the first mark that happened to found the cluster. */
73
+ type Cluster = { lane: Lane; kind: MarkKind; y: number; h: number; marks: { mark: Mark; y: number }[] }
74
+
75
+ /** The member closest to a rail-space y — what a press or peek on a merged
76
+ * cluster resolves to. */
77
+ function nearestMember(cluster: Cluster, y: number): Mark | undefined {
78
+ let best: { mark: Mark; y: number } | undefined
79
+ for (const member of cluster.marks)
80
+ if (!best || Math.abs(member.y - y) < Math.abs(best.y - y)) best = member
81
+ return best?.mark
82
+ }
83
+
84
+ /**
85
+ * The two lanes are **channels, not classes**: left is what went *in* — your
86
+ * prompts, and the sub-agents you dispatched — and right is what came *out* —
87
+ * each turn's answer, and everything that went wrong producing one. That is the
88
+ * question a reader actually asks of a rail ("where did I say something", "where
89
+ * did it go wrong"), and it puts every failure in one column instead of
90
+ * scattering some down the middle.
91
+ *
92
+ * Full width is reserved for what is not a channel at all: a waiting approval
93
+ * (which is the session asking *you*, pinned at the foot), a bookmark (the
94
+ * reader's own annotation) and the catch-up seam (a boundary across both).
95
+ *
96
+ * It also buys the marks their width back: three lanes in a 16px rail is 5px a
97
+ * lane, which is a hard target to hit and a hard colour to see.
98
+ */
99
+ const LANE: Record<MarkKind, Lane> = {
100
+ user: 'l',
101
+ // Delegated work is input: a sub-agent runs because you asked for it, and its
102
+ // stretch of the transcript is *your* dispatch rather than the session's
103
+ // answer. It also gives a folded `Task` its one honest signal on the rail —
104
+ // collapsed, sixty rows of somebody else's working are one line, and this is
105
+ // the mark that says the region is there at all.
106
+ subagent: 'l',
107
+ turn: 'r',
108
+ turnFailed: 'r',
109
+ // Output, with the answers: a failed tool call is something the run produced.
110
+ // It had been full-width on the argument that it is an alarm rather than a
111
+ // step — but "alarm" is not a lane, and half the failures ending up down the
112
+ // middle while `turnFailed` sat in the right lane meant no single column
113
+ // answered "did anything go wrong". Its rank in LOUDNESS and its 55% strength
114
+ // are what keep it from shouting over the turns it now sits beside.
115
+ toolFailed: 'r',
116
+ error: 'r',
117
+ approval: 'f',
118
+ recap: 'f',
119
+ bookmark: 'f',
120
+ }
121
+
122
+ /** Who wins the colour when marks merge. */
123
+ const LOUDNESS: Record<MarkKind, number> = {
124
+ approval: 7,
125
+ error: 6,
126
+ turnFailed: 5,
127
+ // Under `error`, which is the rank that actually does work: both are lane `f`,
128
+ // so a session error and a tool failure a pixel apart merge and the error must
129
+ // keep the cluster. (It cannot merge with `turnFailed` — that is lane `r`, and
130
+ // merging is per lane.) A failed tool call the model recovered from is routine
131
+ // in a way a session error is not, hence quieter here and at 55% in the CSS.
132
+ // It now shares the response lane with the turn marks, which is the rank that
133
+ // matters: a failure a pixel from a turn end keeps the cluster red.
134
+ toolFailed: 4,
135
+ user: 3,
136
+ turn: 2,
137
+ bookmark: 1,
138
+ // Lane `l`, so this is only ever weighed against `user`, and a prompt wins:
139
+ // the prompt is the step you navigate by and the sub-agent band is the
140
+ // annotation on it. (It ties with `bookmark`, which it can never meet.)
141
+ subagent: 1,
142
+ recap: 0,
143
+ }
144
+
145
+ const KIND_NAME: Record<MarkKind, string> = {
146
+ user: 'you',
147
+ subagent: 'sub-agent',
148
+ turn: 'response · turn end',
149
+ turnFailed: 'turn failed',
150
+ toolFailed: 'tool failed',
151
+ error: 'error',
152
+ approval: 'pending approval',
153
+ recap: 'catch-up boundary',
154
+ bookmark: 'bookmark',
155
+ }
156
+
157
+ // The floor, not the height: a mark spans its row's actual extent at rail
158
+ // scale, so a one-line prompt is a tick and a hundred-line response is a bar —
159
+ // the rail is a map, and on a map a long answer looks long. 2px keeps a tick
160
+ // findable while the CSS draws only the first 2px solid (the rest is a 25%
161
+ // tail); the pointer's real target is the 6px-wide lane, and a press resolves
162
+ // through `nearestMember`, so hit reliability does not ride on mark height.
163
+ const MIN_MARK = 2
164
+
165
+ /**
166
+ * Rail pixels per content pixel — the one scale both the marks and the viewport
167
+ * band are drawn at, so they cannot disagree about where a row sits.
168
+ *
169
+ * The denominator is `max(totalSize, viewportH)` and never `totalSize` alone.
170
+ * A transcript **shorter than its viewport** is the case that forces it: with
171
+ * 90px of content in a 906px window, `railH / totalSize` is ~10, and the band
172
+ * — `viewportH * scale` — comes out at 9120px inside a 906px rail. The rail is
173
+ * absolutely positioned *within the scroller*, so that overflow becomes real
174
+ * scrollable height: a short session grew ~8000px of empty space below it, and
175
+ * the reader could scroll away from the only three rows there were.
176
+ *
177
+ * Clamping the denominator says the thing that is actually true: when
178
+ * everything fits, the rail represents the **viewport**, not the content. The
179
+ * band then fills it exactly (`viewportH * railH / viewportH === railH`), which
180
+ * is what "you are looking at all of it" should look like. It also makes the
181
+ * overflow structurally impossible rather than merely unlikely — `bandH` can
182
+ * never exceed `railH` again, for any content, because `viewportH` can never
183
+ * exceed the denominator.
184
+ */
185
+ export function railScale(railH: number, totalSize: number, viewportH: number): number {
186
+ return totalSize > 0 ? railH / Math.max(totalSize, viewportH) : 0
187
+ }
188
+
189
+ /**
190
+ * The right lane is anchored on **the answer, not the turn end**.
191
+ *
192
+ * It used to be built from `turn_result` items alone, which made it silently
193
+ * history-blind: `#backfillHistory` maps only `user` and `assistant` entries, so
194
+ * a session replayed after a gateway restart — or any resumed session — carried
195
+ * no turn rows at all and the whole white lane came back empty. The blue lane
196
+ * survived, which is what made it look like a rendering bug rather than a
197
+ * missing input.
198
+ *
199
+ * So a turn's mark is emitted for the last top-level assistant message of each
200
+ * segment, and a `turn_result` (when there is one) *decorates* it rather than
201
+ * conjuring it — contributing the failed colour and the `turnIndex` its peek
202
+ * shows the done-line from. Live behaviour is unchanged by construction: the
203
+ * item this lands on is exactly the one `pairedResponse` used to find, because
204
+ * a settled `assistant_text` always precedes the `turn_result` that ends it.
205
+ */
206
+ type Segment = { response?: number; turn?: number; failed?: boolean }
207
+
208
+ const doneLine = (turn: Extract<TranscriptItem, { kind: 'turn_result' }>): string =>
209
+ `${turn.isError ? turn.subtype : 'done'} · ${formatDuration(turn.durationMs)} · ${formatCost(turn.totalCostUsd)}`
210
+
211
+ function excerpt(item: TranscriptItem): string {
212
+ switch (item.kind) {
213
+ case 'user':
214
+ case 'assistant_text':
215
+ case 'thinking':
216
+ case 'notice':
217
+ return item.text
218
+ case 'tool_call':
219
+ return `${item.name}(${toolInputPreview(item.input)})`
220
+ case 'turn_result':
221
+ return doneLine(item)
222
+ case 'file_delivered':
223
+ return item.path
224
+ default:
225
+ return ''
226
+ }
227
+ }
228
+
229
+ export interface TerminalScrubberProps {
230
+ items: readonly TranscriptItem[]
231
+ pendingApprovals: readonly PermissionRequest[]
232
+ /** The catch-up boundary's virtual row, when the recap is spliced in. */
233
+ recapRow?: { rowIndex: number; label: string }
234
+ /** Bookmarked item indices. Paint only — no store, no set affordance. */
235
+ bookmarks: readonly number[]
236
+ /** Item index → virtual row index (the off-by-a-fold mapping; see
237
+ * `rowIndexForItem` in `agent/Transcript.tsx`). */
238
+ rowIndexFor: (itemIndex: number) => number
239
+ /** A virtual row's offset in content space — the virtualizer's measurements,
240
+ * which the height calculator keeps honest for unmounted rows. */
241
+ offsetOfRow: (rowIndex: number) => number
242
+ /** A virtual row's height in content space, same source — what a mark's own
243
+ * height is scaled from. */
244
+ sizeOfRow: (rowIndex: number) => number
245
+ /** Where an item sits inside a row shared with other items — a task block's
246
+ * absorbed child or a folded run's member (`positionInRow` in
247
+ * `agent/transcript-rows.ts`). Optional and additive: without it every mark
248
+ * spans its row's extent, which for an expanded task block is the whole
249
+ * subagent area. */
250
+ positionInRow?: (itemIndex: number) => { ordinal: number; count: number } | undefined
251
+ totalSize: number
252
+ scrollOffset: number
253
+ viewportH: number
254
+ /** The transcript's re-aim jump (stopScroll + aim + exact finish). */
255
+ onJumpToRow: (rowIndex: number) => void
256
+ /** False renders passive paint: no pointer events at all. */
257
+ interactive: boolean
258
+ fontSize?: number
259
+ lineHeight?: number
260
+ }
261
+
262
+ /**
263
+ * Exported for `test/scrubber.test.ts` and nothing else — it is not part of the
264
+ * package's surface (`index.ts` does not re-export it). Both of the bugs this
265
+ * function has shipped were pure-logic ones a unit test catches: a live answer
266
+ * with no `turn_result` yet went unmarked for the whole two minutes it was the
267
+ * only thing worth navigating to, and a replayed history — which carries no turn
268
+ * rows at all — came back with an empty right lane.
269
+ */
270
+ export function buildClusters(
271
+ props: TerminalScrubberProps,
272
+ railH: number,
273
+ ): Cluster[] {
274
+ const {
275
+ items,
276
+ bookmarks,
277
+ recapRow,
278
+ pendingApprovals,
279
+ rowIndexFor,
280
+ offsetOfRow,
281
+ sizeOfRow,
282
+ positionInRow,
283
+ totalSize,
284
+ viewportH,
285
+ } = props
286
+ const marks: Mark[] = []
287
+ // Which top-level calls a sub-agent ran inside — by `parentToolUseId` and
288
+ // never by the spawning call's *name*: the SDK's own convention is `Task`,
289
+ // but it is a convention (a background agent arrives as `Agent`), and an id
290
+ // that other items demonstrably nest under IS a sub-agent whatever spawned
291
+ // it. The same membership rule `terminalBlocks` folds by, for the same reason.
292
+ const subagentParents = new Set<string>()
293
+ for (const item of items) {
294
+ const parent = parentOf(item)
295
+ if (parent !== undefined) subagentParents.add(parent)
296
+ }
297
+ // The **outcome** call of each row: the last top-level tool call the row
298
+ // holds. A failed call is marked only when it is one of these — see the
299
+ // `toolFailed` branch below for why, and note this needs no block lookup,
300
+ // only `rowIndexFor`.
301
+ const rowOutcome = new Map<number, number>()
302
+ items.forEach((item, index) => {
303
+ if (item.kind !== 'tool_call' || parentOf(item) !== undefined) return
304
+ rowOutcome.set(rowIndexFor(index), index)
305
+ })
306
+ // One right-lane mark per segment, emitted when the segment closes. A segment
307
+ // is closed by the next prompt, by its own turn end, or by running out of
308
+ // items — that last one is what a replayed history is made of.
309
+ let segment: Segment = {}
310
+ const closeSegment = () => {
311
+ const anchor = segment.response ?? segment.turn
312
+ if (anchor !== undefined) {
313
+ marks.push({
314
+ kind: segment.failed ? 'turnFailed' : 'turn',
315
+ itemIndex: anchor,
316
+ rowIndex: rowIndexFor(anchor),
317
+ turnIndex: segment.turn,
318
+ })
319
+ }
320
+ segment = {}
321
+ }
322
+ items.forEach((item, index) => {
323
+ // The dispatch itself, marked at its row — which is the folded `Task`
324
+ // block, so the band grows to the whole sub-agent area when it is opened
325
+ // and shrinks back to a tick when it is closed. Deliberately NOT part of
326
+ // the chain below: a `Task` whose own result errored earns a red tick in
327
+ // the response lane *and* this band in the input lane, which is the whole
328
+ // point of the two channels — one says a sub-agent ran here, the other says
329
+ // it came back broken. A failed child inside it still marks separately, at
330
+ // its own fraction of the row.
331
+ if (item.kind === 'tool_call' && subagentParents.has(item.id)) {
332
+ marks.push({ kind: 'subagent', itemIndex: index, rowIndex: rowIndexFor(index) })
333
+ }
334
+ // Top-level prompts only, like the answer check below: a subagent's brief
335
+ // is a `user` item too, and it would both paint a "you" mark for something
336
+ // nobody typed and close the segment mid-turn — which mis-anchors the turn
337
+ // mark whenever a task runs between the prompt and the answer.
338
+ if (item.kind === 'user' && parentOf(item) === undefined) {
339
+ closeSegment()
340
+ marks.push({ kind: 'user', itemIndex: index, rowIndex: rowIndexFor(index) })
341
+ } else if (item.kind === 'turn_result') {
342
+ segment.turn = index
343
+ segment.failed = item.isError
344
+ closeSegment()
345
+ } else if (item.kind === 'notice' && item.level === 'error') {
346
+ marks.push({ kind: 'error', itemIndex: index, rowIndex: rowIndexFor(index) })
347
+ } else if (
348
+ // **The rail marks what the transcript reddens** — the whole rule, and
349
+ // why this is not simply the per-call predicate it used to be.
350
+ //
351
+ // The row model already decided, twice, that a routine failure the model
352
+ // recovered from is not a failure: `runFailed` colours a folded run by
353
+ // its LAST call, and `taskFailed` colours a `Task` by its OWN result and
354
+ // never a child's. Both were changed from `contains` for the same reason
355
+ // — a normal working session came back painted red, spending the colour
356
+ // that should have been left for the one broken thing on a grep that
357
+ // matched nothing. The rail was deliberately exempted, on the argument
358
+ // that its question ("is there anything worth navigating to") differs
359
+ // from the row's ("how did this end").
360
+ //
361
+ // Measured against a real session, the exemption did not survive: 178
362
+ // tool calls, 9 failed, EIGHT OF THE NINE recovered from inside their own
363
+ // run, no failed turn and no session error — nine alarms on the rail for
364
+ // a transcript that reddens one row. A red mark beside nothing red is
365
+ // worse than no mark: it sends a reader hunting for damage that is not
366
+ // there.
367
+ //
368
+ // One uniform test covers all three cases: a call is its row's OUTCOME
369
+ // when it is top level and no later top-level call shares its row. For a
370
+ // folded run that is exactly `runFailed`'s last member; for a lone call
371
+ // it is the call; and for a `Task` it is the task itself, because its
372
+ // children are not top level — which is `taskFailed`, spelled a third way
373
+ // and agreeing. A failed child inside a sub-agent is therefore no longer
374
+ // marked, the same call `taskFailed` makes. The sub-agent band still says
375
+ // it ran and its own red tick still says it came back broken, every
376
+ // failure is still red on its own row, and the recap still counts them
377
+ // all.
378
+ //
379
+ // The disjunction is unchanged and both spellings are still needed: an
380
+ // out-of-loop execution failure sets `status` with no `is_error` block to
381
+ // read, and an engine can flag `is_error` on a call this reducer has not
382
+ // settled yet.
383
+ item.kind === 'tool_call' &&
384
+ (item.status === 'failed' || item.result?.isError === true) &&
385
+ rowOutcome.get(rowIndexFor(index)) === index
386
+ ) {
387
+ marks.push({ kind: 'toolFailed', itemIndex: index, rowIndex: rowIndexFor(index) })
388
+ } else if (item.kind === 'assistant_text' && item.parentToolUseId == null) {
389
+ // The live one included, deliberately: a turn in flight has no turn end
390
+ // yet, which left a two-minute answer unrepresented on the rail for the
391
+ // whole two minutes it was the only thing worth navigating to. The mark's
392
+ // height is its row's, so it grows as the answer does with no extra
393
+ // bookkeeping, and it cannot double up — the reducer settles this item in
394
+ // the same action that appends the `turn_result` that closes the segment.
395
+ segment.response = index
396
+ }
397
+ })
398
+ // A history that ends mid-segment still has an answer in it.
399
+ closeSegment()
400
+ for (const index of bookmarks)
401
+ if (index >= 0 && index < items.length)
402
+ marks.push({ kind: 'bookmark', itemIndex: index, rowIndex: rowIndexFor(index) })
403
+ if (recapRow) marks.push({ kind: 'recap', itemIndex: -1, rowIndex: recapRow.rowIndex })
404
+
405
+ const scale = railScale(railH, totalSize, viewportH)
406
+ const lanes = new Map<Lane, { mark: Mark; y: number; h: number }[]>()
407
+ for (const mark of marks) {
408
+ // A mark's height is its row's, at rail scale, floored at the hit target —
409
+ // the row the mark *anchors* (for a turn, the final response), which is
410
+ // where the reader lands and what they came to gauge the size of.
411
+ //
412
+ // EXCEPT an item that SHARES its row (a task block's absorbed child, a
413
+ // folded run's member): there the row's extent is mostly other items' work,
414
+ // and expanded it is the entire subagent area — one failed child of a
415
+ // hundred-call task used to paint a solid red band down the whole rail.
416
+ // Such a mark is a tick at its fractional position within the row.
417
+ // `sizeOfRow` is the virtualizer's *measurement*, so expansion is reflected
418
+ // with no expansion state here (which the scrubber deliberately cannot see,
419
+ // `height.ts`'s "unmounted is collapsed" invariant being load-bearing):
420
+ // collapsed, the fraction rounds onto the row's one line and siblings merge
421
+ // exactly as before; expanded, the ticks distribute down the block —
422
+ // approximately, since children differ in height, which a 12px rail cannot
423
+ // show and exactness would cost the scrubber the one thing it must not know.
424
+ // Applied here rather than per kind because a bookmark on an absorbed child
425
+ // has the identical bug; `recap` is `itemIndex: -1`, hence the guard.
426
+ const within = mark.itemIndex >= 0 ? positionInRow?.(mark.itemIndex) : undefined
427
+ const rowH = sizeOfRow(mark.rowIndex)
428
+ const h = within ? MIN_MARK : Math.max(MIN_MARK, Math.round(rowH * scale))
429
+ const y = Math.min(
430
+ Math.max(0, railH - h),
431
+ Math.round(
432
+ (offsetOfRow(mark.rowIndex) + (within ? (within.ordinal / within.count) * rowH : 0)) * scale,
433
+ ),
434
+ )
435
+ const lane = LANE[mark.kind]
436
+ const list = lanes.get(lane) ?? []
437
+ list.push({ mark, y, h })
438
+ lanes.set(lane, list)
439
+ }
440
+ const clusters: Cluster[] = []
441
+ for (const [lane, list] of lanes) {
442
+ list.sort((a, b) => a.y - b.y)
443
+ let current: Cluster | null = null
444
+ for (const { mark, y, h } of list) {
445
+ // Merge when the gap is under a pixel; the merged mark grows and takes
446
+ // the loudest member's colour.
447
+ if (current && y <= current.y + current.h + 1) {
448
+ current.h = Math.max(current.h, y + h - current.y)
449
+ if (LOUDNESS[mark.kind] > LOUDNESS[current.kind]) current.kind = mark.kind
450
+ current.marks.push({ mark, y })
451
+ } else {
452
+ current = { lane, kind: mark.kind, y, h, marks: [{ mark, y }] }
453
+ clusters.push(current)
454
+ }
455
+ }
456
+ }
457
+ // The approval is not an item — the prompt renders below the transcript —
458
+ // so its mark pins at the rail's foot, where the prompt is.
459
+ if (pendingApprovals.length > 0)
460
+ clusters.push({
461
+ // `LANE.approval`, not a literal: this cluster is built by hand because it
462
+ // has no item to derive a position from, and a hardcoded lane here is how
463
+ // it silently kept the old three-lane layout after the map moved on.
464
+ lane: LANE.approval,
465
+ kind: 'approval',
466
+ y: Math.max(0, railH - MIN_MARK),
467
+ h: MIN_MARK,
468
+ marks: [],
469
+ })
470
+ return clusters
471
+ }
472
+
473
+ function peekContent(
474
+ cluster: Cluster,
475
+ /** The member the pointer resolved to — see {@link nearestMember}. */
476
+ first: Mark | undefined,
477
+ { items, pendingApprovals, recapRow }: TerminalScrubberProps,
478
+ ): ReactNode {
479
+ const more = cluster.marks.length > 1 ? ` · ${cluster.marks.length} marks` : ''
480
+ let body: ReactNode = null
481
+ if (cluster.kind === 'approval') {
482
+ const request = pendingApprovals[0]
483
+ body = request ? (
484
+ <>
485
+ <div data-tone='bright'>{request.title ?? 'Permission required'}</div>
486
+ <div className='term-scrub-ex' data-tone='fg'>
487
+ {`${request.displayName ?? request.toolName}(${toolInputPreview(request.input)})`}
488
+ </div>
489
+ </>
490
+ ) : null
491
+ } else if (cluster.kind === 'recap' && !first) {
492
+ body = <div data-tone='faint'>※ {recapRow?.label}</div>
493
+ } else if (first) {
494
+ const item = items[first.itemIndex]
495
+ if (first.kind === 'recap') {
496
+ body = <div data-tone='faint'>※ {recapRow?.label}</div>
497
+ } else if (first.kind === 'turn' || first.kind === 'turnFailed') {
498
+ // The merged mark's peek carries both halves: the message the turn ended
499
+ // on, and the done-line (with its reasons, when it failed).
500
+ const turn = first.turnIndex === undefined ? undefined : items[first.turnIndex]
501
+ body = (
502
+ <>
503
+ {item?.kind === 'assistant_text' ? (
504
+ <div className='term-scrub-ex' data-tone='fg'>
505
+ <span data-tone='dim'>● </span>
506
+ {item.text}
507
+ </div>
508
+ ) : null}
509
+ {turn?.kind === 'turn_result' ? (
510
+ <>
511
+ <div data-tone={turn.isError ? 'red' : 'faint'}>{doneLine(turn)}</div>
512
+ {turn.errors?.map((message, index) => (
513
+ <div key={index} data-tone='red'>
514
+ {message}
515
+ </div>
516
+ ))}
517
+ </>
518
+ ) : null}
519
+ </>
520
+ )
521
+ } else if (item) {
522
+ const failure =
523
+ first.kind === 'toolFailed' && item.kind === 'tool_call'
524
+ ? item.result?.text.split('\n').find((line) => line.trim() !== '')
525
+ : undefined
526
+ body = (
527
+ <>
528
+ <div
529
+ className='term-scrub-ex'
530
+ data-tone={first.kind === 'error' || first.kind === 'toolFailed' ? 'red' : 'fg'}>
531
+ {first.kind === 'user' ? <span data-tone='dim'>{'❯ '}</span> : null}
532
+ {excerpt(item)}
533
+ </div>
534
+ {/* Which tool failed is rarely the question — `Bash(pnpm test)` is
535
+ what you already expected to see. The first non-blank line of what
536
+ it said back is the thing worth peeking at. */}
537
+ {failure ? (
538
+ <div className='term-scrub-ex' data-tone='red'>
539
+ {failure}
540
+ </div>
541
+ ) : null}
542
+ </>
543
+ )
544
+ }
545
+ }
546
+ return (
547
+ <>
548
+ <div data-tone='faint'>
549
+ {KIND_NAME[first?.kind ?? cluster.kind]}
550
+ {more}
551
+ </div>
552
+ {body}
553
+ </>
554
+ )
555
+ }
556
+
557
+ export function TerminalScrubber(props: TerminalScrubberProps) {
558
+ const {
559
+ scrollOffset,
560
+ viewportH,
561
+ totalSize,
562
+ onJumpToRow,
563
+ interactive,
564
+ fontSize,
565
+ lineHeight,
566
+ } = props
567
+ const stick = useStickToBottomContext()
568
+ const bodyRef = useRef<HTMLDivElement | null>(null)
569
+ const peekRef = useRef<HTMLDivElement | null>(null)
570
+ const [railH, setRailH] = useState(0)
571
+ const [peek, setPeek] = useState<{ cluster: Cluster; mark: Mark | undefined; y: number } | null>(
572
+ null,
573
+ )
574
+ const drag = useRef<{ y: number; moved: boolean; target: EventTarget | null } | null>(null)
575
+
576
+ useEffect(() => {
577
+ const element = bodyRef.current
578
+ if (!element) return
579
+ const observer = new ResizeObserver(() => setRailH(element.clientHeight))
580
+ observer.observe(element)
581
+ setRailH(element.clientHeight)
582
+ return () => observer.disconnect()
583
+ }, [])
584
+
585
+ // The band tracks the scroller's own scroll events, not the `scrollOffset`
586
+ // prop: the virtualizer notifies React only when the virtual row *range*
587
+ // changes, and scrolling inside one tall row changes none — the prop then
588
+ // refreshes only when `isScrolling` flips at the end, which reads as the
589
+ // band lagging the drag and snapping into place. The prop still seeds the
590
+ // first paint, before this listener's first event.
591
+ const [liveOffset, setLiveOffset] = useState(scrollOffset)
592
+ useEffect(() => {
593
+ const scroller = stick.scrollRef.current
594
+ if (!scroller) return
595
+ const onScroll = () => setLiveOffset(scroller.scrollTop)
596
+ scroller.addEventListener('scroll', onScroll, { passive: true })
597
+ setLiveOffset(scroller.scrollTop)
598
+ return () => scroller.removeEventListener('scroll', onScroll)
599
+ }, [stick.scrollRef])
600
+
601
+ // A peek is a snapshot of the cluster it was opened on; if the transcript
602
+ // changes underneath (a fixture/session swap, a burst of new items), drop it
603
+ // rather than describe rows that no longer exist.
604
+ useEffect(() => {
605
+ setPeek(null)
606
+ }, [props.items])
607
+
608
+ // Wheel over the rail scrolls the transcript. Manual listener because it
609
+ // must preventDefault (React's root wheel listeners are passive).
610
+ useEffect(() => {
611
+ if (!interactive) return
612
+ const element = bodyRef.current
613
+ if (!element) return
614
+ const onWheel = (event: WheelEvent) => {
615
+ const scroller = stick.scrollRef.current
616
+ if (!scroller) return
617
+ scroller.scrollTop += event.deltaY
618
+ event.preventDefault()
619
+ }
620
+ element.addEventListener('wheel', onWheel, { passive: false })
621
+ return () => element.removeEventListener('wheel', onWheel)
622
+ }, [interactive, stick.scrollRef])
623
+
624
+ // The peek can be taller than the space beside its mark — clamp it into the
625
+ // rail after it has a measured height.
626
+ useLayoutEffect(() => {
627
+ const element = peekRef.current
628
+ if (!element || !peek) return
629
+ const height = element.offsetHeight
630
+ const railHeight = bodyRef.current?.clientHeight ?? 0
631
+ element.style.top = `${Math.max(4, Math.min(railHeight - height - 4, peek.y - height / 2))}px`
632
+ }, [peek])
633
+
634
+ // Memoized against content and geometry, NOT recomputed per render: the live
635
+ // scroll offset re-renders this component on every scroll event, and
636
+ // rebuilding the clusters there walks every item — O(session) work per
637
+ // wheel tick for output that only changes when content or measurements do.
638
+ // `rowIndexFor`/`offsetOfRow` are closures rebuilt every parent render and
639
+ // deliberately not dependencies; `totalSize` stands in for the measurements
640
+ // behind them — row heights cannot move a mark without moving the total.
641
+ const clusters = useMemo(
642
+ () => (railH > 0 ? buildClusters(props, railH) : []),
643
+ // eslint-disable-next-line react-hooks/exhaustive-deps
644
+ // `viewportH` rides here because it is the scale's other term whenever the
645
+ // transcript is shorter than the window — without it a resize in that
646
+ // regime leaves every mark at the old scale.
647
+ [
648
+ props.items,
649
+ props.bookmarks,
650
+ props.recapRow,
651
+ props.pendingApprovals,
652
+ totalSize,
653
+ railH,
654
+ viewportH,
655
+ ],
656
+ )
657
+ const scale = railScale(railH, totalSize, viewportH)
658
+ const bandH = Math.max(2, Math.min(railH, Math.round(viewportH * scale)))
659
+ // Clamped against the rail's foot as well as its head: an overscroll bounce
660
+ // drives `liveOffset` past `totalSize - viewportH` for a frame or two, and the
661
+ // band is the one child whose top is not already bounded by its own height.
662
+ const bandTop = Math.max(0, Math.min(railH - bandH, Math.round(liveOffset * scale)))
663
+
664
+ const scrub = (clientY: number) => {
665
+ const rail = bodyRef.current
666
+ const scroller = stick.scrollRef.current
667
+ if (!rail || !scroller) return
668
+ const rect = rail.getBoundingClientRect()
669
+ const fraction = Math.min(1, Math.max(0, (clientY - rect.top) / rect.height))
670
+ scroller.scrollTop = fraction * scroller.scrollHeight - scroller.clientHeight / 2
671
+ }
672
+
673
+ /** A pointer's y in rail space. */
674
+ const railY = (clientY: number): number =>
675
+ clientY - (bodyRef.current?.getBoundingClientRect().top ?? 0)
676
+
677
+ const activate = (cluster: Cluster, clientY: number) => {
678
+ if (cluster.kind === 'approval' && cluster.marks.length === 0) {
679
+ void stick.scrollToBottom()
680
+ return
681
+ }
682
+ const mark = nearestMember(cluster, railY(clientY))
683
+ if (mark) onJumpToRow(mark.rowIndex)
684
+ }
685
+
686
+ const showPeek = (cluster: Cluster, clientY: number) => {
687
+ const y = railY(clientY)
688
+ const mark = nearestMember(cluster, y)
689
+ setPeek((previous) =>
690
+ previous && previous.cluster === cluster && previous.mark === mark
691
+ ? previous
692
+ : { cluster, mark, y: Math.min(Math.max(y, cluster.y), cluster.y + cluster.h) },
693
+ )
694
+ }
695
+
696
+ // The rail is a scrollbar to assistive tech when it acts like one, and
697
+ // invisible when it is passive paint (a decorative copy of information the
698
+ // transcript itself carries).
699
+ const maxOffset = Math.max(1, totalSize - viewportH)
700
+ return (
701
+ <TerminalSurface
702
+ fontSize={fontSize}
703
+ lineHeight={lineHeight}
704
+ className='term-scrubber'
705
+ data-interactive={interactive || undefined}
706
+ {...(interactive
707
+ ? {
708
+ role: 'scrollbar',
709
+ 'aria-orientation': 'vertical' as const,
710
+ 'aria-label': 'Transcript overview',
711
+ 'aria-valuemin': 0,
712
+ 'aria-valuemax': 100,
713
+ 'aria-valuenow': Math.min(100, Math.max(0, Math.round((liveOffset / maxOffset) * 100))),
714
+ }
715
+ : { 'aria-hidden': true })}>
716
+ <div
717
+ ref={bodyRef}
718
+ className='term-scrubber-body'
719
+ {...(interactive
720
+ ? {
721
+ onPointerDown: (event) => {
722
+ // The rail is about to write scrollTop; this is the follow
723
+ // spring's own off switch, same as every other jump.
724
+ stick.stopScroll()
725
+ drag.current = { y: event.clientY, moved: false, target: event.target }
726
+ event.currentTarget.setPointerCapture(event.pointerId)
727
+ },
728
+ onPointerMove: (event) => {
729
+ const state = drag.current
730
+ if (!state) return
731
+ if (!state.moved && Math.abs(event.clientY - state.y) < 3) return
732
+ state.moved = true
733
+ setPeek(null)
734
+ scrub(event.clientY)
735
+ },
736
+ onPointerUp: (event) => {
737
+ const state = drag.current
738
+ drag.current = null
739
+ if (!state || state.moved) return
740
+ // A clean press: on a mark it is a jump; on the ground it is a
741
+ // scrub to that spot — scrollbar semantics.
742
+ const mark = (state.target as HTMLElement | null)?.closest?.('[data-ci]')
743
+ const index = mark ? Number((mark as HTMLElement).dataset.ci) : Number.NaN
744
+ if (Number.isInteger(index) && clusters[index])
745
+ activate(clusters[index]!, event.clientY)
746
+ else scrub(event.clientY)
747
+ },
748
+ }
749
+ : null)}>
750
+ {/* The band is the whole "where am I" answer. It used to carry a 2px
751
+ blue line on its top edge as well; with the band already outlined,
752
+ that was a second indicator of one fact, and the loudest colour on
753
+ the rail spent on it. */}
754
+ <div className='term-scrub-band' style={{ top: bandTop, height: bandH }} />
755
+ {clusters.map((cluster, index) => (
756
+ <div
757
+ key={index}
758
+ data-ci={index}
759
+ className='term-scrub-mark'
760
+ data-lane={cluster.lane}
761
+ data-kind={cluster.kind}
762
+ style={{ top: cluster.y, height: cluster.h }}
763
+ {...(interactive
764
+ ? {
765
+ onPointerEnter: (event) => showPeek(cluster, event.clientY),
766
+ // A chain-merged bar can span the rail; sliding along it
767
+ // retargets the peek to the member under the pointer.
768
+ onPointerMove: (event) => {
769
+ if (!drag.current) showPeek(cluster, event.clientY)
770
+ },
771
+ onPointerLeave: () => setPeek(null),
772
+ }
773
+ : null)}
774
+ />
775
+ ))}
776
+ {peek ? (
777
+ <div ref={peekRef} className='term-scrub-peek' style={{ top: peek.y }}>
778
+ {peekContent(peek.cluster, peek.mark, props)}
779
+ </div>
780
+ ) : null}
781
+ </div>
782
+ </TerminalSurface>
783
+ )
784
+ }