@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.
Files changed (47) hide show
  1. package/README.md +7 -0
  2. package/build/{SessionPanel-B9CHoq8x.d.mts → SessionPanel-CnU_IJ3-.d.mts} +36 -9
  3. package/build/{SessionPanel-DII9MmQ8.mjs → SessionPanel-DPx8Iz8a.mjs} +1432 -384
  4. package/build/SessionPanel-DPx8Iz8a.mjs.map +1 -0
  5. package/build/{format-DfI_je9S.d.mts → format-ljc3lKpA.d.mts} +1 -1
  6. package/build/format.d.mts +16 -5
  7. package/build/format.mjs +2 -3
  8. package/build/index.d.mts +288 -8
  9. package/build/index.mjs +620 -165
  10. package/build/index.mjs.map +1 -1
  11. package/build/{format-DqR56Y8l.mjs → status-BE-zg88x.mjs} +154 -2
  12. package/build/status-BE-zg88x.mjs.map +1 -0
  13. package/build/workspace.d.mts +4 -1
  14. package/build/workspace.mjs +4 -3
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +8 -6
  17. package/src/components/agent/ContextRing.tsx +41 -0
  18. package/src/components/agent/EngineIcon.tsx +40 -0
  19. package/src/components/agent/ProjectIcon.tsx +119 -0
  20. package/src/components/agent/SessionBrowser.tsx +191 -17
  21. package/src/components/agent/SessionPanel.tsx +177 -2
  22. package/src/components/agent/SessionSteps.tsx +233 -0
  23. package/src/components/agent/SessionWorkspace.tsx +4 -0
  24. package/src/components/agent/StatusBar.tsx +4 -2
  25. package/src/components/agent/SubagentStrip.tsx +134 -0
  26. package/src/components/agent/ToolCallCard.tsx +72 -5
  27. package/src/components/agent/Transcript.tsx +212 -23
  28. package/src/components/agent/tool-result-fetch.tsx +36 -0
  29. package/src/components/agent/tool-result-image.tsx +209 -0
  30. package/src/components/agent/transcript-rows.ts +122 -23
  31. package/src/components/terminal/TerminalTranscript.tsx +154 -2
  32. package/src/components/terminal/affordances.tsx +34 -0
  33. package/src/components/terminal/blocks.ts +260 -0
  34. package/src/components/terminal/height.ts +73 -6
  35. package/src/components/terminal/image-box.ts +53 -0
  36. package/src/components/terminal/items.tsx +116 -79
  37. package/src/components/terminal/result-preview.ts +20 -6
  38. package/src/components/terminal/scrubber.tsx +172 -26
  39. package/src/components/terminal/tool-run.ts +177 -0
  40. package/src/index.ts +20 -1
  41. package/src/lib/status.ts +16 -3
  42. package/src/styles/terminal.css +85 -5
  43. package/src/styles/theme.css +42 -0
  44. package/build/SessionPanel-DII9MmQ8.mjs.map +0 -1
  45. package/build/format-DqR56Y8l.mjs.map +0 -1
  46. package/build/status-Ydzi7n6j.mjs +0 -143
  47. package/build/status-Ydzi7n6j.mjs.map +0 -1
