@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
@@ -0,0 +1,134 @@
1
+ import { ArrowLeft } from 'lucide-react'
2
+ import type { TranscriptItem } from '@workerdeck/react'
3
+ import { cn } from '../../lib/utils.ts'
4
+ import { formatDuration } from '../../lib/format.ts'
5
+ import { Button } from '../ui/Button.tsx'
6
+ import { Ink, Row } from '../terminal/row.tsx'
7
+ import { TerminalSurface } from '../terminal/surface.tsx'
8
+ import { taskBusy, taskFailed, taskIdentity } from '../terminal/tool-run.ts'
9
+ import type { ToolCallItem } from '../terminal/blocks.ts'
10
+ import { usePulse } from './pulse.tsx'
11
+ import { useTicker } from '../terminal/items.tsx'
12
+
13
+ /**
14
+ * The one line above a sub-agent takeover: who this is, how it is doing, and the
15
+ * way back.
16
+ *
17
+ * **It claims exactly what the `TaskRow` it was opened from claims** — the same
18
+ * `taskBusy` / `taskFailed` / tool count over the same items — and deliberately
19
+ * *not* `SubagentInfo.status`. Protocol documents those two as divergent on
20
+ * purpose (`index.ts:1476-1487`), but that divergence is transcript-versus-*list*
21
+ * and this surface **is** the transcript. The disagreement that must not exist
22
+ * here is between a header and the rows directly beneath it.
23
+ *
24
+ * The rollup is still allowed one job — naming an agent whose `Task` call is not
25
+ * in the transcript — because a label is not content, and `SessionInfo.subagents`
26
+ * keeps only eight settled records (`SUBAGENT_HISTORY`), so it can never be the
27
+ * source of what a frame *shows*.
28
+ *
29
+ * Both variants, because the panel has two and a strip that only existed in one
30
+ * would make the takeover a terminal-theme feature rather than a session feature.
31
+ */
32
+ export function SubagentStrip({
33
+ task,
34
+ items,
35
+ label,
36
+ onBack,
37
+ terminal,
38
+ fontSize,
39
+ lineHeight,
40
+ }: {
41
+ /** The spawning call. Absent when the transcript does not have it — the strip
42
+ * still draws, because the way back must exist even when the content cannot. */
43
+ task: ToolCallItem | undefined
44
+ /** The frame's items, for the busy reading and the tool count. */
45
+ items: readonly TranscriptItem[]
46
+ /** Fallback name when there is no task item. */
47
+ label: string
48
+ onBack: () => void
49
+ terminal: boolean
50
+ fontSize?: number
51
+ lineHeight?: number
52
+ }) {
53
+ const busy = task ? taskBusy(task, items) : false
54
+ const failed = task ? taskFailed(task) : false
55
+ const pulse = usePulse(busy)
56
+ const tools = items.reduce((n, item) => n + (item.kind === 'tool_call' ? 1 : 0), 0)
57
+ // Ticks only while it works, off the same once-a-second clock the working
58
+ // line uses. A settled agent has no end timestamp to measure against, and
59
+ // "N tools" is the settled reading anyway — a frozen clock would read as a
60
+ // stall. Absent `ts` (an item from before the reducer stamped it) simply
61
+ // draws no elapsed rather than counting from the epoch.
62
+ const startedAt = busy ? task?.ts : undefined
63
+ const now = useTicker(startedAt !== undefined)
64
+ const elapsed = startedAt === undefined ? undefined : formatDuration(now - startedAt)
65
+
66
+ const name = task ? taskIdentity(task) : label
67
+ /**
68
+ * Running or finished, in the theme's own words rather than new ones:
69
+ * `taskSummary` already says `working…` and `done` for exactly this state one
70
+ * row over, and a header that said "running"/"completed" about the same agent
71
+ * would be a second vocabulary for one fact. The trailing ellipsis is the
72
+ * theme's in-flight signal; the pulse beside it is the beat.
73
+ *
74
+ * Unknown when the transcript has no `Task` call to read — better silent than
75
+ * confidently wrong about an agent we cannot see.
76
+ */
77
+ const status = !task ? undefined : failed ? 'failed' : busy ? `${pulse} working…` : 'done'
78
+ const detail = [tools > 0 ? `${tools} tool${tools === 1 ? '' : 's'}` : undefined, elapsed]
79
+ .filter(Boolean)
80
+ .join(' · ')
81
+
82
+ if (terminal) {
83
+ return (
84
+ <TerminalSurface fontSize={fontSize} lineHeight={lineHeight} className='shrink-0'>
85
+ {/* A row on the grid, not a chrome bar: the takeover is a mode of the
86
+ transcript, and a toolbar in some other metric above it would read as
87
+ a different application's. The whole line is the target — there is
88
+ exactly one thing to do here. */}
89
+ <button
90
+ type='button'
91
+ onClick={onBack}
92
+ aria-label='Back to the session'
93
+ className='block w-full cursor-pointer text-left'>
94
+ {/* The arrow is in the gutter unconditionally. It used to give way to
95
+ the pulse while the agent worked, which put the way *out* of the
96
+ frame on a timer — the one control here should not come and go.
97
+ The beat moved into the status instead. `indent` because this is a
98
+ frame around the rows rather than one of them, and a marker flush
99
+ against the panel edge reads as a clipped row. */}
100
+ <Row glyph='←' glyphTone='dim' indent={1} tone={failed ? 'red' : 'green'}>
101
+ {name}
102
+ {status ? (
103
+ <Ink tone={failed ? 'red' : busy ? 'mark' : 'dim'}> · {status}</Ink>
104
+ ) : null}
105
+ {detail ? <Ink tone='faint'> · {detail}</Ink> : null}
106
+ </Row>
107
+ </button>
108
+ </TerminalSurface>
109
+ )
110
+ }
111
+
112
+ return (
113
+ <div className='flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5'>
114
+ <Button variant='ghost' size='sm' onClick={onBack} className='h-6 gap-1 px-1.5'>
115
+ <ArrowLeft className='size-3.5' />
116
+ Back
117
+ </Button>
118
+ <span
119
+ className={cn('min-w-0 flex-1 truncate text-body-sm', failed ? 'text-danger' : 'text-fg-2')}>
120
+ {name}
121
+ </span>
122
+ {status ? (
123
+ <span
124
+ className={cn(
125
+ 'shrink-0 text-label',
126
+ failed ? 'text-danger' : busy ? 'text-accent' : 'text-fg-3',
127
+ )}>
128
+ {status}
129
+ </span>
130
+ ) : null}
131
+ {detail ? <span className='shrink-0 text-label text-fg-4'>{detail}</span> : null}
132
+ </div>
133
+ )
134
+ }
@@ -7,6 +7,14 @@ import { Spinner } from '../ui/Spinner.tsx'
7
7
  import { cn } from '../../lib/utils.ts'
