@workerdeck/ui 0.16.0 → 0.18.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 (47) hide show
  1. package/README.md +7 -0
  2. package/build/{SessionPanel-B9CHoq8x.d.mts → SessionPanel-CnU_IJ3-.d.mts} +36 -9
  3. package/build/{SessionPanel-DII9MmQ8.mjs → SessionPanel-DPx8Iz8a.mjs} +1432 -384
  4. package/build/SessionPanel-DPx8Iz8a.mjs.map +1 -0
  5. package/build/{format-DfI_je9S.d.mts → format-ljc3lKpA.d.mts} +1 -1
  6. package/build/format.d.mts +16 -5
  7. package/build/format.mjs +2 -3
  8. package/build/index.d.mts +288 -8
  9. package/build/index.mjs +620 -165
  10. package/build/index.mjs.map +1 -1
  11. package/build/{format-DqR56Y8l.mjs → status-BE-zg88x.mjs} +154 -2
  12. package/build/status-BE-zg88x.mjs.map +1 -0
  13. package/build/workspace.d.mts +4 -1
  14. package/build/workspace.mjs +4 -3
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +8 -6
  17. package/src/components/agent/ContextRing.tsx +41 -0
  18. package/src/components/agent/EngineIcon.tsx +40 -0
  19. package/src/components/agent/ProjectIcon.tsx +119 -0
  20. package/src/components/agent/SessionBrowser.tsx +191 -17
  21. package/src/components/agent/SessionPanel.tsx +177 -2
  22. package/src/components/agent/SessionSteps.tsx +233 -0
  23. package/src/components/agent/SessionWorkspace.tsx +4 -0
  24. package/src/components/agent/StatusBar.tsx +4 -2
  25. package/src/components/agent/SubagentStrip.tsx +134 -0
  26. package/src/components/agent/ToolCallCard.tsx +72 -5
  27. package/src/components/agent/Transcript.tsx +212 -23
  28. package/src/components/agent/tool-result-fetch.tsx +36 -0
  29. package/src/components/agent/tool-result-image.tsx +209 -0
  30. package/src/components/agent/transcript-rows.ts +122 -23
  31. package/src/components/terminal/TerminalTranscript.tsx +154 -2
  32. package/src/components/terminal/affordances.tsx +34 -0
  33. package/src/components/terminal/blocks.ts +260 -0
  34. package/src/components/terminal/height.ts +73 -6
  35. package/src/components/terminal/image-box.ts +53 -0
  36. package/src/components/terminal/items.tsx +116 -79
  37. package/src/components/terminal/result-preview.ts +20 -6
  38. package/src/components/terminal/scrubber.tsx +172 -26
  39. package/src/components/terminal/tool-run.ts +177 -0
  40. package/src/index.ts +20 -1
  41. package/src/lib/status.ts +16 -3
  42. package/src/styles/terminal.css +85 -5
  43. package/src/styles/theme.css +42 -0
  44. package/build/SessionPanel-DII9MmQ8.mjs.map +0 -1
  45. package/build/format-DqR56Y8l.mjs.map +0 -1
  46. package/build/status-Ydzi7n6j.mjs +0 -143
  47. package/build/status-Ydzi7n6j.mjs.map +0 -1
@@ -7,10 +7,32 @@ import { CopyAction, WithActions } from './affordances.tsx'
7
7
  import { TerminalDiff } from './diff.tsx'
8
8
  import { TerminalMarkdown } from './markdown.tsx'
9
9
  import { Pressable, useRevealOnOpen } from './press.tsx'
10
+ import { IMAGE_BOX_LINES, IMAGE_UNAVAILABLE, imagePlaceholder } from './image-box.ts'
10
11
  import { collapsedResult } from './result-preview.ts'
11
- import { foldsTogether, runSummary } from './tool-run.ts'
12
+ import { useToolResultFetcher } from '../agent/tool-result-fetch.tsx'
13
+ import { useToolResultImageSrc } from '../agent/tool-result-image.tsx'
14
+ import { runFailed, runSummary } from './tool-run.ts'
15
+ import { type ToolCallItem } from './blocks.ts'
12
16
  import { Band, Blank, Ink, Row, type Tone } from './row.tsx'
13
17
 