@@ -0,0 +1,36 @@
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
+
3
+ /**
4
+ * How a row gets back the part of a tool result the replay did not send.
5
+ *
6
+ * A **context**, not a prop chain, for the same reason the variant is one: the
7
+ * rows that need it are drawn by `terminalBlocks` and by the cards theme's
8
+ * `ToolCallCard`, several layers below whoever holds the session, and a row
9
+ * composed by hand should get the same behaviour without threading a callback
10
+ * through everything in between.
11
+ *
12
+ * The default is a no-op resolving `false`, which is exactly right for every
13
+ * surface that never asked for truncation (the playground, a fixture, an
14
+ * embedder rendering rows by hand): `result.truncated` is only ever set by a
15
+ * replay a renderer opted into, so a row that has no fetcher also has no head to
16
+ * complete. A press still opens the row; it simply has everything already.
17
+ */
18
+ export type ToolResultFetcher = (toolUseId: string) => Promise<boolean>
19
+
20
+ const FetchContext = createContext<ToolResultFetcher>(async () => false)
21
+
22
+ export function ToolResultFetchProvider({
23
+ value,
24
+ children,
25
+ }: {
26
+ value: ToolResultFetcher | undefined
27
+ children: ReactNode
28
+ }) {
29
+ return <FetchContext.Provider value={value ?? noop}>{children}</FetchContext.Provider>
30
+ }
31
+
32
+ const noop: ToolResultFetcher = async () => false
33
+
34
+ export function useToolResultFetcher(): ToolResultFetcher {
35
+ return useContext(FetchContext)
36
+ }
@@ -0,0 +1,209 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useRef,
7
+ useState,
8
+ type ReactNode,
9
+ } from 'react'
10
+ import type { WorkerDeckClient } from '@workerdeck/client'
11
+
12
+ /**
13
+ * How a row gets the picture the replay refused to send.
14
+ *
15
+ * The sibling of `tool-result-fetch.tsx` and the same shape of seam, because it
16
+ * answers the same shape of question: an opted-in socket delivers a base64
17
+ * `image` part as an `image_ref` — media type, decoded size, and its index in
18
+ * the stored block — and the bytes are fetched over REST by whoever is actually
19
+ * looking at the row. Across a measured corpus that payload was 91% of every
20
+ * tool result and nothing rendered a byte of it.
21
+ *
22
+ * A **context**, not a prop chain, for the variant's reason: the rows are drawn
23
+ * by `terminalBlocks` and by `ToolCallCard`, several layers under whoever holds
24
+ * the session. The default resolves `undefined`, which is exactly right for
25
+ * every surface that never asked (the playground, a fixture, a hand-composed
26
+ * row): `result.images` is only ever set by a replay a renderer opted into, so a
27
+ * row with no loader also has no reference to load. Only `SessionPanel` supplies
28
+ * a real one, because it owns the session's one attach and therefore the only
29
+ * `(seq, toolUseId)` addresses that mean anything.
30
+ */
31
+
32
+ /** One image part, as the row addresses it: the reducer's entry plus the id of
33
+ * the call it came back from. `sourceSeq` is the entry's **own** — the
34
+ * result-level one is cleared by text hydration, and a reader who pressed "show
35
+ * everything" must still be able to load the screenshot afterwards. */
36
+ export type ToolResultImageRef = {
37
+ toolUseId: string
38
+ sourceSeq: number
39
+ partIndex: number
40
+ mediaType: string
41
+ bytes: number
42
+ }
43
+
44
+ /** Resolves an object URL for the picture, or `undefined` when the gateway will
45
+ * not serve it — a stale address after a dormant wake, a gateway with no such
46
+ * route, a dropped connection. The row draws a box either way. */
47
+ export type ToolResultImageLoader = (ref: ToolResultImageRef) => Promise<string | undefined>
48
+
49
+ const noop: ToolResultImageLoader = async () => undefined
50
+
51
+ const ImageContext = createContext<ToolResultImageLoader>(noop)
52
+
53
+ export function ToolResultImageProvider({
54
+ value,
55
+ children,
56
+ }: {
57
+ value: ToolResultImageLoader | undefined
58
+ children: ReactNode
59
+ }) {
60
+ return <ImageContext.Provider value={value ?? noop}>{children}</ImageContext.Provider>
61
+ }
62
+
63
+ export function useToolResultImageLoader(): ToolResultImageLoader {
64
+ return useContext(ImageContext)
65
+ }
66
+
67
+ /**
68
+ * Long enough that a fast scrub through an image-heavy session fetches nothing
69
+ * it flew past, short enough to be invisible to a reader who stopped.
70
+ *
71
+ * There is no second visibility system here on purpose: the transcript is
72
+ * virtualized, so a *mounted* row is by definition within an overscan of the
73
+ * viewport — the virtualizer already is the IntersectionObserver, and a second
74
+ * answer to a question that has one is how the two disagree.
75
+ */
76
+ const MOUNT_SETTLE_MS = 150
77
+
78
+ export type ToolResultImageState = { src?: string; failed: boolean }
79
+
80
+ /**
81
+ * One box's load, for either theme.
82
+ *
83
+ * Fires once the row has been mounted for {@link MOUNT_SETTLE_MS}, and then
84
+ * **runs to completion** — an aborted fetch re-pays the whole image on the
85
+ * return visit, and the gateway is HTTP/1.1, so the browser's per-origin
86
+ * connection cap is the concurrency throttle for free.
87
+ *
88
+ * The effect keys on the address's *primitives*, never on the ref object: the
89
+ * reducer replaces items on every streamed delta, so an object-identity dep
90
+ * would re-run this on every token of the turn after it.
91
+ */
92
+ export function useToolResultImageSrc(ref: ToolResultImageRef): ToolResultImageState {
93
+ const load = useToolResultImageLoader()
94
+ const [state, setState] = useState<ToolResultImageState>({ failed: false })
95
+ const { toolUseId, sourceSeq, partIndex, mediaType, bytes } = ref
96
+ useEffect(() => {
97
+ let live = true
98
+ setState({ failed: false })
99
+ const timer = setTimeout(() => {
100
+ load({ toolUseId, sourceSeq, partIndex, mediaType, bytes })
101
+ .then((src) => {
102
+ if (live) setState({ src, failed: src === undefined })
103
+ })
104
+ .catch(() => {
105
+ if (live) setState({ failed: true })
106
+ })
107
+ }, MOUNT_SETTLE_MS)
108
+ return () => {
109
+ live = false
110
+ clearTimeout(timer)
111
+ }
112
+ }, [load, toolUseId, sourceSeq, partIndex, mediaType, bytes])
113
+ return state
114
+ }
115
+
116
+ /** ~64 MB of decoded pictures held at once. At the corpus's 335 KB median that
117
+ * is ~190 images, which no viewport holds; the budget exists so a session
118
+ * scrolled end to end does not pin every screenshot it passed. */
119
+ const CACHE_BUDGET_BYTES = 64 * 1024 * 1024
120
+
121
+ type Entry = { pending: Promise<string | undefined>; url?: string; bytes: number }
122
+
123
+ /**
124
+ * `useHostImage`'s shape, generalized to the replay route — and **bounded**,
125
+ * which `useHostImage` is not.
126
+ *
127
+ * The promise-per-key cache is what makes this callable from a transcript row at
128
+ * all: rows re-render on every streamed delta, and an uncached resolver would
129
+ * re-fetch each time. The LRU is the part that is new. Object URLs pin their
130
+ * blob until revoked, so a fully-scrolled hundred-image session would otherwise
131
+ * hold ~50 MB until the panel unmounted — and evicting means revoking, or the
132
+ * eviction frees a `Map` entry and nothing else.
133
+ *
134
+ * Re-fetching on a return scroll is fine, and is the whole design: the bytes are
135
+ * one authenticated request away, which is precisely what makes it cheap not to
136
+ * have shipped them in the attach.
137
+ */
138
+ export function useToolResultImages(
139
+ client: WorkerDeckClient,
140
+ sessionId: string | undefined,
141
+ ): ToolResultImageLoader {
142
+ const cache = useRef(new Map<string, Entry>())
143
+ useEffect(
144
+ () => () => {
145
+ for (const entry of cache.current.values()) if (entry.url) URL.revokeObjectURL(entry.url)
146
+ cache.current.clear()
147
+ },
148
+ [],
149
+ )
150
+ return useCallback(
151
+ (ref: ToolResultImageRef) => {
152
+ if (!sessionId) return Promise.resolve(undefined)
153
+ // The whole address, because every part of it can change under a row that
154
+ // is still on screen: a dormant wake restarts the seqs, and a cached
155
+ // address that outlived its log must miss rather than serve another
156
+ // call's pixels.
157
+ const key = `${sessionId}:${ref.sourceSeq}:${ref.toolUseId}:${ref.partIndex}`
158
+ const hit = cache.current.get(key)
159
+ if (hit) {
160
+ // Re-inserting is the "recently used" half of the LRU: `Map` iterates in
161
+ // insertion order, so eviction reads oldest-first for free.
162
+ cache.current.delete(key)
163
+ cache.current.set(key, hit)
164
+ return hit.pending
165
+ }
166
+ // Fetched rather than pointed at: a bare `<img src>` at the gateway
167
+ // carries a credential in exactly one of four clients (the dashboard's
168
+ // same-origin host), and a broken icon in the other three.
169
+ const pending = client
170
+ .toolResultImage(sessionId, ref.sourceSeq, ref.toolUseId, ref.partIndex)
171
+ .then((blob) => {
172
+ if (blob.size === 0) return undefined
173
+ const url = URL.createObjectURL(blob)
174
+ const entry = cache.current.get(key)
175
+ if (entry) {
176
+ entry.url = url
177
+ entry.bytes = blob.size
178
+ evict(cache.current, key)
179
+ } else {
180
+ // Evicted (or unmounted) while in flight — nothing will ever draw
181
+ // this, and an unrevoked URL is the leak the budget exists to stop.
182
+ URL.revokeObjectURL(url)
183
+ }
184
+ return url
185
+ })
186
+ .catch(() => undefined)
187
+ // The declared size is what the budget counts until the bytes land: a
188
+ // hundred fetches in flight must not all read as free.
189
+ cache.current.set(key, { pending, bytes: ref.bytes })
190
+ return pending
191
+ },
192
+ [client, sessionId],
193
+ )
194
+ }
195
+
196
+ /** Drop oldest-first until the held bytes fit the budget, revoking as it goes.
197
+ * `keep` is the entry just resolved — evicting the picture a row is about to
198
+ * draw would be a fetch spent on nothing. */
199
+ function evict(cache: Map<string, Entry>, keep: string): void {
200
+ let held = 0
201
+ for (const entry of cache.values()) held += entry.bytes
202
+ for (const [key, entry] of cache) {
203
+ if (held <= CACHE_BUDGET_BYTES) return
204
+ if (key === keep) continue
205
+ if (entry.url) URL.revokeObjectURL(entry.url)
206
+ cache.delete(key)
207
+ held -= entry.bytes
208
+ }
209
+ }
@@ -7,20 +7,31 @@
7
7
  import type { TranscriptItem } from '@workerdeck/react'
