@workerdeck/ui 0.23.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.
Files changed (145) hide show
  1. package/build/{SessionPanel-DUt2VzXG.mjs → SessionPanel-DDgBkHsi.mjs} +2151 -3357
  2. package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
  3. package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
  4. package/build/format-B8ZM9LPy.d.mts +16 -0
  5. package/build/format.d.mts +8 -75
  6. package/build/format.mjs +2 -2
  7. package/build/index.d.mts +63 -1138
  8. package/build/index.mjs +4 -300
  9. package/build/index.mjs.map +1 -1
  10. package/build/scoped.css +15 -23
  11. package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
  12. package/build/status-C0HSLZas.mjs.map +1 -0
  13. package/build/workspace.d.mts +5 -126
  14. package/build/workspace.mjs +11 -143
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +4 -4
  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,45 +1,29 @@
1
- import {
2
- useCallback,
3
- useEffect,
4
- useMemo,
5
- useRef,
6
- useState,
7
- type MouseEvent as ReactMouseEvent,
8
- type ReactNode,
9
- } from 'react'
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'
10
2
  import type { WorkerDeckClient } from '@workerdeck/client'
11
3
  import {
12
4
  PROTOCOL_VERSION,
13
5
  mergeUsage,
14
6
  orderUsageWindows,
15
- subagentLabel,
16
7
  usageInfos,
17
8
  type ModelOption,
18
9
  type PermissionMode,
19
10
  type RateLimitInfo,
11
+ type SkillInfo,
20
12
  } from '@workerdeck/protocol'
21
13
  import {
22
14
  useAttachments,
23
15
  useClaudeSession,
16
+ useDraft,
24
17
  useHostFileSearch,
25
18
  useProfileUsage,
26
19
  useToolCallHost,
20
+ type ClientToolHandler,
27
21
  type ConnectionState,
28
22
  type ProducedFileRef,
29
23
  type TranscriptState,
30
24
  type UseToolCallHostOptions,
31
25
  } from '@workerdeck/react'
32
- import {
33
- ChartPie,
34
- FolderTree,
35
- Gauge,
36
- Info,
37
- MoreHorizontal,
38
- Plug,
39
- Sparkles,
40
- TriangleAlert,
41
- X,
42
- } from 'lucide-react'
26
+ import { ChartPie, FolderTree, Gauge, Info, MoreHorizontal, Plug, Sparkles, TriangleAlert, X } from 'lucide-react'
43
27
  import { cn } from '../../lib/utils.ts'
44
28
  import { Button } from '../ui/Button.tsx'
45
29
  import { Menu, MenuContent, MenuItem, MenuTrigger } from '../ui/Menu.tsx'
@@ -49,19 +33,15 @@ import { HostFilesDialog } from './HostFilesDialog.tsx'
49
33
  import { McpDialog } from './McpDialog.tsx'
50
34
  import { SkillsDialog } from './SkillsDialog.tsx'
51
35
  import { ModelSelect } from './ModelSelect.tsx'
52
- import {
53
- PermissionModeSelect,
54
- permissionModeChoices,
55
- type PermissionModeChoice,
56
- } from './PermissionModeSelect.tsx'
36
+ import { PermissionModeSelect, permissionModeChoices, type PermissionModeChoice } from './PermissionModeSelect.tsx'
57
37
  import { PermissionPrompt } from './PermissionPrompt.tsx'
58
38
  import { SubagentStrip } from './SubagentStrip.tsx'
59
- import { subagentItems, type ToolCallItem } from '../terminal/blocks.ts'
39
+ import { useSubagentFrame } from './use-subagent-frame.ts'
60
40
  import { QuestionPrompt, parseUserQuestions } from './QuestionPrompt.tsx'
61
41
  import { TerminalPermissionPrompt } from '../terminal/PermissionPrompt.tsx'
62
42
  import { TerminalQuestionPrompt } from '../terminal/QuestionPrompt.tsx'
63
43
  import { TerminalSurface } from '../terminal/surface.tsx'
64
- import type { TerminalAffordances } from '../terminal/affordances.tsx'
44
+ import { BookmarkProvider, type BookmarkHandle, type TerminalAffordances } from '../terminal/affordances.tsx'
65
45
 
66
46
  import { SessionInfoDialog } from './SessionInfoDialog.tsx'
67
47
  import { StatusBar } from './StatusBar.tsx'
@@ -77,14 +57,6 @@ import {
77
57
  } from './transcript-variant.tsx'
78
58
  import { UsageDialog } from './UsageDialog.tsx'
79
59
 
