@workerdeck/ui 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/build/{SessionPanel-B9CHoq8x.d.mts → SessionPanel-CnU_IJ3-.d.mts} +36 -9
- package/build/{SessionPanel-DII9MmQ8.mjs → SessionPanel-DPx8Iz8a.mjs} +1432 -384
- package/build/SessionPanel-DPx8Iz8a.mjs.map +1 -0
- package/build/{format-DfI_je9S.d.mts → format-ljc3lKpA.d.mts} +1 -1
- package/build/format.d.mts +16 -5
- package/build/format.mjs +2 -3
- package/build/index.d.mts +288 -8
- package/build/index.mjs +620 -165
- package/build/index.mjs.map +1 -1
- package/build/{format-DqR56Y8l.mjs → status-BE-zg88x.mjs} +154 -2
- package/build/status-BE-zg88x.mjs.map +1 -0
- package/build/workspace.d.mts +4 -1
- package/build/workspace.mjs +4 -3
- package/build/workspace.mjs.map +1 -1
- package/package.json +8 -6
- package/src/components/agent/ContextRing.tsx +41 -0
- package/src/components/agent/EngineIcon.tsx +40 -0
- package/src/components/agent/ProjectIcon.tsx +119 -0
- package/src/components/agent/SessionBrowser.tsx +191 -17
- package/src/components/agent/SessionPanel.tsx +177 -2
- package/src/components/agent/SessionSteps.tsx +233 -0
- package/src/components/agent/SessionWorkspace.tsx +4 -0
- package/src/components/agent/StatusBar.tsx +4 -2
- package/src/components/agent/SubagentStrip.tsx +134 -0
- package/src/components/agent/ToolCallCard.tsx +72 -5
- package/src/components/agent/Transcript.tsx +212 -23
- package/src/components/agent/tool-result-fetch.tsx +36 -0
- package/src/components/agent/tool-result-image.tsx +209 -0
- package/src/components/agent/transcript-rows.ts +122 -23
- package/src/components/terminal/TerminalTranscript.tsx +154 -2
- package/src/components/terminal/affordances.tsx +34 -0
- package/src/components/terminal/blocks.ts +260 -0
- package/src/components/terminal/height.ts +73 -6
- package/src/components/terminal/image-box.ts +53 -0
- package/src/components/terminal/items.tsx +116 -79
- package/src/components/terminal/result-preview.ts +20 -6
- package/src/components/terminal/scrubber.tsx +172 -26
- package/src/components/terminal/tool-run.ts +177 -0
- package/src/index.ts +20 -1
- package/src/lib/status.ts +16 -3
- package/src/styles/terminal.css +85 -5
- package/src/styles/theme.css +42 -0
- package/build/SessionPanel-DII9MmQ8.mjs.map +0 -1
- package/build/format-DqR56Y8l.mjs.map +0 -1
- package/build/status-Ydzi7n6j.mjs +0 -143
- package/build/status-Ydzi7n6j.mjs.map +0 -1
|
@@ -0,0 +1,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,
|
|
11
|
-
* — under the terminal theme — a folded run of tool calls
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
|
|
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,
|
|
19
|
-
*
|
|
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,
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* `
|
|
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 {
|
|
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 (
|