8
8
  import { toolInputPreview } from '../../lib/format.ts'
9
9
  import { toolIcon } from '../../lib/tool-icon.ts'
10
+ import { useToolResultFetcher } from './tool-result-fetch.tsx'
11
+ import { useToolResultImageSrc } from './tool-result-image.tsx'
12
+ // From the terminal folder, which is where the box's one spelling lives: the
13
+ // height calculator is the reason it is a constant at all, and a second copy
14
+ // here would be a second thing to keep in step for no gain. Cards has no
15
+ // calculator — this frame is about not reflowing a virtualized list when the
16
+ // bytes land, which is a claim both themes make.
17
+ import { IMAGE_UNAVAILABLE, imagePlaceholder } from '../terminal/image-box.ts'
10
18
 
11
19
  export type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
12
20
 
@@ -81,6 +89,8 @@ type Status = keyof typeof STATE_BADGE
81
89
  export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps) {
82
90
  const [open, setOpen] = useState(false)
83
91
  const [fullResult, setFullResult] = useState(false)
92
+ const [fetching, setFetching] = useState(false)
93
+ const fetchResult = useToolResultFetcher()
84
94
  const imagePath = imagePathOf(item)
85
95
  const status: Status = item.status ?? (item.result === undefined ? 'running' : 'settled')
86
96
  const badge = STATE_BADGE[status]
@@ -88,8 +98,14 @@ export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps)
88
98
  const Icon = toolIcon(item.name)
89
99
 
90
100
  const resultText = item.result?.text ?? ''
91
- const truncated = !fullResult && resultText.length > RESULT_PREVIEW_CHARS
92
- const shownResult = truncated ? resultText.slice(0, RESULT_PREVIEW_CHARS) : resultText
101
+ const clipped = !fullResult && resultText.length > RESULT_PREVIEW_CHARS
102
+ const shownResult = clipped ? resultText.slice(0, RESULT_PREVIEW_CHARS) : resultText
103
+ // The replay sent a head (see protocol's `ToolResultBlock.truncated`): what is
104
+ // on screen is not merely clipped, it is all this client was given. The press
105
+ // therefore fetches rather than only lifting the clip, and the count has to be
106
+ // the real one — `resultText.length` here is the head's.
107
+ const headOnly = item.result?.truncated === true
108
+ const totalChars = item.result?.totalChars ?? resultText.length
93
109
 