80
- /**
81
- * The box the pending prompts sit in.
82
- *
83
- * Under the terminal theme they are rows of the same run as the transcript, so
84
- * they need that theme's cell — and its `--term-bleed`, so an approval's diff
85
- * bands reach the same edges the transcript's do. Every other variant keeps the
86
- * centred content column the panel uses everywhere else.
87
- */
88
60
  function PromptSurface({
89
61
  terminal,
90
62
  metrics,
@@ -97,403 +69,67 @@ function PromptSurface({
97
69
  children: ReactNode
98
70
  }) {
99
71
  if (!terminal) {
100
- return (
101
- <div className='mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] flex-col gap-2'>
102
- {children}
103
- </div>
104
- )
72
+ return <div className="mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] flex-col gap-2">{children}</div>
105
73
  }
106
74
  return (
107
75
  <TerminalSurface
108
76
  fontSize={metrics?.fontSize}
109
77
  lineHeight={metrics?.lineHeight}
110
78
  affordances={affordances}
111
- bleed='1ch'
112
- className='term-transcript'>
79
+ bleed="1ch"
80
+ className="term-transcript"
81
+ >
113
82
  {children}
114
83
  </TerminalSurface>
115
84
  )
116
85
  }
117
86
 
118
- /**
119
- * The character cell the terminal theme draws on, in **whole pixels**.
120
- *
121
- * One object rather than two props because the panel mounts three separate
122
- * `TerminalSurface`s — the transcript, the pending prompts, the composer — and
123
- * they must agree: a prompt drawn at a different line height from the rows above
124
- * it is three surfaces on three grids, which is the failure this theme is built
125
- * to make impossible. Passing one value through one prop is what keeps them from
126
- * drifting.
127
- *
128
- * Absent means the CLI's own 13/18. A host that follows an editor font size
129
- * (VS Code) hands that down instead.
130
- */
131
87
  export type TerminalMetrics = { fontSize?: number; lineHeight?: number }
132
88
 
133
89
  export interface SessionPanelProps {
134
90
  client: WorkerDeckClient
135
91
  sessionId: string | undefined
136
- /**
137
- * Optional slot rendered at the top, above the status bar.
138
- *
139
- * Pass a **function** to take the session-actions (`⋯`) menu into your own
140
- * chrome: it is called with the menu element, and wherever you put it is
141
- * where it lives — the status bar then renders without it, so it never
142
- * appears twice. Pass a plain node (or nothing) and the menu stays in the
143
- * status bar's trailing slot.
144
- *
145
- * The seam exists because the menu can only be *built* here — it needs the
146
- * capability record, the host-file verdict and the panel's own dialog state —
147
- * but an embedder with a real header usually wants it up there with the rest
148
- * of the session's controls, not stranded on the status line.
149
- */
150
92
  header?: ReactNode | ((slots: { actions: ReactNode }) => ReactNode)
151
- /**
152
- * Where the info/context/usage/MCP/skills/files surfaces live. `'internal'`
153
- * (default) renders them as dialogs inside the panel. `'external'` renders
154
- * NO dialogs and no `⋯` menu: every affordance that would open one calls
155
- * {@link onOpenPanel} instead, so an embedder can host those surfaces in its
156
- * own chrome (a VS Code sidebar, a drawer) and keep the panel purely a
157
- * conversation surface.
158
- */
159
93
  panelSurface?: 'internal' | 'external'
160
- /**
161
- * Where the status bar lives. `'internal'` (default) draws it across the top
162
- * of the panel. `'external'` draws none — the readings still leave through
163
- * {@link onVitals}, so an embedder with a status line of its own (VS Code's
164
- * window status bar) renders them there instead of stacking a second bar
165
- * inside a panel that already sits in one.
166
- *
167
- * Deliberately independent of {@link panelSurface}: hosting the dialogs and
168
- * hosting the bar are separate decisions. One coupling to know about — the
169
- * `⋯` menu lives in the bar's trailing slot, so `statusSurface: 'external'`
170
- * with `panelSurface: 'internal'` must pass a **function** {@link header} to
171
- * take the menu, or it has nowhere left to go.
172
- */
173
94
  statusSurface?: 'internal' | 'external'
174
- /**
175
- * Which end of the panel the status bar sits at. Default `top`.
176
- *
177
- * `bottom` is the editor convention — VS Code's status bar runs along the
178
- * foot of the window — and suits a host where the panel *is* the editor area
179
- * and the chrome above it already belongs to the app. Placement only; the bar
180
- * is the same bar, with the same `⋯` menu in its trailing slot, so this
181
- * composes with {@link statusSurface} rather than competing with it (external
182
- * still means "there isn't one").
183
- */
184
95
  statusPlacement?: 'top' | 'bottom'
185
- /** Where `panelSurface: 'external'` routes opens. Absent = the affordances
186
- * (status-bar clicks, `/mcp`) become inert rather than half-working. */
187
96
  onOpenPanel?: (panel: SessionSurfacePanel) => void
188
- /** Live session vitals, fired whenever they change — for embedders mirroring
189
- * status/context/usage into chrome outside the panel (identity-stable via an
190
- * internal ref, so an inline closure is fine). */
191
97
  onVitals?: (vitals: SessionVitals) => void
192
- /**
193
- * How the transcript draws a turn — `'cards'` (default, the chat convention)
194
- * or `'terminal'`, the CLI's own form: every row on a character cell, no boxes
195
- * anywhere, diffs as full-width bands. An embedder in a dock (the VS Code
196
- * panel) wants `'terminal'`; a full-width dashboard may prefer cards.
197
- */
198
98
  transcriptVariant?: TranscriptVariant
199
- /**
200
- * Terminal theme only: the pointer affordances a real terminal cannot offer —
201
- * the hover fill, the hover-revealed copy. `false` for none. None of them
202
- * costs layout, so turning them off changes no glyph's position. See
203
- * {@link TerminalAffordances}.
204
- */
205
99
  affordances?: TerminalAffordances | boolean
206
- /**
207
- * Terminal theme only: the character cell, in whole pixels. See
208
- * {@link TerminalMetrics} — it reaches all three of the panel's terminal
209
- * surfaces, which is why it is one prop and not two per surface.
210
- */
211
100
  terminalMetrics?: TerminalMetrics
212
- /**
213
- * Terminal theme only: replace the scrollbar with the **overview ruler** — a
214
- * `2ch` rail of coloured marks in three lanes (what you typed, the answer and
215
- * its turn end, errors and a waiting approval), which you can hover to peek,
216
- * click to jump, and drag to scrub.
217
- *
218
- * Its premise is the terminal theme's own: one line height and one cell make
219
- * every row's height derivable, so a mark's position is *computed* rather than
220
- * guessed from rows that have not mounted. That is why it is not offered under
221
- * `cards` — there the flag is inert.
222
- *
223
- * `false` keeps the native scrollbar. So does `affordances={false}`, which
224
- * leaves the marks painted but inert rather than removing a reader's only way
225
- * to scroll.
226
- */
227
101
  scrubber?: boolean
228
- /**
229
- * Bookmarked **item indices**, painted full-width on the rail. Paint only —
230
- * the panel neither stores bookmarks nor offers a way to set one, because who
231
- * owns that store is the embedder's question (a private pin belongs with the
232
- * client's watermarks; a shared one is session metadata on the gateway).
233
- */
234
- scrubberMarks?: readonly number[]
235
- /**
236
- * Scroll a tool call into view; bump `nonce` to ask again for the same one.
237
- * See {@link TranscriptProps.reveal} — this is the panel's pass-through, and
238
- * exists so a surface *outside* the panel (a sessions list showing a session's
239
- * running sub-agents) can say "take me to that one" without opening a second
240
- * attach to find out where it is.
241
- */
102
+ /** Bookmarked transcript item ids — the host owns membership and persistence. */
103
+ bookmarks?: readonly string[]
104
+ onToggleBookmark?: (itemId: string) => void
242
105
  reveal?: { toolUseId: string; nonce: number }
243
- /**
244
- * Open a **sub-agent takeover**: the panel body becomes that agent's own work,
245
- * with a way back. Bump `nonce` to ask again for the same one.
246
- *
247
- * A *request*, not a controlled value — the panel owns which agent is open,
248
- * exactly as it owns `panel`. Dismissal has to work with zero host wiring
249
- * (Back and Escape are the panel's own affordances), and the two hosts in
250
- * scope reach this across a postMessage bridge where a controlled prop would
251
- * need a live closure at the far end. Same shape and same reason as
252
- * {@link SessionPanelProps.reveal}.
253
- *
254
- * **Withdrawing the request closes the frame.** The prop going away without a
255
- * remount is itself a request — "the conversation, plainly" — and it leaves
256
- * through the same path Back takes, so the reader lands on the Task row they
257
- * came from. It has to mean that, because a host that keeps its request in
258
- * route state (the dashboard's `?subagent=`) has exactly one way to say it:
259
- * clear the search — which is what the sidebar's plain session click already
260
- * navigated with, and what the browser's Back button re-arrives on. Before
261
- * this, both were silently ignored and the frame outlived the address that
262
- * claimed it was gone. Still not a controlled value: the panel enters and
263
- * leaves frames on its own and *reports* through
264
- * {@link SessionPanelProps.onSubagentChange}; only a **change** of the prop
265
- * is a request.
266
- *
267
- * Hosts must still clear their request on a session switch: a stale one
268
- * replayed at remount would open a frame the new transcript cannot answer.
269
- *
270
- * Claude-only in practice, and gated by data rather than by a flag — codex and
271
- * provider sessions have no `parentToolUseId`, so they grow no task blocks and
272
- * no sub-agent rows, and nothing can raise this.
273
- */
274
106
  openSubagent?: { toolUseId: string; nonce: number }
275
- /**
276
- * Which sub-agent the panel now has framed, or `undefined` for the session's
277
- * own conversation — the outward half of
278
- * {@link SessionPanelProps.openSubagent}, and a *statement* where that one is
279
- * a *request*. Deliberately not an echo: the panel enters frames the host
280
- * never asked for (a Task row pressed in the transcript) and leaves them on
281
- * its own (Back, Escape, a reveal), so a host that tracked only its own
282
- * requests would be wrong within one click. No nonce, for the same reason —
283
- * a state that arrives twice is the same state, where a request that arrives
284
- * twice is two requests.
285
- *
286
- * Never fired for a fresh mount's initial `undefined`, and never fired from
287
- * an unmount. The first would be a lie with consequences: the seeding effect
288
- * consumes `openSubagent` in the same commit, so for one commit the state is
289
- * `undefined` even though a frame is about to open, and a host folding
290
- * reports into route state would clear the very `?subagent=` request the
291
- * panel is in the middle of honouring. The second lets a panel keyed away on
292
- * a session switch stomp what the host already believes about the next one.
293
- * See the notify effect for the mechanics.
294
- *
295
- * What the sessions list's secondary selection feeds on: the row of the agent
296
- * on screen takes the blue and its session's card steps back to grey.
297
- */
298
107
  onSubagentChange?: (toolUseId: string | undefined) => void
299
- /**
300
- * Hold the prompt of the turn you are reading at the top of the transcript.
301
- * Works in both variants: the terminal clips to one line (as the CLI does),
302
- * cards shows a frosted bar. The **real row** is pinned rather than a copy
303
- * drawn above it, so it lines up with the rows beneath by construction.
304
- */
305
108
  stickyPrompt?: boolean
306
- /**
307
- * How much air the transcript gives each row — `'comfortable'` (default: a
308
- * blank line between messages, as the Claude Code CLI leaves) or `'compact'`
309
- * (rows tight against one another). Independent of `transcriptVariant`: the
310
- * variant follows from the surface, density is the reader's preference, and a
311
- * dock is allowed to be roomy.
312
- */
313
109
  transcriptDensity?: TranscriptDensity
314
- /**
315
- * The panel's typeface — `'sans'` (default, the host's UI font) or `'mono'`,
316
- * which repoints the sans token at the mono stack **for this subtree only**.
317
- *
318
- * The third reader preference beside variant and density, and the same kind of
319
- * thing: how a transcript should read is a property of the person reading it.
320
- * Scoped to the panel because that is the whole claim — a monospace agent view
321
- * next to an ordinary app, not a monospace app.
322
- */
323
110
  transcriptFont?: TranscriptFont
324
- /**
325
- * Where the session's own controls — model and permission mode — live.
326
- * `'internal'` (default) draws them in the composer's toolbar row.
327
- * `'status'` draws them in the panel's OWN status bar, beside the readings
328
- * they act on; the composer collapses to a single line either way. That is
329
- * VS Code's arrangement without VS Code — a host whose panel carries a status
330
- * bar of its own (`statusPlacement: 'bottom'`) gets the same streamlined
331
- * shape without having to host the pickers itself.
332
- * `'external'` draws neither: the embedder renders the pickers in its own
333
- * chrome (VS Code's window status bar, where a click opens a QuickPick) and
334
- * drives them through {@link onControls}.
335
- *
336
- * `'status'` needs a status bar to put them in — with
337
- * `statusSurface: 'external'` there is none, and the two together would hide
338
- * the controls entirely, so that combination falls back to the composer.
339
- *
340
- * The options themselves ride {@link SessionVitals} — an embedder must not
341
- * attach a second time to learn what the models are.
342
- */
343
111
  controlsSurface?: 'internal' | 'external' | 'status'
344
- /**
345
- * Handed the session's setters once the panel is live, and `undefined` on
346
- * unmount. The counterpart to `controlsSurface: 'external'`: vitals carry the
347
- * readings out, this carries the commands back in. Stable identity — safe to
348
- * stash in a ref.
349
- */
350
112
  onControls?: (controls: SessionControls | undefined) => void
351
- /**
352
- * Click anywhere the panel isn't already doing something and the caret lands
353
- * in the composer — the terminal/chat convention, and what a docked panel
354
- * wants: the field is why the panel is focussed at all.
355
- *
356
- * Only dead space. A click that hits a control (a tool row expanding, a link,
357
- * a button) or that ends a text selection is that action, not a request for
358
- * the input. Off by default: a full-page surface has plenty of dead space that
359
- * means nothing in particular.
360
- */
361
113
  focusComposerOnClick?: boolean
362
- /**
363
- * What this session looked like when it was last looked at: how many
364
- * transcript items had been seen, and when. Present and behind the current
365
- * transcript → **catch-up**: a recap row at the boundary, everything above it
366
- * dimmed, and a bar offering to jump there or to dismiss.
367
- *
368
- * The embedder owns the watermark because only it knows what "looked at"
369
- * means in its own chrome — a hidden dock is not being read. The panel reports
370
- * the number to remember through `SessionVitals.itemCount`.
371
- */
372
114
  unseen?: { itemCount: number; since?: number }
373
- /**
374
- * A viewer, not a seat at the session: transcript, status bar and panels as
375
- * usual, but no composer and no approval prompts.
376
- *
377
- * For a surface that is *about* a run rather than in it — the dashboard's job
378
- * detail, where the session belongs to the queue and typing into it would be a
379
- * second operator arriving mid-run. Deliberately not "disabled controls": a
380
- * greyed-out composer says the session is busy, an absent one says this screen
381
- * does not drive it. The attach is still live and read paths are untouched,
382
- * so the transcript streams and the file tree browses.
383
- *
384
- * It does **not** claim to be an authorization boundary. Anything holding this
385
- * client can still send; what it removes is the affordance, and the honest
386
- * enforcement lives on the gateway.
387
- */
388
115
  readOnly?: boolean
389
- /**
390
- * Options for the browser tool host this panel runs on its own attach — or
391
- * `false` to run none at all.
392
- *
393
- * The panel hosts server-bridged tool calls itself, because the bridge asks
394
- * the *first attached client* and the panel owns the session's one attach: an
395
- * embedder subscribing to the same handle separately would find this host
396
- * already answering, and refusing, anything outside its allow-list. So the
397
- * options come through here.
398
- *
399
- * Merged over the defaults, which host `eval_script` alone. Widening `tools`
400
- * is a real grant — this tab will execute what the gateway asks it to for
401
- * every name in the list — so it names them explicitly rather than accepting
402
- * a wildcard.
403
- */
404
116
  toolHost?: UseToolCallHostOptions | false
405
- /**
406
- * Keep this session's transcript warm across remounts (default true), so
407
- * switching back to a recently viewed session paints instantly and replays
408
- * only what it missed — `UseClaudeSessionOptions.cacheTranscript`. Set
409
- * `false` for an embedder whose principal varies on one gateway URL by means
410
- * the client cannot see.
411
- */
412
117
  cacheTranscript?: boolean
413
- /**
414
- * Replaces the default empty state when the transcript has no messages. Pass
415
- * your product's own onboarding content instead of WorkerDeck's generic
416
- * "`>_` Tell the agent what to do." placeholder.
417
- */
418
118
  emptyState?: ReactNode
419
- /**
420
- * Called when a link in the transcript is clicked. The embedder decides what
421
- * happens: navigate in-app, open a browser tab, show a confirmation, or
422
- * suppress.
423
- *
424
- * Return `true` (or a truthy value) to indicate the click was handled — the
425
- * default action (`window.open(href, '_blank')`) is suppressed. Return
426
- * `false` / `undefined` / nothing to let the browser open the link normally.
427
- *
428
- * Absent means "browser default" — links open in a new tab as Streamdown's
429
- * `target="_blank"` intends. VS Code's webview overrides this through its own
430
- * native handler (the "allow once / add to allowlist" dialog) and does not
431
- * need this prop.
432
- *
433
- * **Typical embedder patterns:**
434
- * - Relative URLs → in-app navigation, no confirmation
435
- * - External URLs → confirmation dialog, or open unconditionally
436
- * - Suppress all links → `() => true`
437
- */
438
119
  onLinkClick?: (href: string) => boolean | void
439
- /**
440
- * Client-side tool handlers. Each key is a tool name the model can call; the
441
- * handler receives the model's input and returns a result. The tool's
442
- * **schema** must be registered server-side (via `tools` on
443
- * `ProviderRunnerOptions`), but the handler runs here — right where the data
444
- * the tool needs lives.
445
- *
446
- * Shorthand for `toolHost.clientTools`; when both are set, this wins for
447
- * overlapping names.
448
- *
449
- * ```tsx
450
- * <SessionPanel
451
- * clientTools={{
452
- * app_navigate: async (input) => {
453
- * router.push((input as { path: string }).path)
454
- * return { value: 'navigated' }
455
- * },
456
- * }}
457
- * />
458
- * ```
459
- */
460
- clientTools?: Record<string, import('@workerdeck/react').ClientToolHandler>
461
- /**
462
- * Base font size in **whole pixels**. Drives the overall scale of everything
463
- * the panel draws — prompt, output, markdown, status bar — in both variants.
464
- *
465
- * Under the terminal theme it sets `--term-font-size` and derives
466
- * `--term-line` at the CLI's own 13 : 18 ratio (unless {@link terminalMetrics}
467
- * overrides those individually). Under cards it sets the panel root's
468
- * `font-size`, which scales every `rem`/`em`-based token the type scale uses.
469
- *
470
- * Absent means "platform default": 13 px for the terminal theme, the
471
- * inherited body size for cards. That is the right choice for a host that has
472
- * no preference — the panel reads at the size the rest of the app does.
473
- */
120
+ clientTools?: Record<string, ClientToolHandler>
474
121
  fontSize?: number
475
122
  className?: string
476
123
  }
477
124
 
478
- /** What an embedder needs to *change* a session it doesn't own the attach for. */
479
125
  export type SessionControls = {
480
126
  setModel: (model?: string) => void
481
127
  setPermissionMode: (mode: PermissionMode) => void
482
128
  interrupt: () => void
483
- /**
484
- * Put the caret in the composer.
485
- *
486
- * For an embedder whose own chrome is how you arrive at a session — clicking a
487
- * row in VS Code's sidebar — where revealing the panel and being able to type
488
- * are the same intention. The panel cannot infer it: from in here, a session
489
- * appearing looks identical whether someone asked for it or it was restored.
490
- */
491
129
  focusComposer: () => void
130
+ insertComposerText: (text: string) => void
492
131
  }
493
132
 
494
- /** Everything a click can mean other than "put the caret in the composer".
495
- * Deliberately broad: mistaking a real target for dead space steals focus from
496
- * whatever the user just opened. */
497
133
  const INTERACTIVE = [
498
134
  'button',
499
135
  'a',
@@ -510,55 +146,32 @@ const INTERACTIVE = [
510
146
  '[role="tab"]',
511
147
  ].join(',')
512
148
 
513
- /** The panels the session surface can raise. One at a time, by identity: a bag
514
- * of booleans would let two open at once. */
515
149
  export type SessionSurfacePanel = 'info' | 'context' | 'usage' | 'mcp' | 'files' | 'skills'
516
150
  type Panel = SessionSurfacePanel
517
151
 
518
- /** What {@link SessionPanelProps.onVitals} reports: the live readings a host
519
- * chrome outside the panel would otherwise have to attach a second time for —
520
- * which the tool bridge forbids (it asks the first attached client). */
521
152
  export type SessionVitals = {
522
153
  status: TranscriptState['status']
523
- /**
524
- * How the client is reaching the gateway. Load-bearing for a host rendering
525
- * these outside the panel: `status` is the last thing the session *said*, and
526
- * over a dropped socket that is a stale reading. The panel's own bar gives
527
- * the connection the status slot when it isn't `'live'` for exactly this
528
- * reason — an embedder showing `status` alone would present stale as current.
529
- */
530
154
  connection: ConnectionState
531
155
  engine: TranscriptState['engine']
532
156
  capabilities: TranscriptState['capabilities']
533
157
  model: string | undefined
534
- /** The models this session can switch to — the panel's own list, so an
535
- * external picker offers exactly what the internal one would. */
536
158
  models: ModelOption[]
537
159
  permissionMode: TranscriptState['permissionMode']
538
- /** The modes it can switch into, already filtered by the capability record
539
- * and the session's bypass grant (see `permissionModeChoices`). */
540
160
  permissionModes: PermissionModeChoice[]
161
+ skills: SkillInfo[] | undefined
541
162
  cwd: TranscriptState['cwd']
542
163
  contextUsage: TranscriptState['contextUsage']
543
164
  rateLimits: TranscriptState['rateLimits']
544
- /** How many transcript rows exist right now — the number an embedder stores
545
- * as its "seen" watermark while the panel is actually on screen, and compares
546
- * against later to know what is new. */
165
+ /**
166
+ * When the newest window in `rateLimits` was reported, as event time not receive time. External chrome (the
167
+ * VS Code status bar, iOS) cannot otherwise tell a live reading from one a days-old session just replayed.
168
+ * Absent means unknown, which is not the same as fresh.
169
+ */
170
+ rateLimitsUpdatedAt: number | undefined
547
171
  itemCount: number
548
- /** Session-cumulative cost in USD. The internal status bar renders this via
549
- * `formatCost`; an external host needs it to reproduce that reading. */
550
172
  totalCostUsd: number
551
173
  }
552
174
 
553
- /**
554
- * The all-in-one embeddable session surface: status bar, streaming transcript,
555
- * permission prompts, composer. Attaches via useClaudeSession; remount (key) to switch
556
- * sessions.
557
- *
558
- * Every affordance is gated on the session's **capability record** rather than on
559
- * the engine name — an absent capability hides the control instead of offering
560
- * one that can only fail.
561
- */
562
175
  export function SessionPanel({
563
176
  client,
564
177
  sessionId,
@@ -574,7 +187,8 @@ export function SessionPanel({
574
187
  affordances,
575
188
  terminalMetrics,
576
189
  scrubber = false,
577
- scrubberMarks,
190
+ bookmarks,
191
+ onToggleBookmark,
578
192
  reveal,
579
193
  openSubagent,
580
194
  onSubagentChange,
@@ -592,52 +206,15 @@ export function SessionPanel({
592
206
  fontSize,
593
207
  className,
594
208
  }: SessionPanelProps) {
595
- // ── Font size resolution ──────────────────────────────────────────────
596
- // `fontSize` is the panel-wide knob; `terminalMetrics` is the terminal-
597
- // specific override that predates it. The two compose: `fontSize` provides
598
- // the default the terminal derives from (13 : 18 ratio), and individual
599
- // terminalMetrics fields can still override each axis.
600
209
  const effectiveTermFontSize = terminalMetrics?.fontSize ?? fontSize
601
- const effectiveTermLineHeight =
602
- terminalMetrics?.lineHeight ??
603
- (fontSize !== undefined ? Math.round(fontSize * (18 / 13)) : undefined)
210
+ const effectiveTermLineHeight = terminalMetrics?.lineHeight ?? (fontSize !== undefined ? Math.round(fontSize * (18 / 13)) : undefined)
604
211
 
605
212
  const external = panelSurface === 'external'
606
213
  const statusExternal = statusSurface === 'external'
607
- // Both non-internal surfaces take the pickers out of the composer, which is
608
- // what collapses it to a single line.
609
214
  const controlsInStatus = controlsSurface === 'status' && !statusExternal
610
215
  const controlsExternal = controlsSurface === 'external' || controlsInStatus
611
- // Rejected commands (the CLI refusing a permission-mode switch, say) render INSIDE
612
- // the panel rather than through `toast`. The panel does not mount a `Toaster`, and
613
- // an embedder that doesn't either would drop the only signal that a command failed
614
- // — the select would just "not stick". An error channel a host can lose by omission
615
- // is not an error channel.
616
216
  const [protocolError, setProtocolError] = useState<string | undefined>(undefined)
617
217
  const [panel, setPanel] = useState<Panel | undefined>()
618
- /**
619
- * The sub-agent takeover: which `Task` call the body is currently showing, or
620
- * undefined for the conversation.
621
- *
622
- * Deliberately **not** a `Panel` member. Those route outward under
623
- * `panelSurface: 'external'` so a host can draw them natively — and a host
624
- * cannot draw this one: it is a transcript, and the transcript is in here. It
625
- * is a mode of the conversation surface, not a screen over it.
626
- */
627
- const [subagentId, setSubagentId] = useState<string | undefined>(undefined)
628
- /**
629
- * Where to land on the way back — the row of the Task you entered from,
630
- * asked for through the ordinary reveal seam.
631
- *
632
- * The alternative was keeping the main transcript mounted-but-hidden to
633
- * preserve its scroll offset, which costs two live virtualizers and an
634
- * absolute-position layout on a panel whose height just changed (the composer
635
- * came back). Re-revealing is both cheaper and more honest: you return to the
636
- * row you left from rather than to wherever you happened to be scrolled.
637
- */
638
- const [returnReveal, setReturnReveal] = useState<
639
- { toolUseId: string; nonce: number } | undefined
640
- >(undefined)
641
218
  const {
642
219
  state,
643
220
  connection,
@@ -655,103 +232,17 @@ export function SessionPanel({
655
232
  reconnectNow,
656
233
  loadFullResult,
657
234
  } = useClaudeSession(client, sessionId, { onProtocolError: setProtocolError, cacheTranscript })
658
- // Callers are told to remount on a session switch, but a changed prop must not leave
659
- // the previous session's failure on screen.
660
235
  useEffect(() => setProtocolError(undefined), [sessionId])
661
- // Belt to the remount contract, for the same reason: a takeover left standing
662
- // across a switch would frame one session's Task id against another's items.
663
- useEffect(() => {
664
- setSubagentId(undefined)
665
- setReturnReveal(undefined)
666
- }, [sessionId])
667
-
668
- // Every way out of the frame that carries no destination of its own — Back,
669
- // Escape, a withdrawn request funnels through here, so they all land the
670
- // reader on the Task row they entered from. A reveal is the one exit that
671
- // doesn't: it brought its own destination.
672
- const leaveSubagent = useCallback(() => {
673
- setSubagentId((current) => {
674
- if (current !== undefined) setReturnReveal({ toolUseId: current, nonce: Date.now() })
675
- return undefined
676
- })
677
- }, [])
678
-
679
- // The host asking — or withdrawing the ask. Keyed on the nonce, so asking
680
- // twice for the same agent works — and so a request that arrives while
681
- // another frame is open swaps it in place rather than being ignored.
682
- //
683
- // A withdrawn request (the prop going away without a remount) CLOSES the
684
- // frame — see the prop's docblock for why a host keeping its request in
685
- // route state needs that to be true. Two non-cases are worth naming because
686
- // they look like hazards and are not: on a fresh mount with no request this
687
- // fires once and `leaveSubagent` finds nothing framed, so first render
688
- // cannot wipe anything; and a host *echoing* the panel's own report back
689
- // (the dashboard folding `onSubagentChange` into `?subagent=`) re-arrives
690
- // with the nonce unchanged, so this effect never re-runs for it — the echo
691
- // is inert by construction, not by a same-value bail-out.
692
- const openSubagentNonce = openSubagent?.nonce
693
- const openSubagentId = openSubagent?.toolUseId
694
- useEffect(() => {
695
- if (openSubagentId === undefined) leaveSubagent()
696
- else setSubagentId(openSubagentId)
697
- // eslint-disable-next-line react-hooks/exhaustive-deps
698
- }, [openSubagentNonce])
699
-
700
- // A *reveal* asked for while framed means the reader wants the conversation:
701
- // latest intent wins. Leaving the frame up and scrolling something invisible
702
- // underneath it would be the worst of both.
703
- const revealNonce = reveal?.nonce
704
- useEffect(() => {
705
- if (revealNonce === undefined) return
706
- setSubagentId(undefined)
707
- // eslint-disable-next-line react-hooks/exhaustive-deps
708
- }, [revealNonce])
709
-
710
- // Escape leaves the frame — the keyboard half of Back. `defaultPrevented`
711
- // keeps a dialog's own Escape (and the composer's) ahead of it: this is the
712
- // outermost thing Escape can mean here, so it goes last.
713
- useEffect(() => {
714
- if (subagentId === undefined) return
715
- const onKey = (event: KeyboardEvent) => {
716
- if (event.key !== 'Escape' || event.defaultPrevented) return
717
- leaveSubagent()
718
- }
719
- window.addEventListener('keydown', onKey)
720
- return () => window.removeEventListener('keydown', onKey)
721
- }, [subagentId, leaveSubagent])
722
-
723
- // The report out — see the prop's docblock for what it claims. Fired off the
724
- // STATE rather than from each exit path, because the state is the only place
725
- // all four ways out and both ways in already meet; a notification hung on
726
- // the affordances would go stale the first time someone added a fifth.
727
- //
728
- // The ref pair is the mount guard, and it is load-bearing. On a deep-linked
729
- // mount, `subagentId` is `undefined` for the whole first commit — the
730
- // seeding effect above runs in that same commit and its setState lands in
731
- // the next one — so a naive `useEffect(..., [subagentId])` reports
732
- // `undefined` first, the host clears the `?subagent=` it was asked with,
733
- // and the withdrawal closes the frame the reader just requested. The ref
734
- // starts at `undefined`, so the first commit is silent by construction —
735
- // not by effect ordering, which is why this works wherever it sits relative
736
- // to the seeding effect (and under StrictMode's double mount). No cleanup
737
- // report on unmount, deliberately: hosts key this panel by session, and a
738
- // departing panel announcing "nothing framed" would stomp whatever the host
739
- // already holds for the next one.
740
- const onSubagentChangeRef = useRef(onSubagentChange)
741
- onSubagentChangeRef.current = onSubagentChange
742
- const reportedSubagentId = useRef<string | undefined>(undefined)
743
- useEffect(() => {
744
- if (reportedSubagentId.current === subagentId) return
745
- reportedSubagentId.current = subagentId
746
- onSubagentChangeRef.current?.(subagentId)
747
- }, [subagentId])
236
+ const {
237
+ subagentId,
238
+ enterSubagent,
239
+ leaveSubagent,
240
+ returnReveal,
241
+ frameItems: subagentFrameItems,
242
+ task: subagentTask,
243
+ fallbackLabel: subagentFallbackLabel,
244
+ } = useSubagentFrame({ sessionId, items: state.items, session: state.session, reveal, openSubagent, onSubagentChange })
748
245
 
749
- // Catch-up is entered once, from the watermark the embedder handed over, and
750
- // left when dismissed or when the user sends anything (they are plainly
751
- // caught up at that point). Snapshotted into state rather than read from the
752
- // prop each render: the embedder keeps updating the watermark while the panel
753
- // is on screen, and a boundary that crept forward under the reader would
754
- // un-dim the very rows they came back to read.
755
246
  const [caughtUp, setCaughtUp] = useState(false)
756
247
  useEffect(() => {
757
248
  setCaughtUp(false)
@@ -760,29 +251,25 @@ export function SessionPanel({
760
251
  const catchUp = caughtUp ? undefined : catchUpMark
761
252
  const newCount = catchUp ? Math.max(0, state.items.length - catchUp.itemCount) : 0
762
253
 
763
- // One router for every panel-opening affordance: internal surface opens the
764
- // dialog, external hands the intent to the embedder (or drops it, absent a
765
- // handler — inert beats half-working).
766
254
  const openPanel = useCallback(
767
255
  (target: SessionSurfacePanel) => {
768
- if (external) onOpenPanel?.(target)
769
- else setPanel(target)
256
+ if (external) {
257
+ onOpenPanel?.(target)
258
+ } else {
259
+ setPanel(target)
260
+ }
770
261
  },
771
262
  [external, onOpenPanel],
772
263
  )
773
- // A tab that was in the background has been sitting out the reconnect backoff;
774
- // coming back to it is exactly when waiting the rest of it out is wrong.
775
264
  useEffect(() => {
776
265
  const onVisible = () => {
777
- if (document.visibilityState === 'visible') reconnectNow()
266
+ if (document.visibilityState === 'visible') {
267
+ reconnectNow()
268
+ }
778
269
  }
779
270
  document.addEventListener('visibilitychange', onVisible)
780
271
  return () => document.removeEventListener('visibilitychange', onVisible)
781
272
  }, [reconnectNow])
782
- // Host server-bridged tool calls (provider-engine sessions) in this tab, on the
783
- // SAME handle the panel attached with — the bridge asks the first attached
784
- // client. Free for Claude sessions: the guest loads lazily on the first call,
785
- // which for them never comes.
786
273
  useToolCallHost(
787
274
  handle,
788
275
  toolHost === false
@@ -793,51 +280,15 @@ export function SessionPanel({
793
280
  )
794
281
  const terminal = transcriptVariant === 'terminal'
795
282
 
796
- // What the strip reads. The frame's items and its spawning call both come from
797
- // the transcript, which is the only complete source: `SessionInfo.subagents`
798
- // keeps eight settled records and is explicitly not a session's Task history.
799
- const subagentFrameItems = useMemo(
800
- () => (subagentId === undefined ? [] : subagentItems(state.items, subagentId)),
801
- [state.items, subagentId],
802
- )
803
- const subagentTask = useMemo(
804
- () =>
805
- subagentId === undefined
806
- ? undefined
807
- : state.items.find(
808
- (item): item is ToolCallItem => item.kind === 'tool_call' && item.id === subagentId,
809
- ),
810
- [state.items, subagentId],
811
- )
812
- // The one thing the rollup is allowed to do: name an agent whose `Task` call
813
- // the transcript does not have. A label is not content.
814
- const subagentFallbackLabel = useMemo(() => {
815
- const record = state.session?.subagents?.find((sub) => sub.toolUseId === subagentId)
816
- return record ? subagentLabel(record) : 'Sub-agent'
817
- }, [state.session, subagentId])
818
283
  const capabilities = state.capabilities
819
284
 
820
- // Plan usage as the *gateway* knows it, merged over this session's own
821
- // reading — one derivation, feeding the bar, the terminal status line, the
822
- // Usage panel and the vitals an external chrome renders from, because four
823
- // surfaces disagreeing about the same percentage is worse than any of them
824
- // being stale. A session's own `rate_limit` readings land only at a turn's
825
- // edges, so an idle session's numbers age silently and a sibling session's
826
- // spend never shows up here at all; `mergeUsage` is where that rule is
827
- // written down. Skipped entirely for an engine that reports no windows.
828
285
  const { usage: profileUsage } = useProfileUsage(client, state.session?.profile, {
829
286
  enabled: capabilities.rateLimits,
830
287
  })
831
288
  const usage = useMemo(
832
- () =>
833
- mergeUsage(
834
- { rateLimits: state.rateLimits, updatedAt: state.rateLimitsUpdatedAt },
835
- profileUsage,
836
- ),
289
+ () => mergeUsage({ rateLimits: state.rateLimits, updatedAt: state.rateLimitsUpdatedAt }, profileUsage),
837
290
  [state.rateLimits, state.rateLimitsUpdatedAt, profileUsage],
838
291
  )
839
- // Undefined rather than an empty map when nothing has been reported: absent is
840
- // *unknown*, and a surface that can't tell the two apart draws 0%.
841
292
  const rateLimits: Record<string, RateLimitInfo> | undefined = useMemo(
842
293
  () => (Object.keys(usage).length > 0 ? usageInfos(usage) : undefined),
843
294
  [usage],
@@ -847,13 +298,9 @@ export function SessionPanel({
847
298
  return stamps.length > 0 ? Math.max(...stamps) : undefined
848
299
  }, [usage])
849
300
 
850
- // Vitals out to the embedder, keyed on the readings themselves so an inline
851
- // closure prop doesn't retrigger it every render.
852
301
  const onVitalsRef = useRef(onVitals)
853
302
  onVitalsRef.current = onVitals
854
303
  const vitalsModel = effectiveModel ?? state.model
855
- // The choices, not just the current values: an external picker has no second
856
- // attach to learn them from.
857
304
  const permissionModes = useMemo(
858
305
  () => permissionModeChoices(capabilities.permissionModes, state.session?.canBypassPermissions),
859
306
  [capabilities.permissionModes, state.session?.canBypassPermissions],
@@ -868,9 +315,11 @@ export function SessionPanel({
868
315
  models,
869
316
  permissionMode: state.permissionMode,
870
317
  permissionModes,
318
+ skills: state.skills,
871
319
  cwd: state.cwd,
872
320
  contextUsage: state.contextUsage,
873
321
  rateLimits,
322
+ rateLimitsUpdatedAt: usageUpdatedAt,
874
323
  itemCount: state.items.length,
875
324
  totalCostUsd: state.totalCostUsd,
876
325
  })
@@ -883,15 +332,15 @@ export function SessionPanel({
883
332
  models,
884
333
  state.permissionMode,
885
334
  permissionModes,
335
+ state.skills,
886
336
  state.cwd,
887
337
  state.contextUsage,
888
338
  rateLimits,
339
+ usageUpdatedAt,
889
340
  state.items.length,
890
341
  state.totalCostUsd,
891
342
  ])
892
343
 
893
- // The commands back in. One stable object reading through refs, so an
894
- // embedder can stash it and a re-render never invalidates what it holds.
895
344
  const onControlsRef = useRef(onControls)
896
345
  onControlsRef.current = onControls
897
346
  const setters = useRef({ setModel, setPermissionMode, interrupt })
@@ -901,161 +350,121 @@ export function SessionPanel({
901
350
  setPermissionMode: (mode) => setters.current.setPermissionMode(mode),
902
351
  interrupt: () => setters.current.interrupt(),
903
352
  focusComposer: () => composerRef.current?.focus(),
353
+ insertComposerText: (text) => composerRef.current?.insertText(text),
904
354
  })
905
355
  useEffect(() => {
906
356
  const handler = onControlsRef.current
907
357
  handler?.(controls.current)
908
358
  return () => handler?.(undefined)
909
359
  }, [sessionId])
360
+ const bookmarkSet = useMemo(() => new Set(bookmarks ?? []), [bookmarks])
361
+ const bookmarkHandle = useMemo<BookmarkHandle | undefined>(
362
+ () => (onToggleBookmark ? { has: (id) => bookmarkSet.has(id), toggle: onToggleBookmark } : undefined),
363
+ [bookmarkSet, onToggleBookmark],
364
+ )
365
+
910
366
  const busy = state.status === 'running' || state.status === 'awaiting_approval'
911
367
  const ended = state.status === 'failed' || state.status === 'closed'
912
368
  const attachments = useAttachments(client, sessionId, {
913
369
  capabilities,
914
370
  engine: state.engine,
915
371
  })
916
- // Rooted at the session's cwd, which arrives with the snapshot — so `@` is
917
- // inert for the moment before it does, and stays inert on a gateway that
918
- // serves no host files.
919
372
  const hostFiles = useHostFileSearch(client, state.cwd)
920
- // Protocol's ordering, over the merged readings — each row keeping its own
921
- // date, since they no longer share one clock.
373
+ const draft = useDraft(client, sessionId)
374
+ // Stable identity: an inline arrow here would bust the Composer's `triggers`
375
+ // memo on every streaming re-render, and with it every prompt-area callback
376
+ // keyed on the triggers.
377
+ const searchComposerFiles = useCallback(
378
+ (query: string, options: { signal: AbortSignal }) => hostFiles.search(query, { ...options, limit: 8 }),
379
+ [hostFiles.search],
380
+ )
922
381
  const windows = useMemo(() => orderUsageWindows(usage), [usage])
923
- // Reads a picture the engine left on the host (codex's `image_gen` reports a
924
- // path, never bytes). Stable and memoized per path: transcript rows re-render
925
- // on every delta, and a fresh function would re-fetch each time.
926
382
  const hostImage = useHostImage(client, sessionId, state.producedFiles)
927
- // The other picture route, and the other store: an image *part* of a tool
928
- // result, which an opted-in replay delivered as a reference rather than half a
929
- // megabyte of base64. Bounded, unlike `useHostImage` — see the module.
930
383
  const resultImages = useToolResultImages(client, sessionId)
931
384
  const composerRef = useRef<ComposerHandle>(null)
932
- // The catch-up strip's way of scrolling the (virtualized, usually unmounted)
933
- // recap row into view — the transcript fills it in. See TranscriptProps.
934
385
  const jumpToRecap = useRef<(() => void) | null>(null)
935
- // Filled by the transcript; pressed on send. See `handleSend`.
936
386
  const repinTranscript = useRef<(() => void) | null>(null)
937
387
 
938
- // "/model" is handled panel-side (see handleSend) — surface it in the autocomplete
939
- // even though the CLI's command list doesn't include it.
940
388
  const commands = useMemo(() => {
941
- if (!state.commands) return undefined
942
- if (state.commands.some((c) => c.name === 'model')) return state.commands
943
- return [
944
- { name: 'model', description: 'Switch the model for this session', argumentHint: '<model>' },
945
- ...state.commands,
946
- ]
389
+ if (!state.commands) {
390
+ return undefined
391
+ }
392
+ if (state.commands.some((c) => c.name === 'model')) {
393
+ return state.commands
394
+ }
395
+ return [{ name: 'model', description: 'Switch the model for this session', argumentHint: '<model>' }, ...state.commands]
947
396
  }, [state.commands])
948
397
 
949
- // Two things are answered here rather than sent, because sending them would
950
- // spend a turn on a model reading the words back.
951
398
  const handleSend = (text: string, attachmentIds: string[]) => {
952
399
  if (attachmentIds.length === 0) {
953
- // "/model <id>" switches the model directly instead of going to the CLI.
954
400
  const modelCommand = /^\/model\s+(\S+)$/.exec(text)
955
401
  if (modelCommand) {
956
402
  setModel(modelCommand[1])
957
403
  return
958
404
  }
959
- // The CLI's own `/mcp` is an interactive picker, not a prompt. Only where
960
- // the capability exists: elsewhere it is ordinary message text, like any
961
- // other slash command on an engine without them.
962
405
  if (capabilities.mcpStatus && text.trim() === '/mcp') {
963
406
  openPanel('mcp')
964
407
  return
965
408
  }
966
409
  }
967
- // Typing into a session is the clearest possible statement that you have
968
- // read it — nothing left to catch up on.
969
410
  setCaughtUp(true)
970
- // ...and sending is the statement that you want to watch what happens next,
971
- // so following resumes here too. Scrolling up escapes the bottom lock, and
972
- // without this the reply streams in off screen: the transcript stays parked
973
- // where you were reading and sending looks like it did nothing. Ordered
974
- // before `send` only for readability — the re-pin is instant and the first
975
- // row is a round trip away either way.
976
411
  repinTranscript.current?.()
977
412
  send(text, attachmentIds)
978
413
  }
979
414
 
980
- // Everything the panel can open, in one place — and each one is also
981
- // reachable by clicking the thing on the bar that summarises it. Entries the
982
- // capability record forswears are absent, not present-and-empty.
983
- //
984
- // Built once and placed once: either the embedder's header takes it (see the
985
- // `header` render-prop) or the status bar does. Never both — two `⋯` menus on
986
- // one screen is worse than either position.
987
415
  const actionsMenu = (
988
416
  <Menu>
989
417
  <MenuTrigger
990
418
  render={
991
- <Button variant='ghost' size='icon-sm' aria-label='Session actions'>
992
- <MoreHorizontal className='size-4' />
419
+ <Button variant="ghost" size="icon-sm" aria-label="Session actions">
420
+ <MoreHorizontal className="size-4" />
993
421
  </Button>
994
422
  }
995
423
  />
996
424
  <MenuContent>
997
425
  {capabilities.contextUsage ? (
998
426
  <MenuItem onClick={() => openPanel('context')}>
999
- <ChartPie className='size-3.5 text-fg-3' /> Context
427
+ <ChartPie className="size-3.5 text-fg-3" /> Context
1000
428
  </MenuItem>
1001
429
  ) : null}
1002
430
  {capabilities.rateLimits ? (
1003
431
  <MenuItem onClick={() => openPanel('usage')}>
1004
- <Gauge className='size-3.5 text-fg-3' /> Usage
432
+ <Gauge className="size-3.5 text-fg-3" /> Usage
1005
433
  </MenuItem>
1006
434
  ) : null}
1007
435
  <MenuItem onClick={() => openPanel('info')}>
1008
- <Info className='size-3.5 text-fg-3' /> Session info
436
+ <Info className="size-3.5 text-fg-3" /> Session info
1009
437
  </MenuItem>
1010
438
  {capabilities.mcpStatus ? (
1011
439
  <MenuItem onClick={() => openPanel('mcp')}>
1012
- <Plug className='size-3.5 text-fg-3' /> MCP servers
440
+ <Plug className="size-3.5 text-fg-3" /> MCP servers
1013
441
  </MenuItem>
1014
442
  ) : null}
1015
- {/* On the capability alone, like MCP's entry. Codex answers
1016
- `skills/list` only over a live child, so before the first turn there
1017
- is no list yet — but hiding the entry until then made the dialog's
1018
- own explanation of that unreachable, which read as the feature being
1019
- missing. The empty state says it instead. */}
1020
443
  {capabilities.skillsList ? (
1021
444
  <MenuItem onClick={() => openPanel('skills')}>
1022
- <Sparkles className='size-3.5 text-fg-3' /> Skills
445
+ <Sparkles className="size-3.5 text-fg-3" /> Skills
1023
446
  </MenuItem>
1024
447
  ) : null}
1025
448
  {hostFiles.available ? (
1026
449
  <MenuItem onClick={() => openPanel('files')}>
1027
- <FolderTree className='size-3.5 text-fg-3' /> Project files
450
+ <FolderTree className="size-3.5 text-fg-3" /> Project files
1028
451
  </MenuItem>
1029
452
  ) : null}
1030
453
  </MenuContent>
1031
454
  </Menu>
1032
455
  )
1033
456
 
1034
- // A function header claims the menu; anything else leaves it on the status bar.
1035
- // The external surface has no menu to claim — those entries live in the
1036
- // embedder's own chrome, reached through onOpenPanel.
1037
457
  const menu = external ? null : actionsMenu
1038
458
  const headerTakesActions = typeof header === 'function'
1039
459
 
1040
- // Built once and placed at one end or the other — the bar has a `⋯` menu and
1041
- // three open handlers, and two copies of that in the tree would be two things
1042
- // to keep in step.
1043
- // Built once, placed by `controlsSurface`: the composer's toolbar row, or the
1044
- // status bar beside the readings they act on, or nowhere at all when the host
1045
- // draws them in its own chrome.
1046
460
  const sessionControls = (
1047
461
  <>
1048
- {/* Codex reports no `capabilities` event, so its models arrive from the
1049
- profile catalog instead — without that fallback its picker would be
1050
- permanently empty and the session unswitchable. */}
1051
462
  {models.length ? (
1052
463
  <ModelSelect
1053
464
  models={models}
1054
465
  model={effectiveModel}
1055
466
  onModelChange={setModel}
1056
467
  disabled={ended}
1057
- // The bar is one line of label-sized text; a 24px trigger would set
1058
- // its height instead of fitting in it.
1059
468
  className={controlsInStatus ? 'h-5' : undefined}
1060
469
  />
1061
470
  ) : null}
@@ -1063,8 +472,6 @@ export function SessionPanel({
1063
472
  <PermissionModeSelect
1064
473
  mode={state.permissionMode}
1065
474
  onModeChange={setPermissionMode}
1066
- // Only what this engine implements — the rest would come back as a
1067
- // protocol_error.
1068
475
  modes={capabilities.permissionModes}
1069
476
  canBypass={state.session?.canBypassPermissions}
1070
477
  disabled={ended}
@@ -1088,22 +495,24 @@ export function SessionPanel({
1088
495
  />
1089
496
  )
1090
497
 
1091
- // ── Link click handler ────────────────────────────────────────────────
1092
- // When onLinkClick is provided, intercept <a> clicks on the panel root so
1093
- // the embedder controls navigation. Capture phase so it fires before any
1094
- // default handling. Only installed when the prop is present — a host that
1095
- // does not provide it (VS Code, the dashboard) gets the browser / webview
1096
- // default, which is the right thing in both cases.
1097
498
  const panelRef = useRef<HTMLDivElement>(null)
1098
499
  useEffect(() => {
1099
- if (!onLinkClick) return
500
+ if (!onLinkClick) {
501
+ return
502
+ }
1100
503
  const el = panelRef.current
1101
- if (!el) return
504
+ if (!el) {
505
+ return
506
+ }
1102
507
  const handler = (e: MouseEvent) => {
1103
508
  const anchor = (e.target as HTMLElement)?.closest?.('a[href]') as HTMLAnchorElement | null
1104
- if (!anchor) return
509
+ if (!anchor) {
510
+ return
511
+ }
1105
512
  const href = anchor.getAttribute('href')
1106
- if (!href) return
513
+ if (!href) {
514
+ return
515
+ }
1107
516
  if (onLinkClick(href)) {
1108
517
  e.preventDefault()
1109
518
  e.stopPropagation()
@@ -1113,311 +522,232 @@ export function SessionPanel({
1113
522
  return () => el.removeEventListener('click', handler, true)
1114
523
  }, [onLinkClick])
1115
524
 
1116
- // Dead-space clicks land in the composer. Anything the user actually aimed at
1117
- // — a control, a link, the end of a drag-selection — keeps its own meaning;
1118
- // this only claims what was left over.
1119
525
  const handleClick = (event: ReactMouseEvent<HTMLDivElement>) => {
1120
- if (!focusComposerOnClick || readOnly) return
526
+ if (!focusComposerOnClick || readOnly) {
527
+ return
528
+ }
1121
529
  const target = event.target as HTMLElement | null
1122
- if (target?.closest(INTERACTIVE)) return
1123
- if (window.getSelection()?.isCollapsed === false) return
530
+ if (target?.closest(INTERACTIVE)) {
531
+ return
532
+ }
533
+ if (window.getSelection()?.isCollapsed === false) {
534
+ return
535
+ }
1124
536
  composerRef.current?.focus()
1125
537
  }
1126
538
 
1127
539
  return (
1128
- // The variant is a panel-wide fact, not a transcript-only one: the composer
1129
- // and the pending prompts live outside the scroller but belong to the same
1130
- // run, and they read it from this context rather than a prop chain.
1131
540
  <TranscriptVariantProvider value={transcriptVariant}>
1132
541
  <TranscriptDensityProvider value={transcriptDensity}>
1133
- {/* The panel owns the session's one attach, so it is the only thing that
1134
- can answer a row asking for the rest of a truncated tool result. Rows
1135
- rendered anywhere else fall back to the context's no-op, which is
1136
- correct for them: nothing truncates a replay they never asked for. */}
1137
- <ToolResultFetchProvider value={loadFullResult}>
1138
- <ToolResultImageProvider value={resultImages}>
1139
- <div
1140
- ref={panelRef}
1141
- data-slot='session-panel'
1142
- // The typeface is a cascade fact, not a React one — one attribute here,
1143
- // and the `[data-agent-font]` rule in theme.css does the rest. Nothing
1144
- // outside this subtree can pick it up.
1145
- data-agent-font={transcriptFont}
1146
- onClick={handleClick}
1147
- className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}
1148
- style={fontSize !== undefined ? { '--wd-font-size': `${Math.round(fontSize)}px` } as React.CSSProperties : undefined}>
1149
- {headerTakesActions ? header({ actions: menu }) : header}
1150
- {statusPlacement === 'top' ? statusBar : null}
1151
- {protocolMismatch !== undefined ? (
1152
- <Notice level='warning'>
1153
- Server speaks protocol v{protocolMismatch}, this build renders v{PROTOCOL_VERSION}. Some
1154
- events may not render.
1155
- </Notice>
1156
- ) : null}
1157
- {protocolError ? (
1158
- <Notice level='error' onDismiss={() => setProtocolError(undefined)}>
1159
- {protocolError}
1160
- </Notice>
1161
- ) : null}
1162
- {/* The takeover's one line: who is on screen, and the way back. Above
1163
- the transcript because it is a frame around it, not a row in it —
1164
- and because Back must be reachable without scrolling a stream that
1165
- is still growing. */}
1166
- {subagentId !== undefined ? (
1167
- <SubagentStrip
1168
- task={subagentTask}
1169
- items={subagentFrameItems}
1170
- label={subagentFallbackLabel}
1171
- onBack={leaveSubagent}
1172
- terminal={terminal}
1173
- fontSize={effectiveTermFontSize}
1174
- lineHeight={effectiveTermLineHeight}
1175
- />
1176
- ) : null}
1177
- <Transcript
1178
- /* A remount, so the frame opens pinned to its own bottom with a fresh
1179
- virtualizer and height epoch — which is the right landing for both
1180
- cases: the live tail of a running agent, and the final report of a
1181
- settled one. */
1182
- key={subagentId ?? 'session'}
1183
- state={state}
1184
- fileUrl={sessionId ? (path) => client.sessionFileUrl(sessionId, path) : undefined}
1185
- attachmentUrl={sessionId ? (id) => client.attachmentUrl(sessionId, id) : undefined}
1186
- canBrowseFiles={hostFiles.available}
1187
- hostImage={hostImage}
1188
- variant={transcriptVariant}
1189
- density={transcriptDensity}
1190
- fontSize={effectiveTermFontSize}
1191
- lineHeight={effectiveTermLineHeight}
1192
- affordances={affordances}
1193
- stickyPrompt={stickyPrompt}
1194
- scrubber={scrubber}
1195
- scrubberMarks={scrubberMarks}
1196
- replaying={replaying}
1197
- catchUp={
1198
- catchUp && newCount > 0
1199
- ? { from: catchUp.itemCount, since: catchUp.since }
1200
- : undefined
1201
- }
1202
- /* On the way back, land on the Task you came from — see `returnReveal`.
1203
- The host's own reveal still wins when it is the newer intent. */
1204
- reveal={returnReveal ?? reveal}
1205
- frame={subagentId === undefined ? undefined : { parentToolUseId: subagentId }}
1206
- onOpenSubagent={setSubagentId}
1207
- emptyState={emptyState}
1208
- jumpToRecapRef={jumpToRecap}
1209
- repinRef={repinTranscript}
1210
- />
1211
- {/* The way back into what you missed. Above the composer because that is
1212
- where the eye already is on returning, and because the transcript
1213
- itself opens pinned to the newest row. Held with the transcript
1214
- while the replay lands — its count is `state.items.length`, which
1215
- during the replay is a number visibly climbing toward its answer. */}
1216
- {catchUp && newCount > 0 && !replaying && subagentId === undefined ? (
1217
- <div className='px-3 pb-1'>
542
+ <ToolResultFetchProvider value={loadFullResult}>
543
+ <ToolResultImageProvider value={resultImages}>
1218
544
  <div
1219
- data-slot='catch-up'
1220
- className='mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] items-center gap-2 text-label text-fg-3'>
1221
- <span
1222
- aria-hidden
1223
- className={cn('select-none', terminal ? 'text-fg-3' : 'text-accent')}>
1224
-
1225
- </span>
1226
- <span className='min-w-0 flex-1 truncate'>
1227
- {newCount} new {newCount === 1 ? 'row' : 'rows'}
1228
- {catchUp.since !== undefined ? ` since you were last here` : ''}
1229
- </span>
1230
- <button
1231
- type='button'
1232
- onClick={() => jumpToRecap.current?.()}
1233
- className='shrink-0 underline-offset-2 hover:text-fg-1 hover:underline'>
1234
- jump
1235
- </button>
1236
- <button
1237
- type='button'
1238
- onClick={() => setCaughtUp(true)}
1239
- className='shrink-0 underline-offset-2 hover:text-fg-1 hover:underline'>
1240
- dismiss
1241
- </button>
1242
- </div>
1243
- </div>
1244
- ) : null}
1245
- {/* An engine with no approval channel never raises these, but a stale
1246
- pending request from a replayed log would still render — the record is
1247
- the authority on whether an approval UI means anything here. */}
1248
- {/* A read-only surface has no answer to give: the status bar still says
1249
- `awaiting approval`, and the place that can act on it is wherever the
1250
- session is actually driven. */}
1251
- {!readOnly && capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? (
1252
- <div className={cn(terminal ? 'pb-2' : 'px-3 pb-2')}>
1253
- <PromptSurface terminal={terminal} metrics={{ fontSize: effectiveTermFontSize, lineHeight: effectiveTermLineHeight }} affordances={affordances}>
1254
- {state.pendingApprovals.map((request) => {
1255
- const isQuestion =
1256
- request.toolName === 'AskUserQuestion' &&
1257
- parseUserQuestions(request.input).length > 0
1258
- if (terminal) {
1259
- return isQuestion ? (
1260
- <TerminalQuestionPrompt
1261
- key={request.id}
1262
- request={request}
1263
- onAnswer={approve}
1264
- onDismiss={(id) => deny(id, 'Question dismissed by user')}
1265
- />
1266
- ) : (
1267
- <TerminalPermissionPrompt
1268
- key={request.id}
1269
- request={request}
1270
- onApprove={approve}
1271
- onDeny={deny}
1272
- />
1273
- )
1274
- }
1275
- return isQuestion ? (
1276
- <QuestionPrompt
1277
- key={request.id}
1278
- request={request}
1279
- onAnswer={approve}
1280
- onDismiss={(id) => deny(id, 'Question dismissed by user')}
545
+ ref={panelRef}
546
+ data-slot="session-panel"
547
+ data-agent-font={transcriptFont}
548
+ onClick={handleClick}
549
+ className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}
550
+ style={fontSize !== undefined ? ({ '--wd-font-size': `${Math.round(fontSize)}px` } as React.CSSProperties) : undefined}
551
+ >
552
+ {headerTakesActions ? header({ actions: menu }) : header}
553
+ {statusPlacement === 'top' ? statusBar : null}
554
+ {protocolMismatch !== undefined ? (
555
+ <Notice level="warning">
556
+ Server speaks protocol v{protocolMismatch}, this build renders v{PROTOCOL_VERSION}. Some events may not render.
557
+ </Notice>
558
+ ) : null}
559
+ {protocolError ? (
560
+ <Notice level="error" onDismiss={() => setProtocolError(undefined)}>
561
+ {protocolError}
562
+ </Notice>
563
+ ) : null}
564
+ {subagentId !== undefined ? (
565
+ <SubagentStrip
566
+ task={subagentTask}
567
+ items={subagentFrameItems}
568
+ label={subagentFallbackLabel}
569
+ onBack={leaveSubagent}
570
+ terminal={terminal}
571
+ fontSize={effectiveTermFontSize}
572
+ lineHeight={effectiveTermLineHeight}
573
+ />
574
+ ) : null}
575
+ <BookmarkProvider value={bookmarkHandle}>
576
+ <Transcript
577
+ key={subagentId ?? 'session'}
578
+ state={state}
579
+ fileUrl={sessionId ? (path) => client.sessionFileUrl(sessionId, path) : undefined}
580
+ attachmentUrl={sessionId ? (id) => client.attachmentUrl(sessionId, id) : undefined}
581
+ canBrowseFiles={hostFiles.available}
582
+ hostImage={hostImage}
583
+ variant={transcriptVariant}
584
+ density={transcriptDensity}
585
+ fontSize={effectiveTermFontSize}
586
+ lineHeight={effectiveTermLineHeight}
587
+ affordances={affordances}
588
+ stickyPrompt={stickyPrompt}
589
+ scrubber={scrubber}
590
+ bookmarks={bookmarks}
591
+ replaying={replaying}
592
+ catchUp={catchUp && newCount > 0 ? { from: catchUp.itemCount, since: catchUp.since } : undefined}
593
+ reveal={returnReveal ?? reveal}
594
+ frame={subagentId === undefined ? undefined : { parentToolUseId: subagentId }}
595
+ onOpenSubagent={enterSubagent}
596
+ emptyState={emptyState}
597
+ jumpToRecapRef={jumpToRecap}
598
+ repinRef={repinTranscript}
599
+ />
600
+ </BookmarkProvider>
601
+ {catchUp && newCount > 0 && !replaying && subagentId === undefined ? (
602
+ <div className="px-3 pb-1">
603
+ <div
604
+ data-slot="catch-up"
605
+ className="mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] items-center gap-2 text-label text-fg-3"
606
+ >
607
+ <span aria-hidden className={cn('select-none', terminal ? 'text-fg-3' : 'text-accent')}>
608
+
609
+ </span>
610
+ <span className="min-w-0 flex-1 truncate">
611
+ {newCount} new {newCount === 1 ? 'row' : 'rows'}
612
+ {catchUp.since !== undefined ? ` since you were last here` : ''}
613
+ </span>
614
+ <button
615
+ type="button"
616
+ onClick={() => jumpToRecap.current?.()}
617
+ className="shrink-0 underline-offset-2 hover:text-fg-1 hover:underline"
618
+ >
619
+ jump
620
+ </button>
621
+ <button
622
+ type="button"
623
+ onClick={() => setCaughtUp(true)}
624
+ className="shrink-0 underline-offset-2 hover:text-fg-1 hover:underline"
625
+ >
626
+ dismiss
627
+ </button>
628
+ </div>
629
+ </div>
630
+ ) : null}
631
+ {!readOnly && capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? (
632
+ <div className={cn(terminal ? 'pb-2' : 'px-3 pb-2')}>
633
+ <PromptSurface
634
+ terminal={terminal}
635
+ metrics={{ fontSize: effectiveTermFontSize, lineHeight: effectiveTermLineHeight }}
636
+ affordances={affordances}
637
+ >
638
+ {state.pendingApprovals.map((request) => {
639
+ const isQuestion = request.toolName === 'AskUserQuestion' && parseUserQuestions(request.input).length > 0
640
+ if (terminal) {
641
+ return isQuestion ? (
642
+ <TerminalQuestionPrompt
643
+ key={request.id}
644
+ request={request}
645
+ onAnswer={approve}
646
+ onDismiss={(id) => deny(id, 'Question dismissed by user')}
647
+ />
648
+ ) : (
649
+ <TerminalPermissionPrompt key={request.id} request={request} onApprove={approve} onDeny={deny} />
650
+ )
651
+ }
652
+ return isQuestion ? (
653
+ <QuestionPrompt
654
+ key={request.id}
655
+ request={request}
656
+ onAnswer={approve}
657
+ onDismiss={(id) => deny(id, 'Question dismissed by user')}
658
+ />
659
+ ) : (
660
+ <PermissionPrompt key={request.id} request={request} onApprove={approve} onDeny={deny} />
661
+ )
662
+ })}
663
+ </PromptSurface>
664
+ </div>
665
+ ) : null}
666
+ {readOnly || subagentId !== undefined ? null : (
667
+ <Composer
668
+ ref={composerRef}
669
+ onSend={handleSend}
670
+ onInterrupt={interrupt}
671
+ busy={busy}
672
+ disabled={ended || !sessionId}
673
+ commands={capabilities.slashCommands ? commands : undefined}
674
+ skills={capabilities.skillsList ? state.skills : undefined}
675
+ attachments={attachments}
676
+ draft={draft}
677
+ onSearchFiles={hostFiles.available ? searchComposerFiles : undefined}
678
+ layout={controlsExternal ? 'inline' : 'stacked'}
679
+ toolbar={controlsExternal ? undefined : sessionControls}
680
+ fontSize={effectiveTermFontSize}
681
+ lineHeight={effectiveTermLineHeight}
682
+ affordances={affordances}
683
+ />
684
+ )}
685
+ {statusPlacement === 'bottom' ? statusBar : null}
686
+
687
+ {!external ? (
688
+ <>
689
+ <SessionInfoDialog
690
+ state={state}
691
+ client={client}
692
+ sessionId={sessionId}
693
+ open={panel === 'info'}
694
+ onOpenChange={(next) => setPanel(next ? 'info' : undefined)}
1281
695
  />
1282
- ) : (
1283
- <PermissionPrompt
1284
- key={request.id}
1285
- request={request}
1286
- onApprove={approve}
1287
- onDeny={deny}
696
+ <ContextDialog
697
+ usage={state.contextUsage}
698
+ open={panel === 'context'}
699
+ onOpenChange={(next) => setPanel(next ? 'context' : undefined)}
1288
700
  />
1289
- )
1290
- })}
1291
- </PromptSurface>
1292
- </div>
1293
- ) : null}
1294
- {/* No composer while a sub-agent is framed: you cannot talk to one, and a
1295
- live-looking input that silently addresses its *parent* is worse than
1296
- no input at all. Its interrupt goes with it — Back is one press away,
1297
- and a second interrupt control would be a second thing that stops
1298
- something the reader is not looking at.
1299
-
1300
- The approval prompts above deliberately do NOT go with it. A
1301
- sub-agent's own tool calls raise session-level permission requests,
1302
- so hiding them here would let the takeover deadlock the very agent it
1303
- is showing until the reader happened to press Back. */}
1304
- {readOnly || subagentId !== undefined ? null : (
1305
- <Composer
1306
- ref={composerRef}
1307
- onSend={handleSend}
1308
- onInterrupt={interrupt}
1309
- busy={busy}
1310
- disabled={ended || !sessionId}
1311
- commands={capabilities.slashCommands ? commands : undefined}
1312
- skills={capabilities.skillsList ? state.skills : undefined}
1313
- attachments={attachments}
1314
- onSearchFiles={
1315
- hostFiles.available
1316
- ? (query, options) => hostFiles.search(query, { ...options, limit: 8 })
1317
- : undefined
1318
- }
1319
- layout={controlsExternal ? 'inline' : 'stacked'}
1320
- toolbar={controlsExternal ? undefined : sessionControls}
1321
- fontSize={effectiveTermFontSize}
1322
- lineHeight={effectiveTermLineHeight}
1323
- affordances={affordances}
1324
- />
1325
- )}
1326
- {/* Below the composer, along the foot of the panel — the editor
1327
- convention. Last in the flex column, so it is the bottom edge. */}
1328
- {statusPlacement === 'bottom' ? statusBar : null}
1329
-
1330
- {/* The internal dialog surface. The external one renders none of these —
1331
- the embedder hosts equivalent surfaces and is handed the intents. */}
1332
- {!external ? (
1333
- <>
1334
- <SessionInfoDialog
1335
- state={state}
1336
- client={client}
1337
- sessionId={sessionId}
1338
- open={panel === 'info'}
1339
- onOpenChange={(next) => setPanel(next ? 'info' : undefined)}
1340
- />
1341
- <ContextDialog
1342
- usage={state.contextUsage}
1343
- open={panel === 'context'}
1344
- onOpenChange={(next) => setPanel(next ? 'context' : undefined)}
1345
- />
1346
- <UsageDialog
1347
- rateLimits={windows}
1348
- subscriptionType={state.subscriptionType}
1349
- engine={state.engine ?? 'claude'}
1350
- totalCostUsd={state.totalCostUsd}
1351
- updatedAt={usageUpdatedAt}
1352
- open={panel === 'usage'}
1353
- onOpenChange={(next) => setPanel(next ? 'usage' : undefined)}
1354
- />
1355
- <McpDialog
1356
- client={client}
1357
- sessionId={sessionId}
1358
- canManageServers={capabilities.mcpServerActions}
1359
- open={panel === 'mcp'}
1360
- onOpenChange={(next) => setPanel(next ? 'mcp' : undefined)}
1361
- />
1362
- <SkillsDialog
1363
- skills={state.skills}
1364
- open={panel === 'skills'}
1365
- onOpenChange={(next) => setPanel(next ? 'skills' : undefined)}
1366
- // Drafts into the composer; the operator sends it. There is no engine
1367
- // call that runs a skill, so there is nothing else this button could do.
1368
- onUse={(skill) => composerRef.current?.insertText(skillPrompt(skill))}
1369
- />
1370
- <HostFilesDialog
1371
- client={client}
1372
- cwd={state.cwd}
1373
- open={panel === 'files'}
1374
- onOpenChange={(next) => setPanel(next ? 'files' : undefined)}
1375
- />
1376
- </>
1377
- ) : null}
1378
- </div>
1379
- </ToolResultImageProvider>
1380
- </ToolResultFetchProvider>
701
+ <UsageDialog
702
+ rateLimits={windows}
703
+ subscriptionType={state.subscriptionType}
704
+ engine={state.engine ?? 'claude'}
705
+ totalCostUsd={state.totalCostUsd}
706
+ updatedAt={usageUpdatedAt}
707
+ open={panel === 'usage'}
708
+ onOpenChange={(next) => setPanel(next ? 'usage' : undefined)}
709
+ />
710
+ <McpDialog
711
+ client={client}
712
+ sessionId={sessionId}
713
+ canManageServers={capabilities.mcpServerActions}
714
+ open={panel === 'mcp'}
715
+ onOpenChange={(next) => setPanel(next ? 'mcp' : undefined)}
716
+ />
717
+ <SkillsDialog
718
+ skills={state.skills}
719
+ open={panel === 'skills'}
720
+ onOpenChange={(next) => setPanel(next ? 'skills' : undefined)}
721
+ onUse={(skill) => composerRef.current?.insertText(skillPrompt(skill))}
722
+ />
723
+ <HostFilesDialog
724
+ client={client}
725
+ cwd={state.cwd}
726
+ open={panel === 'files'}
727
+ onOpenChange={(next) => setPanel(next ? 'files' : undefined)}
728
+ />
729
+ </>
730
+ ) : null}
731
+ </div>
732
+ </ToolResultImageProvider>
733
+ </ToolResultFetchProvider>
1381
734
  </TranscriptDensityProvider>
1382
735
  </TranscriptVariantProvider>
1383
736
  )
1384
737
  }
1385
738
 
1386
- /**
1387
- * Turns a host path a tool card is holding into something an `<img>` can show.
1388
- *
1389
- * Two sources, tried in that order and for a reason:
1390
- *
1391
- * 1. **The session's produced files.** If this session's own runner announced
1392
- * writing that path (`file_produced`), the gateway will serve it from
1393
- * `/sessions/:id/produced/:fileId` — no host-file roots to declare, no byte
1394
- * cap to raise. This is the path codex's generated images take, and it is
1395
- * why they now render out of the box.
1396
- * 2. **`/fs/read`.** For a path nothing produced — a picture the model looked
1397
- * at, an image already in the tree — where the operator's declared roots are
1398
- * the right gate and the answer is legitimately "no" outside them.
1399
- *
1400
- * The cache is what makes this usable from a transcript row: rows re-render on
1401
- * every streamed delta, and an uncached resolver would re-fetch the picture each
1402
- * time. A refusal is cached too, so it costs one request rather than one per
1403
- * render.
1404
- *
1405
- * Keyed by `fileId`-or-path so that a path which becomes produced *after* a
1406
- * failed `/fs/read` is retried under a different key rather than staying cached
1407
- * as a miss.
1408
- */
1409
739
  function useHostImage(
1410
740
  client: WorkerDeckClient,
1411
741
  sessionId: string | undefined,
1412
742
  producedFiles: Record<string, ProducedFileRef> | undefined,
1413
743
  ): (path: string) => Promise<string | undefined> {
1414
744
  const cache = useRef(new Map<string, Promise<string | undefined>>())
1415
- // Object URLs pin their blob until revoked, so a long session that generated
1416
- // a dozen images would hold a dozen megabytes past unmount.
1417
745
  const objectUrls = useRef<string[]>([])
1418
746
  useEffect(
1419
747
  () => () => {
1420
- for (const url of objectUrls.current) URL.revokeObjectURL(url)
748
+ for (const url of objectUrls.current) {
749
+ URL.revokeObjectURL(url)
750
+ }
1421
751
  objectUrls.current = []
1422
752
  },
1423
753
  [],
@@ -1427,16 +757,17 @@ function useHostImage(
1427
757
  const produced = producedFiles?.[path]
1428
758
  const key = produced ? `produced:${produced.fileId}` : `fs:${path}`
1429
759
  const hit = cache.current.get(key)
1430
- if (hit) return hit
760
+ if (hit) {
761
+ return hit
762
+ }
1431
763
  const pending =
1432
764
  produced && sessionId
1433
- ? // Fetched rather than pointed at: the panel may be talking to a
1434
- // header-authenticated gateway, where a bare URL in an `<img src>`
1435
- // carries no credential.
1436
- client
765
+ ? client
1437
766
  .readProducedFile(sessionId, produced.fileId)
1438
767
  .then((blob) => {
1439
- if (blob.size === 0) return undefined
768
+ if (blob.size === 0) {
769
+ return undefined
770
+ }
1440
771
  const url = URL.createObjectURL(blob)
1441
772
  objectUrls.current.push(url)
1442
773
  return url
@@ -1445,9 +776,9 @@ function useHostImage(
1445
776
  : client
1446
777
  .readHostFile(path)
1447
778
  .then((file) => {
1448
- if (file.encoding !== 'base64') return undefined
1449
- // The route reports bytes and an encoding but not a media type;
1450
- // the extension is what a browser needs to decode it.
779
+ if (file.encoding !== 'base64') {
780
+ return undefined
781
+ }
1451
782
  const extension = path.slice(path.lastIndexOf('.') + 1).toLowerCase()
1452
783
  const mediaType = IMAGE_MEDIA_TYPES[extension]
1453
784
  return mediaType ? `data:${mediaType};base64,${file.content}` : undefined
@@ -1460,9 +791,6 @@ function useHostImage(
1460
791
  )
1461
792
  }
1462
793
 
1463
- /** Extensions worth rendering inline, and what to call them. Anything else is
1464
- * left to the card's path text — guessing a media type is how an HTML file ends
1465
- * up in an `<img>`. */
1466
794
  const IMAGE_MEDIA_TYPES: Record<string, string> = {
1467
795
  png: 'image/png',
1468
796
  jpg: 'image/jpeg',
@@ -1471,35 +799,26 @@ const IMAGE_MEDIA_TYPES: Record<string, string> = {
1471
799
  webp: 'image/webp',
1472
800
  }
1473
801
 
1474
- /** A dismissible advisory strip above the transcript. */
1475
- function Notice({
1476
- level,
1477
- onDismiss,
1478
- children,
1479
- }: {
1480
- level: 'warning' | 'error'
1481
- onDismiss?: () => void
1482
- children: ReactNode
1483
- }) {
802
+ function Notice({ level, onDismiss, children }: { level: 'warning' | 'error'; onDismiss?: () => void; children: ReactNode }) {
1484
803
  return (
1485
- <div className='px-3 pt-2'>
804
+ <div className="px-3 pt-2">
1486
805
  <div
1487
- role='alert'
806
+ role="alert"
1488
807
  className={cn(
1489
808
  'mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] items-start gap-2 rounded-md border px-3 py-2 text-body-sm',
1490
- level === 'error'
1491
- ? 'border-danger/40 bg-danger-bg text-danger'
1492
- : 'border-warning/40 bg-warning-bg text-warning',
1493
- )}>
1494
- <TriangleAlert className='mt-0.5 size-3.5 shrink-0' />
1495
- <span className='min-w-0 flex-1 break-words'>{children}</span>
809
+ level === 'error' ? 'border-danger/40 bg-danger-bg text-danger' : 'border-warning/40 bg-warning-bg text-warning',
810
+ )}
811
+ >
812
+ <TriangleAlert className="mt-0.5 size-3.5 shrink-0" />
813
+ <span className="min-w-0 flex-1 break-words">{children}</span>
1496
814
  {onDismiss ? (
1497
815
  <button
1498
- type='button'
816
+ type="button"
1499
817
  onClick={onDismiss}
1500
- aria-label='Dismiss'
1501
- className='shrink-0 opacity-70 transition-opacity hover:opacity-100'>
1502
- <X className='size-3.5' />
818
+ aria-label="Dismiss"
819
+ className="shrink-0 opacity-70 transition-opacity hover:opacity-100"
820
+ >
821
+ <X className="size-3.5" />
1503
822
  </button>
1504
823
  ) : null}
1505
824
  </div>