@workerdeck/ui 0.22.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/{SessionPanel-DgwjH4Ve.mjs → SessionPanel-DDgBkHsi.mjs} +2196 -3364
- package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
- package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
- package/build/format-B8ZM9LPy.d.mts +16 -0
- package/build/format.d.mts +8 -75
- package/build/format.mjs +2 -2
- package/build/index.d.mts +63 -1138
- package/build/index.mjs +4 -300
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +80 -24
- package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
- package/build/status-C0HSLZas.mjs.map +1 -0
- package/build/workspace.d.mts +9 -124
- package/build/workspace.mjs +13 -143
- package/build/workspace.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/agent/CodeEditor.tsx +31 -103
- package/src/components/agent/Composer.tsx +114 -333
- package/src/components/agent/ContextDialog.tsx +20 -41
- package/src/components/agent/ContextRing.tsx +6 -28
- package/src/components/agent/Conversation.tsx +21 -33
- package/src/components/agent/EditorTabs.tsx +39 -67
- package/src/components/agent/EngineIcon.tsx +29 -72
- package/src/components/agent/EntryIcon.tsx +16 -0
- package/src/components/agent/FileCard.tsx +10 -16
- package/src/components/agent/FileTree.tsx +52 -125
- package/src/components/agent/FileViewer.tsx +30 -60
- package/src/components/agent/HostFilesDialog.tsx +40 -69
- package/src/components/agent/Loader.tsx +3 -24
- package/src/components/agent/McpDialog.tsx +64 -137
- package/src/components/agent/Message.tsx +2 -21
- package/src/components/agent/ModelSelect.tsx +33 -62
- package/src/components/agent/PermissionModeSelect.tsx +21 -79
- package/src/components/agent/PermissionPrompt.tsx +59 -63
- package/src/components/agent/ProjectIcon.tsx +228 -93
- package/src/components/agent/PromptTokenText.tsx +10 -16
- package/src/components/agent/QuestionPrompt.tsx +43 -64
- package/src/components/agent/Reasoning.tsx +13 -16
- package/src/components/agent/Response.tsx +4 -6
- package/src/components/agent/Scrubber.tsx +31 -80
- package/src/components/agent/SessionBrowser.tsx +64 -243
- package/src/components/agent/SessionEmptyState.tsx +10 -40
- package/src/components/agent/SessionInfoDialog.tsx +46 -78
- package/src/components/agent/SessionItem.tsx +53 -320
- package/src/components/agent/SessionList.tsx +23 -39
- package/src/components/agent/SessionPanel.tsx +345 -923
- package/src/components/agent/SessionStatusIcon.tsx +14 -28
- package/src/components/agent/SessionSteps.tsx +34 -187
- package/src/components/agent/SessionWorkspace.tsx +108 -215
- package/src/components/agent/SkillsDialog.tsx +35 -80
- package/src/components/agent/StatusBar.tsx +51 -136
- package/src/components/agent/SubagentStrip.tsx +12 -77
- package/src/components/agent/ToolCallCard.tsx +94 -135
- package/src/components/agent/Transcript.tsx +103 -1288
- package/src/components/agent/TranscriptItemView.tsx +155 -0
- package/src/components/agent/TranscriptRows.tsx +418 -0
- package/src/components/agent/UsageDialog.tsx +7 -29
- package/src/components/agent/UsageMeters.tsx +18 -73
- package/src/components/agent/pulse.tsx +9 -32
- package/src/components/agent/scrubber-marks.ts +33 -103
- package/src/components/agent/status.ts +1 -5
- package/src/components/agent/tool-result-fetch.tsx +3 -25
- package/src/components/agent/tool-result-image.tsx +32 -111
- package/src/components/agent/transcript-rows.ts +41 -118
- package/src/components/agent/transcript-variant.tsx +3 -80
- package/src/components/agent/use-height-epoch.ts +10 -24
- package/src/components/agent/use-path-links.ts +45 -63
- package/src/components/agent/use-subagent-frame.ts +106 -0
- package/src/components/agent/use-transcript-jumps.ts +49 -107
- package/src/components/prompt-area/animated-placeholder.tsx +6 -5
- package/src/components/prompt-area/clipboard-helpers.ts +26 -21
- package/src/components/prompt-area/cursor-helpers.ts +79 -37
- package/src/components/prompt-area/dom-helpers.ts +97 -69
- package/src/components/prompt-area/file-strip.tsx +90 -80
- package/src/components/prompt-area/html-to-markdown.ts +67 -46
- package/src/components/prompt-area/image-strip.tsx +8 -18
- package/src/components/prompt-area/index.ts +2 -15
- package/src/components/prompt-area/prompt-area-engine.ts +100 -115
- package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
- package/src/components/prompt-area/prompt-area.tsx +31 -38
- package/src/components/prompt-area/remove-button.tsx +3 -9
- package/src/components/prompt-area/segment-helpers.ts +4 -12
- package/src/components/prompt-area/trigger-popover.tsx +15 -27
- package/src/components/prompt-area/trigger-presets.ts +0 -10
- package/src/components/prompt-area/types.ts +3 -8
- package/src/components/prompt-area/use-chip-editing.ts +305 -0
- package/src/components/prompt-area/use-markdown-mode.ts +7 -17
- package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
- package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
- package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
- package/src/components/prompt-area/use-prompt-area.ts +170 -896
- package/src/components/prompt-area/use-trigger-search.ts +21 -19
- package/src/components/terminal/PermissionPrompt.tsx +56 -59
- package/src/components/terminal/QuestionPrompt.tsx +28 -79
- package/src/components/terminal/StatusLine.tsx +13 -63
- package/src/components/terminal/TerminalTranscript.tsx +44 -160
- package/src/components/terminal/affordances.tsx +68 -98
- package/src/components/terminal/blocks.ts +20 -156
- package/src/components/terminal/diff.tsx +19 -67
- package/src/components/terminal/height.ts +163 -313
- package/src/components/terminal/image-box.ts +0 -44
- package/src/components/terminal/items.tsx +150 -305
- package/src/components/terminal/markdown.tsx +36 -95
- package/src/components/terminal/press.tsx +26 -53
- package/src/components/terminal/prompt.tsx +40 -170
- package/src/components/terminal/result-preview.ts +8 -56
- package/src/components/terminal/row.tsx +11 -98
- package/src/components/terminal/scrubber.tsx +108 -401
- package/src/components/terminal/surface.tsx +6 -54
- package/src/components/terminal/todos.ts +66 -0
- package/src/components/terminal/tool-run.ts +21 -192
- package/src/components/ui/AlertDialog.tsx +8 -23
- package/src/components/ui/Badge.tsx +18 -29
- package/src/components/ui/Button.tsx +7 -15
- package/src/components/ui/Card.tsx +2 -13
- package/src/components/ui/CodeBlock.tsx +10 -37
- package/src/components/ui/CopyButton.tsx +10 -23
- package/src/components/ui/Dialog.tsx +23 -36
- package/src/components/ui/Empty.tsx +6 -29
- package/src/components/ui/Input.tsx +14 -16
- package/src/components/ui/Menu.tsx +6 -18
- package/src/components/ui/PortalScope.tsx +1 -21
- package/src/components/ui/ProgressRing.tsx +8 -19
- package/src/components/ui/Select.tsx +18 -31
- package/src/components/ui/Sonner.tsx +1 -2
- package/src/components/ui/Spinner.tsx +1 -1
- package/src/components/ui/Splitter.tsx +30 -64
- package/src/components/ui/Textarea.tsx +2 -5
- package/src/components/ui/Tooltip.tsx +3 -13
- package/src/format.ts +0 -9
- package/src/index.ts +16 -113
- package/src/lib/clipboard.ts +8 -28
- package/src/lib/css.ts +8 -0
- package/src/lib/format.ts +101 -86
- package/src/lib/plan-request.ts +16 -0
- package/src/lib/status.ts +45 -95
- package/src/lib/tool-icon.ts +39 -47
- package/src/lib/utils.ts +1 -3
- package/src/styles/terminal.css +81 -49
- package/src/styles/theme.css +136 -19
- package/src/workspace.ts +1 -24
- package/build/SessionPanel-13l25ubU.d.mts +0 -609
- package/build/SessionPanel-DgwjH4Ve.mjs.map +0 -1
- package/build/format-ljc3lKpA.d.mts +0 -59
- package/build/status-BE-zg88x.mjs.map +0 -1
|
@@ -1,38 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createContext,
|
|
3
|
-
useCallback,
|
|
4
|
-
useContext,
|
|
5
|
-
useEffect,
|
|
6
|
-
useRef,
|
|
7
|
-
useState,
|
|
8
|
-
type ReactNode,
|
|
9
|
-
} from 'react'
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
10
2
|
import type { WorkerDeckClient } from '@workerdeck/client'
|
|
11
3
|
|
|
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
4
|
export type ToolResultImageRef = {
|
|
37
5
|
toolUseId: string
|
|
38
6
|
sourceSeq: number
|
|
@@ -41,22 +9,13 @@ export type ToolResultImageRef = {
|
|
|
41
9
|
bytes: number
|
|
42
10
|
}
|
|
43
11
|
|
|
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
12
|
export type ToolResultImageLoader = (ref: ToolResultImageRef) => Promise<string | undefined>
|
|
48
13
|
|
|
49
14
|
const noop: ToolResultImageLoader = async () => undefined
|
|
50
15
|
|
|
51
16
|
const ImageContext = createContext<ToolResultImageLoader>(noop)
|
|
52
17
|
|
|
53
|
-
export function ToolResultImageProvider({
|
|
54
|
-
value,
|
|
55
|
-
children,
|
|
56
|
-
}: {
|
|
57
|
-
value: ToolResultImageLoader | undefined
|
|
58
|
-
children: ReactNode
|
|
59
|
-
}) {
|
|
18
|
+
export function ToolResultImageProvider({ value, children }: { value: ToolResultImageLoader | undefined; children: ReactNode }) {
|
|
60
19
|
return <ImageContext.Provider value={value ?? noop}>{children}</ImageContext.Provider>
|
|
61
20
|
}
|
|
62
21
|
|
|
@@ -64,31 +23,10 @@ export function useToolResultImageLoader(): ToolResultImageLoader {
|
|
|
64
23
|
return useContext(ImageContext)
|
|
65
24
|
}
|
|
66
25
|
|
|
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
26
|
const MOUNT_SETTLE_MS = 150
|
|
77
27
|
|
|
78
28
|
export type ToolResultImageState = { src?: string; failed: boolean }
|
|
79
29
|
|
|
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
30
|
export function useToolResultImageSrc(ref: ToolResultImageRef): ToolResultImageState {
|
|
93
31
|
const load = useToolResultImageLoader()
|
|
94
32
|
const [state, setState] = useState<ToolResultImageState>({ failed: false })
|
|
@@ -99,10 +37,14 @@ export function useToolResultImageSrc(ref: ToolResultImageRef): ToolResultImageS
|
|
|
99
37
|
const timer = setTimeout(() => {
|
|
100
38
|
load({ toolUseId, sourceSeq, partIndex, mediaType, bytes })
|
|
101
39
|
.then((src) => {
|
|
102
|
-
if (live)
|
|
40
|
+
if (live) {
|
|
41
|
+
setState({ src, failed: src === undefined })
|
|
42
|
+
}
|
|
103
43
|
})
|
|
104
44
|
.catch(() => {
|
|
105
|
-
if (live)
|
|
45
|
+
if (live) {
|
|
46
|
+
setState({ failed: true })
|
|
47
|
+
}
|
|
106
48
|
})
|
|
107
49
|
}, MOUNT_SETTLE_MS)
|
|
108
50
|
return () => {
|
|
@@ -113,63 +55,41 @@ export function useToolResultImageSrc(ref: ToolResultImageRef): ToolResultImageS
|
|
|
113
55
|
return state
|
|
114
56
|
}
|
|
115
57
|
|
|
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
58
|
const CACHE_BUDGET_BYTES = 64 * 1024 * 1024
|
|
120
59
|
|
|
121
60
|
type Entry = { pending: Promise<string | undefined>; url?: string; bytes: number }
|
|
122
61
|
|
|
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 {
|
|
62
|
+
export function useToolResultImages(client: WorkerDeckClient, sessionId: string | undefined): ToolResultImageLoader {
|
|
142
63
|
const cache = useRef(new Map<string, Entry>())
|
|
143
64
|
useEffect(
|
|
144
65
|
() => () => {
|
|
145
|
-
for (const entry of cache.current.values())
|
|
66
|
+
for (const entry of cache.current.values()) {
|
|
67
|
+
if (entry.url) {
|
|
68
|
+
URL.revokeObjectURL(entry.url)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
146
71
|
cache.current.clear()
|
|
147
72
|
},
|
|
148
73
|
[],
|
|
149
74
|
)
|
|
150
75
|
return useCallback(
|
|
151
76
|
(ref: ToolResultImageRef) => {
|
|
152
|
-
if (!sessionId)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
// address that outlived its log must miss rather than serve another
|
|
156
|
-
// call's pixels.
|
|
77
|
+
if (!sessionId) {
|
|
78
|
+
return Promise.resolve(undefined)
|
|
79
|
+
}
|
|
157
80
|
const key = `${sessionId}:${ref.sourceSeq}:${ref.toolUseId}:${ref.partIndex}`
|
|
158
81
|
const hit = cache.current.get(key)
|
|
159
82
|
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
83
|
cache.current.delete(key)
|
|
163
84
|
cache.current.set(key, hit)
|
|
164
85
|
return hit.pending
|
|
165
86
|
}
|
|
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
87
|
const pending = client
|
|
170
88
|
.toolResultImage(sessionId, ref.sourceSeq, ref.toolUseId, ref.partIndex)
|
|
171
89
|
.then((blob) => {
|
|
172
|
-
if (blob.size === 0)
|
|
90
|
+
if (blob.size === 0) {
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
173
93
|
const url = URL.createObjectURL(blob)
|
|
174
94
|
const entry = cache.current.get(key)
|
|
175
95
|
if (entry) {
|
|
@@ -177,15 +97,11 @@ export function useToolResultImages(
|
|
|
177
97
|
entry.bytes = blob.size
|
|
178
98
|
evict(cache.current, key)
|
|
179
99
|
} 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
100
|
URL.revokeObjectURL(url)
|
|
183
101
|
}
|
|
184
102
|
return url
|
|
185
103
|
})
|
|
186
104
|
.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
105
|
cache.current.set(key, { pending, bytes: ref.bytes })
|
|
190
106
|
return pending
|
|
191
107
|
},
|
|
@@ -193,16 +109,21 @@ export function useToolResultImages(
|
|
|
193
109
|
)
|
|
194
110
|
}
|
|
195
111
|
|
|
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
112
|
function evict(cache: Map<string, Entry>, keep: string): void {
|
|
200
113
|
let held = 0
|
|
201
|
-
for (const entry of cache.values())
|
|
114
|
+
for (const entry of cache.values()) {
|
|
115
|
+
held += entry.bytes
|
|
116
|
+
}
|
|
202
117
|
for (const [key, entry] of cache) {
|
|
203
|
-
if (held <= CACHE_BUDGET_BYTES)
|
|
204
|
-
|
|
205
|
-
|
|
118
|
+
if (held <= CACHE_BUDGET_BYTES) {
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
if (key === keep) {
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
if (entry.url) {
|
|
125
|
+
URL.revokeObjectURL(entry.url)
|
|
126
|
+
}
|
|
206
127
|
cache.delete(key)
|
|
207
128
|
held -= entry.bytes
|
|
208
129
|
}
|
|
@@ -1,114 +1,58 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The virtual row model: what one row of the transcript's virtual list *is*,
|
|
3
|
-
* and the two rules everything positional must go through — which item a row
|
|
4
|
-
* is spaced as, and how an item index maps onto a row index. Pure; shared by
|
|
5
|
-
* the transcript shell and its hooks.
|
|
6
|
-
*/
|
|
7
1
|
import type { TranscriptItem } from '@workerdeck/react'
|
|
8
|
-
import { needsBlank, type TerminalBlock } from '../terminal/
|
|
2
|
+
import { needsBlank, type TerminalBlock } from '../terminal/blocks.ts'
|
|
9
3
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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 }
|
|
4
|
+
const absorbedCache = new WeakMap<readonly TranscriptRow[], Map<number, number>>()
|
|
5
|
+
const positionCache = new WeakMap<readonly TranscriptRow[], Map<number, RowPosition>>()
|
|
6
|
+
|
|
7
|
+
export type TranscriptRow = TerminalBlock | { key: 'recap'; line: string } | { key: 'brief'; text: string }
|
|
26
8
|
|
|
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. */
|
|
30
9
|
export function rowItem(row: TranscriptRow | undefined): TranscriptItem | undefined {
|
|
31
|
-
if (!row)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if ('
|
|
10
|
+
if (!row) {
|
|
11
|
+
return undefined
|
|
12
|
+
}
|
|
13
|
+
if ('item' in row) {
|
|
14
|
+
return row.item
|
|
15
|
+
}
|
|
16
|
+
if ('run' in row) {
|
|
17
|
+
return row.run[0]
|
|
18
|
+
}
|
|
19
|
+
if ('task' in row) {
|
|
20
|
+
return row.task
|
|
21
|
+
}
|
|
35
22
|
return undefined
|
|
36
23
|
}
|
|
37
24
|
|
|
38
|
-
/**
|
|
39
|
-
* Does a blank line go above this row, in the terminal theme?
|
|
40
|
-
*
|
|
41
|
-
* The recap row always earns one — it is a boundary, and a boundary flush
|
|
42
|
-
* against the row above reads as part of it. Otherwise the pair decides
|
|
43
|
-
* (`needsBlank`): consecutive tool calls are one block in the CLI and get none.
|
|
44
|
-
*/
|
|
45
25
|
export function gapBefore(rows: TranscriptRow[], index: number): boolean {
|
|
46
26
|
const before = rowItem(rows[index - 1])
|
|
47
27
|
const after = rowItem(rows[index])
|
|
48
|
-
if (!before || !after)
|
|
28
|
+
if (!before || !after) {
|
|
29
|
+
return true
|
|
30
|
+
}
|
|
49
31
|
return needsBlank(before, after)
|
|
50
32
|
}
|
|
51
33
|
|
|
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
34
|
function absorbedRows(rows: readonly TranscriptRow[]): Map<number, number> {
|
|
63
35
|
const hit = absorbedCache.get(rows)
|
|
64
|
-
if (hit)
|
|
36
|
+
if (hit) {
|
|
37
|
+
return hit
|
|
38
|
+
}
|
|
65
39
|
const map = new Map<number, number>()
|
|
66
40
|
rows.forEach((row, rowIndex) => {
|
|
67
|
-
if ('task' in row)
|
|
41
|
+
if ('task' in row) {
|
|
42
|
+
for (const itemIndex of row.childIndices) {
|
|
43
|
+
map.set(itemIndex, rowIndex)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
68
46
|
})
|
|
69
47
|
absorbedCache.set(rows, map)
|
|
70
48
|
return map
|
|
71
49
|
}
|
|
72
50
|
|
|
73
|
-
/**
|
|
74
|
-
* Transcript-item index → virtual-row index — **the off-by-a-fold trap.**
|
|
75
|
-
*
|
|
76
|
-
* The virtualizer's rows are {@link TerminalBlock}s, not items: a folded tool
|
|
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.
|
|
84
|
-
*
|
|
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`.
|
|
108
|
-
*/
|
|
109
51
|
export function rowIndexForItem(rows: readonly TranscriptRow[], itemIndex: number): number {
|
|
110
52
|
const absorbed = absorbedRows(rows).get(itemIndex)
|
|
111
|
-
if (absorbed !== undefined)
|
|
53
|
+
if (absorbed !== undefined) {
|
|
54
|
+
return absorbed
|
|
55
|
+
}
|
|
112
56
|
let lo = 0
|
|
113
57
|
let hi = rows.length - 1
|
|
114
58
|
let best = 0
|
|
@@ -116,13 +60,16 @@ export function rowIndexForItem(rows: readonly TranscriptRow[], itemIndex: numbe
|
|
|
116
60
|
const mid = (lo + hi) >> 1
|
|
117
61
|
const row = rows[mid]!
|
|
118
62
|
let start: number
|
|
119
|
-
if ('index' in row)
|
|
120
|
-
|
|
63
|
+
if ('index' in row) {
|
|
64
|
+
start = row.index
|
|
65
|
+
} else {
|
|
121
66
|
const next = rows[mid + 1]
|
|
122
67
|
start = next && 'index' in next ? next.index : Number.MAX_SAFE_INTEGER
|
|
123
68
|
}
|
|
124
69
|
if (start <= itemIndex) {
|
|
125
|
-
if ('index' in row)
|
|
70
|
+
if ('index' in row) {
|
|
71
|
+
best = mid
|
|
72
|
+
}
|
|
126
73
|
lo = mid + 1
|
|
127
74
|
} else {
|
|
128
75
|
hi = mid - 1
|
|
@@ -131,15 +78,13 @@ export function rowIndexForItem(rows: readonly TranscriptRow[], itemIndex: numbe
|
|
|
131
78
|
return best
|
|
132
79
|
}
|
|
133
80
|
|
|
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
81
|
export type RowPosition = { ordinal: number; count: number }
|
|
137
82
|
|
|
138
|
-
const positionCache = new WeakMap<readonly TranscriptRow[], Map<number, RowPosition>>()
|
|
139
|
-
|
|
140
83
|
function rowPositions(rows: readonly TranscriptRow[]): Map<number, RowPosition> {
|
|
141
84
|
const hit = positionCache.get(rows)
|
|
142
|
-
if (hit)
|
|
85
|
+
if (hit) {
|
|
86
|
+
return hit
|
|
87
|
+
}
|
|
143
88
|
const map = new Map<number, RowPosition>()
|
|
144
89
|
for (const row of rows) {
|
|
145
90
|
if ('task' in row) {
|
|
@@ -154,28 +99,6 @@ function rowPositions(rows: readonly TranscriptRow[]): Map<number, RowPosition>
|
|
|
154
99
|
return map
|
|
155
100
|
}
|
|
156
101
|
|
|
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 {
|
|
102
|
+
export function positionInRow(rows: readonly TranscriptRow[], itemIndex: number): RowPosition | undefined {
|
|
180
103
|
return rowPositions(rows).get(itemIndex)
|
|
181
104
|
}
|
|
@@ -1,35 +1,10 @@
|
|
|
1
1
|
import { createContext, useContext, type ReactNode } from 'react'
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* How the transcript draws a turn.
|
|
5
|
-
*
|
|
6
|
-
* - `cards` — the chat convention: bubbles, bordered tool cards, generous gaps.
|
|
7
|
-
* Right for a wide dashboard where the transcript is the page.
|
|
8
|
-
* - `terminal` — the CLI's own form: every row on a character cell, no boxes
|
|
9
|
-
* anywhere, diffs as full-width bands. Right where the transcript sits beside
|
|
10
|
-
* a terminal (a VS Code dock) and where vertical space is scarce.
|
|
11
|
-
*
|
|
12
|
-
* `terminal` is **not** a second set of branches in the components under
|
|
13
|
-
* `components/agent/`: it is its own renderer (`components/terminal/`) that the
|
|
14
|
-
* shell mounts *instead* of them. So a row component here never asks which
|
|
15
|
-
* variant it is in — if it is drawing at all, it is drawing cards.
|
|
16
|
-
*
|
|
17
|
-
* A context rather than a prop chain because the pieces that DO need it sit
|
|
18
|
-
* outside the transcript (the composer, the pending prompts) and because
|
|
19
|
-
* `Message`/`ToolCallCard` are exported on their own, so an embedder composing
|
|
20
|
-
* them by hand gets the right treatment for free.
|
|
21
|
-
*/
|
|
22
3
|
export type TranscriptVariant = 'cards' | 'terminal'
|
|
23
4
|
|
|
24
5
|
const VariantContext = createContext<TranscriptVariant>('cards')
|
|
25
6
|
|
|
26
|
-
export function TranscriptVariantProvider({
|
|
27
|
-
value,
|
|
28
|
-
children,
|
|
29
|
-
}: {
|
|
30
|
-
value: TranscriptVariant
|
|
31
|
-
children: ReactNode
|
|
32
|
-
}) {
|
|
7
|
+
export function TranscriptVariantProvider({ value, children }: { value: TranscriptVariant; children: ReactNode }) {
|
|
33
8
|
return <VariantContext.Provider value={value}>{children}</VariantContext.Provider>
|
|
34
9
|
}
|
|
35
10
|
|
|
@@ -37,33 +12,11 @@ export function useTranscriptVariant(): TranscriptVariant {
|
|
|
37
12
|
return useContext(VariantContext)
|
|
38
13
|
}
|
|
39
14
|
|
|
40
|
-
/**
|
|
41
|
-
* How much room the transcript gives each row.
|
|
42
|
-
*
|
|
43
|
-
* - `comfortable` — a blank line between messages, which is what the Claude Code
|
|
44
|
-
* CLI does. The default: a transcript is prose before it is a table.
|
|
45
|
-
* - `compact` — rows tight against each other, for a dock where every line of
|
|
46
|
-
* vertical space is contested.
|
|
47
|
-
*
|
|
48
|
-
* Separate from the variant, and deliberately: they answer different questions.
|
|
49
|
-
* The variant decides *how a row is drawn* and follows from the surface; density
|
|
50
|
-
* decides *how much air is around it* and is a preference the reader holds.
|
|
51
|
-
*
|
|
52
|
-
* Reaches `cards` only. The terminal theme's spacing is a blank *line*, decided
|
|
53
|
-
* per pair of blocks by `needsBlank` — a terminal has one line height, which is
|
|
54
|
-
* the whole premise — so there is nothing there for this to turn.
|
|
55
|
-
*/
|
|
56
15
|
export type TranscriptDensity = 'comfortable' | 'compact'
|
|
57
16
|
|
|
58
17
|
const DensityContext = createContext<TranscriptDensity>('comfortable')
|
|
59
18
|
|
|
60
|
-
export function TranscriptDensityProvider({
|
|
61
|
-
value,
|
|
62
|
-
children,
|
|
63
|
-
}: {
|
|
64
|
-
value: TranscriptDensity
|
|
65
|
-
children: ReactNode
|
|
66
|
-
}) {
|
|
19
|
+
export function TranscriptDensityProvider({ value, children }: { value: TranscriptDensity; children: ReactNode }) {
|
|
67
20
|
return <DensityContext.Provider value={value}>{children}</DensityContext.Provider>
|
|
68
21
|
}
|
|
69
22
|
|
|
@@ -71,39 +24,9 @@ export function useTranscriptDensity(): TranscriptDensity {
|
|
|
71
24
|
return useContext(DensityContext)
|
|
72
25
|
}
|
|
73
26
|
|
|
74
|
-
/**
|
|
75
|
-
* The typeface the panel runs in.
|
|
76
|
-
*
|
|
77
|
-
* `sans` is the host's UI font; `mono` repoints the sans token at the mono stack
|
|
78
|
-
* for the panel's subtree (see the `[data-agent-font='mono']` rule in
|
|
79
|
-
* `theme.css`), so the transcript reads as part of a terminal rather than as a
|
|
80
|
-
* web app beside one.
|
|
81
|
-
*
|
|
82
|
-
* No context and no hook, unlike variant and density: nothing branches on it in
|
|
83
|
-
* JS. It is one attribute on the panel root and the cascade does the rest, which
|
|
84
|
-
* is also what keeps it from leaking past the panel.
|
|
85
|
-
*/
|
|
86
27
|
export type TranscriptFont = 'sans' | 'mono'
|
|
87
28
|
|
|
88
|
-
|
|
89
|
-
* The gap between two rows, per variant and density — the whole of the density
|
|
90
|
-
* feature, since it is the only vertical spacing between rows that exists.
|
|
91
|
-
*
|
|
92
|
-
* `className` goes on the **measured** wrapper (see `Transcript`), so the gap is
|
|
93
|
-
* part of each row's measured height and no pixel constant is load-bearing.
|
|
94
|
-
* `px` is fed to `estimateSize` alone, where being approximate is the contract:
|
|
95
|
-
* it sets the scrollbar's length before rows mount and is replaced by a real
|
|
96
|
-
* measurement the moment one does.
|
|
97
|
-
*/
|
|
98
|
-
export const ROW_GAP: Record<
|
|
99
|
-
TranscriptVariant,
|
|
100
|
-
Record<TranscriptDensity, { className?: string; px: number }>
|
|
101
|
-
> = {
|
|
102
|
-
// The terminal theme has no gap scale: the space between blocks is a **blank
|
|
103
|
-
// line**, decided per pair by `needsBlank` (a tool call and its output get
|
|
104
|
-
// none), so it is a class the shell applies conditionally rather than a
|
|
105
|
-
// constant it applies to every row. Density does not reach it — a terminal
|
|
106
|
-
// has one line height, which is the whole premise.
|
|
29
|
+
export const ROW_GAP: Record<TranscriptVariant, Record<TranscriptDensity, { className?: string; px: number }>> = {
|
|
107
30
|
terminal: {
|
|
108
31
|
comfortable: { className: 'term-row-gap', px: 18 },
|
|
109
32
|
compact: { className: 'term-row-gap', px: 18 },
|
|
@@ -1,22 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The height epoch: one cache generation of computed row heights (terminal
|
|
3
|
-
* theme only — cards have no calculator and keep the flat estimate). Owned by
|
|
4
|
-
* the transcript shell because it owns the virtualizer the heights feed; the
|
|
5
|
-
* WeakMap inside self-invalidates through the reducer's replace-on-mutation,
|
|
6
|
-
* and the epoch itself is replaced wholesale when the wrap width or the cell
|
|
7
|
-
* changes. Measured off the rows container: it *is* the width rows wrap in
|
|
8
|
-
* (the scroller can resize without it moving — `ConversationContent` caps at
|
|
9
|
-
* 48rem — and the window never hears about a splitter drag), and it inherits
|
|
10
|
-
* the surface's font, which is what makes the `ch` probe honest. All DOM
|
|
11
|
-
* reads happen in the effect, debounced; render never touches layout.
|
|
12
|
-
*/
|
|
13
1
|
import { useEffect, useState, type RefObject } from 'react'
|
|
14
2
|
import { createHeightEpoch, measureCh, type HeightEpoch } from '../terminal/height.ts'
|
|
15
3
|
|
|
16
4
|
export function useHeightEpoch(options: {
|
|
17
5
|
terminal: boolean
|
|
18
|
-
/** The terminal cell, when the host set one — only read as a signal that the
|
|
19
|
-
* epoch must re-measure; the epoch's numbers come from the DOM. */
|
|
20
6
|
fontSize?: number
|
|
21
7
|
lineHeight?: number
|
|
22
8
|
rowsRef: RefObject<HTMLDivElement | null>
|
|
@@ -24,17 +10,21 @@ export function useHeightEpoch(options: {
|
|
|
24
10
|
const { terminal, fontSize, lineHeight, rowsRef } = options
|
|
25
11
|
const [epoch, setEpoch] = useState<HeightEpoch | null>(null)
|
|
26
12
|
useEffect(() => {
|
|
27
|
-
if (!terminal)
|
|
13
|
+
if (!terminal) {
|
|
14
|
+
return
|
|
15
|
+
}
|
|
28
16
|
const element = rowsRef.current
|
|
29
|
-
if (!element)
|
|
17
|
+
if (!element) {
|
|
18
|
+
return
|
|
19
|
+
}
|
|
30
20
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
31
21
|
const measure = () => {
|
|
32
|
-
const line = Number.parseFloat(
|
|
33
|
-
getComputedStyle(element).getPropertyValue('--term-line'),
|
|
34
|
-
)
|
|
22
|
+
const line = Number.parseFloat(getComputedStyle(element).getPropertyValue('--term-line'))
|
|
35
23
|
const width = element.clientWidth
|
|
36
24
|
const ch = measureCh(element)
|
|
37
|
-
if (!line || !width || !ch)
|
|
25
|
+
if (!line || !width || !ch) {
|
|
26
|
+
return
|
|
27
|
+
}
|
|
38
28
|
setEpoch((previous) =>
|
|
39
29
|
previous && previous.width === width && previous.ch === ch && previous.line === line
|
|
40
30
|
? previous
|
|
@@ -51,10 +41,6 @@ export function useHeightEpoch(options: {
|
|
|
51
41
|
observer.disconnect()
|
|
52
42
|
clearTimeout(timer)
|
|
53
43
|
}
|
|
54
|
-
// fontSize/lineHeight: a cell change re-renders every row, which usually
|
|
55
|
-
// moves the container's size and fires the observer — but a transcript
|
|
56
|
-
// whose height happens to survive the change would keep a stale `ch`, so
|
|
57
|
-
// the props re-arm the measurement directly.
|
|
58
44
|
}, [terminal, fontSize, lineHeight, rowsRef])
|
|
59
45
|
return epoch
|
|
60
46
|
}
|