18
+ // The pure block model — which rows exist — lives in `blocks.ts` now that a
19
+ // task block made it non-trivial; re-exported here because this file is where
20
+ // consumers have always found it.
21
+ export {
22
+ blockNeedsBlank,
23
+ isRunCall,
24
+ needsBlank,
25
+ parentOf,
26
+ taskChildItems,
27
+ terminalBlocks,
28
+ type ItemBlock,
29
+ type LeafBlock,
30
+ type RunBlock,
31
+ type TaskBlock,
32
+ type TerminalBlock,
33
+ type ToolCallItem,
34
+ } from './blocks.ts'
35
+
14
36
  /**
15
37
  * One transcript item, drawn as terminal rows.
16
38
  *
@@ -44,8 +66,13 @@ import { Band, Blank, Ink, Row, type Tone } from './row.tsx'
44
66
  export const PROMPT_GLYPH = '❯'
45
67
 
46
68
  /** How much the expanded row shows before offering the rest. The collapsed
47
- * budget is `collapsedResult`'s, shared with the height calculator. */
48
- const RESULT_PREVIEW_CHARS = 2000
69
+ * budget is `collapsedResult`'s, shared with the height calculator.
70
+ *
71
+ * Exported for one test and not from the package: protocol's
72
+ * `TOOL_RESULT_HEAD_CHARS` is chosen to exceed it, so that a truncated result's
73
+ * open state is byte-identical to an untruncated one and only the uncapped
74
+ * `full` press ever fetches. That relationship is asserted, not assumed. */
75
+ export const RESULT_PREVIEW_CHARS = 2000
49
76
 
50
77
  /** Whole lines up to a character budget — never zero, because a single line
51
78
  * longer than the budget still has to be shown or the row would open onto
@@ -122,11 +149,14 @@ const TOOL_TONE: Record<string, Tone> = {
122
149
  failed: 'red',
123
150
  }
124
151
 
125
- export type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
126
-
127
152
  export function ToolRow({ item }: { item: ToolCallItem }) {
128
153
  const [open, setOpen] = useState(false)
129
154
  const [full, setFull] = useState(false)
155
+ // Set while the rest of a truncated result is in flight. Row-local, unlike the
156
+ // text itself, which lands in transcript state — this is a spinner, not a
157
+ // fact about the session.
158
+ const [fetching, setFetching] = useState(false)
159
+ const fetchResult = useToolResultFetcher()
130
160
  const reveal = useRevealOnOpen(open)
131
161
  const status = item.status ?? (item.result === undefined ? 'running' : 'settled')
132
162
  const busy = status === 'running' || status === 'pending'
@@ -144,7 +174,9 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
144
174
  // the virtualizer mounts rows, so it cannot help with what is inside a single
145
175
  // one. Without the clip, expanding one row commits thousands of DOM nodes and
146
176
  // the transcript stops being smooth for the rest of the session.
147
- const collapsed = collapsedResult(lines)
177
+ // The true total when the replay delivered only a head — the row must count
178
+ // what is missing, not what it happens to hold.
179
+ const collapsed = collapsedResult(lines, item.result?.totalChars)
148
180
  const preview = open
149
181
  ? full
150
182
  ? lines
@@ -152,6 +184,11 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
152
184
  : collapsed.shown
153
185
  const hidden = lines.length - preview.length
154
186
  const clipped = open && !full && hidden > 0
187
+ // The replay sent a head. `full` then means "fetch the rest", not "lift the
188
+ // clip" — and the marker outlives the clip, because a head short enough to fit
189
+ // the open budget still is not the result.
190
+ const truncated = item.result?.truncated === true
191
+ const missing = truncated ? (item.result?.totalChars ?? 0) - text.length : 0
155
192
 
156
193
  const tone: Tone = isError
157
194
  ? 'red'
@@ -184,6 +221,13 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
184
221
  ) : null}
185
222
  </Row>
186
223
  </Pressable>
224
+ {/* Above the output, because when a call returned a picture the picture is
225
+ what the call was: a screenshot's result text is "took a screenshot".
226
+ Drawn collapsed as well as open — this is not detail behind a press,
227
+ it is the answer. */}
228
+ {item.result?.images?.map((image) => (
229
+ <TerminalImage key={image.partIndex} toolUseId={item.id} image={image} />
230
+ ))}
187
231
  {/* A file edit shows its diff, not its result prose: "The file has been
188
232
  updated" is what the *model* needed to hear, and the change is what the
189
233
  reader did. The text stays reachable by expanding. */}
@@ -214,15 +258,30 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
214
258
  {collapsed.more}
