@workerdeck/ui 0.22.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/build/{SessionPanel-DgwjH4Ve.mjs → SessionPanel-DDgBkHsi.mjs} +2196 -3364
  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 +80 -24
  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 +9 -124
  14. package/build/workspace.mjs +13 -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 +4 -6
  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 +345 -923
  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 +108 -215
  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 +136 -19
  141. package/src/workspace.ts +1 -24
  142. package/build/SessionPanel-13l25ubU.d.mts +0 -609
  143. package/build/SessionPanel-DgwjH4Ve.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,347 +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
119
+ onLinkClick?: (href: string) => boolean | void
120
+ clientTools?: Record<string, ClientToolHandler>
121
+ fontSize?: number
419
122
  className?: string
420
123
  }
421
124
 
422
- /** What an embedder needs to *change* a session it doesn't own the attach for. */
423
125
  export type SessionControls = {
424
126
  setModel: (model?: string) => void
425
127
  setPermissionMode: (mode: PermissionMode) => void
426
128
  interrupt: () => void
427
- /**
428
- * Put the caret in the composer.
429
- *
430
- * For an embedder whose own chrome is how you arrive at a session — clicking a
431
- * row in VS Code's sidebar — where revealing the panel and being able to type
432
- * are the same intention. The panel cannot infer it: from in here, a session
433
- * appearing looks identical whether someone asked for it or it was restored.
434
- */
435
129
  focusComposer: () => void
130
+ insertComposerText: (text: string) => void
436
131
  }
437
132
 