94
110
  const details = open ? (
95
111
  <div className='flex flex-col gap-2 border-t border-border p-2.5'>
@@ -109,12 +125,21 @@ export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps)
109
125
  label={isError ? 'Error' : 'Result'}
110
126
  className={cn(isError && 'border-danger/40 [&_pre]:text-danger')}
111
127
  />
112
- {truncated ? (
128
+ {fetching ? (
129
+ <p className='mt-1 text-label text-fg-3'>
130
+ Fetching {totalChars.toLocaleString()} chars…
131
+ </p>
132
+ ) : clipped || headOnly ? (
113
133
  <button
114
134
  type='button'
115
135
  className='mt-1 text-label text-fg-3 underline-offset-2 hover:underline'
116
- onClick={() => setFullResult(true)}>
117
- Show all {resultText.length.toLocaleString()} chars
136
+ onClick={() => {
137
+ setFullResult(true)
138
+ if (!headOnly) return
139
+ setFetching(true)
140
+ void fetchResult(item.id).finally(() => setFetching(false))
141
+ }}>
142
+ Show all {totalChars.toLocaleString()} chars
118
143
  </button>
119
144
  ) : null}
120
145
  </div>
@@ -126,6 +151,18 @@ export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps)
126
151
  // tool's own output would be if the engine had sent bytes.
127
152
  const image = imagePath && hostImage ? <HostImage path={imagePath} load={hostImage} /> : null
128
153
 
154
+ // Beside the host-path picture above, never instead of it: that one is a file
155
+ // the engine wrote, read back through `/produced` or `/fs`; these are image
156
+ // parts of the result itself, addressed by `(seq, toolUseId, part)`. Different
157
+ // store, different route, and a call can plausibly have both.
158
+ const resultImages = item.result?.images?.length ? (
159
+ <div className='flex flex-col gap-2 border-t border-border p-2.5'>
160
+ {item.result.images.map((ref) => (
161
+ <ResultImage key={ref.partIndex} toolUseId={item.id} image={ref} />
162
+ ))}
163
+ </div>
164
+ ) : null
165
+
129
166
  return (
130
167
  <div
131
168
  data-slot='tool-call'
@@ -158,6 +195,7 @@ export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps)
158
195
  />
159
196
  </button>
160
197
  {image}
198
+ {resultImages}
161
199
  {details}
162
200
  </div>
163
201
  )
