@workerdeck/ui 0.23.0 → 1.0.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/build/{SessionPanel-DUt2VzXG.mjs → SessionPanel-DDgBkHsi.mjs} +2151 -3357
- package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
- package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
- package/build/format-B8ZM9LPy.d.mts +16 -0
- package/build/format.d.mts +8 -75
- package/build/format.mjs +2 -2
- package/build/index.d.mts +63 -1138
- package/build/index.mjs +4 -300
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +15 -23
- package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
- package/build/status-C0HSLZas.mjs.map +1 -0
- package/build/workspace.d.mts +5 -126
- package/build/workspace.mjs +11 -143
- package/build/workspace.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/agent/CodeEditor.tsx +31 -103
- package/src/components/agent/Composer.tsx +114 -333
- package/src/components/agent/ContextDialog.tsx +20 -41
- package/src/components/agent/ContextRing.tsx +6 -28
- package/src/components/agent/Conversation.tsx +21 -33
- package/src/components/agent/EditorTabs.tsx +39 -67
- package/src/components/agent/EngineIcon.tsx +29 -72
- package/src/components/agent/EntryIcon.tsx +16 -0
- package/src/components/agent/FileCard.tsx +10 -16
- package/src/components/agent/FileTree.tsx +52 -125
- package/src/components/agent/FileViewer.tsx +30 -60
- package/src/components/agent/HostFilesDialog.tsx +40 -69
- package/src/components/agent/Loader.tsx +3 -24
- package/src/components/agent/McpDialog.tsx +64 -137
- package/src/components/agent/Message.tsx +2 -21
- package/src/components/agent/ModelSelect.tsx +33 -62
- package/src/components/agent/PermissionModeSelect.tsx +21 -79
- package/src/components/agent/PermissionPrompt.tsx +59 -63
- package/src/components/agent/ProjectIcon.tsx +228 -93
- package/src/components/agent/PromptTokenText.tsx +10 -16
- package/src/components/agent/QuestionPrompt.tsx +43 -64
- package/src/components/agent/Reasoning.tsx +13 -16
- package/src/components/agent/Response.tsx +3 -9
- package/src/components/agent/Scrubber.tsx +31 -80
- package/src/components/agent/SessionBrowser.tsx +64 -243
- package/src/components/agent/SessionEmptyState.tsx +10 -40
- package/src/components/agent/SessionInfoDialog.tsx +46 -78
- package/src/components/agent/SessionItem.tsx +53 -320
- package/src/components/agent/SessionList.tsx +23 -39
- package/src/components/agent/SessionPanel.tsx +315 -996
- package/src/components/agent/SessionStatusIcon.tsx +14 -28
- package/src/components/agent/SessionSteps.tsx +34 -187
- package/src/components/agent/SessionWorkspace.tsx +104 -219
- package/src/components/agent/SkillsDialog.tsx +35 -80
- package/src/components/agent/StatusBar.tsx +51 -136
- package/src/components/agent/SubagentStrip.tsx +12 -77
- package/src/components/agent/ToolCallCard.tsx +94 -135
- package/src/components/agent/Transcript.tsx +103 -1288
- package/src/components/agent/TranscriptItemView.tsx +155 -0
- package/src/components/agent/TranscriptRows.tsx +418 -0
- package/src/components/agent/UsageDialog.tsx +7 -29
- package/src/components/agent/UsageMeters.tsx +18 -73
- package/src/components/agent/pulse.tsx +9 -32
- package/src/components/agent/scrubber-marks.ts +33 -103
- package/src/components/agent/status.ts +1 -5
- package/src/components/agent/tool-result-fetch.tsx +3 -25
- package/src/components/agent/tool-result-image.tsx +32 -111
- package/src/components/agent/transcript-rows.ts +41 -118
- package/src/components/agent/transcript-variant.tsx +3 -80
- package/src/components/agent/use-height-epoch.ts +10 -24
- package/src/components/agent/use-path-links.ts +45 -63
- package/src/components/agent/use-subagent-frame.ts +106 -0
- package/src/components/agent/use-transcript-jumps.ts +49 -107
- package/src/components/prompt-area/animated-placeholder.tsx +6 -5
- package/src/components/prompt-area/clipboard-helpers.ts +26 -21
- package/src/components/prompt-area/cursor-helpers.ts +79 -37
- package/src/components/prompt-area/dom-helpers.ts +97 -69
- package/src/components/prompt-area/file-strip.tsx +90 -80
- package/src/components/prompt-area/html-to-markdown.ts +67 -46
- package/src/components/prompt-area/image-strip.tsx +8 -18
- package/src/components/prompt-area/index.ts +2 -15
- package/src/components/prompt-area/prompt-area-engine.ts +100 -115
- package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
- package/src/components/prompt-area/prompt-area.tsx +31 -38
- package/src/components/prompt-area/remove-button.tsx +3 -9
- package/src/components/prompt-area/segment-helpers.ts +4 -12
- package/src/components/prompt-area/trigger-popover.tsx +15 -27
- package/src/components/prompt-area/trigger-presets.ts +0 -10
- package/src/components/prompt-area/types.ts +3 -8
- package/src/components/prompt-area/use-chip-editing.ts +305 -0
- package/src/components/prompt-area/use-markdown-mode.ts +7 -17
- package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
- package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
- package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
- package/src/components/prompt-area/use-prompt-area.ts +170 -896
- package/src/components/prompt-area/use-trigger-search.ts +21 -19
- package/src/components/terminal/PermissionPrompt.tsx +56 -59
- package/src/components/terminal/QuestionPrompt.tsx +28 -79
- package/src/components/terminal/StatusLine.tsx +13 -63
- package/src/components/terminal/TerminalTranscript.tsx +44 -160
- package/src/components/terminal/affordances.tsx +68 -98
- package/src/components/terminal/blocks.ts +20 -156
- package/src/components/terminal/diff.tsx +19 -67
- package/src/components/terminal/height.ts +163 -313
- package/src/components/terminal/image-box.ts +0 -44
- package/src/components/terminal/items.tsx +150 -305
- package/src/components/terminal/markdown.tsx +36 -95
- package/src/components/terminal/press.tsx +26 -53
- package/src/components/terminal/prompt.tsx +40 -170
- package/src/components/terminal/result-preview.ts +8 -56
- package/src/components/terminal/row.tsx +11 -98
- package/src/components/terminal/scrubber.tsx +108 -401
- package/src/components/terminal/surface.tsx +6 -54
- package/src/components/terminal/todos.ts +66 -0
- package/src/components/terminal/tool-run.ts +21 -192
- package/src/components/ui/AlertDialog.tsx +8 -23
- package/src/components/ui/Badge.tsx +18 -29
- package/src/components/ui/Button.tsx +7 -15
- package/src/components/ui/Card.tsx +2 -13
- package/src/components/ui/CodeBlock.tsx +10 -37
- package/src/components/ui/CopyButton.tsx +10 -23
- package/src/components/ui/Dialog.tsx +23 -36
- package/src/components/ui/Empty.tsx +6 -29
- package/src/components/ui/Input.tsx +14 -16
- package/src/components/ui/Menu.tsx +6 -18
- package/src/components/ui/PortalScope.tsx +1 -21
- package/src/components/ui/ProgressRing.tsx +8 -19
- package/src/components/ui/Select.tsx +18 -31
- package/src/components/ui/Sonner.tsx +1 -2
- package/src/components/ui/Spinner.tsx +1 -1
- package/src/components/ui/Splitter.tsx +30 -64
- package/src/components/ui/Textarea.tsx +2 -5
- package/src/components/ui/Tooltip.tsx +3 -13
- package/src/format.ts +0 -9
- package/src/index.ts +16 -113
- package/src/lib/clipboard.ts +8 -28
- package/src/lib/css.ts +8 -0
- package/src/lib/format.ts +101 -86
- package/src/lib/plan-request.ts +16 -0
- package/src/lib/status.ts +45 -95
- package/src/lib/tool-icon.ts +39 -47
- package/src/lib/utils.ts +1 -3
- package/src/styles/terminal.css +81 -49
- package/src/styles/theme.css +96 -32
- package/src/workspace.ts +1 -24
- package/build/SessionPanel-BobynUo4.d.mts +0 -669
- package/build/SessionPanel-DUt2VzXG.mjs.map +0 -1
- package/build/format-ljc3lKpA.d.mts +0 -59
- package/build/status-BE-zg88x.mjs.map +0 -1
|
@@ -6,112 +6,33 @@ import { formatCost, formatDuration, toolInputPreview } from '../../lib/format.t
|
|
|
6
6
|
import { parentOf } from './blocks.ts'
|
|
7
7
|
import { TerminalSurface } from './surface.tsx'
|
|
8
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
9
|
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'
|
|
10
|
+
type MarkKind = 'user' | 'subagent' | 'turn' | 'turnFailed' | 'toolFailed' | 'error' | 'approval' | 'recap' | 'bookmark'
|
|
58
11
|
|
|
59
12
|
type Mark = {
|
|
60
13
|
kind: MarkKind
|
|
61
|
-
/** The jump anchor (for a turn mark: the paired response). −1 for recap. */
|
|
62
14
|
itemIndex: number
|
|
63
15
|
rowIndex: number
|
|
64
|
-
/** The `turn_result` behind a center mark — the peek shows its done-line. */
|
|
65
16
|
turnIndex?: number
|
|
66
17
|
}
|
|
67
18
|
|
|
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
19
|
type Cluster = { lane: Lane; kind: MarkKind; y: number; h: number; marks: { mark: Mark; y: number }[] }
|
|
74
20
|
|
|
75
|
-
/** The member closest to a rail-space y — what a press or peek on a merged
|
|
76
|
-
* cluster resolves to. */
|
|
77
21
|
function nearestMember(cluster: Cluster, y: number): Mark | undefined {
|
|
78
22
|
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))
|
|
23
|
+
for (const member of cluster.marks) {
|
|
24
|
+
if (!best || Math.abs(member.y - y) < Math.abs(best.y - y)) {
|
|
25
|
+
best = member
|
|
26
|
+
}
|
|
27
|
+
}
|
|
81
28
|
return best?.mark
|
|
82
29
|
}
|
|
83
30
|
|
|
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
31
|
const LANE: Record<MarkKind, Lane> = {
|
|
100
32
|
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
33
|
subagent: 'l',
|
|
107
34
|
turn: 'r',
|
|
108
35
|
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
36
|
toolFailed: 'r',
|
|
116
37
|
error: 'r',
|
|
117
38
|
approval: 'f',
|
|
@@ -119,25 +40,14 @@ const LANE: Record<MarkKind, Lane> = {
|
|
|
119
40
|
bookmark: 'f',
|
|
120
41
|
}
|
|
121
42
|
|
|
122
|
-
/** Who wins the colour when marks merge. */
|
|
123
43
|
const LOUDNESS: Record<MarkKind, number> = {
|
|
124
44
|
approval: 7,
|
|
125
45
|
error: 6,
|
|
126
46
|
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
47
|
toolFailed: 4,
|
|
135
48
|
user: 3,
|
|
136
49
|
turn: 2,
|
|
137
50
|
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
51
|
subagent: 1,
|
|
142
52
|
recap: 0,
|
|
143
53
|
}
|
|
@@ -154,136 +64,62 @@ const KIND_NAME: Record<MarkKind, string> = {
|
|
|
154
64
|
bookmark: 'bookmark',
|
|
155
65
|
}
|
|
156
66
|
|
|
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
67
|
const MIN_MARK = 2
|
|
164
68
|
|
|
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
69
|
export function railScale(railH: number, totalSize: number, viewportH: number): number {
|
|
186
70
|
return totalSize > 0 ? railH / Math.max(totalSize, viewportH) : 0
|
|
187
71
|
}
|
|
188
72
|
|
|
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
73
|
type Segment = { response?: number; turn?: number; failed?: boolean }
|
|
207
74
|
|
|
208
|
-
|
|
209
|
-
`${turn.isError ? turn.subtype : 'done'} · ${formatDuration(turn.durationMs)} · ${formatCost(turn.totalCostUsd)}`
|
|
75
|
+
function doneLine(turn: Extract<TranscriptItem, { kind: 'turn_result' }>): string {
|
|
76
|
+
return `${turn.isError ? turn.subtype : 'done'} · ${formatDuration(turn.durationMs)} · ${formatCost(turn.totalCostUsd)}`
|
|
77
|
+
}
|
|
210
78
|
|
|
211
79
|
function excerpt(item: TranscriptItem): string {
|
|
212
80
|
switch (item.kind) {
|
|
213
81
|
case 'user':
|
|
214
82
|
case 'assistant_text':
|
|
215
83
|
case 'thinking':
|
|
216
|
-
case 'notice':
|
|
84
|
+
case 'notice': {
|
|
217
85
|
return item.text
|
|
218
|
-
|
|
86
|
+
}
|
|
87
|
+
case 'tool_call': {
|
|
219
88
|
return `${item.name}(${toolInputPreview(item.input)})`
|
|
220
|
-
|
|
89
|
+
}
|
|
90
|
+
case 'turn_result': {
|
|
221
91
|
return doneLine(item)
|
|
222
|
-
|
|
92
|
+
}
|
|
93
|
+
case 'file_delivered': {
|
|
223
94
|
return item.path
|
|
224
|
-
|
|
95
|
+
}
|
|
96
|
+
default: {
|
|
225
97
|
return ''
|
|
98
|
+
}
|
|
226
99
|
}
|
|
227
100
|
}
|
|
228
101
|
|
|
229
102
|
export interface TerminalScrubberProps {
|
|
230
103
|
items: readonly TranscriptItem[]
|
|
231
104
|
pendingApprovals: readonly PermissionRequest[]
|
|
232
|
-
/** The catch-up boundary's virtual row, when the recap is spliced in. */
|
|
233
105
|
recapRow?: { rowIndex: number; label: string }
|
|
234
|
-
/** Bookmarked item indices. Paint only — no store, no set affordance. */
|
|
235
106
|
bookmarks: readonly number[]
|
|
236
|
-
/**
|
|
237
|
-
* The sub-agent takeover's parent id, when this rail belongs to a frame.
|
|
238
|
-
*
|
|
239
|
-
* **It is what "top level" means here.** Three of the rules below mark only
|
|
240
|
-
* items at the conversation's own level, so that a sub-agent's work is
|
|
241
|
-
* represented by the one band its `Task` row gets rather than by a second set
|
|
242
|
-
* of prompts and answers scattered through the rail. Inside a frame that same
|
|
243
|
-
* test excludes *everything* — every item there has a parent by construction —
|
|
244
|
-
* and the rail came out empty: mounted, banded, and marking nothing on a
|
|
245
|
-
* hundred-tool agent. So the level is a parameter, `undefined` at the top and
|
|
246
|
-
* the frame's id inside one.
|
|
247
|
-
*/
|
|
248
107
|
frameParentId?: string
|
|
249
|
-
/** Item index → virtual row index (the off-by-a-fold mapping; see
|
|
250
|
-
* `rowIndexForItem` in `agent/Transcript.tsx`). */
|
|
251
108
|
rowIndexFor: (itemIndex: number) => number
|
|
252
|
-
/** A virtual row's offset in content space — the virtualizer's measurements,
|
|
253
|
-
* which the height calculator keeps honest for unmounted rows. */
|
|
254
109
|
offsetOfRow: (rowIndex: number) => number
|
|
255
|
-
/** A virtual row's height in content space, same source — what a mark's own
|
|
256
|
-
* height is scaled from. */
|
|
257
110
|
sizeOfRow: (rowIndex: number) => number
|
|
258
|
-
/** Where an item sits inside a row shared with other items — a task block's
|
|
259
|
-
* absorbed child or a folded run's member (`positionInRow` in
|
|
260
|
-
* `agent/transcript-rows.ts`). Optional and additive: without it every mark
|
|
261
|
-
* spans its row's extent, which for an expanded task block is the whole
|
|
262
|
-
* subagent area. */
|
|
263
111
|
positionInRow?: (itemIndex: number) => { ordinal: number; count: number } | undefined
|
|
264
112
|
totalSize: number
|
|
265
113
|
scrollOffset: number
|
|
266
114
|
viewportH: number
|
|
267
|
-
/** The transcript's re-aim jump (stopScroll + aim + exact finish). */
|
|
268
115
|
onJumpToRow: (rowIndex: number) => void
|
|
269
|
-
/** False renders passive paint: no pointer events at all. */
|
|
270
116
|
interactive: boolean
|
|
271
117
|
fontSize?: number
|
|
272
118
|
lineHeight?: number
|
|
273
119
|
}
|
|
274
120
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
* package's surface (`index.ts` does not re-export it). Both of the bugs this
|
|
278
|
-
* function has shipped were pure-logic ones a unit test catches: a live answer
|
|
279
|
-
* with no `turn_result` yet went unmarked for the whole two minutes it was the
|
|
280
|
-
* only thing worth navigating to, and a replayed history — which carries no turn
|
|
281
|
-
* rows at all — came back with an empty right lane.
|
|
282
|
-
*/
|
|
283
|
-
export function buildClusters(
|
|
284
|
-
props: TerminalScrubberProps,
|
|
285
|
-
railH: number,
|
|
286
|
-
): Cluster[] {
|
|
121
|
+
// Exported for `test/scrubber.test.ts` only, never from the package.
|
|
122
|
+
export function buildClusters(props: TerminalScrubberProps, railH: number): Cluster[] {
|
|
287
123
|
const {
|
|
288
124
|
items,
|
|
289
125
|
bookmarks,
|
|
@@ -298,28 +134,20 @@ export function buildClusters(
|
|
|
298
134
|
viewportH,
|
|
299
135
|
} = props
|
|
300
136
|
const marks: Mark[] = []
|
|
301
|
-
// Which top-level calls a sub-agent ran inside — by `parentToolUseId` and
|
|
302
|
-
// never by the spawning call's *name*: the SDK's own convention is `Task`,
|
|
303
|
-
// but it is a convention (a background agent arrives as `Agent`), and an id
|
|
304
|
-
// that other items demonstrably nest under IS a sub-agent whatever spawned
|
|
305
|
-
// it. The same membership rule `terminalBlocks` folds by, for the same reason.
|
|
306
137
|
const subagentParents = new Set<string>()
|
|
307
138
|
for (const item of items) {
|
|
308
139
|
const parent = parentOf(item)
|
|
309
|
-
if (parent !== undefined)
|
|
140
|
+
if (parent !== undefined) {
|
|
141
|
+
subagentParents.add(parent)
|
|
142
|
+
}
|
|
310
143
|
}
|
|
311
|
-
// The **outcome** call of each row: the last top-level tool call the row
|
|
312
|
-
// holds. A failed call is marked only when it is one of these — see the
|
|
313
|
-
// `toolFailed` branch below for why, and note this needs no block lookup,
|
|
314
|
-
// only `rowIndexFor`.
|
|
315
144
|
const rowOutcome = new Map<number, number>()
|
|
316
145
|
items.forEach((item, index) => {
|
|
317
|
-
if (item.kind !== 'tool_call' || parentOf(item) !== frameParentId)
|
|
146
|
+
if (item.kind !== 'tool_call' || parentOf(item) !== frameParentId) {
|
|
147
|
+
return
|
|
148
|
+
}
|
|
318
149
|
rowOutcome.set(rowIndexFor(index), index)
|
|
319
150
|
})
|
|
320
|
-
// One right-lane mark per segment, emitted when the segment closes. A segment
|
|
321
|
-
// is closed by the next prompt, by its own turn end, or by running out of
|
|
322
|
-
// items — that last one is what a replayed history is made of.
|
|
323
151
|
let segment: Segment = {}
|
|
324
152
|
const closeSegment = () => {
|
|
325
153
|
const anchor = segment.response ?? segment.turn
|
|
@@ -334,21 +162,9 @@ export function buildClusters(
|
|
|
334
162
|
segment = {}
|
|
335
163
|
}
|
|
336
164
|
items.forEach((item, index) => {
|
|
337
|
-
// The dispatch itself, marked at its row — which is the folded `Task`
|
|
338
|
-
// block, so the band grows to the whole sub-agent area when it is opened
|
|
339
|
-
// and shrinks back to a tick when it is closed. Deliberately NOT part of
|
|
340
|
-
// the chain below: a `Task` whose own result errored earns a red tick in
|
|
341
|
-
// the response lane *and* this band in the input lane, which is the whole
|
|
342
|
-
// point of the two channels — one says a sub-agent ran here, the other says
|
|
343
|
-
// it came back broken. A failed child inside it still marks separately, at
|
|
344
|
-
// its own fraction of the row.
|
|
345
165
|
if (item.kind === 'tool_call' && subagentParents.has(item.id)) {
|
|
346
166
|
marks.push({ kind: 'subagent', itemIndex: index, rowIndex: rowIndexFor(index) })
|
|
347
167
|
}
|
|
348
|
-
// Top-level prompts only, like the answer check below: a subagent's brief
|
|
349
|
-
// is a `user` item too, and it would both paint a "you" mark for something
|
|
350
|
-
// nobody typed and close the segment mid-turn — which mis-anchors the turn
|
|
351
|
-
// mark whenever a task runs between the prompt and the answer.
|
|
352
168
|
if (item.kind === 'user' && parentOf(item) === frameParentId) {
|
|
353
169
|
closeSegment()
|
|
354
170
|
marks.push({ kind: 'user', itemIndex: index, rowIndex: rowIndexFor(index) })
|
|
@@ -359,41 +175,6 @@ export function buildClusters(
|
|
|
359
175
|
} else if (item.kind === 'notice' && item.level === 'error') {
|
|
360
176
|
marks.push({ kind: 'error', itemIndex: index, rowIndex: rowIndexFor(index) })
|
|
361
177
|
} else if (
|
|
362
|
-
// **The rail marks what the transcript reddens** — the whole rule, and
|
|
363
|
-
// why this is not simply the per-call predicate it used to be.
|
|
364
|
-
//
|
|
365
|
-
// The row model already decided, twice, that a routine failure the model
|
|
366
|
-
// recovered from is not a failure: `runFailed` colours a folded run by
|
|
367
|
-
// its LAST call, and `taskFailed` colours a `Task` by its OWN result and
|
|
368
|
-
// never a child's. Both were changed from `contains` for the same reason
|
|
369
|
-
// — a normal working session came back painted red, spending the colour
|
|
370
|
-
// that should have been left for the one broken thing on a grep that
|
|
371
|
-
// matched nothing. The rail was deliberately exempted, on the argument
|
|
372
|
-
// that its question ("is there anything worth navigating to") differs
|
|
373
|
-
// from the row's ("how did this end").
|
|
374
|
-
//
|
|
375
|
-
// Measured against a real session, the exemption did not survive: 178
|
|
376
|
-
// tool calls, 9 failed, EIGHT OF THE NINE recovered from inside their own
|
|
377
|
-
// run, no failed turn and no session error — nine alarms on the rail for
|
|
378
|
-
// a transcript that reddens one row. A red mark beside nothing red is
|
|
379
|
-
// worse than no mark: it sends a reader hunting for damage that is not
|
|
380
|
-
// there.
|
|
381
|
-
//
|
|
382
|
-
// One uniform test covers all three cases: a call is its row's OUTCOME
|
|
383
|
-
// when it is top level and no later top-level call shares its row. For a
|
|
384
|
-
// folded run that is exactly `runFailed`'s last member; for a lone call
|
|
385
|
-
// it is the call; and for a `Task` it is the task itself, because its
|
|
386
|
-
// children are not top level — which is `taskFailed`, spelled a third way
|
|
387
|
-
// and agreeing. A failed child inside a sub-agent is therefore no longer
|
|
388
|
-
// marked, the same call `taskFailed` makes. The sub-agent band still says
|
|
389
|
-
// it ran and its own red tick still says it came back broken, every
|
|
390
|
-
// failure is still red on its own row, and the recap still counts them
|
|
391
|
-
// all.
|
|
392
|
-
//
|
|
393
|
-
// The disjunction is unchanged and both spellings are still needed: an
|
|
394
|
-
// out-of-loop execution failure sets `status` with no `is_error` block to
|
|
395
|
-
// read, and an engine can flag `is_error` on a call this reducer has not
|
|
396
|
-
// settled yet.
|
|
397
178
|
item.kind === 'tool_call' &&
|
|
398
179
|
(item.status === 'failed' || item.result?.isError === true) &&
|
|
399
180
|
rowOutcome.get(rowIndexFor(index)) === index
|
|
@@ -401,61 +182,31 @@ export function buildClusters(
|
|
|
401
182
|
marks.push({ kind: 'toolFailed', itemIndex: index, rowIndex: rowIndexFor(index) })
|
|
402
183
|
} else if (item.kind === 'assistant_text' && parentOf(item) === frameParentId) {
|
|
403
184
|
if (frameParentId !== undefined) {
|
|
404
|
-
// **Inside a frame every narration step is its own mark**, where the
|
|
405
|
-
// conversation gets one per segment. The segment machinery has nothing
|
|
406
|
-
// to work with here — a sub-agent's stream carries no prompts and no
|
|
407
|
-
// `turn_result`, so every step would fold into a single mark at the
|
|
408
|
-
// final report, which is the one place a reader can already get to. An
|
|
409
|
-
// agent's rail is a list of what it said on the way, and that is what
|
|
410
|
-
// makes a fifty-step run navigable.
|
|
411
185
|
marks.push({ kind: 'turn', itemIndex: index, rowIndex: rowIndexFor(index) })
|
|
412
186
|
return
|
|
413
187
|
}
|
|
414
|
-
// The live one included, deliberately: a turn in flight has no turn end
|
|
415
|
-
// yet, which left a two-minute answer unrepresented on the rail for the
|
|
416
|
-
// whole two minutes it was the only thing worth navigating to. The mark's
|
|
417
|
-
// height is its row's, so it grows as the answer does with no extra
|
|
418
|
-
// bookkeeping, and it cannot double up — the reducer settles this item in
|
|
419
|
-
// the same action that appends the `turn_result` that closes the segment.
|
|
420
188
|
segment.response = index
|
|
421
189
|
}
|
|
422
190
|
})
|
|
423
|
-
// A history that ends mid-segment still has an answer in it.
|
|
424
191
|
closeSegment()
|
|
425
|
-
for (const index of bookmarks)
|
|
426
|
-
if (index >= 0 && index < items.length)
|
|
192
|
+
for (const index of bookmarks) {
|
|
193
|
+
if (index >= 0 && index < items.length) {
|
|
427
194
|
marks.push({ kind: 'bookmark', itemIndex: index, rowIndex: rowIndexFor(index) })
|
|
428
|
-
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (recapRow) {
|
|
198
|
+
marks.push({ kind: 'recap', itemIndex: -1, rowIndex: recapRow.rowIndex })
|
|
199
|
+
}
|
|
429
200
|
|
|
430
201
|
const scale = railScale(railH, totalSize, viewportH)
|
|
431
202
|
const lanes = new Map<Lane, { mark: Mark; y: number; h: number }[]>()
|
|
432
203
|
for (const mark of marks) {
|
|
433
|
-
// A mark's height is its row's, at rail scale, floored at the hit target —
|
|
434
|
-
// the row the mark *anchors* (for a turn, the final response), which is
|
|
435
|
-
// where the reader lands and what they came to gauge the size of.
|
|
436
|
-
//
|
|
437
|
-
// EXCEPT an item that SHARES its row (a task block's absorbed child, a
|
|
438
|
-
// folded run's member): there the row's extent is mostly other items' work,
|
|
439
|
-
// and expanded it is the entire subagent area — one failed child of a
|
|
440
|
-
// hundred-call task used to paint a solid red band down the whole rail.
|
|
441
|
-
// Such a mark is a tick at its fractional position within the row.
|
|
442
|
-
// `sizeOfRow` is the virtualizer's *measurement*, so expansion is reflected
|
|
443
|
-
// with no expansion state here (which the scrubber deliberately cannot see,
|
|
444
|
-
// `height.ts`'s "unmounted is collapsed" invariant being load-bearing):
|
|
445
|
-
// collapsed, the fraction rounds onto the row's one line and siblings merge
|
|
446
|
-
// exactly as before; expanded, the ticks distribute down the block —
|
|
447
|
-
// approximately, since children differ in height, which a 12px rail cannot
|
|
448
|
-
// show and exactness would cost the scrubber the one thing it must not know.
|
|
449
|
-
// Applied here rather than per kind because a bookmark on an absorbed child
|
|
450
|
-
// has the identical bug; `recap` is `itemIndex: -1`, hence the guard.
|
|
451
204
|
const within = mark.itemIndex >= 0 ? positionInRow?.(mark.itemIndex) : undefined
|
|
452
205
|
const rowH = sizeOfRow(mark.rowIndex)
|
|
453
206
|
const h = within ? MIN_MARK : Math.max(MIN_MARK, Math.round(rowH * scale))
|
|
454
207
|
const y = Math.min(
|
|
455
208
|
Math.max(0, railH - h),
|
|
456
|
-
Math.round(
|
|
457
|
-
(offsetOfRow(mark.rowIndex) + (within ? (within.ordinal / within.count) * rowH : 0)) * scale,
|
|
458
|
-
),
|
|
209
|
+
Math.round((offsetOfRow(mark.rowIndex) + (within ? (within.ordinal / within.count) * rowH : 0)) * scale),
|
|
459
210
|
)
|
|
460
211
|
const lane = LANE[mark.kind]
|
|
461
212
|
const list = lanes.get(lane) ?? []
|
|
@@ -467,11 +218,11 @@ export function buildClusters(
|
|
|
467
218
|
list.sort((a, b) => a.y - b.y)
|
|
468
219
|
let current: Cluster | null = null
|
|
469
220
|
for (const { mark, y, h } of list) {
|
|
470
|
-
// Merge when the gap is under a pixel; the merged mark grows and takes
|
|
471
|
-
// the loudest member's colour.
|
|
472
221
|
if (current && y <= current.y + current.h + 1) {
|
|
473
222
|
current.h = Math.max(current.h, y + h - current.y)
|
|
474
|
-
if (LOUDNESS[mark.kind] > LOUDNESS[current.kind])
|
|
223
|
+
if (LOUDNESS[mark.kind] > LOUDNESS[current.kind]) {
|
|
224
|
+
current.kind = mark.kind
|
|
225
|
+
}
|
|
475
226
|
current.marks.push({ mark, y })
|
|
476
227
|
} else {
|
|
477
228
|
current = { lane, kind: mark.kind, y, h, marks: [{ mark, y }] }
|
|
@@ -479,55 +230,44 @@ export function buildClusters(
|
|
|
479
230
|
}
|
|
480
231
|
}
|
|
481
232
|
}
|
|
482
|
-
|
|
483
|
-
// so its mark pins at the rail's foot, where the prompt is.
|
|
484
|
-
if (pendingApprovals.length > 0)
|
|
233
|
+
if (pendingApprovals.length > 0) {
|
|
485
234
|
clusters.push({
|
|
486
|
-
// `LANE.approval`, not a literal: this cluster is built by hand because it
|
|
487
|
-
// has no item to derive a position from, and a hardcoded lane here is how
|
|
488
|
-
// it silently kept the old three-lane layout after the map moved on.
|
|
489
235
|
lane: LANE.approval,
|
|
490
236
|
kind: 'approval',
|
|
491
237
|
y: Math.max(0, railH - MIN_MARK),
|
|
492
238
|
h: MIN_MARK,
|
|
493
239
|
marks: [],
|
|
494
240
|
})
|
|
241
|
+
}
|
|
495
242
|
return clusters
|
|
496
243
|
}
|
|
497
244
|
|
|
498
|
-
function peekContent(
|
|
499
|
-
cluster: Cluster,
|
|
500
|
-
/** The member the pointer resolved to — see {@link nearestMember}. */
|
|
501
|
-
first: Mark | undefined,
|
|
502
|
-
{ items, pendingApprovals, recapRow }: TerminalScrubberProps,
|
|
503
|
-
): ReactNode {
|
|
245
|
+
function peekContent(cluster: Cluster, first: Mark | undefined, { items, pendingApprovals, recapRow }: TerminalScrubberProps): ReactNode {
|
|
504
246
|
const more = cluster.marks.length > 1 ? ` · ${cluster.marks.length} marks` : ''
|
|
505
247
|
let body: ReactNode = null
|
|
506
248
|
if (cluster.kind === 'approval') {
|
|
507
249
|
const request = pendingApprovals[0]
|
|
508
250
|
body = request ? (
|
|
509
251
|
<>
|
|
510
|
-
<div data-tone=
|
|
511
|
-
<div className=
|
|
252
|
+
<div data-tone="bright">{request.title ?? 'Permission required'}</div>
|
|
253
|
+
<div className="term-scrub-ex" data-tone="fg">
|
|
512
254
|
{`${request.displayName ?? request.toolName}(${toolInputPreview(request.input)})`}
|
|
513
255
|
</div>
|
|
514
256
|
</>
|
|
515
257
|
) : null
|
|
516
258
|
} else if (cluster.kind === 'recap' && !first) {
|
|
517
|
-
body = <div data-tone=
|
|
259
|
+
body = <div data-tone="faint">※ {recapRow?.label}</div>
|
|
518
260
|
} else if (first) {
|
|
519
261
|
const item = items[first.itemIndex]
|
|
520
262
|
if (first.kind === 'recap') {
|
|
521
|
-
body = <div data-tone=
|
|
263
|
+
body = <div data-tone="faint">※ {recapRow?.label}</div>
|
|
522
264
|
} else if (first.kind === 'turn' || first.kind === 'turnFailed') {
|
|
523
|
-
// The merged mark's peek carries both halves: the message the turn ended
|
|
524
|
-
// on, and the done-line (with its reasons, when it failed).
|
|
525
265
|
const turn = first.turnIndex === undefined ? undefined : items[first.turnIndex]
|
|
526
266
|
body = (
|
|
527
267
|
<>
|
|
528
268
|
{item?.kind === 'assistant_text' ? (
|
|
529
|
-
<div className=
|
|
530
|
-
<span data-tone=
|
|
269
|
+
<div className="term-scrub-ex" data-tone="fg">
|
|
270
|
+
<span data-tone="dim">● </span>
|
|
531
271
|
{item.text}
|
|
532
272
|
</div>
|
|
533
273
|
) : null}
|
|
@@ -535,7 +275,7 @@ function peekContent(
|
|
|
535
275
|
<>
|
|
536
276
|
<div data-tone={turn.isError ? 'red' : 'faint'}>{doneLine(turn)}</div>
|
|
537
277
|
{turn.errors?.map((message, index) => (
|
|
538
|
-
<div key={index} data-tone=
|
|
278
|
+
<div key={index} data-tone="red">
|
|
539
279
|
{message}
|
|
540
280
|
</div>
|
|
541
281
|
))}
|
|
@@ -550,17 +290,12 @@ function peekContent(
|
|
|
550
290
|
: undefined
|
|
551
291
|
body = (
|
|
552
292
|
<>
|
|
553
|
-
<div
|
|
554
|
-
|
|
555
|
-
data-tone={first.kind === 'error' || first.kind === 'toolFailed' ? 'red' : 'fg'}>
|
|
556
|
-
{first.kind === 'user' ? <span data-tone='dim'>{'❯ '}</span> : null}
|
|
293
|
+
<div className="term-scrub-ex" data-tone={first.kind === 'error' || first.kind === 'toolFailed' ? 'red' : 'fg'}>
|
|
294
|
+
{first.kind === 'user' ? <span data-tone="dim">{'❯ '}</span> : null}
|
|
557
295
|
{excerpt(item)}
|
|
558
296
|
</div>
|
|
559
|
-
{/* Which tool failed is rarely the question — `Bash(pnpm test)` is
|
|
560
|
-
what you already expected to see. The first non-blank line of what
|
|
561
|
-
it said back is the thing worth peeking at. */}
|
|
562
297
|
{failure ? (
|
|
563
|
-
<div className=
|
|
298
|
+
<div className="term-scrub-ex" data-tone="red">
|
|
564
299
|
{failure}
|
|
565
300
|
</div>
|
|
566
301
|
) : null}
|
|
@@ -570,7 +305,7 @@ function peekContent(
|
|
|
570
305
|
}
|
|
571
306
|
return (
|
|
572
307
|
<>
|
|
573
|
-
<div data-tone=
|
|
308
|
+
<div data-tone="faint">
|
|
574
309
|
{KIND_NAME[first?.kind ?? cluster.kind]}
|
|
575
310
|
{more}
|
|
576
311
|
</div>
|
|
@@ -580,65 +315,56 @@ function peekContent(
|
|
|
580
315
|
}
|
|
581
316
|
|
|
582
317
|
export function TerminalScrubber(props: TerminalScrubberProps) {
|
|
583
|
-
const {
|
|
584
|
-
scrollOffset,
|
|
585
|
-
viewportH,
|
|
586
|
-
totalSize,
|
|
587
|
-
onJumpToRow,
|
|
588
|
-
interactive,
|
|
589
|
-
fontSize,
|
|
590
|
-
lineHeight,
|
|
591
|
-
} = props
|
|
318
|
+
const { scrollOffset, viewportH, totalSize, onJumpToRow, interactive, fontSize, lineHeight } = props
|
|
592
319
|
const stick = useStickToBottomContext()
|
|
593
320
|
const bodyRef = useRef<HTMLDivElement | null>(null)
|
|
594
321
|
const peekRef = useRef<HTMLDivElement | null>(null)
|
|
595
322
|
const [railH, setRailH] = useState(0)
|
|
596
|
-
const [peek, setPeek] = useState<{ cluster: Cluster; mark: Mark | undefined; y: number } | null>(
|
|
597
|
-
null,
|
|
598
|
-
)
|
|
323
|
+
const [peek, setPeek] = useState<{ cluster: Cluster; mark: Mark | undefined; y: number } | null>(null)
|
|
599
324
|
const drag = useRef<{ y: number; moved: boolean; target: EventTarget | null } | null>(null)
|
|
600
325
|
|
|
601
326
|
useEffect(() => {
|
|
602
327
|
const element = bodyRef.current
|
|
603
|
-
if (!element)
|
|
328
|
+
if (!element) {
|
|
329
|
+
return
|
|
330
|
+
}
|
|
604
331
|
const observer = new ResizeObserver(() => setRailH(element.clientHeight))
|
|
605
332
|
observer.observe(element)
|
|
606
333
|
setRailH(element.clientHeight)
|
|
607
334
|
return () => observer.disconnect()
|
|
608
335
|
}, [])
|
|
609
336
|
|
|
610
|
-
// The band tracks the scroller's own scroll events, not the
|
|
611
|
-
// prop: the virtualizer notifies React only when the virtual row *range*
|
|
612
|
-
// changes, and scrolling inside one tall row changes none — the prop then
|
|
613
|
-
// refreshes only when `isScrolling` flips at the end, which reads as the
|
|
614
|
-
// band lagging the drag and snapping into place. The prop still seeds the
|
|
615
|
-
// first paint, before this listener's first event.
|
|
337
|
+
// The band tracks the scroller's own scroll events, not `scrollOffset`: the virtualizer notifies React only when the row range changes.
|
|
616
338
|
const [liveOffset, setLiveOffset] = useState(scrollOffset)
|
|
617
339
|
useEffect(() => {
|
|
618
340
|
const scroller = stick.scrollRef.current
|
|
619
|
-
if (!scroller)
|
|
341
|
+
if (!scroller) {
|
|
342
|
+
return
|
|
343
|
+
}
|
|
620
344
|
const onScroll = () => setLiveOffset(scroller.scrollTop)
|
|
621
345
|
scroller.addEventListener('scroll', onScroll, { passive: true })
|
|
622
346
|
setLiveOffset(scroller.scrollTop)
|
|
623
347
|
return () => scroller.removeEventListener('scroll', onScroll)
|
|
624
348
|
}, [stick.scrollRef])
|
|
625
349
|
|
|
626
|
-
// A peek is a snapshot of the cluster it was opened on; if the transcript
|
|
627
|
-
// changes underneath (a fixture/session swap, a burst of new items), drop it
|
|
628
|
-
// rather than describe rows that no longer exist.
|
|
629
350
|
useEffect(() => {
|
|
630
351
|
setPeek(null)
|
|
631
352
|
}, [props.items])
|
|
632
353
|
|
|
633
|
-
//
|
|
634
|
-
// must preventDefault (React's root wheel listeners are passive).
|
|
354
|
+
// Manual listener because it must `preventDefault` — React's root wheel listeners are passive.
|
|
635
355
|
useEffect(() => {
|
|
636
|
-
if (!interactive)
|
|
356
|
+
if (!interactive) {
|
|
357
|
+
return
|
|
358
|
+
}
|
|
637
359
|
const element = bodyRef.current
|
|
638
|
-
if (!element)
|
|
360
|
+
if (!element) {
|
|
361
|
+
return
|
|
362
|
+
}
|
|
639
363
|
const onWheel = (event: WheelEvent) => {
|
|
640
364
|
const scroller = stick.scrollRef.current
|
|
641
|
-
if (!scroller)
|
|
365
|
+
if (!scroller) {
|
|
366
|
+
return
|
|
367
|
+
}
|
|
642
368
|
scroller.scrollTop += event.deltaY
|
|
643
369
|
event.preventDefault()
|
|
644
370
|
}
|
|
@@ -646,58 +372,38 @@ export function TerminalScrubber(props: TerminalScrubberProps) {
|
|
|
646
372
|
return () => element.removeEventListener('wheel', onWheel)
|
|
647
373
|
}, [interactive, stick.scrollRef])
|
|
648
374
|
|
|
649
|
-
// The peek can be taller than the space beside its mark — clamp it into the
|
|
650
|
-
// rail after it has a measured height.
|
|
651
375
|
useLayoutEffect(() => {
|
|
652
376
|
const element = peekRef.current
|
|
653
|
-
if (!element || !peek)
|
|
377
|
+
if (!element || !peek) {
|
|
378
|
+
return
|
|
379
|
+
}
|
|
654
380
|
const height = element.offsetHeight
|
|
655
381
|
const railHeight = bodyRef.current?.clientHeight ?? 0
|
|
656
382
|
element.style.top = `${Math.max(4, Math.min(railHeight - height - 4, peek.y - height / 2))}px`
|
|
657
383
|
}, [peek])
|
|
658
384
|
|
|
659
|
-
//
|
|
660
|
-
// scroll offset re-renders this component on every scroll event, and
|
|
661
|
-
// rebuilding the clusters there walks every item — O(session) work per
|
|
662
|
-
// wheel tick for output that only changes when content or measurements do.
|
|
663
|
-
// `rowIndexFor`/`offsetOfRow` are closures rebuilt every parent render and
|
|
664
|
-
// deliberately not dependencies; `totalSize` stands in for the measurements
|
|
665
|
-
// behind them — row heights cannot move a mark without moving the total.
|
|
385
|
+
// Deps are content and geometry: `rowIndexFor`/`offsetOfRow` are per-render closures, and `totalSize` stands in for their measurements.
|
|
666
386
|
const clusters = useMemo(
|
|
667
387
|
() => (railH > 0 ? buildClusters(props, railH) : []),
|
|
668
388
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
669
|
-
|
|
670
|
-
// transcript is shorter than the window — without it a resize in that
|
|
671
|
-
// regime leaves every mark at the old scale.
|
|
672
|
-
[
|
|
673
|
-
props.items,
|
|
674
|
-
props.bookmarks,
|
|
675
|
-
props.recapRow,
|
|
676
|
-
props.pendingApprovals,
|
|
677
|
-
totalSize,
|
|
678
|
-
railH,
|
|
679
|
-
viewportH,
|
|
680
|
-
],
|
|
389
|
+
[props.items, props.bookmarks, props.recapRow, props.pendingApprovals, totalSize, railH, viewportH],
|
|
681
390
|
)
|
|
682
391
|
const scale = railScale(railH, totalSize, viewportH)
|
|
683
392
|
const bandH = Math.max(2, Math.min(railH, Math.round(viewportH * scale)))
|
|
684
|
-
// Clamped against the rail's foot as well as its head: an overscroll bounce
|
|
685
|
-
// drives `liveOffset` past `totalSize - viewportH` for a frame or two, and the
|
|
686
|
-
// band is the one child whose top is not already bounded by its own height.
|
|
687
393
|
const bandTop = Math.max(0, Math.min(railH - bandH, Math.round(liveOffset * scale)))
|
|
688
394
|
|
|
689
395
|
const scrub = (clientY: number) => {
|
|
690
396
|
const rail = bodyRef.current
|
|
691
397
|
const scroller = stick.scrollRef.current
|
|
692
|
-
if (!rail || !scroller)
|
|
398
|
+
if (!rail || !scroller) {
|
|
399
|
+
return
|
|
400
|
+
}
|
|
693
401
|
const rect = rail.getBoundingClientRect()
|
|
694
402
|
const fraction = Math.min(1, Math.max(0, (clientY - rect.top) / rect.height))
|
|
695
403
|
scroller.scrollTop = fraction * scroller.scrollHeight - scroller.clientHeight / 2
|
|
696
404
|
}
|
|
697
405
|
|
|
698
|
-
|
|
699
|
-
const railY = (clientY: number): number =>
|
|
700
|
-
clientY - (bodyRef.current?.getBoundingClientRect().top ?? 0)
|
|
406
|
+
const railY = (clientY: number): number => clientY - (bodyRef.current?.getBoundingClientRect().top ?? 0)
|
|
701
407
|
|
|
702
408
|
const activate = (cluster: Cluster, clientY: number) => {
|
|
703
409
|
if (cluster.kind === 'approval' && cluster.marks.length === 0) {
|
|
@@ -705,7 +411,9 @@ export function TerminalScrubber(props: TerminalScrubberProps) {
|
|
|
705
411
|
return
|
|
706
412
|
}
|
|
707
413
|
const mark = nearestMember(cluster, railY(clientY))
|
|
708
|
-
if (mark)
|
|
414
|
+
if (mark) {
|
|
415
|
+
onJumpToRow(mark.rowIndex)
|
|
416
|
+
}
|
|
709
417
|
}
|
|
710
418
|
|
|
711
419
|
const showPeek = (cluster: Cluster, clientY: number) => {
|
|
@@ -718,15 +426,12 @@ export function TerminalScrubber(props: TerminalScrubberProps) {
|
|
|
718
426
|
)
|
|
719
427
|
}
|
|
720
428
|
|
|
721
|
-
// The rail is a scrollbar to assistive tech when it acts like one, and
|
|
722
|
-
// invisible when it is passive paint (a decorative copy of information the
|
|
723
|
-
// transcript itself carries).
|
|
724
429
|
const maxOffset = Math.max(1, totalSize - viewportH)
|
|
725
430
|
return (
|
|
726
431
|
<TerminalSurface
|
|
727
432
|
fontSize={fontSize}
|
|
728
433
|
lineHeight={lineHeight}
|
|
729
|
-
className=
|
|
434
|
+
className="term-scrubber"
|
|
730
435
|
data-interactive={interactive || undefined}
|
|
731
436
|
{...(interactive
|
|
732
437
|
? {
|
|
@@ -737,23 +442,26 @@ export function TerminalScrubber(props: TerminalScrubberProps) {
|
|
|
737
442
|
'aria-valuemax': 100,
|
|
738
443
|
'aria-valuenow': Math.min(100, Math.max(0, Math.round((liveOffset / maxOffset) * 100))),
|
|
739
444
|
}
|
|
740
|
-
: { 'aria-hidden': true })}
|
|
445
|
+
: { 'aria-hidden': true })}
|
|
446
|
+
>
|
|
741
447
|
<div
|
|
742
448
|
ref={bodyRef}
|
|
743
|
-
className=
|
|
449
|
+
className="term-scrubber-body"
|
|
744
450
|
{...(interactive
|
|
745
451
|
? {
|
|
746
452
|
onPointerDown: (event) => {
|
|
747
|
-
// The rail is about to write scrollTop; this is the follow
|
|
748
|
-
// spring's own off switch, same as every other jump.
|
|
749
453
|
stick.stopScroll()
|
|
750
454
|
drag.current = { y: event.clientY, moved: false, target: event.target }
|
|
751
455
|
event.currentTarget.setPointerCapture(event.pointerId)
|
|
752
456
|
},
|
|
753
457
|
onPointerMove: (event) => {
|
|
754
458
|
const state = drag.current
|
|
755
|
-
if (!state)
|
|
756
|
-
|
|
459
|
+
if (!state) {
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
if (!state.moved && Math.abs(event.clientY - state.y) < 3) {
|
|
463
|
+
return
|
|
464
|
+
}
|
|
757
465
|
state.moved = true
|
|
758
466
|
setPeek(null)
|
|
759
467
|
scrub(event.clientY)
|
|
@@ -761,37 +469,36 @@ export function TerminalScrubber(props: TerminalScrubberProps) {
|
|
|
761
469
|
onPointerUp: (event) => {
|
|
762
470
|
const state = drag.current
|
|
763
471
|
drag.current = null
|
|
764
|
-
if (!state || state.moved)
|
|
765
|
-
|
|
766
|
-
|
|
472
|
+
if (!state || state.moved) {
|
|
473
|
+
return
|
|
474
|
+
}
|
|
767
475
|
const mark = (state.target as HTMLElement | null)?.closest?.('[data-ci]')
|
|
768
476
|
const index = mark ? Number((mark as HTMLElement).dataset.ci) : Number.NaN
|
|
769
|
-
if (Number.isInteger(index) && clusters[index])
|
|
477
|
+
if (Number.isInteger(index) && clusters[index]) {
|
|
770
478
|
activate(clusters[index]!, event.clientY)
|
|
771
|
-
else
|
|
479
|
+
} else {
|
|
480
|
+
scrub(event.clientY)
|
|
481
|
+
}
|
|
772
482
|
},
|
|
773
483
|
}
|
|
774
|
-
: null)}
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
that was a second indicator of one fact, and the loudest colour on
|
|
778
|
-
the rail spent on it. */}
|
|
779
|
-
<div className='term-scrub-band' style={{ top: bandTop, height: bandH }} />
|
|
484
|
+
: null)}
|
|
485
|
+
>
|
|
486
|
+
<div className="term-scrub-band" style={{ top: bandTop, height: bandH }} />
|
|
780
487
|
{clusters.map((cluster, index) => (
|
|
781
488
|
<div
|
|
782
489
|
key={index}
|
|
783
490
|
data-ci={index}
|
|
784
|
-
className=
|
|
491
|
+
className="term-scrub-mark"
|
|
785
492
|
data-lane={cluster.lane}
|
|
786
493
|
data-kind={cluster.kind}
|
|
787
494
|
style={{ top: cluster.y, height: cluster.h }}
|
|
788
495
|
{...(interactive
|
|
789
496
|
? {
|
|
790
497
|
onPointerEnter: (event) => showPeek(cluster, event.clientY),
|
|
791
|
-
// A chain-merged bar can span the rail; sliding along it
|
|
792
|
-
// retargets the peek to the member under the pointer.
|
|
793
498
|
onPointerMove: (event) => {
|
|
794
|
-
if (!drag.current)
|
|
499
|
+
if (!drag.current) {
|
|
500
|
+
showPeek(cluster, event.clientY)
|
|
501
|
+
}
|
|
795
502
|
},
|
|
796
503
|
onPointerLeave: () => setPeek(null),
|
|
797
504
|
}
|
|
@@ -799,7 +506,7 @@ export function TerminalScrubber(props: TerminalScrubberProps) {
|
|
|
799
506
|
/>
|
|
800
507
|
))}
|
|
801
508
|
{peek ? (
|
|
802
|
-
<div ref={peekRef} className=
|
|
509
|
+
<div ref={peekRef} className="term-scrub-peek" style={{ top: peek.y }}>
|
|
803
510
|
{peekContent(peek.cluster, peek.mark, props)}
|
|
804
511
|
</div>
|
|
805
512
|
) : null}
|