@workerdeck/ui 0.16.0 → 0.17.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.
@@ -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,23 @@
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. */
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. */
16
17
  export type TranscriptRow = TerminalBlock | { key: 'recap'; line: string }
17
18
 
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. */
19
+ /** The item a row is spaced *as*. A run stands for the calls it folded, and a
20
+ * task block for the `Task` call it absorbed into all tool calls, so a run,
21
+ * a task and a lone tool call below them still read as one block. */
20
22
  export function rowItem(row: TranscriptRow | undefined): TranscriptItem | undefined {
21
23
  if (!row) return undefined
22
24
  if ('item' in row) return row.item
23
25
  if ('run' in row) return row.run[0]
26
+ if ('task' in row) return row.task
24
27
  return undefined
25
28
  }
26
29
 
@@ -38,27 +41,66 @@ export function gapBefore(rows: TranscriptRow[], index: number): boolean {
38
41
  return needsBlank(before, after)
39
42
  }
40
43
 
44
+ /**
45
+ * Which items each task block absorbed, as itemIndex → rowIndex — the one
46
+ * lookup {@link rowIndexForItem} cannot answer from ordering (see its comment).
47
+ * Memoized per rows array identity: the shell builds `rows` in a `useMemo`, so
48
+ * within one row list this is built once, and a WeakMap means a discarded list
49
+ * takes its map with it. Memoization only — the answer is a pure function of
50
+ * the array.
51
+ */
52
+ const absorbedCache = new WeakMap<readonly TranscriptRow[], Map<number, number>>()
53
+
54
+ function absorbedRows(rows: readonly TranscriptRow[]): Map<number, number> {
55
+ const hit = absorbedCache.get(rows)
56
+ if (hit) return hit
57
+ const map = new Map<number, number>()
58
+ rows.forEach((row, rowIndex) => {
59
+ if ('task' in row) for (const itemIndex of row.childIndices) map.set(itemIndex, rowIndex)
60
+ })
61
+ absorbedCache.set(rows, map)
62
+ return map
63
+ }
64
+
41
65
  /**
42
66
  * Transcript-item index → virtual-row index — **the off-by-a-fold trap.**
43
67
  *
44
68
  * 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.
69
+ * run occupies ONE row for `run.length` consecutive items, a task block
70
+ * occupies ONE row for its `Task` call *plus every item its subagent produced*,
71
+ * and the recap boundary is a row with *no* item index at all, shifting every
72
+ * row after it by one. `virtualizer.scrollToIndex(itemIndex)` is therefore
73
+ * wrong by construction on any folded or spliced transcript every jump that
74
+ * starts from an item (the scrubber's marks, a future bookmark) must come
75
+ * through here first.
51
76
  *
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`.
77
+ * The contract, in two halves:
78
+ *
79
+ * - An index a task block **absorbed** maps to that block's row, wherever the
80
+ * child fell in the stream. Subagents run in parallel, so absorbed indices
81
+ * interleave arbitrarily with later rows' starts no ordering argument can
82
+ * find their row, which is why they are answered first, from a per-row-list
83
+ * map ({@link absorbedRows}) built once per rows array. A row's coverage is
84
+ * its `childIndices`, never `[index, index + N)` arithmetic.
85
+ * - Every other index maps to the **last non-recap row whose start (`index`)
86
+ * is ≤ the target** — the original rule, still a binary search. Rows stay
87
+ * ordered by `index`, and the ordering argument is now: between one row's
88
+ * start and the next row's, every index is either absorbed (answered above)
89
+ * or a member of the earlier row — note that is *weaker* than the old
90
+ * contiguity claim, because a run can fold across an absorbed gap (two
91
+ * top-level calls separated only by a subagent's step are adjacent on
92
+ * screen), so `[index, index + run.length)` arithmetic no longer describes
93
+ * a run's coverage; membership does. The recap row is skipped by giving it
94
+ * its successor's start for navigation (both qualify at the boundary, and
95
+ * "last wins" lands on the real row) while never letting it be the answer.
96
+ *
97
+ * Exhaustively checked against a linear reference — every fixture × every item
98
+ * index × several splice positions — by `__wdCheckMapping` in `dev/App.tsx`,
99
+ * and against constructed interleavings in `test/transcript-rows.test.ts`.
60
100
  */
