@workerdeck/ui 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,6 +20,8 @@ import {
20
20
  filterRows,
21
21
  groupRows,
22
22
  hasFacetFilter,
23
+ projectLabel,
24
+ projectsOf,
23
25
  sessionLabel,
24
26
  subsetSummary,
25
27
  } from '@workerdeck/protocol'
@@ -36,6 +38,7 @@ import { Empty } from '../ui/Empty.tsx'
36
38
  import { Input } from '../ui/Input.tsx'
37
39
  import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger, SelectValue } from '../ui/Select.tsx'
38
40
  import { Spinner } from '../ui/Spinner.tsx'
41
+ import { ProjectIcon } from './ProjectIcon.tsx'
39
42
  import { cn } from '../../lib/utils.ts'
40
43
  import { formatCost, formatRelativeTime, friendlyModel } from '../../lib/format.ts'
41
44
 
@@ -88,6 +91,15 @@ export interface SessionBrowserProps {
88
91
  * filtered by a control you can't currently see still says so.
89
92
  */
90
93
  showControls?: boolean
94
+ /**
95
+ * Resolved project-icon bytes by content hash — `useProjectIcons`' output.
96
+ *
97
+ * Passed in rather than fetched here for the reason `ProjectIcon` states: the
98
+ * wire carries an *address*, and who can fetch it differs per client. Absent,
99
+ * or a hash not in it yet, simply draws no picture; the project's name is
100
+ * already there.
101
+ */
102
+ projectIcons?: Record<string, string>
91
103
  className?: string
92
104
  }
93
105
 
