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