8
8
  import { needsBlank, type TerminalBlock } from '../terminal/items.tsx'
9
9
 
10
- /** One row of the virtual list: a {@link TerminalBlock} (a transcript item, or
11
- * — under the terminal theme — a folded run of tool calls), or the recap
12
- * boundary line spliced in at `catchUp.from`. One flat array so the virtualizer
13
- * sees stable indices, and each row carries the key the item was already
14
- * React-keyed by measurements are cached per key, so a row keeps its measured
15
- * height when the recap splice shifts every index after it. */
16
- export type TranscriptRow = TerminalBlock | { key: 'recap'; line: string }
10
+ /** One row of the virtual list: a {@link TerminalBlock} (a transcript item,
11
+ * — under the terminal theme — a folded run of tool calls, or a task block
12
+ * standing for a `Task` call and everything its subagent produced), or the
13
+ * recap boundary line spliced in at `catchUp.from`. One flat array so the
14
+ * virtualizer sees stable indices, and each row carries the key the item was
15
+ * already React-keyed by measurements are cached per key, so a row keeps its
16
+ * measured height when the recap splice shifts every index after it. */
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 }
17
26
 
18
- /** The item a row is spaced *as*. A run stands for the calls it folded, so a
19
- * run and a lone tool call below it still read as one block. */
27
+ /** The item a row is spaced *as*. A run stands for the calls it folded, and a
28
+ * task block for the `Task` call it absorbed into all tool calls, so a run,
29
+ * a task and a lone tool call below them still read as one block. */
20
30
  export function rowItem(row: TranscriptRow | undefined): TranscriptItem | undefined {
21
31
  if (!row) return undefined
22
32
  if ('item' in row) return row.item
23
33
  if ('run' in row) return row.run[0]
34
+ if ('task' in row) return row.task
24
35
  return undefined
25
36
  }