61
101
  export function rowIndexForItem(rows: readonly TranscriptRow[], itemIndex: number): number {
102
+ const absorbed = absorbedRows(rows).get(itemIndex)
103
+ if (absorbed !== undefined) return absorbed
62
104
  let lo = 0
63
105
  let hi = rows.length - 1
64
106
  let best = 0
@@ -80,3 +122,52 @@ export function rowIndexForItem(rows: readonly TranscriptRow[], itemIndex: numbe
80
122
  }
81
123
  return best
82
124
  }
125
+
126
+ /** Where an item sits inside a row it shares with other items: its 0-based
127
+ * ordinal in stream order, out of `count` siblings. `0 ≤ ordinal < count`. */
128
+ export type RowPosition = { ordinal: number; count: number }
129
+
130
+ const positionCache = new WeakMap<readonly TranscriptRow[], Map<number, RowPosition>>()
131
+
132
+ function rowPositions(rows: readonly TranscriptRow[]): Map<number, RowPosition> {
133
+ const hit = positionCache.get(rows)
134
+ if (hit) return hit
135
+ const map = new Map<number, RowPosition>()
136
+ for (const row of rows) {
137
+ if ('task' in row) {
138
+ const count = row.childIndices.length
139
+ row.childIndices.forEach((itemIndex, ordinal) => map.set(itemIndex, { ordinal, count }))
140
+ } else if ('run' in row && row.run.length > 1) {
141
+ const count = row.run.length
142
+ row.indices.forEach((itemIndex, ordinal) => map.set(itemIndex, { ordinal, count }))
143
+ }
144
+ }
145
+ positionCache.set(rows, map)
146
+ return map
147
+ }
148
+
149
+ /**
150
+ * Where an item sits inside a row that holds MORE than itself — a task block's
151
+ * absorbed child, or a member of a folded run of two or more. `undefined` for
152
+ * everything else, including a row's own head item (the `Task` call, a run's
153
+ * first member is *not* exempt) and a **singleton run**: there the row's extent
154
+ * IS the item's, and a mark spanning it is honest.
155
+ *
156
+ * That carve-out is load-bearing rather than tidy: `pushLeaf` makes *every*
157
+ * top-level tool call a `RunBlock`, usually of length 1, so without it every
158
+ * ordinary failed call's scrubber mark would shrink from its row's extent to a
159
+ * tick and the rail would stop reading as a map — a regression traded for a fix.
160
+ *
161
+ * The scrubber is the consumer: a mark for a shared-row item anchors at
162
+ * `ordinal / count` of the row's *measured* height instead of inheriting an
163
+ * extent that is mostly other items' work (one failed child of a hundred-call
164
+ * task painted the whole expanded block red). Memoized per rows array identity
165
+ * exactly like {@link absorbedRows}, and pure — the answer is a function of the
166
+ * array alone, and a discarded array takes its map with it.
167
+ */
168
+ export function positionInRow(
169
+ rows: readonly TranscriptRow[],
170
+ itemIndex: number,
171
+ ): RowPosition | undefined {
172
+ return rowPositions(rows).get(itemIndex)
173
+ }
@@ -13,9 +13,14 @@ import {
13
13
  UserRow,
14
14
  WorkingRow,
15
15
  blockNeedsBlank,
16
+ taskChildItems,
16
17
  terminalBlocks,
18
+ type TaskBlock,
17
19
  } from './items.tsx'
18
- import { Blank } from './row.tsx'
20
+ import { usePulse } from '../agent/pulse.tsx'
21
+ import { Pressable, useRevealOnOpen } from './press.tsx'
22
+ import { taskBusy, taskFailed, taskSummary } from './tool-run.ts'
23
+ import { Blank, Row } from './row.tsx'
19
24
  import { TerminalSurface } from './surface.tsx'
20
25
 
