@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.
- package/README.md +7 -0
- package/build/{SessionPanel-B9CHoq8x.d.mts → SessionPanel-CnU_IJ3-.d.mts} +36 -9
- package/build/{SessionPanel-DII9MmQ8.mjs → SessionPanel-DPx8Iz8a.mjs} +1432 -384
- package/build/SessionPanel-DPx8Iz8a.mjs.map +1 -0
- package/build/{format-DfI_je9S.d.mts → format-ljc3lKpA.d.mts} +1 -1
- package/build/format.d.mts +16 -5
- package/build/format.mjs +2 -3
- package/build/index.d.mts +288 -8
- package/build/index.mjs +620 -165
- package/build/index.mjs.map +1 -1
- package/build/{format-DqR56Y8l.mjs → status-BE-zg88x.mjs} +154 -2
- package/build/status-BE-zg88x.mjs.map +1 -0
- package/build/workspace.d.mts +4 -1
- package/build/workspace.mjs +4 -3
- package/build/workspace.mjs.map +1 -1
- package/package.json +8 -6
- package/src/components/agent/ContextRing.tsx +41 -0
- package/src/components/agent/EngineIcon.tsx +40 -0
- package/src/components/agent/ProjectIcon.tsx +119 -0
- package/src/components/agent/SessionBrowser.tsx +191 -17
- package/src/components/agent/SessionPanel.tsx +177 -2
- package/src/components/agent/SessionSteps.tsx +233 -0
- package/src/components/agent/SessionWorkspace.tsx +4 -0
- package/src/components/agent/StatusBar.tsx +4 -2
- package/src/components/agent/SubagentStrip.tsx +134 -0
- package/src/components/agent/ToolCallCard.tsx +72 -5
- package/src/components/agent/Transcript.tsx +212 -23
- 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 +122 -23
- package/src/components/terminal/TerminalTranscript.tsx +154 -2
- package/src/components/terminal/affordances.tsx +34 -0
- package/src/components/terminal/blocks.ts +260 -0
- package/src/components/terminal/height.ts +73 -6
- package/src/components/terminal/image-box.ts +53 -0
- package/src/components/terminal/items.tsx +116 -79
- package/src/components/terminal/result-preview.ts +20 -6
- package/src/components/terminal/scrubber.tsx +172 -26
- package/src/components/terminal/tool-run.ts +177 -0
- package/src/index.ts +20 -1
- package/src/lib/status.ts +16 -3
- package/src/styles/terminal.css +85 -5
- package/src/styles/theme.css +42 -0
- package/build/SessionPanel-DII9MmQ8.mjs.map +0 -1
- package/build/format-DqR56Y8l.mjs.map +0 -1
- package/build/status-Ydzi7n6j.mjs +0 -143
- 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
|
|
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,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 {
|
|
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
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
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(
|
|
1025
|
-
|
|
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,
|
|
1196
|
+
...fold(boundary, items.length),
|
|
1033
1197
|
]
|
|
1034
|
-
}, [
|
|
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
|
-
{
|
|
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={
|
|
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,
|