26
37
 
@@ -38,27 +49,66 @@ export function gapBefore(rows: TranscriptRow[], index: number): boolean {
38
49
  return needsBlank(before, after)
39
50
  }
40
51
 
52
+ /**
53
+ * Which items each task block absorbed, as itemIndex → rowIndex — the one
54
+ * lookup {@link rowIndexForItem} cannot answer from ordering (see its comment).
55
+ * Memoized per rows array identity: the shell builds `rows` in a `useMemo`, so
56
+ * within one row list this is built once, and a WeakMap means a discarded list
57
+ * takes its map with it. Memoization only — the answer is a pure function of
58
+ * the array.
59
+ */
60
+ const absorbedCache = new WeakMap<readonly TranscriptRow[], Map<number, number>>()
61
+
62
+ function absorbedRows(rows: readonly TranscriptRow[]): Map<number, number> {
63
+ const hit = absorbedCache.get(rows)
64
+ if (hit) return hit
65
+ const map = new Map<number, number>()
66
+ rows.forEach((row, rowIndex) => {
67
+ if ('task' in row) for (const itemIndex of row.childIndices) map.set(itemIndex, rowIndex)
68
+ })
69
+ absorbedCache.set(rows, map)
70
+ return map
71
+ }
72
+
41
73
  /**
42
74
  * Transcript-item index → virtual-row index — **the off-by-a-fold trap.**
43
75
  *
44
76
  * The virtualizer's rows are {@link TerminalBlock}s, not items: a folded tool
45
- * run occupies ONE row for `run.length` consecutive items, and the recap
46
- * boundary is a row with *no* item index at all, shifting every row after it
47
- * by one. `virtualizer.scrollToIndex(itemIndex)` is therefore wrong by
48
- * construction on any folded or spliced transcript — every jump that starts
49
- * from an item (the scrubber's marks, a future bookmark) must come through
50
- * here first.
77
+ * run occupies ONE row for `run.length` consecutive items, a task block
78
+ * occupies ONE row for its `Task` call *plus every item its subagent produced*,
79
+ * and the recap boundary is a row with *no* item index at all, shifting every
80
+ * row after it by one. `virtualizer.scrollToIndex(itemIndex)` is therefore
81
+ * wrong by construction on any folded or spliced transcript every jump that
82
+ * starts from an item (the scrubber's marks, a future bookmark) must come
83
+ * through here first.
51
84
  *
52
- * The rule: the **last non-recap row whose first item index is ≤ the target**.
53
- * Rows are ordered by `index` (a run's row covers
54
- * `[index, index + run.length)`), so this is a binary search; the recap row
55
- * is skipped by giving it its successor's start for navigation (both qualify
56
- * at the boundary, and "last wins" lands on the real row) while never letting
57
- * it be the answer. Exhaustively checked against a linear reference every
58
- * fixture × every item index × several splice positions by
59
- * `__wdCheckMapping` in `dev/App.tsx`.
85
+ * The contract, in two halves:
86
+ *
87
+ * - An index a task block **absorbed** maps to that block's row, wherever the
88
+ * child fell in the stream. Subagents run in parallel, so absorbed indices
89
+ * interleave arbitrarily with later rows' starts no ordering argument can
90
+ * find their row, which is why they are answered first, from a per-row-list
91
+ * map ({@link absorbedRows}) built once per rows array. A row's coverage is
92
+ * its `childIndices`, never `[index, index + N)` arithmetic.
93
+ * - Every other index maps to the **last non-recap row whose start (`index`)
94
+ * is ≤ the target** — the original rule, still a binary search. Rows stay
95
+ * ordered by `index`, and the ordering argument is now: between one row's
96
+ * start and the next row's, every index is either absorbed (answered above)
97
+ * or a member of the earlier row — note that is *weaker* than the old
98
+ * contiguity claim, because a run can fold across an absorbed gap (two
99
+ * top-level calls separated only by a subagent's step are adjacent on
100
+ * screen), so `[index, index + run.length)` arithmetic no longer describes
101
+ * a run's coverage; membership does. The recap row is skipped by giving it
102
+ * its successor's start for navigation (both qualify at the boundary, and
103
+ * "last wins" lands on the real row) while never letting it be the answer.
104
+ *
105
+ * Exhaustively checked against a linear reference — every fixture × every item
106
+ * index × several splice positions — by `__wdCheckMapping` in `dev/App.tsx`,
107
+ * and against constructed interleavings in `test/transcript-rows.test.ts`.
60
108
  */
