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