215
259
  </Row>
216
260
  ) : null
217
- ) : hidden > 0 ? (
261
+ ) : clipped || truncated ? (
218
262
  <Row indent={1} columns={3} tone='faint'>
219
- {clipped ? (
263
+ {fetching ? (
264
+ // Never a row that does nothing when pressed: it says what it is
265
+ // doing instead. See `planToolCall`'s comment on the same rule.
266
+ <>… fetching {(item.result?.totalChars ?? 0).toLocaleString()} chars</>
267
+ ) : clipped || truncated ? (
220
268
  <button
221
269
  type='button'
222
270
  className='term-press term-link'
223
- onClick={() => setFull(true)}>
224
- +{hidden} line{hidden === 1 ? '' : 's'} show all{' '}
225
- {text.length.toLocaleString()} chars
271
+ onClick={() => {
272
+ // One press, two acts, in the order that keeps the row
273
+ // honest: lift the clip immediately (that part is local and
274
+ // instant), and fetch the rest when there is a rest. The
275
+ // fetched text lands in transcript state, so the row
276
+ // re-renders with the marker gone.
277
+ setFull(true)
278
+ if (!truncated) return
279
+ setFetching(true)
280
+ void fetchResult(item.id).finally(() => setFetching(false))
281
+ }}>
282
+ {truncated
283
+ ? `… +${missing.toLocaleString()} chars — fetch the rest`
284
+ : `… +${hidden} line${hidden === 1 ? '' : 's'} — show all ${text.length.toLocaleString()} chars`}
226
285
  </button>
227
286
  ) : (
228
287
  <>
@@ -230,6 +289,10 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
230
289
  </>
231
290
  )}
232
291
  </Row>
292
+ ) : hidden > 0 ? (
293
+ <Row indent={1} columns={3} tone='faint'>
294
+ … +{hidden} line{hidden === 1 ? '' : 's'}
295
+ </Row>
233
296
  ) : null}
234
297
  </>
235
298
  ) : null}
@@ -238,10 +301,41 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
238
301
  )
239
302
  }
240
303
 