61
109
  export function rowIndexForItem(rows: readonly TranscriptRow[], itemIndex: number): number {
110
+ const absorbed = absorbedRows(rows).get(itemIndex)
111
+ if (absorbed !== undefined) return absorbed
62
112
  let lo = 0
63
113
  let hi = rows.length - 1
64
114
  let best = 0
@@ -80,3 +130,52 @@ export function rowIndexForItem(rows: readonly TranscriptRow[], itemIndex: numbe
80
130
  }
81
131
  return best
82
132
  }
133
+
134
+ /** Where an item sits inside a row it shares with other items: its 0-based
135
+ * ordinal in stream order, out of `count` siblings. `0 ≤ ordinal < count`. */
136
+ export type RowPosition = { ordinal: number; count: number }
137
+
138
+ const positionCache = new WeakMap<readonly TranscriptRow[], Map<number, RowPosition>>()
139
+
140
+ function rowPositions(rows: readonly TranscriptRow[]): Map<number, RowPosition> {
141
+ const hit = positionCache.get(rows)
142
+ if (hit) return hit
143
+ const map = new Map<number, RowPosition>()
144
+ for (const row of rows) {
145
+ if ('task' in row) {
146
+ const count = row.childIndices.length
147
+ row.childIndices.forEach((itemIndex, ordinal) => map.set(itemIndex, { ordinal, count }))
148
+ } else if ('run' in row && row.run.length > 1) {
149
+ const count = row.run.length
150
+ row.indices.forEach((itemIndex, ordinal) => map.set(itemIndex, { ordinal, count }))
151
+ }
152
+ }
153
+ positionCache.set(rows, map)
154
+ return map
155
+ }
156
+
157
+ /**
158
+ * Where an item sits inside a row that holds MORE than itself — a task block's
159
+ * absorbed child, or a member of a folded run of two or more. `undefined` for
160
+ * everything else, including a row's own head item (the `Task` call, a run's
161
+ * first member is *not* exempt) and a **singleton run**: there the row's extent
162
+ * IS the item's, and a mark spanning it is honest.
163
+ *
164
+ * That carve-out is load-bearing rather than tidy: `pushLeaf` makes *every*
165
+ * top-level tool call a `RunBlock`, usually of length 1, so without it every
166
+ * ordinary failed call's scrubber mark would shrink from its row's extent to a
167
+ * tick and the rail would stop reading as a map — a regression traded for a fix.
168
+ *
169
+ * The scrubber is the consumer: a mark for a shared-row item anchors at
170
+ * `ordinal / count` of the row's *measured* height instead of inheriting an
171
+ * extent that is mostly other items' work (one failed child of a hundred-call
172
+ * task painted the whole expanded block red). Memoized per rows array identity
173
+ * exactly like {@link absorbedRows}, and pure — the answer is a function of the
174
+ * array alone, and a discarded array takes its map with it.
175
+ */
176
+ export function positionInRow(
177
+ rows: readonly TranscriptRow[],
178
+ itemIndex: number,
179
+ ): RowPosition | undefined {
180
+ return rowPositions(rows).get(itemIndex)
181
+ }
@@ -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,
@@ -13,9 +14,15 @@ import {
13
14
  UserRow,
14
15
  WorkingRow,
15
16
  blockNeedsBlank,
17
+ taskChildItems,
16
18
  terminalBlocks,
19
+ type TaskBlock,
17
20
  } from './items.tsx'