@@ -178,6 +216,35 @@ function PlainPayload({
178
216
  return <CodeBlock code={code} label={label} variant='panel' className={className} />
179
217
  }
180
218
 
219
+ /** One image part of a tool result, as the reducer holds it. */
220
+ type ToolResultImage = NonNullable<NonNullable<ToolCallItem['result']>['images']>[number]
221
+
222
+ /**
223
+ * An image part of the result, fetched by reference.
224
+ *
225
+ * The frame is a **fixed height in all three states** — placeholder, picture,
226
+ * failure — which is the one rule this shares with the terminal theme and the
227
+ * only reason it is worth a component: the transcript is virtualized in both,
228
+ * and a box that appears when the bytes land shoves every row below it down
229
+ * while the reader is mid-sentence. Unlike `HostImage`, a failure here is *said*
230
+ * rather than swallowed: there is no host path in the result text to fall back
231
+ * on, so silence would be a blank frame with no account of itself.
232
+ */
233
+ function ResultImage({ toolUseId, image }: { toolUseId: string; image: ToolResultImage }) {
234
+ const { src, failed } = useToolResultImageSrc({ toolUseId, ...image })
235
+ return (
236
+ <div className='flex h-60 items-start overflow-hidden rounded-md border border-border bg-surface-hover'>
237
+ {src ? (
238
+ <img src={src} alt={imagePlaceholder(image)} className='h-full max-w-full object-contain' />
239
+ ) : (
240
+ <span className='p-2 text-label text-fg-4'>
241
+ {failed ? IMAGE_UNAVAILABLE : imagePlaceholder(image)}
242
+ </span>
243
+ )}
244
+ </div>
245
+ )
246
+ }
247
+
181
248
  /**
182
249
  * A picture that lives on the host, fetched through the gateway's host-file
183
250
  * route and shown inline.
@@ -25,15 +25,18 @@ import { Response } from './Response.tsx'
25
25
  import { SessionEmptyState } from './SessionEmptyState.tsx'
26
26
  import { ToolCallCard } from './ToolCallCard.tsx'
27
27
  import { resolveAffordances, type TerminalAffordances } from '../terminal/affordances.tsx'
28
- import { ToolRunRow, WorkingRow, terminalBlocks } from '../terminal/items.tsx'
29
- import { estimateBlockPx } from '../terminal/height.ts'
28
+ import { ToolRunRow, WorkingRow, parentOf, terminalBlocks } from '../terminal/items.tsx'
29
+ import { subagentItems, type ToolCallItem } from '../terminal/blocks.ts'
30
+ import { taskBrief } from '../terminal/tool-run.ts'
31
+ import { taskBusy } from '../terminal/tool-run.ts'
32
+ import { briefPx, estimateBlockPx } from '../terminal/height.ts'
30
33
  import { TerminalScrubber } from '../terminal/scrubber.tsx'
31
- import { gapBefore, rowIndexForItem, type TranscriptRow } from './transcript-rows.ts'
34
+ import { gapBefore, positionInRow, rowIndexForItem, type TranscriptRow } from './transcript-rows.ts'
32
35
  import { useHeightEpoch } from './use-height-epoch.ts'
33
36
  import { useTranscriptJumps } from './use-transcript-jumps.ts'
34
37
  import { Row } from '../terminal/row.tsx'
35
38
  import { TerminalSurface } from '../terminal/surface.tsx'
36
- import { TerminalItemView } from '../terminal/TerminalTranscript.tsx'
39
+ import { BriefRow, TaskRow, TerminalItemView } from '../terminal/TerminalTranscript.tsx'
37
40
  import {
38
41
  ROW_GAP,
39
42
  TranscriptVariantProvider,
@@ -300,9 +303,15 @@ function read(boundary: number | undefined, index: number): boolean {
300
303
 
301
304
  /** Rows produced inside a subagent (`parentToolUseId != null`) are stepped in
302
305
  * behind a rule, so a Task's own output reads as belonging to the tool call
303
- * above it rather than as the main thread carrying on. */
304
- function nestedClass(item: TranscriptItem): string | undefined {
305
- const nested = 'parentToolUseId' in item && item.parentToolUseId != null
306
+ * above it rather than as the main thread carrying on.
307
+ *
308
+ * **Except inside that sub-agent's own frame**, where those same items are the
309
+ * top level and there is no main thread to be an aside from — stepping every row
310
+ * in would draw a rule down the whole surface saying "this happened somewhere
311
+ * else" about the only thing on screen. */
312
+ function nestedClass(item: TranscriptItem, frameParentId?: string): string | undefined {
313
+ const parent = 'parentToolUseId' in item ? item.parentToolUseId : undefined
314
+ const nested = parent != null && parent !== frameParentId
306
315
  return nested ? 'border-l-2 border-border pl-3' : undefined
307
316
  }
308
317
 
@@ -430,6 +439,8 @@ function TranscriptRows({
430
439
  terminal,
431
440
  replaying,
432
441
  stickyPrompt,
442
+ frameParentId,
443
+ onOpenSubagent,
433
444
  gap,
434
445
  fontSize,
435
446
  lineHeight,
@@ -443,6 +454,7 @@ function TranscriptRows({
443
454
  hostImage,
444
455
  jumpToRecapRef,
445
456
  repinRef,
457
+ reveal,
446
458
  }: {
447
459
  rows: TranscriptRow[]
448
460
  boundary: number | undefined
@@ -467,12 +479,18 @@ function TranscriptRows({
467
479
  /** Mount the overview-ruler rail (terminal theme only). */
468
480
  scrubber?: boolean
469
481
  scrubberMarks?: readonly number[]
482
+ /** Set when these rows are a sub-agent's frame — the id everything here was
483
+ * produced inside. Only `nestedClass` needs it: inside the frame those items
484
+ * are the top level and must not be stepped in. */
485
+ frameParentId?: string
486
+ onOpenSubagent?: (toolUseId: string) => void
470
487
  affordances?: TerminalAffordances | boolean
471
488
  fileUrl?: (path: string) => string
472
489
  attachmentUrl?: (attachmentId: string) => string
473
490
  hostImage?: (path: string) => Promise<string | undefined>
474
491
  jumpToRecapRef?: RefObject<(() => void) | null>
475
492
  repinRef?: RefObject<(() => void) | null>
493
+ reveal?: { toolUseId: string; nonce: number }
476
494
  }) {
477
495
  const stick = useStickToBottomContext()
478
496
  // The scroll element belongs to an ancestor — `StickToBottom.Content`
@@ -487,7 +505,15 @@ function TranscriptRows({
487
505
  // changes, so the per-scroll work below is a walk over prompts rather than
488
506
  // over the transcript.
489
507
  const promptRows = useMemo(
490
- () => rows.flatMap((row, index) => ('item' in row && row.item.kind === 'user' ? [index] : [])),
508
+ // Top-level prompts only: a subagent's brief is a `user` item too, and one
509
+ // that escaped absorption (an orphan) must not become the pinned prompt —
510
+ // it is not what the answer on screen belongs to.
511
+ () =>
512
+ rows.flatMap((row, index) =>
513
+ 'item' in row && row.item.kind === 'user' && parentOf(row.item) === undefined
514
+ ? [index]
515
+ : [],
516
+ ),
491
517
  [rows],
492
518
  )
493
519
  // The pinned row must stay mounted even when it is far above the window, so
@@ -603,7 +629,13 @@ function TranscriptRows({
603
629
  if (terminal && epoch) {
604
630
  const row = rows[index]
605
631
  const gapPx = index > 0 && gapBefore(rows, index) ? epoch.line : 0
606
- if (row && ('item' in row || 'run' in row))
632
+ if (row && 'text' in row && row.key === 'brief')
633
+ // Collapsed by default and clipped to BRIEF_LINES, so its height is
634
+ // known before it mounts — the same discipline the task row keeps by
635
+ // always being collapsed when unmounted. Expanding is local state on
636
+ // a mounted row, which the virtualizer re-measures.
637
+ return briefPx(row.text, epoch) + gapPx
638
+ if (row && !('line' in row))
607
639
  return estimateBlockPx(row, epoch) + gapPx
608
640
  return epoch.line + gapPx // recap: one Row, one line
609
641
  }
@@ -715,6 +747,34 @@ function TranscriptRows({
715
747
  repinRef,
716
748
  })
717
749
 
750
+ // Reveal a tool call from outside the transcript — a sub-agent picked in a
751
+ // sessions list, whose `Task` row is the thing the reader asked for.
752
+ //
753
+ // Keyed on the **nonce**, never on the id: asking for the same sub-agent twice
754
+ // is a second request, and a props-equal effect would answer only the first.
755
+ // The lookup goes through `rowIndexForItem` for the reason that function
756
+ // exists — a row covers a *membership*, not a contiguous span, so a Task's id
757
+ // resolves to the folded row that absorbed it rather than to a position. That
758
+ // also makes a nested child's id work, which is what a client holding only a
759
+ // `parentToolUseId` can offer.
760
+ //
761
+ // `'start'`, like the recap seam and the scrubber's marks: the sub-agent's
762
+ // work runs *downward* from its row, so the reader wants the screen below it.
763
+ const revealNonce = reveal?.nonce
764
+ const revealId = reveal?.toolUseId
765
+ useEffect(() => {
766
+ if (revealId === undefined) return
767
+ const itemIndex = items.findIndex(
768
+ (item) => item.kind === 'tool_call' && item.id === revealId,
769
+ )
770
+ // Not here: a compaction, a `/clear`, or simply a client whose list knows
771
+ // about a Task this transcript has not replayed yet. Staying put beats
772
+ // jumping somewhere arbitrary.
773
+ if (itemIndex < 0) return
774
+ jumpToRow(rowIndexForItem(rows, itemIndex), 'start')
775
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- the nonce IS the trigger
776
+ }, [revealNonce])
777
+
718
778
  // The scrubber. Interactivity follows the hover affordance — with
719
779
  // `affordances={false}` the rail is passive paint (pointer-events off) and
720
780
  // the native scrollbar stays; interactive, the rail IS the scrollbar, so the
@@ -799,7 +859,7 @@ function TranscriptRows({
799
859
  <ToolRunRow items={row.run} />
800
860
  </div>
801
861
  ) : 'item' in row ? (
802
- <div className={cn(read(boundary, row.index) && 'opacity-45', nestedClass(row.item))}>
862
+ <div className={cn(read(boundary, row.index) && 'opacity-45', nestedClass(row.item, frameParentId))}>
803
863
  <TranscriptItemView
804
864
  item={row.item}
805
865
  fileUrl={fileUrl}
@@ -808,8 +868,18 @@ function TranscriptRows({
808
868
  terminal={terminal}
809
869
  />
810
870
  </div>
811
- ) : (
871
+ ) : 'text' in row && row.key === 'brief' ? (
872
+ <BriefRow text={row.text} terminal={terminal} />
873
+ ) : 'line' in row ? (
812
874
  <RecapRow line={row.line} since={since} terminal={terminal} />
875
+ ) : (
876
+ // A task block. No `nestedClass` here: the rule belongs *inside*
877
+ // the row, around the children it opens onto — the collapsed line
878
+ // is the main thread's, and stepping it in would say a `Task` call
879
+ // happened somewhere else.
880
+ <div className={cn(read(boundary, row.index) && 'opacity-45')}>
881
+ <TaskRow block={row} fileUrl={fileUrl} onOpenSubagent={onOpenSubagent} />
882
+ </div>
813
883
  )
814
884
  // A prompt row's sticky lane — see the pinned-prompt comment above.
815
885
  // The lane is sized to the turn; the sticky **head** (one clipped
@@ -824,7 +894,15 @@ function TranscriptRows({
824
894
  // paint-only, so under a translate the head would stick against the
825
895
  // lane's un-translated box at the top of the list — observed as the
826
896
  // row clamped to its lane's bottom edge, never pinning at all.
827
- if (terminal && stickyPrompt && 'item' in row && row.item.kind === 'user') {
897
+ // Same predicate as `promptRows` above the lane and the forced range
898
+ // must agree on which rows are prompts.
899
+ if (
900
+ terminal &&
901
+ stickyPrompt &&
902
+ 'item' in row &&
903
+ row.item.kind === 'user' &&
904
+ parentOf(row.item) === undefined
905
+ ) {
828
906
  const next = promptRows.find((index) => index > virtualRow.index)
829
907
  const laneEnd =
830
908
  next === undefined
@@ -867,7 +945,9 @@ function TranscriptRows({
867
945
  pendingApprovals={pendingApprovals}
868
946
  recapRow={recapRow}
869
947
  bookmarks={scrubberMarks ?? []}
948
+ frameParentId={frameParentId}
870
949
  rowIndexFor={(itemIndex) => rowIndexForItem(rows, itemIndex)}
950
+ positionInRow={(itemIndex) => positionInRow(rows, itemIndex)}
871
951
  // The public memoized measurements array — `getTotalSize()` just
872
952
  // above refreshed it, and with the calculator feeding
873
953
  // `estimateSize` these starts are honest for unmounted rows too.
@@ -976,6 +1056,52 @@ export interface TranscriptProps {
976
1056
  * message look like it did nothing at all.
977
1057
  */
978
1058
  repinRef?: RefObject<(() => void) | null>
1059
+ /**
1060
+ * Scroll a tool call into view — bump `nonce` to ask again for the same one.
1061
+ *
1062
+ * The seam a *list* needs: sub-agent work is nested inside the `Task` call
1063
+ * that spawned it, so "open that sub-agent" can only ever mean "take me to its
1064
+ * row". A `parentToolUseId` works here as well as the Task's own id, since the
1065
+ * lookup resolves an absorbed child to the row that folded it.
1066
+ *
1067
+ * A prop rather than a ref (the shape `jumpToRecapRef` uses) because the asker
1068
+ * is outside this webview entirely and the request travels as data; a ref
1069
+ * would need a live closure at the other end of a postMessage bridge.
1070
+ */
1071
+ reveal?: { toolUseId: string; nonce: number }
1072
+ /**
1073
+ * Render **only** the work one sub-agent did, rather than the conversation —
1074
+ * the sub-agent takeover's frame.
1075
+ *
1076
+ * Membership is `subagentItems` (`terminal/blocks.ts`), which is also the rule
1077
+ * iOS will mirror: everything the agent produced, and not the spawning `Task`
1078
+ * call itself, which *is* the frame rather than a row in it.
1079
+ *
1080
+ * Features are switched off internally whenever it is set, and the gate lives
1081
+ * here rather than at the call site on purpose: each is keyed to a
1082
+ * **full-transcript item index**, so a host that passed a frame and a catch-up
1083
+ * boundary together would not be making a strange choice, it would be making
1084
+ * an incoherent one. Those are the catch-up boundary and its recap row, the
1085
+ * sticky prompt, `reveal`, and the scrubber's **bookmarks** (host indices in
1086
+ * full-transcript space).
1087
+ *
1088
+ * The **scrubber itself stays**, and the distinction is the point: the rail
1089
+ * derives every one of its inputs from the rows it is given, and inside a
1090
+ * frame those are the sub-agent's own — so it marks that agent's prompts,
1091
+ * answers and failures at that agent's offsets. It was originally gated with
1092
+ * the marks, on the reasonable-looking argument that they are one feature;
1093
+ * they are two, and a fifty-tool agent run is exactly where a rail earns its
1094
+ * keep. What stays besides is everything that makes a long stream readable —
1095
+ * virtualization, the height epoch, the follow spring, the replay hold.
1096
+ */
1097
+ frame?: { parentToolUseId: string }
1098
+ /**
1099
+ * Raise the takeover from a `Task` row. Absent draws no affordance, which is
1100
+ * what the plain renderer and the cards variant get: cards folds nothing, so
1101
+ * it has no task blocks to hang this on, and reaches the takeover from the
1102
+ * sessions list instead.
1103
+ */
1104
+ onOpenSubagent?: (toolUseId: string) => void
979
1105
  className?: string
980
1106
  }
981
1107
 
@@ -997,9 +1123,31 @@ export function Transcript({
997
1123
  catchUp,
998
1124
  jumpToRecapRef,
999
1125
  repinRef,
1126
+ reveal,
1127
+ frame,
1128
+ onOpenSubagent,
1000
1129
  className,
1001
1130
  }: TranscriptProps) {
1002
1131
  const terminal = variant === 'terminal'
1132
+ // The frame's own item list, and the single place the takeover's content is
1133
+ // decided. Everything below reads `items` rather than `state.items`, so the
1134
+ // row build, the empty state and the loader all describe the same surface.
1135
+ const items = useMemo(
1136
+ () => (frame ? subagentItems(state.items, frame.parentToolUseId) : state.items),
1137
+ [state.items, frame],
1138
+ )
1139
+ // The spawning call, for the header's claim and to tell "not here yet" from
1140
+ // "not in this transcript" — see the frame placeholder below.
1141
+ const frameTask = useMemo(
1142
+ () =>
1143
+ frame
1144
+ ? state.items.find(
1145
+ (item): item is ToolCallItem =>
1146
+ item.kind === 'tool_call' && item.id === frame.parentToolUseId,
1147
+ )
1148
+ : undefined,
1149
+ [state.items, frame],
1150
+ )
1003
1151
  const gap = ROW_GAP[variant][density]
1004
1152
  const runStartedAt = useRunStart(state.status)
1005
1153
  // A boundary at (or past) the end means nothing is new — no row, no dimming.
@@ -1014,24 +1162,40 @@ export function Transcript({
1014
1162
  // cannot say what you missed. Clamping it would land on `items.length` and
1015
1163
  // read as "nothing is new" — the same outcome, told less truthfully.
1016
1164
  const boundary =
1017
- catchUp && catchUp.from > 0 && catchUp.from < state.items.length ? catchUp.from : undefined
1165
+ !frame && catchUp && catchUp.from > 0 && catchUp.from < state.items.length
1166
+ ? catchUp.from
1167
+ : undefined
1018
1168
  const recap = useMemo(
1019
1169
  () => (boundary === undefined ? undefined : recapLine(summarizeSince(state, boundary))),
1020
1170
  [state, boundary],
1021
1171
  )
1172
+ // What this agent was asked, when the stream does not already say. A
1173
+ // foreground `Task` forwards its brief as a real nested user message and it is
1174
+ // already the frame's first row; a background agent forwards nothing, and
1175
+ // without this the takeover shows an answer with the question missing. Hence
1176
+ // the guard rather than an unconditional splice — drawn both ways, the reader
1177
+ // would see the same instruction twice.
1178
+ const brief = useMemo(
1179
+ () =>
1180
+ frame && frameTask && !items.some((item) => item.kind === 'user')
1181
+ ? taskBrief(frameTask)
1182
+ : undefined,
1183
+ [frame, frameTask, items],
1184
+ )
1022
1185
  const rows = useMemo<TranscriptRow[]>(() => {
1023
1186
  const fold = (from: number, to: number) =>
1024
- terminalBlocks(state.items.slice(from, to), from, terminal)
1025
- if (boundary === undefined || !recap) return fold(0, state.items.length)
1187
+ terminalBlocks(items.slice(from, to), from, terminal)
1188
+ const lead: TranscriptRow[] = brief ? [{ key: 'brief' as const, text: brief }] : []
1189
+ if (boundary === undefined || !recap) return [...lead, ...fold(0, items.length)]
1026
1190
  // Each side of the boundary folds separately, so a shell run never spans it:
1027
1191
  // "what happened while you were away" must not hide inside a count that also
1028
1192
  // covers what you have already read.
1029
1193
  return [
1030
1194
  ...fold(0, boundary),
1031
1195
  { key: 'recap' as const, line: recap },
1032
- ...fold(boundary, state.items.length),
1196
+ ...fold(boundary, items.length),
1033
1197
  ]
1034
- }, [state.items, boundary, recap, terminal])
1198
+ }, [items, boundary, recap, terminal, brief])
1035
1199
  return (
1036
1200
  <TranscriptVariantProvider value={variant}>
1037
1201
  {/* The replay hold hides by VISIBILITY, never by not mounting. The rows
@@ -1052,37 +1216,62 @@ export function Transcript({
1052
1216
  fontSize={fontSize}
1053
1217
  lineHeight={lineHeight}
1054
1218
  affordances={affordances}>
1055
- {state.items.length === 0 && state.status !== 'starting' ? (
1219
+ {frame ? (
1220
+ // The frame's own empty states, and they are two different facts.
1221
+ // A task that is present with nothing under it yet is simply an
1222
+ // agent that has not spoken — the loader below says so. A task the
1223
+ // transcript does not have is either still replaying (say nothing,
1224
+ // the hold is up) or genuinely absent: a `/clear` retired the
1225
+ // conversation it lived in, or the id was never this session's.
1226
+ // **Never auto-exit on that** — navigating out from under a reader
1227
+ // is worse than one honest line they can leave when they choose.
1228
+ frameTask === undefined && !replaying ? (
1229
+ <div className={cn(terminal ? 'term-row text-fg-4' : 'p-4 text-body-sm text-fg-4')}>
1230
+ This sub-agent's work is not in this transcript.
1231
+ </div>
1232
+ ) : null
1233
+ ) : items.length === 0 && state.status !== 'starting' ? (
1056
1234
  <SessionEmptyState
1057
1235
  cwd={state.cwd}
1058
1236
  hasCommands={!!state.commands?.length}
1059
1237
  hasSkills={!!state.skills?.some((s) => s.enabled)}
1060
1238
  canBrowseFiles={canBrowseFiles}
1061
1239
  />
1062
- ) : (
1240
+ ) : null}
1241
+ {frame && frameTask === undefined && !replaying ? null : (
1063
1242
  <TranscriptRows
1064
1243
  rows={rows}
1065
1244
  boundary={boundary}
1066
1245
  since={catchUp?.since}
1067
1246
  terminal={terminal}
1068
1247
  replaying={replaying}
1069
- stickyPrompt={stickyPrompt}
1248
+ stickyPrompt={!frame && stickyPrompt}
1070
1249
  gap={gap}
1071
1250
  fontSize={fontSize}
1072
1251
  lineHeight={lineHeight}
1073
- items={state.items}
1252
+ items={items}
1074
1253
  pendingApprovals={state.pendingApprovals}
1254
+ /* The rail rides the frame's OWN rows, so inside a takeover it
1255
+ marks the sub-agent's prompts, answers and failures — the thing
1256
+ a long agent run most needs, and coherent because every input
1257
+ it takes (`items`, `rowIndexFor`, `offsetOfRow`) is the frame's.
1258
+ The **bookmarks are not**: those indices are the host's, in
1259
+ full-transcript space, and painting them here would put marks
1260
+ at meaningless offsets. See `frame`'s doc for the rest. */
1075
1261
  scrubber={scrubber}
1076
- scrubberMarks={scrubberMarks}
1262
+ scrubberMarks={frame ? undefined : scrubberMarks}
1077
1263
  affordances={affordances}
1078
1264
  fileUrl={fileUrl}
1079
1265
  attachmentUrl={attachmentUrl}
1080
1266
  hostImage={hostImage}
1081
1267
  jumpToRecapRef={jumpToRecapRef}
1082
1268
  repinRef={repinRef}
1269
+ reveal={frame ? undefined : reveal}
1270
+ frameParentId={frame?.parentToolUseId}
1271
+ onOpenSubagent={frame ? undefined : onOpenSubagent}
1083
1272
  />
1084
1273
  )}
1085
- {showLoader(state) ? (
1274
+ {(frame ? frameTask !== undefined && taskBusy(frameTask, items) : showLoader(state)) ? (
1086
1275
  terminal ? (
1087
1276
  // The CLI's own working line, and it is a *row of the transcript*
1088
1277
  // rather than a spinner floating over it — one blank line down,