@@ -125,6 +137,7 @@ export function SessionBrowser({
125
137
  onRename,
126
138
  emptyState,
127
139
  showControls = true,
140
+ projectIcons,
128
141
  className,
129
142
  }: SessionBrowserProps) {
130
143
  const visible = useMemo(() => filterRows(rows, config, scope), [rows, config, scope])
@@ -133,6 +146,7 @@ export function SessionBrowser({
133
146
  // Derived, not enumerated: a new engine or a new gateway needs no change here,
134
147
  // and a facet with one possible value is not a choice worth showing.
135
148
  const adapters = useMemo(() => adaptersOf(rows), [rows])
149
+ const projects = useMemo(() => projectsOf(rows), [rows])
136
150
  const gateways = useMemo(() => {
137
151
  const seen = new Map<string, string>()
138
152
  for (const row of rows) seen.set(row.hostId, row.hostName)
@@ -187,6 +201,16 @@ export function SessionBrowser({
187
201
  />
188
202
  </FilterRow>
189
203
  ) : null}
204
+ {projects.length > 1 ? (
205
+ <FilterRow label='Project'>
206
+ <FacetSelect
207
+ label='Project'
208
+ value={config.projects ?? []}
209
+ options={projects.map((p) => ({ value: p.key, label: p.label }))}
210
+ onChange={(next) => set({ projects: next })}
211
+ />
212
+ </FilterRow>
213
+ ) : null}
190
214
  <FilterRow label='Group'>
191
215
  <OneOfSelect
192
216
  label='Group'
@@ -195,6 +219,7 @@ export function SessionBrowser({
195
219
  { value: 'none', label: 'No grouping' },
196
220
  { value: 'state', label: 'By state' },
197
221
  { value: 'adapter', label: 'By engine' },
222
+ ...(projects.length > 1 ? [{ value: 'project' as const, label: 'By project' }] : []),
198
223
  ...(gateways.length > 1 ? [{ value: 'gateway' as const, label: 'By gateway' }] : []),
199
224
  ]}
200
225
  onChange={(groupBy) => set({ groupBy: groupBy as GroupBy })}
@@ -208,6 +233,7 @@ export function SessionBrowser({
208
233
  { value: 'recent', label: 'Recent' },
209
234
  { value: 'name', label: 'Name' },
210
235
  { value: 'state', label: 'State' },
236
+ ...(projects.length > 1 ? [{ value: 'project' as const, label: 'Project' }] : []),
211
237
  ...(gateways.length > 1 ? [{ value: 'gateway' as const, label: 'Gateway' }] : []),
212
238
  ]}
213
239
  onChange={(sortBy) => set({ sortBy: sortBy as SortBy })}
@@ -253,7 +279,16 @@ export function SessionBrowser({
253
279
  {groups.map((group) => (
254
280
  <div key={group.key} className='flex flex-col gap-1'>
255
281
  {config.groupBy !== 'none' && group.label ? (
256
- <div className='flex items-baseline gap-2 px-3 text-label font-medium text-fg-4'>
282
+ <div className='flex items-center gap-2 px-3 text-label font-medium text-fg-4'>
283
+ {/* Only the project facet has a mark of its own, and a group
284
+ IS one project root, so the first row is a fair source. */}
285
+ {config.groupBy === 'project' ? (
286
+ <ProjectIcon
287
+ icon={group.rows[0]?.info.project?.icon}
288
+ src={iconSrcOf(group.rows[0], projectIcons)}
289
+ name={group.label}
290
+ />
291
+ ) : null}
257
292
  <span className='uppercase tracking-wide'>{group.label}</span>
258
293
  <span className='text-fg-4/70'>{group.rows.length}</span>
259
294
  </div>
@@ -264,6 +299,8 @@ export function SessionBrowser({
264
299
  row={row}
265
300
  active={row.info.id === activeId}
266
301
  showGateway={gateways.length > 1}
302
+ showProject={config.groupBy !== 'project'}
303
+ projectIcons={projectIcons}
267
304
  onSelect={onSelect}
268
305
  onDelete={onDelete}
269
306
  onRename={onRename}
@@ -277,10 +314,27 @@ export function SessionBrowser({
277
314
  )
278
315
  }
279
316
 
317
+ /** The bytes for a row's project icon, if it has an image one and the caller has
318
+ * fetched it yet. Shared by the row and its group header so the two cannot draw
319
+ * different pictures for one project. */
320
+ function iconSrcOf(
321
+ row: SessionRow | undefined,
322
+ icons: Record<string, string> | undefined,
323
+ ): string | undefined {
324
+ const icon = row?.info.project?.icon
325
+ return icon?.type === 'image' ? icons?.[icon.hash] : undefined
326
+ }
327
+
280
328
  interface SessionRowItemProps {
281
329
  row: SessionRow
282
330
  active?: boolean
283
331
  showGateway?: boolean
332
+ /** False when the list is already grouped by project — the header has said
333
+ * the name, so the slot goes back to the cwd's basename, which inside a
334
+ * project group is the one thing the header cannot say. The rule `showGateway`
335
+ * follows one facet over. */
336
+ showProject?: boolean
337
+ projectIcons?: Record<string, string>
284
338
  onSelect?: (row: SessionRow) => void
285
339
  onDelete?: (row: SessionRow) => void
286
340
  onRename?: (row: SessionRow, title: string) => void
@@ -290,6 +344,8 @@ function SessionRowItem({
290
344
  row,
291
345
  active,
292
346
  showGateway,
347
+ showProject = true,
348
+ projectIcons,
293
349
  onSelect,
294
350
  onDelete,
295
351
  onRename,
@@ -300,13 +356,23 @@ function SessionRowItem({
300
356
  // What it is and what it has spent, in one line — the same set the extension
301
357
  // shows, joined the same way, so the two lists read as one product.
302
358
  const folder = info.cwd.split('/').filter(Boolean).pop() ?? info.cwd
359
+ // protocol's own spelling, so this row, the group header above it and the
360
+ // project facet cannot disagree about what a project is called. It falls back
361
+ // to exactly the basename this line drew before the feature existed.
362
+ const project = showProject ? projectLabel(row) : folder
363
+ const projectIcon = showProject ? info.project?.icon : undefined
303
364
  const details = [
304
365
  showGateway ? row.hostName : undefined,
305
366
  friendlyModel(info.model),
306
- folder,
367
+ project,
307
368
  info.profile ? `@${info.profile}` : undefined,
308
369
  formatCost(info.totalCostUsd),
309
370
  ].filter(Boolean)
371
+ // Where the icon goes: immediately before the project's own name, wherever
372
+ // that landed in the joined line. Split rather than interleaved as nodes,
373
+ // because everything here is one truncating mono run and a flex of pieces
374
+ // would each shrink a little and leave several half-words.
375
+ const cut = details.indexOf(project)
310
376
 
311
377
  return (
312
378
  <div
@@ -371,7 +437,22 @@ function SessionRowItem({
371
437
  type='button'
372
438
  tabIndex={-1}
373
439
  className='min-w-0 flex-1 truncate text-left font-mono outline-none'>
374
- {details.join(' · ')}
440
+ {cut < 0 ? (
441
+ details.join(' · ')
442
+ ) : (
443
+ <>
444
+ {details.slice(0, cut).map((part) => `${part} · `)}
445
+ <ProjectIcon
446
+ icon={projectIcon}
447
+ src={iconSrcOf(row, projectIcons)}
448
+ name={project}
449
+ /* Nudged onto the text baseline: a 12px glyph box against an
450
+ 11px line sits a hair proud without it. */
451
+ className='mr-1 align-[-0.2em]'
452
+ />
453
+ {details.slice(cut).join(' · ')}
454
+ </>
455
+ )}
375
456
  </button>
376
457
  {onRename && !editing ? (
377
458
  <Button
@@ -63,6 +63,8 @@ import type { TerminalAffordances } from '../terminal/affordances.tsx'
63
63
  import { SessionInfoDialog } from './SessionInfoDialog.tsx'
64
64
  import { StatusBar } from './StatusBar.tsx'
65
65
  import { Transcript } from './Transcript.tsx'
66
+ import { ToolResultFetchProvider } from './tool-result-fetch.tsx'
67
+ import { ToolResultImageProvider, useToolResultImages } from './tool-result-image.tsx'
66
68
  import {
67
69
  TranscriptDensityProvider,
68
70
  TranscriptVariantProvider,
@@ -227,6 +229,14 @@ export interface SessionPanelProps {
227
229
  * client's watermarks; a shared one is session metadata on the gateway).
228
230
  */
229
231
  scrubberMarks?: readonly number[]
232
+ /**
233
+ * Scroll a tool call into view; bump `nonce` to ask again for the same one.
234
+ * See {@link TranscriptProps.reveal} — this is the panel's pass-through, and
235
+ * exists so a surface *outside* the panel (a sessions list showing a session's
236
+ * running sub-agents) can say "take me to that one" without opening a second
237
+ * attach to find out where it is.
238
+ */
239
+ reveal?: { toolUseId: string; nonce: number }
230
240
  /**
231
241
  * Terminal theme only: hold the prompt of the turn you are reading at the top
232
242
  * of the transcript, as the Claude Code CLI does. The **real row** is pinned
@@ -441,6 +451,7 @@ export function SessionPanel({
441
451
  terminalMetrics,
442
452
  scrubber = false,
443
453
  scrubberMarks,
454
+ reveal,
444
455
  stickyPrompt = false,
445
456
  controlsSurface = 'internal',
446
457
  onControls,
@@ -479,6 +490,7 @@ export function SessionPanel({
479
490
  setModel,
480
491
  setPermissionMode,
481
492
  reconnectNow,
493
+ loadFullResult,
482
494
  } = useClaudeSession(client, sessionId, { onProtocolError: setProtocolError, cacheTranscript })
483
495
  // Callers are told to remount on a session switch, but a changed prop must not leave
484
496
  // the previous session's failure on screen.
@@ -630,6 +642,10 @@ export function SessionPanel({
630
642
  // path, never bytes). Stable and memoized per path: transcript rows re-render
631
643
  // on every delta, and a fresh function would re-fetch each time.
632
644
  const hostImage = useHostImage(client, sessionId, state.producedFiles)
645
+ // The other picture route, and the other store: an image *part* of a tool
646
+ // result, which an opted-in replay delivered as a reference rather than half a
647
+ // megabyte of base64. Bounded, unlike `useHostImage` — see the module.
648
+ const resultImages = useToolResultImages(client, sessionId)
633
649
  const composerRef = useRef<ComposerHandle>(null)
634
650
  // The catch-up strip's way of scrolling the (virtualized, usually unmounted)
635
651
  // recap row into view — the transcript fills it in. See TranscriptProps.
@@ -807,6 +823,12 @@ export function SessionPanel({
807
823
  // run, and they read it from this context rather than a prop chain.
808
824
  <TranscriptVariantProvider value={transcriptVariant}>
809
825
  <TranscriptDensityProvider value={transcriptDensity}>
826
+ {/* The panel owns the session's one attach, so it is the only thing that
827
+ can answer a row asking for the rest of a truncated tool result. Rows
828
+ rendered anywhere else fall back to the context's no-op, which is
829
+ correct for them: nothing truncates a replay they never asked for. */}
830
+ <ToolResultFetchProvider value={loadFullResult}>
831
+ <ToolResultImageProvider value={resultImages}>
810
832
  <div
811
833
  data-slot='session-panel'
812
834
  // The typeface is a cascade fact, not a React one — one attribute here,
@@ -848,6 +870,7 @@ export function SessionPanel({
848
870
  ? { from: catchUp.itemCount, since: catchUp.since }
849
871
  : undefined
850
872
  }
873
+ reveal={reveal}
851
874
  jumpToRecapRef={jumpToRecap}
852
875
  repinRef={repinTranscript}
853
876
  />
@@ -1009,6 +1032,8 @@ export function SessionPanel({
1009
1032
  </>
1010
1033
  ) : null}
1011
1034
  </div>
1035
+ </ToolResultImageProvider>
1036
+ </ToolResultFetchProvider>
1012
1037
  </TranscriptDensityProvider>
1013
1038
  </TranscriptVariantProvider>
1014
1039
  )
@@ -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,15 @@ 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'
28
+ import { ToolRunRow, WorkingRow, parentOf, terminalBlocks } from '../terminal/items.tsx'
29
29
  import { estimateBlockPx } from '../terminal/height.ts'
30
30
  import { TerminalScrubber } from '../terminal/scrubber.tsx'
31
- import { gapBefore, rowIndexForItem, type TranscriptRow } from './transcript-rows.ts'
31
+ import { gapBefore, positionInRow, rowIndexForItem, type TranscriptRow } from './transcript-rows.ts'
32
32
  import { useHeightEpoch } from './use-height-epoch.ts'
33
33
  import { useTranscriptJumps } from './use-transcript-jumps.ts'
34
34
  import { Row } from '../terminal/row.tsx'
35
35
  import { TerminalSurface } from '../terminal/surface.tsx'
36
- import { TerminalItemView } from '../terminal/TerminalTranscript.tsx'
36
+ import { TaskRow, TerminalItemView } from '../terminal/TerminalTranscript.tsx'
37
37
  import {
38
38
  ROW_GAP,
39
39
  TranscriptVariantProvider,
@@ -443,6 +443,7 @@ function TranscriptRows({
443
443
  hostImage,
444
444
  jumpToRecapRef,
445
445
  repinRef,
446
+ reveal,
446
447
  }: {
447
448
  rows: TranscriptRow[]
448
449
  boundary: number | undefined
@@ -473,6 +474,7 @@ function TranscriptRows({
473
474
  hostImage?: (path: string) => Promise<string | undefined>
474
475
  jumpToRecapRef?: RefObject<(() => void) | null>
475
476
  repinRef?: RefObject<(() => void) | null>
477
+ reveal?: { toolUseId: string; nonce: number }
476
478
  }) {
477
479
  const stick = useStickToBottomContext()
478
480
  // The scroll element belongs to an ancestor — `StickToBottom.Content`
@@ -487,7 +489,15 @@ function TranscriptRows({
487
489
  // changes, so the per-scroll work below is a walk over prompts rather than
488
490
  // over the transcript.
489
491
  const promptRows = useMemo(
490
- () => rows.flatMap((row, index) => ('item' in row && row.item.kind === 'user' ? [index] : [])),
492
+ // Top-level prompts only: a subagent's brief is a `user` item too, and one
493
+ // that escaped absorption (an orphan) must not become the pinned prompt —
494
+ // it is not what the answer on screen belongs to.
495
+ () =>
496
+ rows.flatMap((row, index) =>
497
+ 'item' in row && row.item.kind === 'user' && parentOf(row.item) === undefined
498
+ ? [index]
499
+ : [],
500
+ ),
491
501
  [rows],
492
502
  )
493
503
  // The pinned row must stay mounted even when it is far above the window, so
@@ -603,7 +613,7 @@ function TranscriptRows({
603
613
  if (terminal && epoch) {
604
614
  const row = rows[index]
605
615
  const gapPx = index > 0 && gapBefore(rows, index) ? epoch.line : 0
606
- if (row && ('item' in row || 'run' in row))
616
+ if (row && !('line' in row))
607
617
  return estimateBlockPx(row, epoch) + gapPx
608
618
  return epoch.line + gapPx // recap: one Row, one line
609
619
  }
@@ -715,6 +725,34 @@ function TranscriptRows({
715
725
  repinRef,
716
726
  })
717
727
 
728
+ // Reveal a tool call from outside the transcript — a sub-agent picked in a
729
+ // sessions list, whose `Task` row is the thing the reader asked for.
730
+ //
731
+ // Keyed on the **nonce**, never on the id: asking for the same sub-agent twice
732
+ // is a second request, and a props-equal effect would answer only the first.
733
+ // The lookup goes through `rowIndexForItem` for the reason that function
734
+ // exists — a row covers a *membership*, not a contiguous span, so a Task's id
735
+ // resolves to the folded row that absorbed it rather than to a position. That
736
+ // also makes a nested child's id work, which is what a client holding only a
737
+ // `parentToolUseId` can offer.
738
+ //
739
+ // `'start'`, like the recap seam and the scrubber's marks: the sub-agent's
740
+ // work runs *downward* from its row, so the reader wants the screen below it.
741
+ const revealNonce = reveal?.nonce
742
+ const revealId = reveal?.toolUseId
743
+ useEffect(() => {
744
+ if (revealId === undefined) return
745
+ const itemIndex = items.findIndex(
746
+ (item) => item.kind === 'tool_call' && item.id === revealId,
747
+ )
748
+ // Not here: a compaction, a `/clear`, or simply a client whose list knows
749
+ // about a Task this transcript has not replayed yet. Staying put beats
750
+ // jumping somewhere arbitrary.
751
+ if (itemIndex < 0) return
752
+ jumpToRow(rowIndexForItem(rows, itemIndex), 'start')
753
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- the nonce IS the trigger
754
+ }, [revealNonce])
755
+
718
756
  // The scrubber. Interactivity follows the hover affordance — with
719
757
  // `affordances={false}` the rail is passive paint (pointer-events off) and
720
758
  // the native scrollbar stays; interactive, the rail IS the scrollbar, so the
@@ -808,8 +846,16 @@ function TranscriptRows({
808
846
  terminal={terminal}
809
847
  />
810
848
  </div>
811
- ) : (
849
+ ) : 'line' in row ? (
812
850
  <RecapRow line={row.line} since={since} terminal={terminal} />
851
+ ) : (
852
+ // A task block. No `nestedClass` here: the rule belongs *inside*
853
+ // the row, around the children it opens onto — the collapsed line
854
+ // is the main thread's, and stepping it in would say a `Task` call
855
+ // happened somewhere else.
856
+ <div className={cn(read(boundary, row.index) && 'opacity-45')}>
857
+ <TaskRow block={row} fileUrl={fileUrl} />
858
+ </div>
813
859
  )
814
860
  // A prompt row's sticky lane — see the pinned-prompt comment above.
815
861
  // The lane is sized to the turn; the sticky **head** (one clipped
@@ -824,7 +870,15 @@ function TranscriptRows({
824
870
  // paint-only, so under a translate the head would stick against the
825
871
  // lane's un-translated box at the top of the list — observed as the
826
872
  // row clamped to its lane's bottom edge, never pinning at all.
827
- if (terminal && stickyPrompt && 'item' in row && row.item.kind === 'user') {
873
+ // Same predicate as `promptRows` above the lane and the forced range
874
+ // must agree on which rows are prompts.
875
+ if (
876
+ terminal &&
877
+ stickyPrompt &&
878
+ 'item' in row &&
879
+ row.item.kind === 'user' &&
880
+ parentOf(row.item) === undefined
881
+ ) {
828
882
  const next = promptRows.find((index) => index > virtualRow.index)
829
883
  const laneEnd =
830
884
  next === undefined
@@ -868,6 +922,7 @@ function TranscriptRows({
868
922
  recapRow={recapRow}
869
923
  bookmarks={scrubberMarks ?? []}
870
924
  rowIndexFor={(itemIndex) => rowIndexForItem(rows, itemIndex)}
925
+ positionInRow={(itemIndex) => positionInRow(rows, itemIndex)}
871
926
  // The public memoized measurements array — `getTotalSize()` just
872
927
  // above refreshed it, and with the calculator feeding
873
928
  // `estimateSize` these starts are honest for unmounted rows too.
@@ -976,6 +1031,19 @@ export interface TranscriptProps {
976
1031
  * message look like it did nothing at all.
977
1032
  */
978
1033
  repinRef?: RefObject<(() => void) | null>
1034
+ /**
1035
+ * Scroll a tool call into view — bump `nonce` to ask again for the same one.
1036
+ *
1037
+ * The seam a *list* needs: sub-agent work is nested inside the `Task` call
1038
+ * that spawned it, so "open that sub-agent" can only ever mean "take me to its
1039
+ * row". A `parentToolUseId` works here as well as the Task's own id, since the
1040
+ * lookup resolves an absorbed child to the row that folded it.
1041
+ *
1042
+ * A prop rather than a ref (the shape `jumpToRecapRef` uses) because the asker
1043
+ * is outside this webview entirely and the request travels as data; a ref
1044
+ * would need a live closure at the other end of a postMessage bridge.
1045
+ */
1046
+ reveal?: { toolUseId: string; nonce: number }
979
1047
  className?: string
980
1048
  }
981
1049
 
@@ -997,6 +1065,7 @@ export function Transcript({
997
1065
  catchUp,
998
1066
  jumpToRecapRef,
999
1067
  repinRef,
1068
+ reveal,
1000
1069
  className,
1001
1070
  }: TranscriptProps) {
1002
1071
  const terminal = variant === 'terminal'
@@ -1080,6 +1149,7 @@ export function Transcript({
1080
1149
  hostImage={hostImage}
1081
1150
  jumpToRecapRef={jumpToRecapRef}
1082
1151
  repinRef={repinRef}
1152
+ reveal={reveal}
1083
1153
  />
1084
1154
  )}
1085
1155
  {showLoader(state) ? (
@@ -0,0 +1,36 @@
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
+
3
+ /**
4
+ * How a row gets back the part of a tool result the replay did not send.
5
+ *
6
+ * A **context**, not a prop chain, for the same reason the variant is one: the
7
+ * rows that need it are drawn by `terminalBlocks` and by the cards theme's
8
+ * `ToolCallCard`, several layers below whoever holds the session, and a row
9
+ * composed by hand should get the same behaviour without threading a callback
10
+ * through everything in between.
11
+ *
12
+ * The default is a no-op resolving `false`, which is exactly right for every
13
+ * surface that never asked for truncation (the playground, a fixture, an
14
+ * embedder rendering rows by hand): `result.truncated` is only ever set by a
15
+ * replay a renderer opted into, so a row that has no fetcher also has no head to
16
+ * complete. A press still opens the row; it simply has everything already.
17
+ */
18
+ export type ToolResultFetcher = (toolUseId: string) => Promise<boolean>
19
+
20
+ const FetchContext = createContext<ToolResultFetcher>(async () => false)
21
+
22
+ export function ToolResultFetchProvider({
23
+ value,
24
+ children,
25
+ }: {
26
+ value: ToolResultFetcher | undefined
27
+ children: ReactNode
28
+ }) {
29
+ return <FetchContext.Provider value={value ?? noop}>{children}</FetchContext.Provider>
30
+ }
31
+
32
+ const noop: ToolResultFetcher = async () => false
33
+
34
+ export function useToolResultFetcher(): ToolResultFetcher {
35
+ return useContext(FetchContext)
36
+ }