438
- /** Everything a click can mean other than "put the caret in the composer".
439
- * Deliberately broad: mistaking a real target for dead space steals focus from
440
- * whatever the user just opened. */
441
133
  const INTERACTIVE = [
442
134
  'button',
443
135
  'a',
@@ -454,55 +146,32 @@ const INTERACTIVE = [
454
146
  '[role="tab"]',
455
147
  ].join(',')
456
148
 
457
- /** The panels the session surface can raise. One at a time, by identity: a bag
458
- * of booleans would let two open at once. */
459
149
  export type SessionSurfacePanel = 'info' | 'context' | 'usage' | 'mcp' | 'files' | 'skills'
460
150
  type Panel = SessionSurfacePanel
461
151
 
462
- /** What {@link SessionPanelProps.onVitals} reports: the live readings a host
463
- * chrome outside the panel would otherwise have to attach a second time for —
464
- * which the tool bridge forbids (it asks the first attached client). */
465
152
  export type SessionVitals = {
466
153
  status: TranscriptState['status']
467
- /**
468
- * How the client is reaching the gateway. Load-bearing for a host rendering
469
- * these outside the panel: `status` is the last thing the session *said*, and
470
- * over a dropped socket that is a stale reading. The panel's own bar gives
471
- * the connection the status slot when it isn't `'live'` for exactly this
472
- * reason — an embedder showing `status` alone would present stale as current.
473
- */
474
154
  connection: ConnectionState
475
155
  engine: TranscriptState['engine']
476
156
  capabilities: TranscriptState['capabilities']
477
157
  model: string | undefined
478
- /** The models this session can switch to — the panel's own list, so an
479
- * external picker offers exactly what the internal one would. */
480
158
  models: ModelOption[]
481
159
  permissionMode: TranscriptState['permissionMode']
482
- /** The modes it can switch into, already filtered by the capability record
483
- * and the session's bypass grant (see `permissionModeChoices`). */
484
160
  permissionModes: PermissionModeChoice[]
161
+ skills: SkillInfo[] | undefined
485
162
  cwd: TranscriptState['cwd']
486
163
  contextUsage: TranscriptState['contextUsage']
487
164
  rateLimits: TranscriptState['rateLimits']
488
- /** How many transcript rows exist right now — the number an embedder stores
489
- * as its "seen" watermark while the panel is actually on screen, and compares
490
- * 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
491
171
  itemCount: number
492
- /** Session-cumulative cost in USD. The internal status bar renders this via
493
- * `formatCost`; an external host needs it to reproduce that reading. */
494
172
  totalCostUsd: number
495
173
  }
496
174
 
497
- /**
498
- * The all-in-one embeddable session surface: status bar, streaming transcript,
499
- * permission prompts, composer. Attaches via useClaudeSession; remount (key) to switch
500
- * sessions.
501
- *
502
- * Every affordance is gated on the session's **capability record** rather than on
503
- * the engine name — an absent capability hides the control instead of offering
504
- * one that can only fail.
505
- */
506
175
  export function SessionPanel({
507
176
  client,
508
177
  sessionId,
@@ -518,7 +187,8 @@ export function SessionPanel({
518
187
  affordances,
519
188
  terminalMetrics,
520
189
  scrubber = false,
521
- scrubberMarks,
190
+ bookmarks,
191
+ onToggleBookmark,
522
192
  reveal,
523
193
  openSubagent,
524
194
  onSubagentChange,
@@ -529,46 +199,22 @@ export function SessionPanel({
529
199
  unseen,
530
200
  readOnly = false,
531
201
  toolHost,
202
+ clientTools,
532
203
  cacheTranscript,
533
204
  emptyState,
205
+ onLinkClick,
206
+ fontSize,
534
207
  className,
535
208
  }: SessionPanelProps) {
209
+ const effectiveTermFontSize = terminalMetrics?.fontSize ?? fontSize
210
+ const effectiveTermLineHeight = terminalMetrics?.lineHeight ?? (fontSize !== undefined ? Math.round(fontSize * (18 / 13)) : undefined)
211
+
536
212
  const external = panelSurface === 'external'
537
213
  const statusExternal = statusSurface === 'external'
538
- // Both non-internal surfaces take the pickers out of the composer, which is
539
- // what collapses it to a single line.
540
214
  const controlsInStatus = controlsSurface === 'status' && !statusExternal
541
215
  const controlsExternal = controlsSurface === 'external' || controlsInStatus
542
- // Rejected commands (the CLI refusing a permission-mode switch, say) render INSIDE
543
- // the panel rather than through `toast`. The panel does not mount a `Toaster`, and
544
- // an embedder that doesn't either would drop the only signal that a command failed
545
- // — the select would just "not stick". An error channel a host can lose by omission
546
- // is not an error channel.
547
216
  const [protocolError, setProtocolError] = useState<string | undefined>(undefined)
548
217
  const [panel, setPanel] = useState<Panel | undefined>()
549
- /**
550
- * The sub-agent takeover: which `Task` call the body is currently showing, or
551
- * undefined for the conversation.
552
- *
553
- * Deliberately **not** a `Panel` member. Those route outward under
554
- * `panelSurface: 'external'` so a host can draw them natively — and a host
555
- * cannot draw this one: it is a transcript, and the transcript is in here. It
556
- * is a mode of the conversation surface, not a screen over it.
557
- */
558
- const [subagentId, setSubagentId] = useState<string | undefined>(undefined)
559
- /**
560
- * Where to land on the way back — the row of the Task you entered from,
561
- * asked for through the ordinary reveal seam.
562
- *
563
- * The alternative was keeping the main transcript mounted-but-hidden to
564
- * preserve its scroll offset, which costs two live virtualizers and an
565
- * absolute-position layout on a panel whose height just changed (the composer
566
- * came back). Re-revealing is both cheaper and more honest: you return to the
567
- * row you left from rather than to wherever you happened to be scrolled.
568
- */
569
- const [returnReveal, setReturnReveal] = useState<
570
- { toolUseId: string; nonce: number } | undefined
571
- >(undefined)
572
218
  const {
573
219
  state,
574
220
  connection,
@@ -586,103 +232,17 @@ export function SessionPanel({
586
232
  reconnectNow,
587
233
  loadFullResult,
588
234
  } = useClaudeSession(client, sessionId, { onProtocolError: setProtocolError, cacheTranscript })
589
- // Callers are told to remount on a session switch, but a changed prop must not leave
590
- // the previous session's failure on screen.
591
235
  useEffect(() => setProtocolError(undefined), [sessionId])
592
- // Belt to the remount contract, for the same reason: a takeover left standing
593
- // across a switch would frame one session's Task id against another's items.
594
- useEffect(() => {
595
- setSubagentId(undefined)
596
- setReturnReveal(undefined)
597
- }, [sessionId])
598
-
599
- // Every way out of the frame that carries no destination of its own — Back,
600
- // Escape, a withdrawn request funnels through here, so they all land the
601
- // reader on the Task row they entered from. A reveal is the one exit that
602
- // doesn't: it brought its own destination.
603
- const leaveSubagent = useCallback(() => {
604
- setSubagentId((current) => {
605
- if (current !== undefined) setReturnReveal({ toolUseId: current, nonce: Date.now() })
606
- return undefined
607
- })
608
- }, [])
609
-
610
- // The host asking — or withdrawing the ask. Keyed on the nonce, so asking
611
- // twice for the same agent works — and so a request that arrives while
612
- // another frame is open swaps it in place rather than being ignored.
613
- //
614
- // A withdrawn request (the prop going away without a remount) CLOSES the
615
- // frame — see the prop's docblock for why a host keeping its request in
616
- // route state needs that to be true. Two non-cases are worth naming because
617
- // they look like hazards and are not: on a fresh mount with no request this
618
- // fires once and `leaveSubagent` finds nothing framed, so first render
619
- // cannot wipe anything; and a host *echoing* the panel's own report back
620
- // (the dashboard folding `onSubagentChange` into `?subagent=`) re-arrives
621
- // with the nonce unchanged, so this effect never re-runs for it — the echo
622
- // is inert by construction, not by a same-value bail-out.
623
- const openSubagentNonce = openSubagent?.nonce
624
- const openSubagentId = openSubagent?.toolUseId
625
- useEffect(() => {
626
- if (openSubagentId === undefined) leaveSubagent()
627
- else setSubagentId(openSubagentId)
628
- // eslint-disable-next-line react-hooks/exhaustive-deps
629
- }, [openSubagentNonce])
630
-
631
- // A *reveal* asked for while framed means the reader wants the conversation:
632
- // latest intent wins. Leaving the frame up and scrolling something invisible
633
- // underneath it would be the worst of both.
634
- const revealNonce = reveal?.nonce
635
- useEffect(() => {
636
- if (revealNonce === undefined) return
637
- setSubagentId(undefined)
638
- // eslint-disable-next-line react-hooks/exhaustive-deps
639
- }, [revealNonce])
640
-
641
- // Escape leaves the frame — the keyboard half of Back. `defaultPrevented`
642
- // keeps a dialog's own Escape (and the composer's) ahead of it: this is the
643
- // outermost thing Escape can mean here, so it goes last.
644
- useEffect(() => {
645
- if (subagentId === undefined) return
646
- const onKey = (event: KeyboardEvent) => {
647
- if (event.key !== 'Escape' || event.defaultPrevented) return
648
- leaveSubagent()
649
- }
650
- window.addEventListener('keydown', onKey)
651
- return () => window.removeEventListener('keydown', onKey)
652
- }, [subagentId, leaveSubagent])
653
-
654
- // The report out — see the prop's docblock for what it claims. Fired off the
655
- // STATE rather than from each exit path, because the state is the only place
656
- // all four ways out and both ways in already meet; a notification hung on
657
- // the affordances would go stale the first time someone added a fifth.
658
- //
659
- // The ref pair is the mount guard, and it is load-bearing. On a deep-linked
660
- // mount, `subagentId` is `undefined` for the whole first commit — the
661
- // seeding effect above runs in that same commit and its setState lands in
662
- // the next one — so a naive `useEffect(..., [subagentId])` reports
663
- // `undefined` first, the host clears the `?subagent=` it was asked with,
664
- // and the withdrawal closes the frame the reader just requested. The ref
665
- // starts at `undefined`, so the first commit is silent by construction —
666
- // not by effect ordering, which is why this works wherever it sits relative
667
- // to the seeding effect (and under StrictMode's double mount). No cleanup
668
- // report on unmount, deliberately: hosts key this panel by session, and a
669
- // departing panel announcing "nothing framed" would stomp whatever the host
670
- // already holds for the next one.
671
- const onSubagentChangeRef = useRef(onSubagentChange)
672
- onSubagentChangeRef.current = onSubagentChange
673
- const reportedSubagentId = useRef<string | undefined>(undefined)
674
- useEffect(() => {
675
- if (reportedSubagentId.current === subagentId) return
676
- reportedSubagentId.current = subagentId
677
- onSubagentChangeRef.current?.(subagentId)
678
- }, [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 })
679
245
 
680
- // Catch-up is entered once, from the watermark the embedder handed over, and
681
- // left when dismissed or when the user sends anything (they are plainly
682
- // caught up at that point). Snapshotted into state rather than read from the
683
- // prop each render: the embedder keeps updating the watermark while the panel
684
- // is on screen, and a boundary that crept forward under the reader would
685
- // un-dim the very rows they came back to read.
686
246
  const [caughtUp, setCaughtUp] = useState(false)
687
247
  useEffect(() => {
688
248
  setCaughtUp(false)
@@ -691,77 +251,44 @@ export function SessionPanel({
691
251
  const catchUp = caughtUp ? undefined : catchUpMark
692
252
  const newCount = catchUp ? Math.max(0, state.items.length - catchUp.itemCount) : 0
693
253
 
694
- // One router for every panel-opening affordance: internal surface opens the
695
- // dialog, external hands the intent to the embedder (or drops it, absent a
696
- // handler — inert beats half-working).
697
254
  const openPanel = useCallback(
698
255
  (target: SessionSurfacePanel) => {
699
- if (external) onOpenPanel?.(target)
700
- else setPanel(target)
256
+ if (external) {
257
+ onOpenPanel?.(target)
258
+ } else {
259
+ setPanel(target)
260
+ }
701
261
  },
702
262
  [external, onOpenPanel],
703
263
  )
704
- // A tab that was in the background has been sitting out the reconnect backoff;
705
- // coming back to it is exactly when waiting the rest of it out is wrong.
706
264
  useEffect(() => {
707
265
  const onVisible = () => {
708
- if (document.visibilityState === 'visible') reconnectNow()
266
+ if (document.visibilityState === 'visible') {
267
+ reconnectNow()
268
+ }
709
269
  }
710
270
  document.addEventListener('visibilitychange', onVisible)
711
271
  return () => document.removeEventListener('visibilitychange', onVisible)
712
272
  }, [reconnectNow])
713
- // Host server-bridged tool calls (provider-engine sessions) in this tab, on the
714
- // SAME handle the panel attached with — the bridge asks the first attached
715
- // client. Free for Claude sessions: the guest loads lazily on the first call,
716
- // which for them never comes.
717
- useToolCallHost(handle, toolHost === false ? { enabled: false } : toolHost)
273
+ useToolCallHost(
274
+ handle,
275
+ toolHost === false
276
+ ? { enabled: false }
277
+ : clientTools
278
+ ? { ...toolHost, clientTools: { ...toolHost?.clientTools, ...clientTools } }
279
+ : toolHost,
280
+ )
718
281
  const terminal = transcriptVariant === 'terminal'
719
282
 
720
- // What the strip reads. The frame's items and its spawning call both come from
721
- // the transcript, which is the only complete source: `SessionInfo.subagents`
722
- // keeps eight settled records and is explicitly not a session's Task history.
723
- const subagentFrameItems = useMemo(
724
- () => (subagentId === undefined ? [] : subagentItems(state.items, subagentId)),
725
- [state.items, subagentId],
726
- )
727
- const subagentTask = useMemo(
728
- () =>
729
- subagentId === undefined
730
- ? undefined
731
- : state.items.find(
732
- (item): item is ToolCallItem => item.kind === 'tool_call' && item.id === subagentId,
733
- ),
734
- [state.items, subagentId],
735
- )
736
- // The one thing the rollup is allowed to do: name an agent whose `Task` call
737
- // the transcript does not have. A label is not content.
738
- const subagentFallbackLabel = useMemo(() => {
739
- const record = state.session?.subagents?.find((sub) => sub.toolUseId === subagentId)
740
- return record ? subagentLabel(record) : 'Sub-agent'
741
- }, [state.session, subagentId])
742
283
  const capabilities = state.capabilities
743
284
 
744
- // Plan usage as the *gateway* knows it, merged over this session's own
745
- // reading — one derivation, feeding the bar, the terminal status line, the
746
- // Usage panel and the vitals an external chrome renders from, because four
747
- // surfaces disagreeing about the same percentage is worse than any of them
748
- // being stale. A session's own `rate_limit` readings land only at a turn's
749
- // edges, so an idle session's numbers age silently and a sibling session's
750
- // spend never shows up here at all; `mergeUsage` is where that rule is
751
- // written down. Skipped entirely for an engine that reports no windows.
752
285
  const { usage: profileUsage } = useProfileUsage(client, state.session?.profile, {
753
286
  enabled: capabilities.rateLimits,
754
287
  })
755
288
  const usage = useMemo(
756
- () =>
757
- mergeUsage(
758
- { rateLimits: state.rateLimits, updatedAt: state.rateLimitsUpdatedAt },
759
- profileUsage,
760
- ),
289
+ () => mergeUsage({ rateLimits: state.rateLimits, updatedAt: state.rateLimitsUpdatedAt }, profileUsage),
761
290
  [state.rateLimits, state.rateLimitsUpdatedAt, profileUsage],
762
291
  )
763
- // Undefined rather than an empty map when nothing has been reported: absent is
764
- // *unknown*, and a surface that can't tell the two apart draws 0%.
765
292
  const rateLimits: Record<string, RateLimitInfo> | undefined = useMemo(
766
293
  () => (Object.keys(usage).length > 0 ? usageInfos(usage) : undefined),
767
294
  [usage],
@@ -771,13 +298,9 @@ export function SessionPanel({
771
298
  return stamps.length > 0 ? Math.max(...stamps) : undefined
772
299
  }, [usage])
773
300
 
774
- // Vitals out to the embedder, keyed on the readings themselves so an inline
775
- // closure prop doesn't retrigger it every render.
776
301
  const onVitalsRef = useRef(onVitals)
777
302
  onVitalsRef.current = onVitals
778
303
  const vitalsModel = effectiveModel ?? state.model
779
- // The choices, not just the current values: an external picker has no second
780
- // attach to learn them from.
781
304
  const permissionModes = useMemo(
782
305
  () => permissionModeChoices(capabilities.permissionModes, state.session?.canBypassPermissions),
783
306
  [capabilities.permissionModes, state.session?.canBypassPermissions],
@@ -792,9 +315,11 @@ export function SessionPanel({
792
315
  models,
793
316
  permissionMode: state.permissionMode,
794
317
  permissionModes,
318
+ skills: state.skills,
795
319
  cwd: state.cwd,
796
320
  contextUsage: state.contextUsage,
797
321
  rateLimits,
322
+ rateLimitsUpdatedAt: usageUpdatedAt,
798
323
  itemCount: state.items.length,
799
324
  totalCostUsd: state.totalCostUsd,
800
325
  })
@@ -807,15 +332,15 @@ export function SessionPanel({
807
332
  models,
808
333
  state.permissionMode,
809
334
  permissionModes,
335
+ state.skills,
810
336
  state.cwd,
811
337
  state.contextUsage,
812
338
  rateLimits,
339
+ usageUpdatedAt,
813
340
  state.items.length,
814
341
  state.totalCostUsd,
815
342
  ])
816
343
 
817
- // The commands back in. One stable object reading through refs, so an
818
- // embedder can stash it and a re-render never invalidates what it holds.
819
344
  const onControlsRef = useRef(onControls)
820
345
  onControlsRef.current = onControls
821
346
  const setters = useRef({ setModel, setPermissionMode, interrupt })
@@ -825,161 +350,121 @@ export function SessionPanel({
825
350
  setPermissionMode: (mode) => setters.current.setPermissionMode(mode),
826
351
  interrupt: () => setters.current.interrupt(),
827
352
  focusComposer: () => composerRef.current?.focus(),
353
+ insertComposerText: (text) => composerRef.current?.insertText(text),
828
354
  })
829
355
  useEffect(() => {
830
356
  const handler = onControlsRef.current
831
357
  handler?.(controls.current)
832
358
  return () => handler?.(undefined)
833
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
+
834
366
  const busy = state.status === 'running' || state.status === 'awaiting_approval'
835
367
  const ended = state.status === 'failed' || state.status === 'closed'
836
368
  const attachments = useAttachments(client, sessionId, {
837
369
  capabilities,
838
370
  engine: state.engine,
839
371
  })
840
- // Rooted at the session's cwd, which arrives with the snapshot — so `@` is
841
- // inert for the moment before it does, and stays inert on a gateway that
842
- // serves no host files.
843
372
  const hostFiles = useHostFileSearch(client, state.cwd)
844
- // Protocol's ordering, over the merged readings — each row keeping its own
845
- // 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
+ )
846
381
  const windows = useMemo(() => orderUsageWindows(usage), [usage])
847
- // Reads a picture the engine left on the host (codex's `image_gen` reports a
848
- // path, never bytes). Stable and memoized per path: transcript rows re-render
849
- // on every delta, and a fresh function would re-fetch each time.
850
382
  const hostImage = useHostImage(client, sessionId, state.producedFiles)
851
- // The other picture route, and the other store: an image *part* of a tool
852
- // result, which an opted-in replay delivered as a reference rather than half a
853
- // megabyte of base64. Bounded, unlike `useHostImage` — see the module.
854
383
  const resultImages = useToolResultImages(client, sessionId)
855
384
  const composerRef = useRef<ComposerHandle>(null)
856
- // The catch-up strip's way of scrolling the (virtualized, usually unmounted)
857
- // recap row into view — the transcript fills it in. See TranscriptProps.
858
385
  const jumpToRecap = useRef<(() => void) | null>(null)
859
- // Filled by the transcript; pressed on send. See `handleSend`.
860
386
  const repinTranscript = useRef<(() => void) | null>(null)
861
387
 
862
- // "/model" is handled panel-side (see handleSend) — surface it in the autocomplete
863
- // even though the CLI's command list doesn't include it.
864
388
  const commands = useMemo(() => {
865
- if (!state.commands) return undefined
866
- if (state.commands.some((c) => c.name === 'model')) return state.commands
867
- return [
868
- { name: 'model', description: 'Switch the model for this session', argumentHint: '<model>' },
869
- ...state.commands,
870
- ]
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]
871
396
  }, [state.commands])
872
397
 
873
- // Two things are answered here rather than sent, because sending them would
874
- // spend a turn on a model reading the words back.
875
398
  const handleSend = (text: string, attachmentIds: string[]) => {
876
399
  if (attachmentIds.length === 0) {
877
- // "/model <id>" switches the model directly instead of going to the CLI.
878
400
  const modelCommand = /^\/model\s+(\S+)$/.exec(text)
879
401
  if (modelCommand) {
880
402
  setModel(modelCommand[1])
881
403
  return
882
404
  }
883
- // The CLI's own `/mcp` is an interactive picker, not a prompt. Only where
884
- // the capability exists: elsewhere it is ordinary message text, like any
885
- // other slash command on an engine without them.
886
405
  if (capabilities.mcpStatus && text.trim() === '/mcp') {
887
406
  openPanel('mcp')
888
407
  return
889
408
  }
890
409
  }
891
- // Typing into a session is the clearest possible statement that you have
892
- // read it — nothing left to catch up on.
893
410
  setCaughtUp(true)
894
- // ...and sending is the statement that you want to watch what happens next,
895
- // so following resumes here too. Scrolling up escapes the bottom lock, and
896
- // without this the reply streams in off screen: the transcript stays parked
897
- // where you were reading and sending looks like it did nothing. Ordered
898
- // before `send` only for readability — the re-pin is instant and the first
899
- // row is a round trip away either way.
900
411
  repinTranscript.current?.()
901
412
  send(text, attachmentIds)
902
413
  }
903
414
 
904
- // Everything the panel can open, in one place — and each one is also
905
- // reachable by clicking the thing on the bar that summarises it. Entries the
906
- // capability record forswears are absent, not present-and-empty.
907
- //
908
- // Built once and placed once: either the embedder's header takes it (see the
909
- // `header` render-prop) or the status bar does. Never both — two `⋯` menus on
910
- // one screen is worse than either position.
911
415
  const actionsMenu = (
912
416
  <Menu>
913
417
  <MenuTrigger
914
418
  render={
915
- <Button variant='ghost' size='icon-sm' aria-label='Session actions'>
916
- <MoreHorizontal className='size-4' />
419
+ <Button variant="ghost" size="icon-sm" aria-label="Session actions">
420
+ <MoreHorizontal className="size-4" />
917
421
  </Button>
918
422
  }
919
423
  />
920
424
  <MenuContent>
921
425
  {capabilities.contextUsage ? (
922
426
  <MenuItem onClick={() => openPanel('context')}>
923
- <ChartPie className='size-3.5 text-fg-3' /> Context
427
+ <ChartPie className="size-3.5 text-fg-3" /> Context
924
428
  </MenuItem>
925
429
  ) : null}
926
430
  {capabilities.rateLimits ? (
927
431
  <MenuItem onClick={() => openPanel('usage')}>
928
- <Gauge className='size-3.5 text-fg-3' /> Usage
432
+ <Gauge className="size-3.5 text-fg-3" /> Usage
929
433
  </MenuItem>
930
434
  ) : null}
931
435
  <MenuItem onClick={() => openPanel('info')}>
932
- <Info className='size-3.5 text-fg-3' /> Session info
436
+ <Info className="size-3.5 text-fg-3" /> Session info
933
437
  </MenuItem>
934
438
  {capabilities.mcpStatus ? (
935
439
  <MenuItem onClick={() => openPanel('mcp')}>
936
- <Plug className='size-3.5 text-fg-3' /> MCP servers
440
+ <Plug className="size-3.5 text-fg-3" /> MCP servers
937
441
  </MenuItem>
938
442
  ) : null}
939
- {/* On the capability alone, like MCP's entry. Codex answers
940
- `skills/list` only over a live child, so before the first turn there
941
- is no list yet — but hiding the entry until then made the dialog's
942
- own explanation of that unreachable, which read as the feature being
943
- missing. The empty state says it instead. */}
944
443
  {capabilities.skillsList ? (
945
444
  <MenuItem onClick={() => openPanel('skills')}>
946
- <Sparkles className='size-3.5 text-fg-3' /> Skills
445
+ <Sparkles className="size-3.5 text-fg-3" /> Skills
947
446
  </MenuItem>
948
447
  ) : null}
949
448
  {hostFiles.available ? (
950
449
  <MenuItem onClick={() => openPanel('files')}>
951
- <FolderTree className='size-3.5 text-fg-3' /> Project files
450
+ <FolderTree className="size-3.5 text-fg-3" /> Project files
952
451
  </MenuItem>
953
452
  ) : null}
954
453
  </MenuContent>
955
454
  </Menu>
956
455
  )
957
456
 
958
- // A function header claims the menu; anything else leaves it on the status bar.
959
- // The external surface has no menu to claim — those entries live in the
960
- // embedder's own chrome, reached through onOpenPanel.
961
457
  const menu = external ? null : actionsMenu
962
458
  const headerTakesActions = typeof header === 'function'
963
459
 
964
- // Built once and placed at one end or the other — the bar has a `⋯` menu and
965
- // three open handlers, and two copies of that in the tree would be two things
966
- // to keep in step.
967
- // Built once, placed by `controlsSurface`: the composer's toolbar row, or the
968
- // status bar beside the readings they act on, or nowhere at all when the host
969
- // draws them in its own chrome.
970
460
  const sessionControls = (
971
461
  <>
972
- {/* Codex reports no `capabilities` event, so its models arrive from the
973
- profile catalog instead — without that fallback its picker would be
974
- permanently empty and the session unswitchable. */}
975
462
  {models.length ? (
976
463
  <ModelSelect
977
464
  models={models}
978
465
  model={effectiveModel}
979
466
  onModelChange={setModel}
980
467
  disabled={ended}
981
- // The bar is one line of label-sized text; a 24px trigger would set
982
- // its height instead of fitting in it.
983
468
  className={controlsInStatus ? 'h-5' : undefined}
984
469
  />
985
470
  ) : null}
@@ -987,8 +472,6 @@ export function SessionPanel({
987
472
  <PermissionModeSelect
988
473
  mode={state.permissionMode}
989
474
  onModeChange={setPermissionMode}
990
- // Only what this engine implements — the rest would come back as a
991
- // protocol_error.
992
475
  modes={capabilities.permissionModes}
993
476
  canBypass={state.session?.canBypassPermissions}
994
477
  disabled={ended}
@@ -1012,309 +495,259 @@ export function SessionPanel({
1012
495
  />
1013
496
  )
1014
497
 
1015
- // Dead-space clicks land in the composer. Anything the user actually aimed at
1016
- // a control, a link, the end of a drag-selection — keeps its own meaning;
1017
- // this only claims what was left over.
498
+ const panelRef = useRef<HTMLDivElement>(null)
499
+ useEffect(() => {
500
+ if (!onLinkClick) {
501
+ return
502
+ }
503
+ const el = panelRef.current
504
+ if (!el) {
505
+ return
506
+ }
507
+ const handler = (e: MouseEvent) => {
508
+ const anchor = (e.target as HTMLElement)?.closest?.('a[href]') as HTMLAnchorElement | null
509
+ if (!anchor) {
510
+ return
511
+ }
512
+ const href = anchor.getAttribute('href')
513
+ if (!href) {
514
+ return
515
+ }
516
+ if (onLinkClick(href)) {
517
+ e.preventDefault()
518
+ e.stopPropagation()
519
+ }
520
+ }
521
+ el.addEventListener('click', handler, true)
522
+ return () => el.removeEventListener('click', handler, true)
523
+ }, [onLinkClick])
524
+
1018
525
  const handleClick = (event: ReactMouseEvent<HTMLDivElement>) => {
1019
- if (!focusComposerOnClick || readOnly) return
526
+ if (!focusComposerOnClick || readOnly) {
527
+ return
528
+ }
1020
529
  const target = event.target as HTMLElement | null
1021
- if (target?.closest(INTERACTIVE)) return
1022
- if (window.getSelection()?.isCollapsed === false) return
530
+ if (target?.closest(INTERACTIVE)) {
531
+ return
532
+ }
533
+ if (window.getSelection()?.isCollapsed === false) {
534
+ return
535
+ }
1023
536
  composerRef.current?.focus()
1024
537
  }
1025
538
 
1026
539
  return (
1027
- // The variant is a panel-wide fact, not a transcript-only one: the composer
1028
- // and the pending prompts live outside the scroller but belong to the same
1029
- // run, and they read it from this context rather than a prop chain.
1030
540
  <TranscriptVariantProvider value={transcriptVariant}>
1031
541
  <TranscriptDensityProvider value={transcriptDensity}>
1032
- {/* The panel owns the session's one attach, so it is the only thing that
1033
- can answer a row asking for the rest of a truncated tool result. Rows
1034
- rendered anywhere else fall back to the context's no-op, which is
1035
- correct for them: nothing truncates a replay they never asked for. */}
1036
- <ToolResultFetchProvider value={loadFullResult}>
1037
- <ToolResultImageProvider value={resultImages}>
1038
- <div
1039
- data-slot='session-panel'
1040
- // The typeface is a cascade fact, not a React one — one attribute here,
1041
- // and the `[data-agent-font]` rule in theme.css does the rest. Nothing
1042
- // outside this subtree can pick it up.
1043
- data-agent-font={transcriptFont}
1044
- onClick={handleClick}
1045
- className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}>
1046
- {headerTakesActions ? header({ actions: menu }) : header}
1047
- {statusPlacement === 'top' ? statusBar : null}
1048
- {protocolMismatch !== undefined ? (
1049
- <Notice level='warning'>
1050
- Server speaks protocol v{protocolMismatch}, this build renders v{PROTOCOL_VERSION}. Some
1051
- events may not render.
1052
- </Notice>
1053
- ) : null}
1054
- {protocolError ? (
1055
- <Notice level='error' onDismiss={() => setProtocolError(undefined)}>
1056
- {protocolError}
1057
- </Notice>
1058
- ) : null}
1059
- {/* The takeover's one line: who is on screen, and the way back. Above
1060
- the transcript because it is a frame around it, not a row in it —
1061
- and because Back must be reachable without scrolling a stream that
1062
- is still growing. */}
1063
- {subagentId !== undefined ? (
1064
- <SubagentStrip
1065
- task={subagentTask}
1066
- items={subagentFrameItems}
1067
- label={subagentFallbackLabel}
1068
- onBack={leaveSubagent}
1069
- terminal={terminal}
1070
- fontSize={terminalMetrics?.fontSize}
1071
- lineHeight={terminalMetrics?.lineHeight}
1072
- />
1073
- ) : null}
1074
- <Transcript
1075
- /* A remount, so the frame opens pinned to its own bottom with a fresh
1076
- virtualizer and height epoch — which is the right landing for both
1077
- cases: the live tail of a running agent, and the final report of a
1078
- settled one. */
1079
- key={subagentId ?? 'session'}
1080
- state={state}
1081
- fileUrl={sessionId ? (path) => client.sessionFileUrl(sessionId, path) : undefined}
1082
- attachmentUrl={sessionId ? (id) => client.attachmentUrl(sessionId, id) : undefined}
1083
- canBrowseFiles={hostFiles.available}
1084
- hostImage={hostImage}
1085
- variant={transcriptVariant}
1086
- density={transcriptDensity}
1087
- fontSize={terminalMetrics?.fontSize}
1088
- lineHeight={terminalMetrics?.lineHeight}
1089
- affordances={affordances}
1090
- stickyPrompt={stickyPrompt}
1091
- scrubber={scrubber}
1092
- scrubberMarks={scrubberMarks}
1093
- replaying={replaying}
1094
- catchUp={
1095
- catchUp && newCount > 0
1096
- ? { from: catchUp.itemCount, since: catchUp.since }
1097
- : undefined
1098
- }
1099
- /* On the way back, land on the Task you came from — see `returnReveal`.
1100
- The host's own reveal still wins when it is the newer intent. */
1101
- reveal={returnReveal ?? reveal}
1102
- frame={subagentId === undefined ? undefined : { parentToolUseId: subagentId }}
1103
- onOpenSubagent={setSubagentId}
1104
- emptyState={emptyState}
1105
- jumpToRecapRef={jumpToRecap}
1106
- repinRef={repinTranscript}
1107
- />
1108
- {/* The way back into what you missed. Above the composer because that is
1109
- where the eye already is on returning, and because the transcript
1110
- itself opens pinned to the newest row. Held with the transcript
1111
- while the replay lands — its count is `state.items.length`, which
1112
- during the replay is a number visibly climbing toward its answer. */}
1113
- {catchUp && newCount > 0 && !replaying && subagentId === undefined ? (
1114
- <div className='px-3 pb-1'>
542
+ <ToolResultFetchProvider value={loadFullResult}>
543
+ <ToolResultImageProvider value={resultImages}>
1115
544
  <div
1116
- data-slot='catch-up'
1117
- className='mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] items-center gap-2 text-label text-fg-3'>
1118
- <span
1119
- aria-hidden
1120
- className={cn('select-none', terminal ? 'text-fg-3' : 'text-accent')}>
1121
-
1122
- </span>
1123
- <span className='min-w-0 flex-1 truncate'>
1124
- {newCount} new {newCount === 1 ? 'row' : 'rows'}
1125
- {catchUp.since !== undefined ? ` since you were last here` : ''}
1126
- </span>
1127
- <button
1128
- type='button'
1129
- onClick={() => jumpToRecap.current?.()}
1130
- className='shrink-0 underline-offset-2 hover:text-fg-1 hover:underline'>
1131
- jump
1132
- </button>
1133
- <button
1134
- type='button'
1135
- onClick={() => setCaughtUp(true)}
1136
- className='shrink-0 underline-offset-2 hover:text-fg-1 hover:underline'>
1137
- dismiss
1138
- </button>
1139
- </div>
1140
- </div>
1141
- ) : null}
1142
- {/* An engine with no approval channel never raises these, but a stale
1143
- pending request from a replayed log would still render — the record is
1144
- the authority on whether an approval UI means anything here. */}
1145
- {/* A read-only surface has no answer to give: the status bar still says
1146
- `awaiting approval`, and the place that can act on it is wherever the
1147
- session is actually driven. */}
1148
- {!readOnly && capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? (
1149
- <div className={cn(terminal ? 'pb-2' : 'px-3 pb-2')}>
1150
- <PromptSurface terminal={terminal} metrics={terminalMetrics} affordances={affordances}>
1151
- {state.pendingApprovals.map((request) => {
1152
- const isQuestion =
1153
- request.toolName === 'AskUserQuestion' &&
1154
- parseUserQuestions(request.input).length > 0
1155
- if (terminal) {
1156
- return isQuestion ? (
1157
- <TerminalQuestionPrompt
1158
- key={request.id}
1159
- request={request}
1160
- onAnswer={approve}
1161
- onDismiss={(id) => deny(id, 'Question dismissed by user')}
1162
- />
1163
- ) : (
1164
- <TerminalPermissionPrompt
1165
- key={request.id}
1166
- request={request}
1167
- onApprove={approve}
1168
- onDeny={deny}
1169
- />
1170
- )
1171
- }
1172
- return isQuestion ? (
1173
- <QuestionPrompt
1174
- key={request.id}
1175
- request={request}
1176
- onAnswer={approve}
1177
- 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)}
1178
695
  />
1179
- ) : (
1180
- <PermissionPrompt
1181
- key={request.id}
1182
- request={request}
1183
- onApprove={approve}
1184
- onDeny={deny}
696
+ <ContextDialog
697
+ usage={state.contextUsage}
698
+ open={panel === 'context'}
699
+ onOpenChange={(next) => setPanel(next ? 'context' : undefined)}
1185
700
  />
1186
- )
1187
- })}
1188
- </PromptSurface>
1189
- </div>
1190
- ) : null}
1191
- {/* No composer while a sub-agent is framed: you cannot talk to one, and a
1192
- live-looking input that silently addresses its *parent* is worse than
1193
- no input at all. Its interrupt goes with it — Back is one press away,
1194
- and a second interrupt control would be a second thing that stops
1195
- something the reader is not looking at.
1196
-
1197
- The approval prompts above deliberately do NOT go with it. A
1198
- sub-agent's own tool calls raise session-level permission requests,
1199
- so hiding them here would let the takeover deadlock the very agent it
1200
- is showing until the reader happened to press Back. */}
1201
- {readOnly || subagentId !== undefined ? null : (
1202
- <Composer
1203
- ref={composerRef}
1204
- onSend={handleSend}
1205
- onInterrupt={interrupt}
1206
- busy={busy}
1207
- disabled={ended || !sessionId}
1208
- commands={capabilities.slashCommands ? commands : undefined}
1209
- skills={capabilities.skillsList ? state.skills : undefined}
1210
- attachments={attachments}
1211
- onSearchFiles={
1212
- hostFiles.available
1213
- ? (query, options) => hostFiles.search(query, { ...options, limit: 8 })
1214
- : undefined
1215
- }
1216
- layout={controlsExternal ? 'inline' : 'stacked'}
1217
- toolbar={controlsExternal ? undefined : sessionControls}
1218
- fontSize={terminalMetrics?.fontSize}
1219
- lineHeight={terminalMetrics?.lineHeight}
1220
- affordances={affordances}
1221
- />
1222
- )}
1223
- {/* Below the composer, along the foot of the panel — the editor
1224
- convention. Last in the flex column, so it is the bottom edge. */}
1225
- {statusPlacement === 'bottom' ? statusBar : null}
1226
-
1227
- {/* The internal dialog surface. The external one renders none of these —
1228
- the embedder hosts equivalent surfaces and is handed the intents. */}
1229
- {!external ? (
1230
- <>
1231
- <SessionInfoDialog
1232
- state={state}
1233
- client={client}
1234
- sessionId={sessionId}
1235
- open={panel === 'info'}
1236
- onOpenChange={(next) => setPanel(next ? 'info' : undefined)}
1237
- />
1238
- <ContextDialog
1239
- usage={state.contextUsage}
1240
- open={panel === 'context'}
1241
- onOpenChange={(next) => setPanel(next ? 'context' : undefined)}
1242
- />
1243
- <UsageDialog
1244
- rateLimits={windows}
1245
- subscriptionType={state.subscriptionType}
1246
- engine={state.engine ?? 'claude'}
1247
- totalCostUsd={state.totalCostUsd}
1248
- updatedAt={usageUpdatedAt}
1249
- open={panel === 'usage'}
1250
- onOpenChange={(next) => setPanel(next ? 'usage' : undefined)}
1251
- />
1252
- <McpDialog
1253
- client={client}
1254
- sessionId={sessionId}
1255
- canManageServers={capabilities.mcpServerActions}
1256
- open={panel === 'mcp'}
1257
- onOpenChange={(next) => setPanel(next ? 'mcp' : undefined)}
1258
- />
1259
- <SkillsDialog
1260
- skills={state.skills}
1261
- open={panel === 'skills'}
1262
- onOpenChange={(next) => setPanel(next ? 'skills' : undefined)}
1263
- // Drafts into the composer; the operator sends it. There is no engine
1264
- // call that runs a skill, so there is nothing else this button could do.
1265
- onUse={(skill) => composerRef.current?.insertText(skillPrompt(skill))}
1266
- />
1267
- <HostFilesDialog
1268
- client={client}
1269
- cwd={state.cwd}
1270
- open={panel === 'files'}
1271
- onOpenChange={(next) => setPanel(next ? 'files' : undefined)}
1272
- />
1273
- </>
1274
- ) : null}
1275
- </div>
1276
- </ToolResultImageProvider>
1277
- </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>
1278
734
  </TranscriptDensityProvider>
1279
735
  </TranscriptVariantProvider>
1280
736
  )
1281
737
  }
1282
738
 
1283
- /**
1284
- * Turns a host path a tool card is holding into something an `<img>` can show.
1285
- *
1286
- * Two sources, tried in that order and for a reason:
1287
- *
1288
- * 1. **The session's produced files.** If this session's own runner announced
1289
- * writing that path (`file_produced`), the gateway will serve it from
1290
- * `/sessions/:id/produced/:fileId` — no host-file roots to declare, no byte
1291
- * cap to raise. This is the path codex's generated images take, and it is
1292
- * why they now render out of the box.
1293
- * 2. **`/fs/read`.** For a path nothing produced — a picture the model looked
1294
- * at, an image already in the tree — where the operator's declared roots are
1295
- * the right gate and the answer is legitimately "no" outside them.
1296
- *
1297
- * The cache is what makes this usable from a transcript row: rows re-render on
1298
- * every streamed delta, and an uncached resolver would re-fetch the picture each
1299
- * time. A refusal is cached too, so it costs one request rather than one per
1300
- * render.
1301
- *
1302
- * Keyed by `fileId`-or-path so that a path which becomes produced *after* a
1303
- * failed `/fs/read` is retried under a different key rather than staying cached
1304
- * as a miss.
1305
- */
1306
739
  function useHostImage(
1307
740
  client: WorkerDeckClient,
1308
741
  sessionId: string | undefined,
1309
742
  producedFiles: Record<string, ProducedFileRef> | undefined,
1310
743
  ): (path: string) => Promise<string | undefined> {
1311
744
  const cache = useRef(new Map<string, Promise<string | undefined>>())
1312
- // Object URLs pin their blob until revoked, so a long session that generated
1313
- // a dozen images would hold a dozen megabytes past unmount.
1314
745
  const objectUrls = useRef<string[]>([])
1315
746
  useEffect(
1316
747
  () => () => {
1317
- for (const url of objectUrls.current) URL.revokeObjectURL(url)
748
+ for (const url of objectUrls.current) {
749
+ URL.revokeObjectURL(url)
750
+ }
1318
751
  objectUrls.current = []
1319
752
  },
1320
753
  [],
@@ -1324,16 +757,17 @@ function useHostImage(
1324
757
  const produced = producedFiles?.[path]
1325
758
  const key = produced ? `produced:${produced.fileId}` : `fs:${path}`
1326
759
  const hit = cache.current.get(key)
1327
- if (hit) return hit
760
+ if (hit) {
761
+ return hit
762
+ }
1328
763
  const pending =
1329
764
  produced && sessionId
1330
- ? // Fetched rather than pointed at: the panel may be talking to a
1331
- // header-authenticated gateway, where a bare URL in an `<img src>`
1332
- // carries no credential.
1333
- client
765
+ ? client
1334
766
  .readProducedFile(sessionId, produced.fileId)
1335
767
  .then((blob) => {
1336
- if (blob.size === 0) return undefined
768
+ if (blob.size === 0) {
769
+ return undefined
770
+ }
1337
771
  const url = URL.createObjectURL(blob)
1338
772
  objectUrls.current.push(url)
1339
773
  return url
@@ -1342,9 +776,9 @@ function useHostImage(
1342
776
  : client
1343
777
  .readHostFile(path)
1344
778
  .then((file) => {
1345
- if (file.encoding !== 'base64') return undefined
1346
- // The route reports bytes and an encoding but not a media type;
1347
- // the extension is what a browser needs to decode it.
779
+ if (file.encoding !== 'base64') {
780
+ return undefined
781
+ }
1348
782
  const extension = path.slice(path.lastIndexOf('.') + 1).toLowerCase()
1349
783
  const mediaType = IMAGE_MEDIA_TYPES[extension]
1350
784
  return mediaType ? `data:${mediaType};base64,${file.content}` : undefined
@@ -1357,9 +791,6 @@ function useHostImage(
1357
791
  )
1358
792
  }
1359
793
 
1360
- /** Extensions worth rendering inline, and what to call them. Anything else is
1361
- * left to the card's path text — guessing a media type is how an HTML file ends
1362
- * up in an `<img>`. */
1363
794
  const IMAGE_MEDIA_TYPES: Record<string, string> = {
1364
795
  png: 'image/png',
1365
796
  jpg: 'image/jpeg',
@@ -1368,35 +799,26 @@ const IMAGE_MEDIA_TYPES: Record<string, string> = {
1368
799
  webp: 'image/webp',
1369
800
  }
1370
801
 
1371
- /** A dismissible advisory strip above the transcript. */
1372
- function Notice({
1373
- level,
1374
- onDismiss,
1375
- children,
1376
- }: {
1377
- level: 'warning' | 'error'
1378
- onDismiss?: () => void
1379
- children: ReactNode
1380
- }) {
802
+ function Notice({ level, onDismiss, children }: { level: 'warning' | 'error'; onDismiss?: () => void; children: ReactNode }) {
1381
803
  return (
1382
- <div className='px-3 pt-2'>
804
+ <div className="px-3 pt-2">
1383
805
  <div
1384
- role='alert'
806
+ role="alert"
1385
807
  className={cn(
1386
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',
1387
- level === 'error'
1388
- ? 'border-danger/40 bg-danger-bg text-danger'
1389
- : 'border-warning/40 bg-warning-bg text-warning',
1390
- )}>
1391
- <TriangleAlert className='mt-0.5 size-3.5 shrink-0' />
1392
- <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>
1393
814
  {onDismiss ? (
1394
815
  <button
1395
- type='button'
816
+ type="button"
1396
817
  onClick={onDismiss}
1397
- aria-label='Dismiss'
1398
- className='shrink-0 opacity-70 transition-opacity hover:opacity-100'>
1399
- <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" />
1400
822
  </button>
1401
823
  ) : null}
1402
824
  </div>