@workerdeck/ui 0.17.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/build/{SessionPanel-CnNYEX80.d.mts → SessionPanel-CnU_IJ3-.d.mts} +24 -9
- package/build/{SessionPanel-DMPhsNlW.mjs → SessionPanel-DPx8Iz8a.mjs} +439 -74
- 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 +237 -8
- package/build/index.mjs +399 -167
- 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 +4 -4
- package/src/components/agent/ContextRing.tsx +41 -0
- package/src/components/agent/EngineIcon.tsx +40 -0
- package/src/components/agent/SessionBrowser.tsx +115 -22
- package/src/components/agent/SessionPanel.tsx +153 -3
- 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/Transcript.tsx +138 -19
- package/src/components/agent/transcript-rows.ts +9 -1
- package/src/components/terminal/TerminalTranscript.tsx +77 -3
- package/src/components/terminal/affordances.tsx +34 -0
- package/src/components/terminal/blocks.ts +28 -0
- package/src/components/terminal/height.ts +24 -0
- package/src/components/terminal/items.tsx +1 -1
- package/src/components/terminal/scrubber.tsx +28 -3
- package/src/components/terminal/tool-run.ts +49 -5
- package/src/index.ts +19 -1
- package/src/lib/status.ts +16 -3
- package/src/styles/terminal.css +10 -0
- package/src/styles/theme.css +42 -0
- package/build/SessionPanel-DMPhsNlW.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
|
@@ -26,14 +26,17 @@ import { SessionEmptyState } from './SessionEmptyState.tsx'
|
|
|
26
26
|
import { ToolCallCard } from './ToolCallCard.tsx'
|
|
27
27
|
import { resolveAffordances, type TerminalAffordances } from '../terminal/affordances.tsx'
|
|
28
28
|
import { ToolRunRow, WorkingRow, parentOf, terminalBlocks } from '../terminal/items.tsx'
|
|
29
|
-
import {
|
|
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
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 { TaskRow, 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,
|
|
@@ -468,6 +479,11 @@ function TranscriptRows({
|
|
|
468
479
|
/** Mount the overview-ruler rail (terminal theme only). */
|
|
469
480
|
scrubber?: boolean
|
|
470
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
|
|
471
487
|
affordances?: TerminalAffordances | boolean
|
|
472
488
|
fileUrl?: (path: string) => string
|
|
473
489
|
attachmentUrl?: (attachmentId: string) => string
|
|
@@ -613,6 +629,12 @@ function TranscriptRows({
|
|
|
613
629
|
if (terminal && epoch) {
|
|
614
630
|
const row = rows[index]
|
|
615
631
|
const gapPx = index > 0 && gapBefore(rows, index) ? epoch.line : 0
|
|
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
|
|
616
638
|
if (row && !('line' in row))
|
|
617
639
|
return estimateBlockPx(row, epoch) + gapPx
|
|
618
640
|
return epoch.line + gapPx // recap: one Row, one line
|
|
@@ -837,7 +859,7 @@ function TranscriptRows({
|
|
|
837
859
|
<ToolRunRow items={row.run} />
|
|
838
860
|
</div>
|
|
839
861
|
) : 'item' in row ? (
|
|
840
|
-
<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))}>
|
|
841
863
|
<TranscriptItemView
|
|
842
864
|
item={row.item}
|
|
843
865
|
fileUrl={fileUrl}
|
|
@@ -846,6 +868,8 @@ function TranscriptRows({
|
|
|
846
868
|
terminal={terminal}
|
|
847
869
|
/>
|
|
848
870
|
</div>
|
|
871
|
+
) : 'text' in row && row.key === 'brief' ? (
|
|
872
|
+
<BriefRow text={row.text} terminal={terminal} />
|
|
849
873
|
) : 'line' in row ? (
|
|
850
874
|
<RecapRow line={row.line} since={since} terminal={terminal} />
|
|
851
875
|
) : (
|
|
@@ -854,7 +878,7 @@ function TranscriptRows({
|
|
|
854
878
|
// is the main thread's, and stepping it in would say a `Task` call
|
|
855
879
|
// happened somewhere else.
|
|
856
880
|
<div className={cn(read(boundary, row.index) && 'opacity-45')}>
|
|
857
|
-
<TaskRow block={row} fileUrl={fileUrl} />
|
|
881
|
+
<TaskRow block={row} fileUrl={fileUrl} onOpenSubagent={onOpenSubagent} />
|
|
858
882
|
</div>
|
|
859
883
|
)
|
|
860
884
|
// A prompt row's sticky lane — see the pinned-prompt comment above.
|
|
@@ -921,6 +945,7 @@ function TranscriptRows({
|
|
|
921
945
|
pendingApprovals={pendingApprovals}
|
|
922
946
|
recapRow={recapRow}
|
|
923
947
|
bookmarks={scrubberMarks ?? []}
|
|
948
|
+
frameParentId={frameParentId}
|
|
924
949
|
rowIndexFor={(itemIndex) => rowIndexForItem(rows, itemIndex)}
|
|
925
950
|
positionInRow={(itemIndex) => positionInRow(rows, itemIndex)}
|
|
926
951
|
// The public memoized measurements array — `getTotalSize()` just
|
|
@@ -1044,6 +1069,39 @@ export interface TranscriptProps {
|
|
|
1044
1069
|
* would need a live closure at the other end of a postMessage bridge.
|
|
1045
1070
|
*/
|
|
1046
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
|
|
1047
1105
|
className?: string
|
|
1048
1106
|
}
|
|
1049
1107
|
|
|
@@ -1066,9 +1124,30 @@ export function Transcript({
|
|
|
1066
1124
|
jumpToRecapRef,
|
|
1067
1125
|
repinRef,
|
|
1068
1126
|
reveal,
|
|
1127
|
+
frame,
|
|
1128
|
+
onOpenSubagent,
|
|
1069
1129
|
className,
|
|
1070
1130
|
}: TranscriptProps) {
|
|
1071
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
|
+
)
|
|
1072
1151
|
const gap = ROW_GAP[variant][density]
|
|
1073
1152
|
const runStartedAt = useRunStart(state.status)
|
|
1074
1153
|
// A boundary at (or past) the end means nothing is new — no row, no dimming.
|
|
@@ -1083,24 +1162,40 @@ export function Transcript({
|
|
|
1083
1162
|
// cannot say what you missed. Clamping it would land on `items.length` and
|
|
1084
1163
|
// read as "nothing is new" — the same outcome, told less truthfully.
|
|
1085
1164
|
const boundary =
|
|
1086
|
-
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
|
|
1087
1168
|
const recap = useMemo(
|
|
1088
1169
|
() => (boundary === undefined ? undefined : recapLine(summarizeSince(state, boundary))),
|
|
1089
1170
|
[state, boundary],
|
|
1090
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
|
+
)
|
|
1091
1185
|
const rows = useMemo<TranscriptRow[]>(() => {
|
|
1092
1186
|
const fold = (from: number, to: number) =>
|
|
1093
|
-
terminalBlocks(
|
|
1094
|
-
|
|
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)]
|
|
1095
1190
|
// Each side of the boundary folds separately, so a shell run never spans it:
|
|
1096
1191
|
// "what happened while you were away" must not hide inside a count that also
|
|
1097
1192
|
// covers what you have already read.
|
|
1098
1193
|
return [
|
|
1099
1194
|
...fold(0, boundary),
|
|
1100
1195
|
{ key: 'recap' as const, line: recap },
|
|
1101
|
-
...fold(boundary,
|
|
1196
|
+
...fold(boundary, items.length),
|
|
1102
1197
|
]
|
|
1103
|
-
}, [
|
|
1198
|
+
}, [items, boundary, recap, terminal, brief])
|
|
1104
1199
|
return (
|
|
1105
1200
|
<TranscriptVariantProvider value={variant}>
|
|
1106
1201
|
{/* The replay hold hides by VISIBILITY, never by not mounting. The rows
|
|
@@ -1121,38 +1216,62 @@ export function Transcript({
|
|
|
1121
1216
|
fontSize={fontSize}
|
|
1122
1217
|
lineHeight={lineHeight}
|
|
1123
1218
|
affordances={affordances}>
|
|
1124
|
-
{
|
|
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' ? (
|
|
1125
1234
|
<SessionEmptyState
|
|
1126
1235
|
cwd={state.cwd}
|
|
1127
1236
|
hasCommands={!!state.commands?.length}
|
|
1128
1237
|
hasSkills={!!state.skills?.some((s) => s.enabled)}
|
|
1129
1238
|
canBrowseFiles={canBrowseFiles}
|
|
1130
1239
|
/>
|
|
1131
|
-
) :
|
|
1240
|
+
) : null}
|
|
1241
|
+
{frame && frameTask === undefined && !replaying ? null : (
|
|
1132
1242
|
<TranscriptRows
|
|
1133
1243
|
rows={rows}
|
|
1134
1244
|
boundary={boundary}
|
|
1135
1245
|
since={catchUp?.since}
|
|
1136
1246
|
terminal={terminal}
|
|
1137
1247
|
replaying={replaying}
|
|
1138
|
-
stickyPrompt={stickyPrompt}
|
|
1248
|
+
stickyPrompt={!frame && stickyPrompt}
|
|
1139
1249
|
gap={gap}
|
|
1140
1250
|
fontSize={fontSize}
|
|
1141
1251
|
lineHeight={lineHeight}
|
|
1142
|
-
items={
|
|
1252
|
+
items={items}
|
|
1143
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. */
|
|
1144
1261
|
scrubber={scrubber}
|
|
1145
|
-
scrubberMarks={scrubberMarks}
|
|
1262
|
+
scrubberMarks={frame ? undefined : scrubberMarks}
|
|
1146
1263
|
affordances={affordances}
|
|
1147
1264
|
fileUrl={fileUrl}
|
|
1148
1265
|
attachmentUrl={attachmentUrl}
|
|
1149
1266
|
hostImage={hostImage}
|
|
1150
1267
|
jumpToRecapRef={jumpToRecapRef}
|
|
1151
1268
|
repinRef={repinRef}
|
|
1152
|
-
reveal={reveal}
|
|
1269
|
+
reveal={frame ? undefined : reveal}
|
|
1270
|
+
frameParentId={frame?.parentToolUseId}
|
|
1271
|
+
onOpenSubagent={frame ? undefined : onOpenSubagent}
|
|
1153
1272
|
/>
|
|
1154
1273
|
)}
|
|
1155
|
-
{showLoader(state) ? (
|
|
1274
|
+
{(frame ? frameTask !== undefined && taskBusy(frameTask, items) : showLoader(state)) ? (
|
|
1156
1275
|
terminal ? (
|
|
1157
1276
|
// The CLI's own working line, and it is a *row of the transcript*
|
|
1158
1277
|
// rather than a spinner floating over it — one blank line down,
|
|
@@ -14,7 +14,15 @@ import { needsBlank, type TerminalBlock } from '../terminal/items.tsx'
|
|
|
14
14
|
* virtualizer sees stable indices, and each row carries the key the item was
|
|
15
15
|
* already React-keyed by — measurements are cached per key, so a row keeps its
|
|
16
16
|
* measured height when the recap splice shifts every index after it. */
|
|
17
|
-
export type TranscriptRow =
|
|
17
|
+
export type TranscriptRow =
|
|
18
|
+
| TerminalBlock
|
|
19
|
+
| { key: 'recap'; line: string }
|
|
20
|
+
/** The sub-agent's brief, spliced in as the takeover frame's first row — what
|
|
21
|
+
* the agent was asked, before what it did. Synthetic like the recap row and
|
|
22
|
+
* for the same reason: it is not a transcript item (the engine puts the
|
|
23
|
+
* instruction in the spawning call's `prompt`, never in the stream), but it
|
|
24
|
+
* has to be a row so the virtualizer can size and key it. */
|
|
25
|
+
| { key: 'brief'; text: string }
|
|
18
26
|
|
|
19
27
|
/** The item a row is spaced *as*. A run stands for the calls it folded, and a
|
|
20
28
|
* task block for the `Task` call it absorbed into — all tool calls, so a run,
|
|
@@ -2,6 +2,7 @@ import { Fragment, useEffect, useMemo, useState } from 'react'
|
|
|
2
2
|
import type { TranscriptItem, TranscriptState } from '@workerdeck/react'
|
|
3
3
|
import { cn } from '../../lib/utils.ts'
|
|
4
4
|
import type { TerminalAffordances } from './affordances.tsx'
|
|
5
|
+
import { OpenSubagentAction, WithActions } from './affordances.tsx'
|
|
5
6
|
import {
|
|
6
7
|
AssistantRow,
|
|
7
8
|
FileRow,
|
|
@@ -19,7 +20,8 @@ import {
|
|
|
19
20
|
} from './items.tsx'
|
|
20
21
|
import { usePulse } from '../agent/pulse.tsx'
|
|
21
22
|
import { Pressable, useRevealOnOpen } from './press.tsx'
|
|
22
|
-
import { taskBusy, taskFailed, taskSummary } from './tool-run.ts'
|
|
23
|
+
import { taskBrief, taskBusy, taskFailed, taskSummary } from './tool-run.ts'
|
|
24
|
+
import { BRIEF_LINES } from './height.ts'
|
|
23
25
|
import { Blank, Row } from './row.tsx'
|
|
24
26
|
import { TerminalSurface } from './surface.tsx'
|
|
25
27
|
|
|
@@ -105,21 +107,72 @@ export function TerminalItemView({
|
|
|
105
107
|
* they fold among themselves: a subagent's consecutive tool calls are as much
|
|
106
108
|
* an aside inside its frame as they are in the main thread.
|
|
107
109
|
*/
|
|
110
|
+
/**
|
|
111
|
+
* **What the agent was asked** — the sub-agent's brief, clipped to
|
|
112
|
+
* {@link BRIEF_LINES} and pressable for the whole of it.
|
|
113
|
+
*
|
|
114
|
+
* It leads the takeover's frame and the inline task expansion alike, because
|
|
115
|
+
* both are answering the same question and an answer without its question is
|
|
116
|
+
* half a transcript. This is the one row here built from something other than
|
|
117
|
+
* the stream, and it exists for the case the stream does not cover: a
|
|
118
|
+
* **background** agent forwards no brief, where a foreground `Task` forwards a
|
|
119
|
+
* real nested user item that renders as an ordinary prompt row. The callers
|
|
120
|
+
* splice this in only when that row is absent. Codex draws nothing either way —
|
|
121
|
+
* its spawn message is encrypted on the wire, so there is no brief to give.
|
|
122
|
+
*
|
|
123
|
+
* `>` and blue, the prompt's own marker and colour, because that is what this
|
|
124
|
+
* is: somebody's instruction, one level in. The clip is `line-clamp`, which
|
|
125
|
+
* cuts on the same wrapped lines `briefPx` counts — one rule, so the reserved
|
|
126
|
+
* height and the drawn height cannot disagree.
|
|
127
|
+
*/
|
|
128
|
+
export function BriefRow({ text, terminal }: { text: string; terminal?: boolean }) {
|
|
129
|
+
const [open, setOpen] = useState(false)
|
|
130
|
+
if (!terminal) {
|
|
131
|
+
return (
|
|
132
|
+
<div data-slot='brief' className='px-4 py-2 text-body-sm whitespace-pre-wrap text-fg-2'>
|
|
133
|
+
{text}
|
|
134
|
+
</div>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
return (
|
|
138
|
+
<div data-slot='brief'>
|
|
139
|
+
<Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
|
|
140
|
+
<Row glyph='>' glyphTone='blue' tone='dim'>
|
|
141
|
+
<span
|
|
142
|
+
className={cn('whitespace-pre-wrap', !open && 'term-brief-clip')}
|
|
143
|
+
style={open ? undefined : { WebkitLineClamp: BRIEF_LINES }}>
|
|
144
|
+
{text}
|
|
145
|
+
</span>
|
|
146
|
+
</Row>
|
|
147
|
+
</Pressable>
|
|
148
|
+
</div>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
108
152
|
export function TaskRow({
|
|
109
153
|
block,
|
|
110
154
|
fileUrl,
|
|
155
|
+
onOpenSubagent,
|
|
111
156
|
}: {
|
|
112
157
|
block: TaskBlock
|
|
113
158
|
fileUrl?: (path: string) => string
|
|
159
|
+
/** Take over the panel with this sub-agent's own frame. Absent draws no
|
|
160
|
+
* affordance at all — the plain renderer has no surface to take over, and an
|
|
161
|
+
* action that opens nothing is worse than no action. */
|
|
162
|
+
onOpenSubagent?: (toolUseId: string) => void
|
|
114
163
|
}) {
|
|
115
164
|
const [open, setOpen] = useState(false)
|
|
116
165
|
const reveal = useRevealOnOpen(open)
|
|
117
166
|
const children = useMemo(() => taskChildItems(block), [block])
|
|
167
|
+
// Only when the sub-agent's own stream carries no brief — a foreground Task
|
|
168
|
+
// forwards one as a real user item, and two spellings of one instruction is
|
|
169
|
+
// worse than none. See `taskBrief`.
|
|
170
|
+
const brief = children.some((item) => item.kind === 'user') ? undefined : taskBrief(block.task)
|
|
118
171
|
const busy = taskBusy(block.task, children)
|
|
119
172
|
const failed = taskFailed(block.task)
|
|
120
173
|
const pulse = usePulse(busy)
|
|
121
174
|
|
|
122
|
-
|
|
175
|
+
const row = (
|
|
123
176
|
<div ref={reveal} className={open ? 'term-open' : undefined}>
|
|
124
177
|
<Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
|
|
125
178
|
{/* A marker, where a folded run of calls gets none: a run is an aside,
|
|
@@ -127,10 +180,18 @@ export function TaskRow({
|
|
|
127
180
|
the row stands for the whole of it. The body is `taskSummary`
|
|
128
181
|
verbatim — it is the string `height.ts` wraps to size this row, and
|
|
129
182
|
a second spelling here would be a second height. */}
|
|
183
|
+
{/* Green means sub-agent, and it means it here for the same reason it
|
|
184
|
+
means it on the rail: every other colour is spoken for — blue is
|
|
185
|
+
you, white is the answer, red is an alarm, magenta is your bookmark,
|
|
186
|
+
yellow is the session waiting on you (see `terminal.css`). The
|
|
187
|
+
*body* is green and the marker is not: a green glyph already means
|
|
188
|
+
"wrote to the workspace" a few rows down, and one colour cannot mean
|
|
189
|
+
two things in the same gutter. Failure still outranks it — an alarm
|
|
190
|
+
is not a category. */}
|
|
130
191
|
<Row
|
|
131
192
|
glyph={busy ? pulse : '●'}
|
|
132
193
|
glyphTone={failed ? 'red' : busy ? 'mark' : 'dim'}
|
|
133
|
-
tone={failed ? 'red' : '
|
|
194
|
+
tone={failed ? 'red' : 'green'}>
|
|
134
195
|
{taskSummary(block.task, children)}
|
|
135
196
|
</Row>
|
|
136
197
|
</Pressable>
|
|
@@ -139,6 +200,9 @@ export function TaskRow({
|
|
|
139
200
|
// sits on the open block's wash, where that border token is invisible,
|
|
140
201
|
// and its 14px would take every nested marker off the cell grid.
|
|
141
202
|
<div className='term-nested'>
|
|
203
|
+
{/* The brief leads the children for the same reason it leads the
|
|
204
|
+
frame: the instruction, then the work. */}
|
|
205
|
+
{brief ? <BriefRow text={brief} terminal /> : null}
|
|
142
206
|
{block.children.map((leaf, index) => (
|
|
143
207
|
<Fragment key={leaf.key}>
|
|
144
208
|
{index > 0 && blockNeedsBlank(block.children[index - 1]!, leaf) ? <Blank /> : null}
|
|
@@ -153,6 +217,16 @@ export function TaskRow({
|
|
|
153
217
|
) : null}
|
|
154
218
|
</div>
|
|
155
219
|
)
|
|
220
|
+
// Wrapped only when there is somewhere to go. `WithActions` is a no-op when
|
|
221
|
+
// affordances are off, but wrapping unconditionally would still put an
|
|
222
|
+
// "open" glyph on a renderer that cannot honour it.
|
|
223
|
+
return onOpenSubagent === undefined ? (
|
|
224
|
+
row
|
|
225
|
+
) : (
|
|
226
|
+
<WithActions actions={<OpenSubagentAction onOpen={() => onOpenSubagent(block.task.id)} />}>
|
|
227
|
+
{row}
|
|
228
|
+
</WithActions>
|
|
229
|
+
)
|
|
156
230
|
}
|
|
157
231
|
|
|
158
232
|
/** When the current run began — the clock the working line counts from. Held
|
|
@@ -89,6 +89,40 @@ export function WithActions({
|
|
|
89
89
|
* else is on, and reads as a button borrowed from another application. The tick
|
|
90
90
|
* replaces the glyph in place, so the confirmation costs no width either.
|
|
91
91
|
*/
|
|
92
|
+
/**
|
|
93
|
+
* Open a sub-agent's own surface, as a character.
|
|
94
|
+
*
|
|
95
|
+
* A row **action** and not the row's press, because the press already means
|
|
96
|
+
* expand/collapse and that is the cheaper, more common intent — a reader
|
|
97
|
+
* glancing at what an agent did wants the four rows inline, not a new screen.
|
|
98
|
+
* Taking over the panel is the deliberate move, so it gets the deliberate
|
|
99
|
+
* target. Same zero-layout contract as `CopyAction`: it lives in the hover
|
|
100
|
+
* actions overlay, so a collapsed Task row is exactly as tall with it as
|
|
101
|
+
* without, which is what keeps the height book honest.
|
|
102
|
+
*/
|
|
103
|
+
export function OpenSubagentAction({
|
|
104
|
+
onOpen,
|
|
105
|
+
label = 'Open sub-agent',
|
|
106
|
+
}: {
|
|
107
|
+
onOpen: () => void
|
|
108
|
+
label?: string
|
|
109
|
+
}) {
|
|
110
|
+
return (
|
|
111
|
+
<button
|
|
112
|
+
type='button'
|
|
113
|
+
className='term-action'
|
|
114
|
+
title={label}
|
|
115
|
+
aria-label={label}
|
|
116
|
+
onClick={(event) => {
|
|
117
|
+
// The row underneath expands; opening is not expanding.
|
|
118
|
+
event.stopPropagation()
|
|
119
|
+
onOpen()
|
|
120
|
+
}}>
|
|
121
|
+
⤢
|
|
122
|
+
</button>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
92
126
|
export function CopyAction({ text, label = 'Copy' }: { text: string; label?: string }) {
|
|
93
127
|
const [copied, setCopied] = useState(false)
|
|
94
128
|
return (
|
|
@@ -67,6 +67,34 @@ export function parentOf(item: TranscriptItem): string | undefined {
|
|
|
67
67
|
return parent ?? undefined
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* **The frame membership rule**: the items a sub-agent produced, and nothing
|
|
72
|
+
* else — what the takeover renders instead of the whole conversation.
|
|
73
|
+
*
|
|
74
|
+
* One exported function rather than a `filter` at each call site, because this
|
|
75
|
+
* is a rule two renderers have to agree on: iOS mirrors the terminal model out
|
|
76
|
+
* of this module, and a phone that framed a sub-agent slightly differently would
|
|
77
|
+
* be a second answer to "what is this agent doing".
|
|
78
|
+
*
|
|
79
|
+
* It picks up the brief (a `user` item *with* a parent), the thinking, the
|
|
80
|
+
* streamed text (deltas are namespaced per sidechain, so `streaming:<parentId>`
|
|
81
|
+
* carries the parent like any other item), every tool call with its result, and
|
|
82
|
+
* the final report. It excludes the spawning `Task` call itself — that is the
|
|
83
|
+
* frame, not a row in it — and every other agent's work.
|
|
84
|
+
*
|
|
85
|
+
* The slice is safe to hand straight to {@link terminalBlocks} at offset 0:
|
|
86
|
+
* nothing in it is top-level, so nothing absorbs, and consecutive calls still
|
|
87
|
+
* fold into runs because {@link foldsTogether} keys on an *equal* parent rather
|
|
88
|
+
* than on absence of one. Row indices are internally consistent because the rows
|
|
89
|
+
* and the `items` they came from are the same array.
|
|
90
|
+
*/
|
|
91
|
+
export function subagentItems(
|
|
92
|
+
items: readonly TranscriptItem[],
|
|
93
|
+
parentToolUseId: string,
|
|
94
|
+
): TranscriptItem[] {
|
|
95
|
+
return items.filter((item) => parentOf(item) === parentToolUseId)
|
|
96
|
+
}
|
|
97
|
+
|
|
70
98
|
/** Is this a row the transcript folds into a run? Any tool call is — see
|
|
71
99
|
* `tool-run.ts` for why this is no longer shell-only. */
|
|
72
100
|
export function isRunCall(item: TranscriptItem): item is ToolCallItem {
|
|
@@ -132,6 +132,30 @@ export function estimateBlockPx(block: TerminalBlock, epoch: HeightEpoch): numbe
|
|
|
132
132
|
return computed.px
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* How many lines of a sub-agent's brief the collapsed row shows.
|
|
137
|
+
*
|
|
138
|
+
* Four, and the number is a judgement rather than a measurement: a brief runs
|
|
139
|
+
* to thousands of characters, and an uncapped one buries the work it was asking
|
|
140
|
+
* for under its own instructions. Four is enough to recognise the task and
|
|
141
|
+
* short enough that the agent's first line stays on screen beside it.
|
|
142
|
+
*/
|
|
143
|
+
export const BRIEF_LINES = 4
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The collapsed brief row's height — the frame's first row, which is synthetic
|
|
147
|
+
* and so cannot go through {@link estimateBlockPx}.
|
|
148
|
+
*
|
|
149
|
+
* Clipped, so it is `min(wrapped, BRIEF_LINES)` lines plus the row the header
|
|
150
|
+
* occupies. Expanding is local state on a *mounted* row, which the virtualizer
|
|
151
|
+
* re-measures; what has to be right before it mounts is the collapsed height,
|
|
152
|
+
* exactly as a task row is always collapsed when unmounted for the same reason.
|
|
153
|
+
*/
|
|
154
|
+
export function briefPx(text: string, m: CellMetrics): number {
|
|
155
|
+
const cols = Math.max(1, Math.floor(m.width / m.ch + EPS))
|
|
156
|
+
return (Math.min(textLines(text, cols).lines, BRIEF_LINES) + 1) * m.line
|
|
157
|
+
}
|
|
158
|
+
|
|
135
159
|
/** Measure the advance of `0` on the live surface. A DOM read — call it from
|
|
136
160
|
* the epoch's measurement pass (an effect / ResizeObserver callback), never
|
|
137
161
|
* from render. The probe is absolutely positioned, so it contributes no layout
|
|
@@ -442,7 +442,7 @@ export function FileRow({
|
|
|
442
442
|
}
|
|
443
443
|
|
|
444
444
|
/** A once-a-second clock, running only while `on`. */
|
|
445
|
-
function useTicker(on: boolean): number {
|
|
445
|
+
export function useTicker(on: boolean): number {
|
|
446
446
|
const [now, setNow] = useState(() => Date.now())
|
|
447
447
|
useEffect(() => {
|
|
448
448
|
if (!on) return
|
|
@@ -233,6 +233,19 @@ export interface TerminalScrubberProps {
|
|
|
233
233
|
recapRow?: { rowIndex: number; label: string }
|
|
234
234
|
/** Bookmarked item indices. Paint only — no store, no set affordance. */
|
|
235
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
|
|
236
249
|
/** Item index → virtual row index (the off-by-a-fold mapping; see
|
|
237
250
|
* `rowIndexForItem` in `agent/Transcript.tsx`). */
|
|
238
251
|
rowIndexFor: (itemIndex: number) => number
|
|
@@ -274,6 +287,7 @@ export function buildClusters(
|
|
|
274
287
|
const {
|
|
275
288
|
items,
|
|
276
289
|
bookmarks,
|
|
290
|
+
frameParentId,
|
|
277
291
|
recapRow,
|
|
278
292
|
pendingApprovals,
|
|
279
293
|
rowIndexFor,
|
|
@@ -300,7 +314,7 @@ export function buildClusters(
|
|
|
300
314
|
// only `rowIndexFor`.
|
|
301
315
|
const rowOutcome = new Map<number, number>()
|
|
302
316
|
items.forEach((item, index) => {
|
|
303
|
-
if (item.kind !== 'tool_call' || parentOf(item) !==
|
|
317
|
+
if (item.kind !== 'tool_call' || parentOf(item) !== frameParentId) return
|
|
304
318
|
rowOutcome.set(rowIndexFor(index), index)
|
|
305
319
|
})
|
|
306
320
|
// One right-lane mark per segment, emitted when the segment closes. A segment
|
|
@@ -335,7 +349,7 @@ export function buildClusters(
|
|
|
335
349
|
// is a `user` item too, and it would both paint a "you" mark for something
|
|
336
350
|
// nobody typed and close the segment mid-turn — which mis-anchors the turn
|
|
337
351
|
// mark whenever a task runs between the prompt and the answer.
|
|
338
|
-
if (item.kind === 'user' && parentOf(item) ===
|
|
352
|
+
if (item.kind === 'user' && parentOf(item) === frameParentId) {
|
|
339
353
|
closeSegment()
|
|
340
354
|
marks.push({ kind: 'user', itemIndex: index, rowIndex: rowIndexFor(index) })
|
|
341
355
|
} else if (item.kind === 'turn_result') {
|
|
@@ -385,7 +399,18 @@ export function buildClusters(
|
|
|
385
399
|
rowOutcome.get(rowIndexFor(index)) === index
|
|
386
400
|
) {
|
|
387
401
|
marks.push({ kind: 'toolFailed', itemIndex: index, rowIndex: rowIndexFor(index) })
|
|
388
|
-
} 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
|
+
}
|
|
389
414
|
// The live one included, deliberately: a turn in flight has no turn end
|
|
390
415
|
// yet, which left a two-minute answer unrepresented on the rail for the
|
|
391
416
|
// whole two minutes it was the only thing worth navigating to. The mark's
|