@workerdeck/ui 0.23.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/build/{SessionPanel-DUt2VzXG.mjs → SessionPanel-I5QSLLb0.mjs} +2266 -3459
  2. package/build/SessionPanel-I5QSLLb0.mjs.map +1 -0
  3. package/build/SessionPanel-yNO60Tzw.d.mts +151 -0
  4. package/build/format-C_lpFq0l.d.mts +16 -0
  5. package/build/format.d.mts +8 -76
  6. package/build/format.mjs +2 -2
  7. package/build/index.d.mts +291 -1695
  8. package/build/index.mjs +6 -301
  9. package/build/index.mjs.map +1 -1
  10. package/build/scoped.css +32 -23
  11. package/build/{status-BE-zg88x.mjs → status-Dm9INVTt.mjs} +17 -88
  12. package/build/status-Dm9INVTt.mjs.map +1 -0
  13. package/build/workspace.d.mts +8 -187
  14. package/build/workspace.mjs +13 -144
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +24 -24
  17. package/src/components/agent/CodeEditor.tsx +31 -103
  18. package/src/components/agent/Composer.tsx +114 -333
  19. package/src/components/agent/ContextDialog.tsx +20 -41
  20. package/src/components/agent/ContextRing.tsx +6 -28
  21. package/src/components/agent/Conversation.tsx +21 -33
  22. package/src/components/agent/EditorTabs.tsx +39 -67
  23. package/src/components/agent/EngineIcon.tsx +29 -72
  24. package/src/components/agent/EntryIcon.tsx +16 -0
  25. package/src/components/agent/FileCard.tsx +10 -16
  26. package/src/components/agent/FileTree.tsx +52 -125
  27. package/src/components/agent/FileViewer.tsx +30 -60
  28. package/src/components/agent/HostFilesDialog.tsx +40 -69
  29. package/src/components/agent/Loader.tsx +3 -24
  30. package/src/components/agent/McpDialog.tsx +64 -137
  31. package/src/components/agent/Message.tsx +2 -21
  32. package/src/components/agent/ModelSelect.tsx +33 -62
  33. package/src/components/agent/PermissionModeSelect.tsx +21 -79
  34. package/src/components/agent/PermissionPrompt.tsx +59 -63
  35. package/src/components/agent/ProjectIcon.tsx +228 -93
  36. package/src/components/agent/PromptTokenText.tsx +10 -16
  37. package/src/components/agent/QuestionPrompt.tsx +43 -64
  38. package/src/components/agent/Reasoning.tsx +13 -16
  39. package/src/components/agent/Response.tsx +3 -9
  40. package/src/components/agent/Scrubber.tsx +31 -80
  41. package/src/components/agent/SessionBrowser.tsx +64 -243
  42. package/src/components/agent/SessionEmptyState.tsx +10 -40
  43. package/src/components/agent/SessionInfoDialog.tsx +46 -78
  44. package/src/components/agent/SessionItem.tsx +53 -320
  45. package/src/components/agent/SessionList.tsx +23 -39
  46. package/src/components/agent/SessionPanel.tsx +315 -996
  47. package/src/components/agent/SessionStatusIcon.tsx +14 -28
  48. package/src/components/agent/SessionSteps.tsx +34 -187
  49. package/src/components/agent/SessionWorkspace.tsx +104 -219
  50. package/src/components/agent/SkillsDialog.tsx +35 -80
  51. package/src/components/agent/StatusBar.tsx +51 -136
  52. package/src/components/agent/SubagentStrip.tsx +12 -77
  53. package/src/components/agent/ToolCallCard.tsx +94 -135
  54. package/src/components/agent/Transcript.tsx +103 -1288
  55. package/src/components/agent/TranscriptItemView.tsx +155 -0
  56. package/src/components/agent/TranscriptRows.tsx +418 -0
  57. package/src/components/agent/UsageDialog.tsx +7 -29
  58. package/src/components/agent/UsageMeters.tsx +18 -73
  59. package/src/components/agent/pulse.tsx +9 -32
  60. package/src/components/agent/scrubber-marks.ts +33 -103
  61. package/src/components/agent/status.ts +1 -5
  62. package/src/components/agent/tool-result-fetch.tsx +3 -25
  63. package/src/components/agent/tool-result-image.tsx +32 -111
  64. package/src/components/agent/transcript-rows.ts +41 -118
  65. package/src/components/agent/transcript-variant.tsx +3 -80
  66. package/src/components/agent/use-height-epoch.ts +10 -24
  67. package/src/components/agent/use-path-links.ts +45 -63
  68. package/src/components/agent/use-subagent-frame.ts +106 -0
  69. package/src/components/agent/use-transcript-jumps.ts +49 -107
  70. package/src/components/prompt-area/animated-placeholder.tsx +6 -5
  71. package/src/components/prompt-area/clipboard-helpers.ts +26 -21
  72. package/src/components/prompt-area/cursor-helpers.ts +79 -37
  73. package/src/components/prompt-area/dom-helpers.ts +97 -69
  74. package/src/components/prompt-area/file-strip.tsx +90 -80
  75. package/src/components/prompt-area/html-to-markdown.ts +67 -46
  76. package/src/components/prompt-area/image-strip.tsx +8 -18
  77. package/src/components/prompt-area/index.ts +2 -15
  78. package/src/components/prompt-area/prompt-area-engine.ts +100 -115
  79. package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
  80. package/src/components/prompt-area/prompt-area.tsx +31 -38
  81. package/src/components/prompt-area/remove-button.tsx +3 -9
  82. package/src/components/prompt-area/segment-helpers.ts +4 -12
  83. package/src/components/prompt-area/trigger-popover.tsx +15 -27
  84. package/src/components/prompt-area/trigger-presets.ts +0 -10
  85. package/src/components/prompt-area/types.ts +3 -8
  86. package/src/components/prompt-area/use-chip-editing.ts +305 -0
  87. package/src/components/prompt-area/use-markdown-mode.ts +7 -17
  88. package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
  89. package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
  90. package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
  91. package/src/components/prompt-area/use-prompt-area.ts +170 -896
  92. package/src/components/prompt-area/use-trigger-search.ts +21 -19
  93. package/src/components/terminal/PermissionPrompt.tsx +56 -59
  94. package/src/components/terminal/QuestionPrompt.tsx +28 -79
  95. package/src/components/terminal/StatusLine.tsx +13 -63
  96. package/src/components/terminal/TerminalTranscript.tsx +44 -160
  97. package/src/components/terminal/affordances.tsx +68 -98
  98. package/src/components/terminal/blocks.ts +20 -156
  99. package/src/components/terminal/diff.tsx +19 -67
  100. package/src/components/terminal/height.ts +163 -313
  101. package/src/components/terminal/image-box.ts +0 -44
  102. package/src/components/terminal/items.tsx +150 -305
  103. package/src/components/terminal/markdown.tsx +36 -95
  104. package/src/components/terminal/press.tsx +26 -53
  105. package/src/components/terminal/prompt.tsx +40 -170
  106. package/src/components/terminal/result-preview.ts +8 -56
  107. package/src/components/terminal/row.tsx +11 -98
  108. package/src/components/terminal/scrubber.tsx +108 -401
  109. package/src/components/terminal/surface.tsx +6 -54
  110. package/src/components/terminal/todos.ts +66 -0
  111. package/src/components/terminal/tool-run.ts +21 -192
  112. package/src/components/ui/AlertDialog.tsx +8 -23
  113. package/src/components/ui/Badge.tsx +18 -29
  114. package/src/components/ui/Button.tsx +7 -15
  115. package/src/components/ui/Card.tsx +2 -13
  116. package/src/components/ui/CodeBlock.tsx +10 -37
  117. package/src/components/ui/CopyButton.tsx +10 -23
  118. package/src/components/ui/Dialog.tsx +23 -36
  119. package/src/components/ui/Empty.tsx +6 -29
  120. package/src/components/ui/Input.tsx +14 -16
  121. package/src/components/ui/Menu.tsx +6 -18
  122. package/src/components/ui/PortalScope.tsx +1 -21
  123. package/src/components/ui/ProgressRing.tsx +8 -19
  124. package/src/components/ui/Select.tsx +18 -31
  125. package/src/components/ui/Sonner.tsx +1 -2
  126. package/src/components/ui/Spinner.tsx +1 -1
  127. package/src/components/ui/Splitter.tsx +30 -64
  128. package/src/components/ui/Textarea.tsx +2 -5
  129. package/src/components/ui/Tooltip.tsx +3 -13
  130. package/src/format.ts +0 -9
  131. package/src/index.ts +16 -113
  132. package/src/lib/clipboard.ts +8 -28
  133. package/src/lib/css.ts +8 -0
  134. package/src/lib/format.ts +101 -86
  135. package/src/lib/plan-request.ts +16 -0
  136. package/src/lib/status.ts +45 -95
  137. package/src/lib/tool-icon.ts +39 -47
  138. package/src/lib/utils.ts +1 -3
  139. package/src/styles/terminal.css +81 -49
  140. package/src/styles/theme.css +96 -32
  141. package/src/workspace.ts +1 -24
  142. package/build/SessionPanel-BobynUo4.d.mts +0 -669
  143. package/build/SessionPanel-DUt2VzXG.mjs.map +0 -1
  144. package/build/format-ljc3lKpA.d.mts +0 -59
  145. 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) setState({ src, failed: src === undefined })
40
+ if (live) {
41
+ setState({ src, failed: src === undefined })
42
+ }
103
43
  })
104
44
  .catch(() => {
105
- if (live) setState({ failed: true })
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()) if (entry.url) URL.revokeObjectURL(entry.url)
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) 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.
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) return undefined
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()) held += entry.bytes
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) return
204
- if (key === keep) continue
205
- if (entry.url) URL.revokeObjectURL(entry.url)
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/items.tsx'
2
+ import { needsBlank, type TerminalBlock } from '../terminal/blocks.ts'
9
3
 
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 }
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) return undefined
32
- if ('item' in row) return row.item
33
- if ('run' in row) return row.run[0]
34
- if ('task' in row) return row.task
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) return true
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) return 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) for (const itemIndex of row.childIndices) map.set(itemIndex, rowIndex)
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) return absorbed
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) start = row.index
120
- else {
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) best = mid
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) return 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) return
13
+ if (!terminal) {
14
+ return
15
+ }
28
16
  const element = rowsRef.current
29
- if (!element) return
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) return
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
  }