@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.
Files changed (40) hide show
  1. package/build/{SessionPanel-CnNYEX80.d.mts → SessionPanel-CnU_IJ3-.d.mts} +24 -9
  2. package/build/{SessionPanel-DMPhsNlW.mjs → SessionPanel-DPx8Iz8a.mjs} +439 -74
  3. package/build/SessionPanel-DPx8Iz8a.mjs.map +1 -0
  4. package/build/{format-DfI_je9S.d.mts → format-ljc3lKpA.d.mts} +1 -1
  5. package/build/format.d.mts +16 -5
  6. package/build/format.mjs +2 -3
  7. package/build/index.d.mts +237 -8
  8. package/build/index.mjs +399 -167
  9. package/build/index.mjs.map +1 -1
  10. package/build/{format-DqR56Y8l.mjs → status-BE-zg88x.mjs} +154 -2
  11. package/build/status-BE-zg88x.mjs.map +1 -0
  12. package/build/workspace.d.mts +4 -1
  13. package/build/workspace.mjs +4 -3
  14. package/build/workspace.mjs.map +1 -1
  15. package/package.json +4 -4
  16. package/src/components/agent/ContextRing.tsx +41 -0
  17. package/src/components/agent/EngineIcon.tsx +40 -0
  18. package/src/components/agent/SessionBrowser.tsx +115 -22
  19. package/src/components/agent/SessionPanel.tsx +153 -3
  20. package/src/components/agent/SessionSteps.tsx +233 -0
  21. package/src/components/agent/SessionWorkspace.tsx +4 -0
  22. package/src/components/agent/StatusBar.tsx +4 -2
  23. package/src/components/agent/SubagentStrip.tsx +134 -0
  24. package/src/components/agent/Transcript.tsx +138 -19
  25. package/src/components/agent/transcript-rows.ts +9 -1
  26. package/src/components/terminal/TerminalTranscript.tsx +77 -3
  27. package/src/components/terminal/affordances.tsx +34 -0
  28. package/src/components/terminal/blocks.ts +28 -0
  29. package/src/components/terminal/height.ts +24 -0
  30. package/src/components/terminal/items.tsx +1 -1
  31. package/src/components/terminal/scrubber.tsx +28 -3
  32. package/src/components/terminal/tool-run.ts +49 -5
  33. package/src/index.ts +19 -1
  34. package/src/lib/status.ts +16 -3
  35. package/src/styles/terminal.css +10 -0
  36. package/src/styles/theme.css +42 -0
  37. package/build/SessionPanel-DMPhsNlW.mjs.map +0 -1
  38. package/build/format-DqR56Y8l.mjs.map +0 -1
  39. package/build/status-Ydzi7n6j.mjs +0 -143
  40. 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 { estimateBlockPx } from '../terminal/height.ts'
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
- function nestedClass(item: TranscriptItem): string | undefined {
305
- const nested = 'parentToolUseId' in item && item.parentToolUseId != null
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 ? catchUp.from : undefined
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(state.items.slice(from, to), from, terminal)
1094
- if (boundary === undefined || !recap) return fold(0, state.items.length)
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, state.items.length),
1196
+ ...fold(boundary, items.length),
1102
1197
  ]
1103
- }, [state.items, boundary, recap, terminal])
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
- {state.items.length === 0 && state.status !== 'starting' ? (
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={state.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 = TerminalBlock | { key: 'recap'; line: string }
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
- return (
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' : 'fg'}>
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) !== undefined) return
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) === undefined) {
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.parentToolUseId == null) {
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