@workerdeck/ui 0.23.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/build/{SessionPanel-DUt2VzXG.mjs → SessionPanel-I5QSLLb0.mjs} +2266 -3459
  2. package/build/SessionPanel-I5QSLLb0.mjs.map +1 -0
  3. package/build/SessionPanel-yNO60Tzw.d.mts +151 -0
  4. package/build/format-C_lpFq0l.d.mts +16 -0
  5. package/build/format.d.mts +8 -76
  6. package/build/format.mjs +2 -2
  7. package/build/index.d.mts +291 -1695
  8. package/build/index.mjs +6 -301
  9. package/build/index.mjs.map +1 -1
  10. package/build/scoped.css +32 -23
  11. package/build/{status-BE-zg88x.mjs → status-Dm9INVTt.mjs} +17 -88
  12. package/build/status-Dm9INVTt.mjs.map +1 -0
  13. package/build/workspace.d.mts +8 -187
  14. package/build/workspace.mjs +13 -144
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +24 -24
  17. package/src/components/agent/CodeEditor.tsx +31 -103
  18. package/src/components/agent/Composer.tsx +114 -333
  19. package/src/components/agent/ContextDialog.tsx +20 -41
  20. package/src/components/agent/ContextRing.tsx +6 -28
  21. package/src/components/agent/Conversation.tsx +21 -33
  22. package/src/components/agent/EditorTabs.tsx +39 -67
  23. package/src/components/agent/EngineIcon.tsx +29 -72
  24. package/src/components/agent/EntryIcon.tsx +16 -0
  25. package/src/components/agent/FileCard.tsx +10 -16
  26. package/src/components/agent/FileTree.tsx +52 -125
  27. package/src/components/agent/FileViewer.tsx +30 -60
  28. package/src/components/agent/HostFilesDialog.tsx +40 -69
  29. package/src/components/agent/Loader.tsx +3 -24
  30. package/src/components/agent/McpDialog.tsx +64 -137
  31. package/src/components/agent/Message.tsx +2 -21
  32. package/src/components/agent/ModelSelect.tsx +33 -62
  33. package/src/components/agent/PermissionModeSelect.tsx +21 -79
  34. package/src/components/agent/PermissionPrompt.tsx +59 -63
  35. package/src/components/agent/ProjectIcon.tsx +228 -93
  36. package/src/components/agent/PromptTokenText.tsx +10 -16
  37. package/src/components/agent/QuestionPrompt.tsx +43 -64
  38. package/src/components/agent/Reasoning.tsx +13 -16
  39. package/src/components/agent/Response.tsx +3 -9
  40. package/src/components/agent/Scrubber.tsx +31 -80
  41. package/src/components/agent/SessionBrowser.tsx +64 -243
  42. package/src/components/agent/SessionEmptyState.tsx +10 -40
  43. package/src/components/agent/SessionInfoDialog.tsx +46 -78
  44. package/src/components/agent/SessionItem.tsx +53 -320
  45. package/src/components/agent/SessionList.tsx +23 -39
  46. package/src/components/agent/SessionPanel.tsx +315 -996
  47. package/src/components/agent/SessionStatusIcon.tsx +14 -28
  48. package/src/components/agent/SessionSteps.tsx +34 -187
  49. package/src/components/agent/SessionWorkspace.tsx +104 -219
  50. package/src/components/agent/SkillsDialog.tsx +35 -80
  51. package/src/components/agent/StatusBar.tsx +51 -136
  52. package/src/components/agent/SubagentStrip.tsx +12 -77
  53. package/src/components/agent/ToolCallCard.tsx +94 -135
  54. package/src/components/agent/Transcript.tsx +103 -1288
  55. package/src/components/agent/TranscriptItemView.tsx +155 -0
  56. package/src/components/agent/TranscriptRows.tsx +418 -0
  57. package/src/components/agent/UsageDialog.tsx +7 -29
  58. package/src/components/agent/UsageMeters.tsx +18 -73
  59. package/src/components/agent/pulse.tsx +9 -32
  60. package/src/components/agent/scrubber-marks.ts +33 -103
  61. package/src/components/agent/status.ts +1 -5
  62. package/src/components/agent/tool-result-fetch.tsx +3 -25
  63. package/src/components/agent/tool-result-image.tsx +32 -111
  64. package/src/components/agent/transcript-rows.ts +41 -118
  65. package/src/components/agent/transcript-variant.tsx +3 -80
  66. package/src/components/agent/use-height-epoch.ts +10 -24
  67. package/src/components/agent/use-path-links.ts +45 -63
  68. package/src/components/agent/use-subagent-frame.ts +106 -0
  69. package/src/components/agent/use-transcript-jumps.ts +49 -107
  70. package/src/components/prompt-area/animated-placeholder.tsx +6 -5
  71. package/src/components/prompt-area/clipboard-helpers.ts +26 -21
  72. package/src/components/prompt-area/cursor-helpers.ts +79 -37
  73. package/src/components/prompt-area/dom-helpers.ts +97 -69
  74. package/src/components/prompt-area/file-strip.tsx +90 -80
  75. package/src/components/prompt-area/html-to-markdown.ts +67 -46
  76. package/src/components/prompt-area/image-strip.tsx +8 -18
  77. package/src/components/prompt-area/index.ts +2 -15
  78. package/src/components/prompt-area/prompt-area-engine.ts +100 -115
  79. package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
  80. package/src/components/prompt-area/prompt-area.tsx +31 -38
  81. package/src/components/prompt-area/remove-button.tsx +3 -9
  82. package/src/components/prompt-area/segment-helpers.ts +4 -12
  83. package/src/components/prompt-area/trigger-popover.tsx +15 -27
  84. package/src/components/prompt-area/trigger-presets.ts +0 -10
  85. package/src/components/prompt-area/types.ts +3 -8
  86. package/src/components/prompt-area/use-chip-editing.ts +305 -0
  87. package/src/components/prompt-area/use-markdown-mode.ts +7 -17
  88. package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
  89. package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
  90. package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
  91. package/src/components/prompt-area/use-prompt-area.ts +170 -896
  92. package/src/components/prompt-area/use-trigger-search.ts +21 -19
  93. package/src/components/terminal/PermissionPrompt.tsx +56 -59
  94. package/src/components/terminal/QuestionPrompt.tsx +28 -79
  95. package/src/components/terminal/StatusLine.tsx +13 -63
  96. package/src/components/terminal/TerminalTranscript.tsx +44 -160
  97. package/src/components/terminal/affordances.tsx +68 -98
  98. package/src/components/terminal/blocks.ts +20 -156
  99. package/src/components/terminal/diff.tsx +19 -67
  100. package/src/components/terminal/height.ts +163 -313
  101. package/src/components/terminal/image-box.ts +0 -44
  102. package/src/components/terminal/items.tsx +150 -305
  103. package/src/components/terminal/markdown.tsx +36 -95
  104. package/src/components/terminal/press.tsx +26 -53
  105. package/src/components/terminal/prompt.tsx +40 -170
  106. package/src/components/terminal/result-preview.ts +8 -56
  107. package/src/components/terminal/row.tsx +11 -98
  108. package/src/components/terminal/scrubber.tsx +108 -401
  109. package/src/components/terminal/surface.tsx +6 -54
  110. package/src/components/terminal/todos.ts +66 -0
  111. package/src/components/terminal/tool-run.ts +21 -192
  112. package/src/components/ui/AlertDialog.tsx +8 -23
  113. package/src/components/ui/Badge.tsx +18 -29
  114. package/src/components/ui/Button.tsx +7 -15
  115. package/src/components/ui/Card.tsx +2 -13
  116. package/src/components/ui/CodeBlock.tsx +10 -37
  117. package/src/components/ui/CopyButton.tsx +10 -23
  118. package/src/components/ui/Dialog.tsx +23 -36
  119. package/src/components/ui/Empty.tsx +6 -29
  120. package/src/components/ui/Input.tsx +14 -16
  121. package/src/components/ui/Menu.tsx +6 -18
  122. package/src/components/ui/PortalScope.tsx +1 -21
  123. package/src/components/ui/ProgressRing.tsx +8 -19
  124. package/src/components/ui/Select.tsx +18 -31
  125. package/src/components/ui/Sonner.tsx +1 -2
  126. package/src/components/ui/Spinner.tsx +1 -1
  127. package/src/components/ui/Splitter.tsx +30 -64
  128. package/src/components/ui/Textarea.tsx +2 -5
  129. package/src/components/ui/Tooltip.tsx +3 -13
  130. package/src/format.ts +0 -9
  131. package/src/index.ts +16 -113
  132. package/src/lib/clipboard.ts +8 -28
  133. package/src/lib/css.ts +8 -0
  134. package/src/lib/format.ts +101 -86
  135. package/src/lib/plan-request.ts +16 -0
  136. package/src/lib/status.ts +45 -95
  137. package/src/lib/tool-icon.ts +39 -47
  138. package/src/lib/utils.ts +1 -3
  139. package/src/styles/terminal.css +81 -49
  140. package/src/styles/theme.css +96 -32
  141. package/src/workspace.ts +1 -24
  142. package/build/SessionPanel-BobynUo4.d.mts +0 -669
  143. package/build/SessionPanel-DUt2VzXG.mjs.map +0 -1
  144. package/build/format-ljc3lKpA.d.mts +0 -59
  145. 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)) best = member
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
- const doneLine = (turn: Extract<TranscriptItem, { kind: 'turn_result' }>): string =>
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
- case 'tool_call':
86
+ }
87
+ case 'tool_call': {
219
88
  return `${item.name}(${toolInputPreview(item.input)})`
220
- case 'turn_result':
89
+ }
90
+ case 'turn_result': {
221
91
  return doneLine(item)
222
- case 'file_delivered':
92
+ }
93
+ case 'file_delivered': {
223
94
  return item.path
224
- default:
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
- * Exported for `test/scrubber.test.ts` and nothing else — it is not part of the
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) subagentParents.add(parent)
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) return
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
- if (recapRow) marks.push({ kind: 'recap', itemIndex: -1, rowIndex: recapRow.rowIndex })
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]) current.kind = mark.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
- // The approval is not an item — the prompt renders below the transcript —
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='bright'>{request.title ?? 'Permission required'}</div>
511
- <div className='term-scrub-ex' data-tone='fg'>
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='faint'>※ {recapRow?.label}</div>
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='faint'>※ {recapRow?.label}</div>
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='term-scrub-ex' data-tone='fg'>
530
- <span data-tone='dim'>● </span>
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='red'>
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
- className='term-scrub-ex'
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='term-scrub-ex' data-tone='red'>
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='faint'>
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) return
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 `scrollOffset`
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) return
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
- // Wheel over the rail scrolls the transcript. Manual listener because it
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) return
356
+ if (!interactive) {
357
+ return
358
+ }
637
359
  const element = bodyRef.current
638
- if (!element) return
360
+ if (!element) {
361
+ return
362
+ }
639
363
  const onWheel = (event: WheelEvent) => {
640
364
  const scroller = stick.scrollRef.current
641
- if (!scroller) return
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) return
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
- // Memoized against content and geometry, NOT recomputed per render: the live
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
- // `viewportH` rides here because it is the scale's other term whenever the
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) return
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
- /** A pointer's y in rail space. */
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) onJumpToRow(mark.rowIndex)
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='term-scrubber'
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='term-scrubber-body'
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) return
756
- if (!state.moved && Math.abs(event.clientY - state.y) < 3) return
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) return
765
- // A clean press: on a mark it is a jump; on the ground it is a
766
- // scrub to that spot — scrollbar semantics.
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 scrub(event.clientY)
479
+ } else {
480
+ scrub(event.clientY)
481
+ }
772
482
  },
773
483
  }
774
- : null)}>
775
- {/* The band is the whole "where am I" answer. It used to carry a 2px
776
- blue line on its top edge as well; with the band already outlined,
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='term-scrub-mark'
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) showPeek(cluster, event.clientY)
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='term-scrub-peek' style={{ top: peek.y }}>
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}