@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.
- package/README.md +60 -0
- package/build/{SessionPanel-J2U8v88q.d.mts → SessionPanel-CnNYEX80.d.mts} +170 -21
- package/build/{SessionPanel-DI1NO4l8.mjs → SessionPanel-DMPhsNlW.mjs} +4759 -1483
- package/build/SessionPanel-DMPhsNlW.mjs.map +1 -0
- package/build/{format-ljc3lKpA.d.mts → format-DfI_je9S.d.mts} +1 -1
- package/build/format.d.mts +39 -4
- package/build/format.mjs +2 -118
- package/build/index.d.mts +493 -45
- package/build/index.mjs +343 -17
- package/build/index.mjs.map +1 -1
- package/build/status-Ydzi7n6j.mjs +143 -0
- package/build/status-Ydzi7n6j.mjs.map +1 -0
- package/build/workspace.d.mts +9 -1
- package/build/workspace.mjs +108 -5
- package/build/workspace.mjs.map +1 -1
- package/package.json +16 -7
- package/src/components/agent/Composer.tsx +189 -89
- package/src/components/agent/Conversation.tsx +12 -12
- package/src/components/agent/FileCard.tsx +0 -26
- package/src/components/agent/FileTree.tsx +9 -8
- package/src/components/agent/Loader.tsx +22 -72
- package/src/components/agent/Message.tsx +11 -46
- package/src/components/agent/PermissionPrompt.tsx +0 -92
- package/src/components/agent/ProjectIcon.tsx +119 -0
- package/src/components/agent/QuestionPrompt.tsx +0 -122
- package/src/components/agent/Reasoning.tsx +5 -19
- package/src/components/agent/Response.tsx +1 -132
- package/src/components/agent/SessionBrowser.tsx +84 -3
- package/src/components/agent/SessionPanel.tsx +249 -28
- package/src/components/agent/SessionWorkspace.tsx +29 -0
- package/src/components/agent/StatusBar.tsx +20 -4
- package/src/components/agent/ToolCallCard.tsx +85 -112
- package/src/components/agent/Transcript.tsx +780 -203
- package/src/components/agent/UsageDialog.tsx +20 -106
- package/src/components/agent/UsageMeters.tsx +133 -0
- package/src/components/agent/pulse.tsx +3 -2
- package/src/components/agent/tool-result-fetch.tsx +36 -0
- package/src/components/agent/tool-result-image.tsx +209 -0
- package/src/components/agent/transcript-rows.ts +173 -0
- package/src/components/agent/transcript-variant.tsx +29 -51
- package/src/components/agent/use-height-epoch.ts +60 -0
- package/src/components/agent/use-path-links.ts +147 -0
- package/src/components/agent/use-transcript-jumps.ts +190 -0
- package/src/components/prompt-area/cursor-helpers.ts +65 -0
- package/src/components/prompt-area/use-prompt-area.ts +16 -10
- package/src/components/terminal/PermissionPrompt.tsx +119 -0
- package/src/components/terminal/QuestionPrompt.tsx +322 -0
- package/src/components/terminal/StatusLine.tsx +159 -0
- package/src/components/terminal/TerminalTranscript.tsx +225 -0
- package/src/components/terminal/affordances.tsx +118 -0
- package/src/components/terminal/blocks.ts +232 -0
- package/src/components/terminal/diff.tsx +130 -0
- package/src/components/terminal/height.ts +770 -0
- package/src/components/terminal/image-box.ts +53 -0
- package/src/components/terminal/items.tsx +486 -0
- package/src/components/terminal/markdown.tsx +191 -0
- package/src/components/terminal/press.tsx +120 -0
- package/src/components/terminal/prompt.tsx +343 -0
- package/src/components/terminal/result-preview.ts +86 -0
- package/src/components/terminal/row.tsx +132 -0
- package/src/components/terminal/scrubber.tsx +784 -0
- package/src/components/terminal/surface.tsx +80 -0
- package/src/components/terminal/tool-run.ts +224 -0
- package/src/index.ts +36 -1
- package/src/lib/status.ts +59 -3
- package/src/lib/tool-icon.ts +14 -0
- package/src/styles/terminal.css +1081 -0
- package/src/styles/theme.css +41 -0
- package/build/SessionPanel-DI1NO4l8.mjs.map +0 -1
- package/build/format.mjs.map +0 -1
- 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
|
+
}
|