18
- import { Blank } from './row.tsx'
21
+ import { usePulse } from '../agent/pulse.tsx'
22
+ import { Pressable, useRevealOnOpen } from './press.tsx'
23
+ import { taskBrief, taskBusy, taskFailed, taskSummary } from './tool-run.ts'
24
+ import { BRIEF_LINES } from './height.ts'
25
+ import { Blank, Row } from './row.tsx'
19
26
  import { TerminalSurface } from './surface.tsx'
20
27
 
21
28
  /**
@@ -79,6 +86,149 @@ export function TerminalItemView({
79
86
  }
80
87
  }
81
88
 
89
+ /**
90
+ * A `Task` and everything the subagent it spawned produced, as one row.
91
+ *
92
+ * The same claim the tool-run fold makes, and a stronger one: a subagent is
93
+ * *sixty* rows of somebody else's working — a brief, a dozen greps, its own
94
+ * thinking — and none of it is what you came back to read. What you came back
95
+ * to read is the report, and the report is the model's next sentence. So the
96
+ * whole frame collapses to one line saying what was asked and how big the
97
+ * answer was, and opens in full the moment it is the thing you want.
98
+ *
99
+ * **Always collapsed when unmounted**, and that is load-bearing rather than
100
+ * tidy: `height.ts` predicts this row as exactly one wrapped `taskSummary`, and
101
+ * expansion is component-local state that dies with the row. A row auto-opening
102
+ * because its subagent happens to be running would make its own height
103
+ * unpredictable — which is why the live signal is *in* the collapsed line (the
104
+ * pulse, and a count that climbs) rather than in an open block.
105
+ *
106
+ * The children are the theme's ordinary rows, stepped in behind a rule, and
107
+ * they fold among themselves: a subagent's consecutive tool calls are as much
108
+ * an aside inside its frame as they are in the main thread.
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
+
152
+ export function TaskRow({
153
+ block,
154
+ fileUrl,
155
+ onOpenSubagent,
156
+ }: {
157
+ block: TaskBlock
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
163
+ }) {
164
+ const [open, setOpen] = useState(false)
165
+ const reveal = useRevealOnOpen(open)
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)
171
+ const busy = taskBusy(block.task, children)
172
+ const failed = taskFailed(block.task)
173
+ const pulse = usePulse(busy)
174
+
175
+ const row = (
176
+ <div ref={reveal} className={open ? 'term-open' : undefined}>
177
+ <Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
178
+ {/* A marker, where a folded run of calls gets none: a run is an aside,
179
+ but delegating a piece of the work is something the model *did*, and
180
+ the row stands for the whole of it. The body is `taskSummary`
181
+ verbatim — it is the string `height.ts` wraps to size this row, and
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. */}
191
+ <Row
192
+ glyph={busy ? pulse : '●'}
193
+ glyphTone={failed ? 'red' : busy ? 'mark' : 'dim'}
194
+ tone={failed ? 'red' : 'green'}>
195
+ {taskSummary(block.task, children)}
196
+ </Row>
197
+ </Pressable>
198
+ {open ? (
199
+ // `term-nested` and not the shell's cards-era `border-l-2 pl-3`: this
200
+ // sits on the open block's wash, where that border token is invisible,
201
+ // and its 14px would take every nested marker off the cell grid.
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}
206
+ {block.children.map((leaf, index) => (
207
+ <Fragment key={leaf.key}>
208
+ {index > 0 && blockNeedsBlank(block.children[index - 1]!, leaf) ? <Blank /> : null}
209
+ {'run' in leaf ? (
210
+ <ToolRunRow items={leaf.run} />
211
+ ) : (
212
+ <TerminalItemView item={leaf.item} fileUrl={fileUrl} />
213
+ )}
214
+ </Fragment>
215
+ ))}
216
+ </div>
217
+ ) : null}
218
+ </div>
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
+ )
230
+ }
231
+
82
232
  /** When the current run began — the clock the working line counts from. Held
83
233
  * here, not in the row, because the row comes and goes within a single turn (it
84
234
  * hides the moment text streams) and a clock restarting at every tool call
@@ -127,8 +277,10 @@ export function TerminalTranscript({
127
277
  {index > 0 && blockNeedsBlank(blocks[index - 1]!, block) ? <Blank /> : null}
128
278
  {'run' in block ? (
129
279
  <ToolRunRow items={block.run} />
130
- ) : (
280
+ ) : 'item' in block ? (
131
281
  <TerminalItemView item={block.item} fileUrl={fileUrl} />
282
+ ) : (
283
+ <TaskRow block={block} fileUrl={fileUrl} />
132
284
  )}
133
285
  </Fragment>
134
286
  ))}
@@ -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 (