21
26
  /**
@@ -79,6 +84,77 @@ export function TerminalItemView({
79
84
  }
80
85
  }
81
86
 
87
+ /**
88
+ * A `Task` and everything the subagent it spawned produced, as one row.
89
+ *
90
+ * The same claim the tool-run fold makes, and a stronger one: a subagent is
91
+ * *sixty* rows of somebody else's working — a brief, a dozen greps, its own
92
+ * thinking — and none of it is what you came back to read. What you came back
93
+ * to read is the report, and the report is the model's next sentence. So the
94
+ * whole frame collapses to one line saying what was asked and how big the
95
+ * answer was, and opens in full the moment it is the thing you want.
96
+ *
97
+ * **Always collapsed when unmounted**, and that is load-bearing rather than
98
+ * tidy: `height.ts` predicts this row as exactly one wrapped `taskSummary`, and
99
+ * expansion is component-local state that dies with the row. A row auto-opening
100
+ * because its subagent happens to be running would make its own height
101
+ * unpredictable — which is why the live signal is *in* the collapsed line (the
102
+ * pulse, and a count that climbs) rather than in an open block.
103
+ *
104
+ * The children are the theme's ordinary rows, stepped in behind a rule, and
105
+ * they fold among themselves: a subagent's consecutive tool calls are as much
106
+ * an aside inside its frame as they are in the main thread.
107
+ */
108
+ export function TaskRow({
109
+ block,
110
+ fileUrl,
111
+ }: {
112
+ block: TaskBlock
113
+ fileUrl?: (path: string) => string
114
+ }) {
115
+ const [open, setOpen] = useState(false)
116
+ const reveal = useRevealOnOpen(open)
117
+ const children = useMemo(() => taskChildItems(block), [block])
118
+ const busy = taskBusy(block.task, children)
119
+ const failed = taskFailed(block.task)
120
+ const pulse = usePulse(busy)
121
+
122
+ return (
123
+ <div ref={reveal} className={open ? 'term-open' : undefined}>
124
+ <Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
125
+ {/* A marker, where a folded run of calls gets none: a run is an aside,
126
+ but delegating a piece of the work is something the model *did*, and
127
+ the row stands for the whole of it. The body is `taskSummary`
128
+ verbatim — it is the string `height.ts` wraps to size this row, and
129
+ a second spelling here would be a second height. */}
130
+ <Row
131
+ glyph={busy ? pulse : '●'}
132
+ glyphTone={failed ? 'red' : busy ? 'mark' : 'dim'}
133
+ tone={failed ? 'red' : 'fg'}>
134
+ {taskSummary(block.task, children)}
135
+ </Row>
136
+ </Pressable>
137
+ {open ? (
138
+ // `term-nested` and not the shell's cards-era `border-l-2 pl-3`: this
139
+ // sits on the open block's wash, where that border token is invisible,
140
+ // and its 14px would take every nested marker off the cell grid.
141
+ <div className='term-nested'>
142
+ {block.children.map((leaf, index) => (
143
+ <Fragment key={leaf.key}>
144
+ {index > 0 && blockNeedsBlank(block.children[index - 1]!, leaf) ? <Blank /> : null}
145
+ {'run' in leaf ? (
146
+ <ToolRunRow items={leaf.run} />
147
+ ) : (
148
+ <TerminalItemView item={leaf.item} fileUrl={fileUrl} />
149
+ )}
150
+ </Fragment>
151
+ ))}
152
+ </div>
153
+ ) : null}
154
+ </div>
155
+ )
156
+ }
157
+
82
158
  /** When the current run began — the clock the working line counts from. Held
83
159
  * here, not in the row, because the row comes and goes within a single turn (it
84
160
  * hides the moment text streams) and a clock restarting at every tool call
@@ -127,8 +203,10 @@ export function TerminalTranscript({
127
203
  {index > 0 && blockNeedsBlank(blocks[index - 1]!, block) ? <Blank /> : null}
128
204
  {'run' in block ? (
129
205
  <ToolRunRow items={block.run} />
130
- ) : (
206
+ ) : 'item' in block ? (
131
207
  <TerminalItemView item={block.item} fileUrl={fileUrl} />
208
+ ) : (
209
+ <TaskRow block={block} fileUrl={fileUrl} />
132
210
  )}
133
211
  </Fragment>
134
212
  ))}