@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.
- package/README.md +7 -0
- package/build/{SessionPanel-B9CHoq8x.d.mts → SessionPanel-CnNYEX80.d.mts} +13 -1
- package/build/{SessionPanel-DII9MmQ8.mjs → SessionPanel-DMPhsNlW.mjs} +1015 -332
- package/build/SessionPanel-DMPhsNlW.mjs.map +1 -0
- package/build/index.d.mts +54 -3
- package/build/index.mjs +240 -17
- package/build/index.mjs.map +1 -1
- package/build/workspace.d.mts +1 -1
- package/build/workspace.mjs +1 -1
- package/package.json +8 -6
- package/src/components/agent/ProjectIcon.tsx +119 -0
- package/src/components/agent/SessionBrowser.tsx +84 -3
- package/src/components/agent/SessionPanel.tsx +25 -0
- package/src/components/agent/ToolCallCard.tsx +72 -5
- package/src/components/agent/Transcript.tsx +77 -7
- package/src/components/agent/tool-result-fetch.tsx +36 -0
- package/src/components/agent/tool-result-image.tsx +209 -0
- package/src/components/agent/transcript-rows.ts +113 -22
- package/src/components/terminal/TerminalTranscript.tsx +80 -2
- package/src/components/terminal/blocks.ts +232 -0
- package/src/components/terminal/height.ts +49 -6
- package/src/components/terminal/image-box.ts +53 -0
- package/src/components/terminal/items.tsx +115 -78
- package/src/components/terminal/result-preview.ts +20 -6
- package/src/components/terminal/scrubber.tsx +146 -25
- package/src/components/terminal/tool-run.ts +133 -0
- package/src/index.ts +2 -1
- package/src/styles/terminal.css +75 -5
- package/build/SessionPanel-DII9MmQ8.mjs.map +0 -1
|
@@ -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-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
92
|
-
const shownResult =
|
|
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
|
-
{
|
|
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={() =>
|
|
117
|
-
|
|
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
|
-
|
|
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 && ('
|
|
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
|
-
|
|
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
|
+
}
|