241
- /** Is this a row the transcript folds into a run? Any tool call is — see
242
- * `tool-run.ts` for why this is no longer shell-only. */
243
- export function isRunCall(item: TranscriptItem): item is ToolCallItem {
244
- return item.kind === 'tool_call'
304
+ /** One image part of a tool result, as the reducer holds it. */
305
+ type ToolResultImage = NonNullable<NonNullable<ToolCallItem['result']>['images']>[number]
306
+
307
+ /**
308
+ * A picture a tool returned, in a box of {@link IMAGE_BOX_LINES} whole lines.
309
+ *
310
+ * **Three states, one height.** Before the fetch lands the box is a wash and the
311
+ * size the gateway declared; after it, the picture, letterboxed inside the same
312
+ * box; on a refusal, `image unavailable` in it. Nothing here may ever collapse
313
+ * to nothing — that is `HostImage`'s return-null-then-pop, which in a
314
+ * *virtualized* list is not a flicker but a reflow of every row below it, and
315
+ * the height calculator would have been lying about the row from plan time.
316
+ *
317
+ * The box is why the calculator can stay exact: it is a constant, not a function
318
+ * of pixels nobody has downloaded yet.
319
+ */
320
+ function TerminalImage({ toolUseId, image }: { toolUseId: string; image: ToolResultImage }) {
321
+ const { src, failed } = useToolResultImageSrc({ toolUseId, ...image })
322
+ return (
323
+ <Row indent={1} columns={3}>
324
+ <div
325
+ className='term-image'
326
+ data-state={src ? 'loaded' : failed ? 'failed' : 'pending'}
327
+ // The one measurement in this file, and it is the shared constant
328
+ // spelled once — `height.ts` adds exactly this many lines for exactly
329
+ // this box.
330
+ style={{ height: `calc(var(--term-line) * ${IMAGE_BOX_LINES})` }}>
331
+ {src ? (
332
+ <img src={src} alt={imagePlaceholder(image)} />
333
+ ) : (
334
+ <Ink tone='faint'>{failed ? IMAGE_UNAVAILABLE : imagePlaceholder(image)}</Ink>
335
+ )}
336
+ </div>
337
+ </Row>
338
+ )
245
339
  }
246
340
 
247
341
  /**
@@ -254,10 +348,11 @@ export function isRunCall(item: TranscriptItem): item is ToolCallItem {
254
348
  * collapses to its count and gets out of the way — and opens, in full, the
255
349
  * moment it is the thing you actually want.
256
350
  *
257
- * The membership and wording rules live in `tool-run.ts`, shared with the height
258
- * calculator. A failed member does not break the run — it *colours* it, which is
259
- * the same call the scrubber makes: a failure is worth seeing, and fragmenting
260
- * the run around it would hide it in a longer list rather than surface it.
351
+ * The membership, wording and failure rules live in `tool-run.ts`, shared with
352
+ * the height calculator. A failure never breaks the run — fragmenting it around
353
+ * one would hide the failure in a longer list rather than surface it — but only
354
+ * the run's **last** call colours it, because that is the run's outcome and an
355
+ * outcome is what a collapsed row can honestly claim (see `runFailed`).
261
356
  */
262
357
  export function ToolRunRow({ items }: { items: ToolCallItem[] }) {
263
358
  const [open, setOpen] = useState(false)
@@ -266,7 +361,7 @@ export function ToolRunRow({ items }: { items: ToolCallItem[] }) {
266
361
  const status = item.status ?? (item.result === undefined ? 'running' : 'settled')
267
362
  return status === 'running' || status === 'pending'
268
363
  })
269
- const failed = items.some((item) => item.status === 'failed' || item.result?.isError === true)
364
+ const failed = runFailed(items)
270
365
  const pulse = usePulse(busy)
271
366
 
272
367
  return (
@@ -293,49 +388,6 @@ export function ToolRunRow({ items }: { items: ToolCallItem[] }) {
293
388
  )
294
389
  }
295
390
 
296
- /**
297
- * Fold consecutive tool calls into runs, leaving everything else alone.
298
- *
299
- * Shared by both renderers — the virtualized shell in `agent/Transcript.tsx` and
300
- * the plain {@link TerminalTranscript} — because which rows exist is part of what
301
- * the theme *is*, and a client that grouped differently would be showing a
302
- * different transcript of the same session.
303
- */
304
- export type TerminalBlock =
305
- | { key: string; item: TranscriptItem; index: number }
306
- | { key: string; run: ToolCallItem[]; index: number }
307
-
308
- /**
309
- * @param offset What `items[0]`'s index is in the whole transcript — the
310
- * virtualized shell folds each side of the recap boundary separately, and the
311
- * rows still have to say where they sit for the catch-up dimming.
312
- * @param fold Whether to group at all. `false` gives one block per item,
313
- * which is what the cards variant renders: this is the terminal theme's rule
314
- * and must not silently reshape another renderer's row list.
315
- */
316
- export function terminalBlocks(
317
- items: readonly TranscriptItem[],
318
- offset = 0,
319
- fold = true,
320
- ): TerminalBlock[] {
321
- const out: TerminalBlock[] = []
322
- for (const [position, item] of items.entries()) {
323
- const index = offset + position
324
- const previous = out.at(-1)
325
- if (fold && isRunCall(item)) {
326
- if (previous && 'run' in previous && foldsTogether(previous.run[0]!, item)) {
327
- previous.run.push(item)
328
- } else {
329
- // Keyed by the run's *first* call, so the key is stable as the run grows.
330
- out.push({ key: `run:${item.id}`, run: [item], index })
331
- }
332
- continue
333
- }
334
- out.push({ key: `${item.kind}:${item.id}`, item, index })
335
- }
336
- return out
337
- }
338
-
339
391
  export function TurnResultRow({
340
392
  item,
341
393
  }: {
@@ -390,7 +442,7 @@ export function FileRow({
390
442
  }
391
443
 
392
444
  /** A once-a-second clock, running only while `on`. */
393
- function useTicker(on: boolean): number {
445
+ export function useTicker(on: boolean): number {
394
446
  const [now, setNow] = useState(() => Date.now())
395
447
  useEffect(() => {
396
448
  if (!on) return
@@ -431,19 +483,4 @@ export function WorkingRow({
431
483
  )
432
484
  }
433
485
 
434
- /** Spacing between two items: a blank line, unless the pair belongs together.
435
- * Tool output already sits under its call, and a run of tool calls reads as one
436
- * block — the CLI leaves no blank line inside either. */
437
- export function needsBlank(previous: TranscriptItem, next: TranscriptItem): boolean {
438
- if (previous.kind === 'tool_call' && next.kind === 'tool_call') return false
439
- return true
440
- }
441
-
442
- /** The same rule over blocks: a shell run counts as the tool calls it folded. */
443
- export function blockNeedsBlank(previous: TerminalBlock, next: TerminalBlock): boolean {
444
- const before = 'run' in previous ? 'tool_call' : previous.item.kind
445
- const after = 'run' in next ? 'tool_call' : next.item.kind
446
- return !(before === 'tool_call' && after === 'tool_call')
447
- }
448
-
449
486
  export { Band, Blank }
@@ -45,7 +45,16 @@ export type CollapsedResult = {
45
45
  * lines otherwise — a one-line JSON blob has no hidden lines to count, and
46
46
  * "+0 lines" under a visibly cut-off row is worse than saying nothing.
47
47
  */
48
- export function collapsedResult(lines: string[]): CollapsedResult {
48
+ /**
49
+ * `totalChars` is the **untruncated** length when the replay delivered only a
50
+ * head (protocol's `ToolResultBlock.total_chars`). Passing it is not cosmetic:
51
+ * computed from the head this row would say "… +7,600 chars" where the truth is
52
+ * 641,003, and the wrong string is a *different pixel height* — which is exactly
53
+ * the drift this module exists to prevent, since `height.ts` sizes the row by
54
+ * wrapping this same text. Omitted for a whole result, where the lines are the
55
+ * whole truth.
56
+ */
57
+ export function collapsedResult(lines: string[], totalChars?: number): CollapsedResult {
49
58
  const shown: string[] = []
50
59
  let chars = 0
51
60
  let cut = false
@@ -62,11 +71,16 @@ export function collapsedResult(lines: string[]): CollapsedResult {
62
71
  chars += line.length + 1
63
72
  }
64
73
 
65
- if (cut) {
66
- // `join` because the newlines are part of what is not being shown.
67
- const hidden = lines.join('\n').length - chars
68
- return { shown, more: `… +${hidden.toLocaleString()} chars` }
69
- }
74
+ // `join` because the newlines are part of what is not being shown — and
75
+ // `totalChars` wins when it exists, because the lines in hand are then a head
76
+ // rather than the result.
77
+ const held = lines.join('\n').length
78
+ const total = totalChars ?? held
79
+ if (cut) return { shown, more: `… +${(total - chars).toLocaleString()} chars` }
80
+ // A truncated result always has more, even when its head happened to fit the
81
+ // line budget: the row must never claim to be showing everything.
82
+ if (totalChars !== undefined && total > held)
83
+ return { shown, more: `… +${(total - chars).toLocaleString()} chars` }
70
84
  const hidden = lines.length - shown.length
71
85
  return { shown, more: hidden > 0 ? `… +${hidden} line${hidden === 1 ? '' : 's'}` : undefined }
72
86
  }
@@ -3,6 +3,7 @@ import { useStickToBottomContext } from 'use-stick-to-bottom'
3
3
  import type { PermissionRequest } from '@workerdeck/protocol'
4
4
  import type { TranscriptItem } from '@workerdeck/react'
5
5
  import { formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
6
+ import { parentOf } from './blocks.ts'
6
7
  import { TerminalSurface } from './surface.tsx'
7
8
 
8
9
  /**
@@ -46,6 +47,7 @@ import { TerminalSurface } from './surface.tsx'
46
47
  type Lane = 'l' | 'r' | 'f'
47
48
  type MarkKind =
48
49
  | 'user'
50
+ | 'subagent'
49
51
  | 'turn'
50
52
  | 'turnFailed'
51
53
  | 'toolFailed'
@@ -80,26 +82,38 @@ function nearestMember(cluster: Cluster, y: number): Mark | undefined {
80
82
  }
81
83
 
82
84
  /**
83
- * Two lanes and a full-width annotation, which is a claim about what a rail is
84
- * *for*: the two things you navigate by are what you asked and what came back,
85
- * so they get a lane each and split the rail evenly. Everything else — an error,
86
- * a waiting approval, a bookmark, the catch-up seam is an **annotation on the
87
- * run** rather than a step through it, so it spans the full width and reads as
88
- * a different class of thing rather than as a third column of steps.
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).
89
95
  *
90
96
  * It also buys the marks their width back: three lanes in a 16px rail is 5px a
91
97
  * lane, which is a hard target to hit and a hard colour to see.
92
98
  */
93
99
  const LANE: Record<MarkKind, Lane> = {
94
100
  user: 'l',
101
+ // Delegated work is input: a sub-agent runs because you asked for it, and its
102
+ // stretch of the transcript is *your* dispatch rather than the session's
103
+ // answer. It also gives a folded `Task` its one honest signal on the rail —
104
+ // collapsed, sixty rows of somebody else's working are one line, and this is
105
+ // the mark that says the region is there at all.
106
+ subagent: 'l',
95
107
  turn: 'r',
96
108
  turnFailed: 'r',
97
- // Full-width, like every other alarm: a failed tool call is something that
98
- // went wrong *during* a step, not a step you scroll between. A lane mark would
99
- // also put it in the response lane, where it would compete with the turn marks
100
- // that are the rail's actual navigation.
101
- toolFailed: 'f',
102
- error: 'f',
109
+ // Output, with the answers: a failed tool call is something the run produced.
110
+ // It had been full-width on the argument that it is an alarm rather than a
111
+ // step but "alarm" is not a lane, and half the failures ending up down the
112
+ // middle while `turnFailed` sat in the right lane meant no single column
113
+ // answered "did anything go wrong". Its rank in LOUDNESS and its 55% strength
114
+ // are what keep it from shouting over the turns it now sits beside.
115
+ toolFailed: 'r',
116
+ error: 'r',
103
117
  approval: 'f',
104
118
  recap: 'f',
105
119
  bookmark: 'f',
@@ -115,17 +129,22 @@ const LOUDNESS: Record<MarkKind, number> = {
115
129
  // keep the cluster. (It cannot merge with `turnFailed` — that is lane `r`, and
116
130
  // merging is per lane.) A failed tool call the model recovered from is routine
117
131
  // in a way a session error is not, hence quieter here and at 55% in the CSS.
118
- // The one thing it does outrank is `bookmark`, which loses its magenta to a
119
- // failure it sits beside.
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.
120
134
  toolFailed: 4,
121
135
  user: 3,
122
136
  turn: 2,
123
137
  bookmark: 1,
138
+ // Lane `l`, so this is only ever weighed against `user`, and a prompt wins:
139
+ // the prompt is the step you navigate by and the sub-agent band is the
140
+ // annotation on it. (It ties with `bookmark`, which it can never meet.)
141
+ subagent: 1,
124
142
  recap: 0,
125
143
  }
126
144
 
127
145
  const KIND_NAME: Record<MarkKind, string> = {
128
146
  user: 'you',
147
+ subagent: 'sub-agent',
129
148
  turn: 'response · turn end',
130
149
  turnFailed: 'turn failed',
131
150
  toolFailed: 'tool failed',
@@ -163,7 +182,7 @@ const MIN_MARK = 2
163
182
  * never exceed `railH` again, for any content, because `viewportH` can never
164
183
  * exceed the denominator.
165
184
  */
166
- function railScale(railH: number, totalSize: number, viewportH: number): number {
185
+ export function railScale(railH: number, totalSize: number, viewportH: number): number {
167
186
  return totalSize > 0 ? railH / Math.max(totalSize, viewportH) : 0
168
187
  }
169
188
 
@@ -214,6 +233,19 @@ export interface TerminalScrubberProps {
214
233
  recapRow?: { rowIndex: number; label: string }
215
234
  /** Bookmarked item indices. Paint only — no store, no set affordance. */
216
235
  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
+ frameParentId?: string
217
249
  /** Item index → virtual row index (the off-by-a-fold mapping; see
218
250
  * `rowIndexForItem` in `agent/Transcript.tsx`). */
219
251
  rowIndexFor: (itemIndex: number) => number
@@ -223,6 +255,12 @@ export interface TerminalScrubberProps {
223
255
  /** A virtual row's height in content space, same source — what a mark's own
224
256
  * height is scaled from. */
225
257
  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
+ positionInRow?: (itemIndex: number) => { ordinal: number; count: number } | undefined
226
264
  totalSize: number
227
265
  scrollOffset: number
228
266
  viewportH: number
@@ -234,22 +272,51 @@ export interface TerminalScrubberProps {
234
272
  lineHeight?: number
235
273
  }
236
274
 
237
- function buildClusters(
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(
238
284
  props: TerminalScrubberProps,
239
285
  railH: number,
240
286
  ): Cluster[] {
241
287
  const {
242
288
  items,
243
289
  bookmarks,
290
+ frameParentId,
244
291
  recapRow,
245
292
  pendingApprovals,
246
293
  rowIndexFor,
247
294
  offsetOfRow,
248
295
  sizeOfRow,
296
+ positionInRow,
249
297
  totalSize,
250
298
  viewportH,
251
299
  } = props
252
300
  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
+ const subagentParents = new Set<string>()
307
+ for (const item of items) {
308
+ const parent = parentOf(item)
309
+ if (parent !== undefined) subagentParents.add(parent)
310
+ }
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
+ const rowOutcome = new Map<number, number>()
316
+ items.forEach((item, index) => {
317
+ if (item.kind !== 'tool_call' || parentOf(item) !== frameParentId) return
318
+ rowOutcome.set(rowIndexFor(index), index)
319
+ })
253
320
  // One right-lane mark per segment, emitted when the segment closes. A segment
254
321
  // is closed by the next prompt, by its own turn end, or by running out of
255
322
  // items — that last one is what a replayed history is made of.
@@ -267,7 +334,22 @@ function buildClusters(
267
334
  segment = {}
268
335
  }
269
336
  items.forEach((item, index) => {
270
- if (item.kind === 'user') {
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
+ if (item.kind === 'tool_call' && subagentParents.has(item.id)) {
346
+ marks.push({ kind: 'subagent', itemIndex: index, rowIndex: rowIndexFor(index) })
347
+ }
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
+ if (item.kind === 'user' && parentOf(item) === frameParentId) {
271
353
  closeSegment()
272
354
  marks.push({ kind: 'user', itemIndex: index, rowIndex: rowIndexFor(index) })
273
355
  } else if (item.kind === 'turn_result') {
@@ -277,16 +359,58 @@ function buildClusters(
277
359
  } else if (item.kind === 'notice' && item.level === 'error') {
278
360
  marks.push({ kind: 'error', itemIndex: index, rowIndex: rowIndexFor(index) })
279
361
  } else if (
280
- // The same predicate the row itself reddens with (`items.tsx`), and the
281
- // same one the recap counts errors by the two spellings are not
282
- // redundant: an out-of-loop execution failure sets `status` with no
283
- // `is_error` block to read, and an engine can flag `is_error` on a call
284
- // this reducer has not settled yet.
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.
285
397
  item.kind === 'tool_call' &&
286
- (item.status === 'failed' || item.result?.isError === true)
398
+ (item.status === 'failed' || item.result?.isError === true) &&
399
+ rowOutcome.get(rowIndexFor(index)) === index
287
400
  ) {
288
401
  marks.push({ kind: 'toolFailed', itemIndex: index, rowIndex: rowIndexFor(index) })
289
- } else if (item.kind === 'assistant_text' && item.parentToolUseId == null) {
402
+ } else if (item.kind === 'assistant_text' && parentOf(item) === frameParentId) {
403
+ 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
+ marks.push({ kind: 'turn', itemIndex: index, rowIndex: rowIndexFor(index) })
412
+ return
413
+ }
290
414
  // The live one included, deliberately: a turn in flight has no turn end
291
415
  // yet, which left a two-minute answer unrepresented on the rail for the
292
416
  // whole two minutes it was the only thing worth navigating to. The mark's
@@ -309,8 +433,30 @@ function buildClusters(
309
433
  // A mark's height is its row's, at rail scale, floored at the hit target —
310
434
  // the row the mark *anchors* (for a turn, the final response), which is
311
435
  // where the reader lands and what they came to gauge the size of.
312
- const h = Math.max(MIN_MARK, Math.round(sizeOfRow(mark.rowIndex) * scale))
313
- const y = Math.min(Math.max(0, railH - h), Math.round(offsetOfRow(mark.rowIndex) * scale))
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
+ const within = mark.itemIndex >= 0 ? positionInRow?.(mark.itemIndex) : undefined
452
+ const rowH = sizeOfRow(mark.rowIndex)
453
+ const h = within ? MIN_MARK : Math.max(MIN_MARK, Math.round(rowH * scale))
454
+ const y = Math.min(
455
+ Math.max(0, railH - h),
456
+ Math.round(
457
+ (offsetOfRow(mark.rowIndex) + (within ? (within.ordinal / within.count) * rowH : 0)) * scale,
458
+ ),
459
+ )
314
460
  const lane = LANE[mark.kind]
315
461
  const list = lanes.get(lane) ?? []
316
462
  list.push({ mark, y, h })