@workerdeck/ui 0.9.0 → 0.12.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 (65) hide show
  1. package/README.md +81 -3
  2. package/build/SessionPanel-Dy9lQrOV.d.mts +319 -0
  3. package/build/SessionPanel-NQ8ksCfj.mjs +8474 -0
  4. package/build/SessionPanel-NQ8ksCfj.mjs.map +1 -0
  5. package/build/format-DqR56Y8l.mjs +162 -0
  6. package/build/format-DqR56Y8l.mjs.map +1 -0
  7. package/build/format-ljc3lKpA.d.mts +59 -0
  8. package/build/format.d.mts +66 -0
  9. package/build/format.mjs +119 -0
  10. package/build/format.mjs.map +1 -0
  11. package/build/index.d.mts +671 -88
  12. package/build/index.mjs +387 -5160
  13. package/build/index.mjs.map +1 -1
  14. package/build/workspace.d.mts +226 -0
  15. package/build/workspace.mjs +861 -0
  16. package/build/workspace.mjs.map +1 -0
  17. package/package.json +22 -4
  18. package/src/components/agent/CodeEditor.tsx +300 -0
  19. package/src/components/agent/Composer.tsx +522 -87
  20. package/src/components/agent/ContextDialog.tsx +99 -0
  21. package/src/components/agent/Conversation.tsx +11 -3
  22. package/src/components/agent/EditorTabs.tsx +165 -0
  23. package/src/components/agent/FileCard.tsx +26 -0
  24. package/src/components/agent/FileTree.tsx +287 -0
  25. package/src/components/agent/FileViewer.tsx +148 -0
  26. package/src/components/agent/HostFilesDialog.tsx +218 -0
  27. package/src/components/agent/Loader.tsx +82 -14
  28. package/src/components/agent/McpDialog.tsx +363 -0
  29. package/src/components/agent/Message.tsx +51 -17
  30. package/src/components/agent/ModelSelect.tsx +34 -6
  31. package/src/components/agent/PermissionModeSelect.tsx +133 -22
  32. package/src/components/agent/PermissionPrompt.tsx +164 -6
  33. package/src/components/agent/PromptTokenText.tsx +39 -0
  34. package/src/components/agent/QuestionPrompt.tsx +122 -0
  35. package/src/components/agent/Reasoning.tsx +20 -5
  36. package/src/components/agent/Response.tsx +128 -0
  37. package/src/components/agent/SessionBrowser.tsx +428 -0
  38. package/src/components/agent/SessionEmptyState.tsx +65 -0
  39. package/src/components/agent/SessionInfoDialog.tsx +163 -0
  40. package/src/components/agent/SessionPanel.tsx +783 -91
  41. package/src/components/agent/SessionWorkspace.tsx +317 -0
  42. package/src/components/agent/SkillsDialog.tsx +195 -0
  43. package/src/components/agent/StatusBar.tsx +85 -18
  44. package/src/components/agent/ToolCallCard.tsx +252 -30
  45. package/src/components/agent/Transcript.tsx +513 -30
  46. package/src/components/agent/UsageDialog.tsx +168 -0
  47. package/src/components/agent/line-prompt.tsx +249 -0
  48. package/src/components/agent/pulse.tsx +60 -0
  49. package/src/components/agent/transcript-variant.tsx +123 -0
  50. package/src/components/prompt-area/prompt-area-engine.ts +53 -0
  51. package/src/components/prompt-area/types.ts +15 -0
  52. package/src/components/prompt-area/use-prompt-area.ts +20 -0
  53. package/src/components/ui/CodeBlock.tsx +40 -2
  54. package/src/components/ui/CopyButton.tsx +28 -3
  55. package/src/components/ui/Dialog.tsx +92 -0
  56. package/src/components/ui/Menu.tsx +55 -0
  57. package/src/components/ui/Splitter.tsx +133 -0
  58. package/src/components/ui/Tooltip.tsx +22 -5
  59. package/src/format.ts +11 -0
  60. package/src/index.ts +67 -2
  61. package/src/lib/clipboard.ts +56 -0
  62. package/src/lib/format.ts +114 -0
  63. package/src/lib/status.ts +124 -0
  64. package/src/lib/tool-icon.ts +96 -0
  65. package/src/workspace.ts +28 -0
@@ -1,48 +1,412 @@
1
- import { useEffect, useMemo, useState, type ReactNode } from 'react'
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type MouseEvent as ReactMouseEvent,
8
+ type ReactNode,
9
+ } from 'react'
2
10
  import type { WorkerDeckClient } from '@workerdeck/client'
3
- import { PROVIDER_PERMISSION_MODES } from '@workerdeck/protocol'
4
- import { useClaudeSession, useToolCallHost } from '@workerdeck/react'
11
+ import { PROTOCOL_VERSION, type ModelOption, type PermissionMode } from '@workerdeck/protocol'
12
+ import {
13
+ rateLimitWindows,
14
+ useAttachments,
15
+ useClaudeSession,
16
+ useHostFileSearch,
17
+ useToolCallHost,
18
+ type ConnectionState,
19
+ type ProducedFileRef,
20
+ type TranscriptState,
21
+ } from '@workerdeck/react'
22
+ import {
23
+ ChartPie,
24
+ FolderTree,
25
+ Gauge,
26
+ Info,
27
+ MoreHorizontal,
28
+ Plug,
29
+ Sparkles,
30
+ TriangleAlert,
31
+ X,
32
+ } from 'lucide-react'
5
33
  import { cn } from '../../lib/utils.ts'
6
- import { Composer } from './Composer.tsx'
34
+ import { Button } from '../ui/Button.tsx'
35
+ import { Menu, MenuContent, MenuItem, MenuTrigger } from '../ui/Menu.tsx'
36
+ import { Composer, skillPrompt, type ComposerHandle } from './Composer.tsx'
37
+ import { ContextDialog } from './ContextDialog.tsx'
38
+ import { HostFilesDialog } from './HostFilesDialog.tsx'
39
+ import { McpDialog } from './McpDialog.tsx'
40
+ import { SkillsDialog } from './SkillsDialog.tsx'
7
41
  import { ModelSelect } from './ModelSelect.tsx'
8
- import { PermissionModeSelect } from './PermissionModeSelect.tsx'
42
+ import {
43
+ PermissionModeSelect,
44
+ permissionModeChoices,
45
+ type PermissionModeChoice,
46
+ } from './PermissionModeSelect.tsx'
9
47
  import { PermissionPrompt } from './PermissionPrompt.tsx'
10
48
  import { QuestionPrompt, parseUserQuestions } from './QuestionPrompt.tsx'
49
+ import { SessionInfoDialog } from './SessionInfoDialog.tsx'
11
50
  import { StatusBar } from './StatusBar.tsx'
12
51
  import { Transcript } from './Transcript.tsx'
52
+ import {
53
+ TranscriptDensityProvider,
54
+ TranscriptVariantProvider,
55
+ type TranscriptDensity,
56
+ type TranscriptVariant,
57
+ } from './transcript-variant.tsx'
58
+ import { UsageDialog } from './UsageDialog.tsx'
13
59
 
14
60
  export interface SessionPanelProps {
15
61
  client: WorkerDeckClient
16
62
  sessionId: string | undefined
17
- /** Optional slot rendered at the top, above the status bar. */
18
- header?: ReactNode
63
+ /**
64
+ * Optional slot rendered at the top, above the status bar.
65
+ *
66
+ * Pass a **function** to take the session-actions (`⋯`) menu into your own
67
+ * chrome: it is called with the menu element, and wherever you put it is
68
+ * where it lives — the status bar then renders without it, so it never
69
+ * appears twice. Pass a plain node (or nothing) and the menu stays in the
70
+ * status bar's trailing slot.
71
+ *
72
+ * The seam exists because the menu can only be *built* here — it needs the
73
+ * capability record, the host-file verdict and the panel's own dialog state —
74
+ * but an embedder with a real header usually wants it up there with the rest
75
+ * of the session's controls, not stranded on the status line.
76
+ */
77
+ header?: ReactNode | ((slots: { actions: ReactNode }) => ReactNode)
78
+ /**
79
+ * Where the info/context/usage/MCP/skills/files surfaces live. `'internal'`
80
+ * (default) renders them as dialogs inside the panel. `'external'` renders
81
+ * NO dialogs and no `⋯` menu: every affordance that would open one calls
82
+ * {@link onOpenPanel} instead, so an embedder can host those surfaces in its
83
+ * own chrome (a VS Code sidebar, a drawer) and keep the panel purely a
84
+ * conversation surface.
85
+ */
86
+ panelSurface?: 'internal' | 'external'
87
+ /**
88
+ * Where the status bar lives. `'internal'` (default) draws it across the top
89
+ * of the panel. `'external'` draws none — the readings still leave through
90
+ * {@link onVitals}, so an embedder with a status line of its own (VS Code's
91
+ * window status bar) renders them there instead of stacking a second bar
92
+ * inside a panel that already sits in one.
93
+ *
94
+ * Deliberately independent of {@link panelSurface}: hosting the dialogs and
95
+ * hosting the bar are separate decisions. One coupling to know about — the
96
+ * `⋯` menu lives in the bar's trailing slot, so `statusSurface: 'external'`
97
+ * with `panelSurface: 'internal'` must pass a **function** {@link header} to
98
+ * take the menu, or it has nowhere left to go.
99
+ */
100
+ statusSurface?: 'internal' | 'external'
101
+ /** Where `panelSurface: 'external'` routes opens. Absent = the affordances
102
+ * (status-bar clicks, `/mcp`) become inert rather than half-working. */
103
+ onOpenPanel?: (panel: SessionSurfacePanel) => void
104
+ /** Live session vitals, fired whenever they change — for embedders mirroring
105
+ * status/context/usage into chrome outside the panel (identity-stable via an
106
+ * internal ref, so an inline closure is fine). */
107
+ onVitals?: (vitals: SessionVitals) => void
108
+ /**
109
+ * How the transcript draws a turn — `'cards'` (default) or `'lines'`, the
110
+ * space-efficient terminal treatment: no boxes, no bubbles, one full-width
111
+ * hover-highlit row per event behind a gutter glyph. An embedder in a dock
112
+ * (the VS Code panel) wants `'lines'`; a full-width dashboard usually doesn't.
113
+ */
114
+ transcriptVariant?: TranscriptVariant
115
+ /**
116
+ * How much air the transcript gives each row — `'comfortable'` (default: a
117
+ * blank line between messages, as the Claude Code CLI leaves) or `'compact'`
118
+ * (rows tight against one another). Independent of `transcriptVariant`: the
119
+ * variant follows from the surface, density is the reader's preference, and a
120
+ * dock is allowed to be roomy.
121
+ */
122
+ transcriptDensity?: TranscriptDensity
123
+ /**
124
+ * Where the session's own controls — model and permission mode — live.
125
+ * `'internal'` (default) draws them in the composer's toolbar row.
126
+ * `'external'` draws neither, and the composer collapses to a single line
127
+ * with its attach/send buttons beside the field: the embedder renders the
128
+ * pickers in its own chrome (VS Code's status bar, where a click opens a
129
+ * QuickPick) and drives them through {@link onControls}.
130
+ *
131
+ * The options themselves ride {@link SessionVitals} — an embedder must not
132
+ * attach a second time to learn what the models are.
133
+ */
134
+ controlsSurface?: 'internal' | 'external'
135
+ /**
136
+ * Handed the session's setters once the panel is live, and `undefined` on
137
+ * unmount. The counterpart to `controlsSurface: 'external'`: vitals carry the
138
+ * readings out, this carries the commands back in. Stable identity — safe to
139
+ * stash in a ref.
140
+ */
141
+ onControls?: (controls: SessionControls | undefined) => void
142
+ /**
143
+ * Click anywhere the panel isn't already doing something and the caret lands
144
+ * in the composer — the terminal/chat convention, and what a docked panel
145
+ * wants: the field is why the panel is focussed at all.
146
+ *
147
+ * Only dead space. A click that hits a control (a tool row expanding, a link,
148
+ * a button) or that ends a text selection is that action, not a request for
149
+ * the input. Off by default: a full-page surface has plenty of dead space that
150
+ * means nothing in particular.
151
+ */
152
+ focusComposerOnClick?: boolean
153
+ /**
154
+ * What this session looked like when it was last looked at: how many
155
+ * transcript items had been seen, and when. Present and behind the current
156
+ * transcript → **catch-up**: a recap row at the boundary, everything above it
157
+ * dimmed, and a bar offering to jump there or to dismiss.
158
+ *
159
+ * The embedder owns the watermark because only it knows what "looked at"
160
+ * means in its own chrome — a hidden dock is not being read. The panel reports
161
+ * the number to remember through `SessionVitals.itemCount`.
162
+ */
163
+ unseen?: { itemCount: number; since?: number }
19
164
  className?: string
20
165
  }
21
166
 
167
+ /** What an embedder needs to *change* a session it doesn't own the attach for. */
168
+ export type SessionControls = {
169
+ setModel: (model?: string) => void
170
+ setPermissionMode: (mode: PermissionMode) => void
171
+ interrupt: () => void
172
+ /**
173
+ * Put the caret in the composer.
174
+ *
175
+ * For an embedder whose own chrome is how you arrive at a session — clicking a
176
+ * row in VS Code's sidebar — where revealing the panel and being able to type
177
+ * are the same intention. The panel cannot infer it: from in here, a session
178
+ * appearing looks identical whether someone asked for it or it was restored.
179
+ */
180
+ focusComposer: () => void
181
+ }
182
+
183
+ /** Everything a click can mean other than "put the caret in the composer".
184
+ * Deliberately broad: mistaking a real target for dead space steals focus from
185
+ * whatever the user just opened. */
186
+ const INTERACTIVE = [
187
+ 'button',
188
+ 'a',
189
+ 'input',
190
+ 'textarea',
191
+ 'select',
192
+ 'summary',
193
+ 'img',
194
+ '[contenteditable="true"]',
195
+ '[role="button"]',
196
+ '[role="menuitem"]',
197
+ '[role="checkbox"]',
198
+ '[role="radio"]',
199
+ '[role="tab"]',
200
+ ].join(',')
201
+
202
+ /** The panels the session surface can raise. One at a time, by identity: a bag
203
+ * of booleans would let two open at once. */
204
+ export type SessionSurfacePanel = 'info' | 'context' | 'usage' | 'mcp' | 'files' | 'skills'
205
+ type Panel = SessionSurfacePanel
206
+
207
+ /** What {@link SessionPanelProps.onVitals} reports: the live readings a host
208
+ * chrome outside the panel would otherwise have to attach a second time for —
209
+ * which the tool bridge forbids (it asks the first attached client). */
210
+ export type SessionVitals = {
211
+ status: TranscriptState['status']
212
+ /**
213
+ * How the client is reaching the gateway. Load-bearing for a host rendering
214
+ * these outside the panel: `status` is the last thing the session *said*, and
215
+ * over a dropped socket that is a stale reading. The panel's own bar gives
216
+ * the connection the status slot when it isn't `'live'` for exactly this
217
+ * reason — an embedder showing `status` alone would present stale as current.
218
+ */
219
+ connection: ConnectionState
220
+ engine: TranscriptState['engine']
221
+ capabilities: TranscriptState['capabilities']
222
+ model: string | undefined
223
+ /** The models this session can switch to — the panel's own list, so an
224
+ * external picker offers exactly what the internal one would. */
225
+ models: ModelOption[]
226
+ permissionMode: TranscriptState['permissionMode']
227
+ /** The modes it can switch into, already filtered by the capability record
228
+ * and the session's bypass grant (see `permissionModeChoices`). */
229
+ permissionModes: PermissionModeChoice[]
230
+ cwd: TranscriptState['cwd']
231
+ contextUsage: TranscriptState['contextUsage']
232
+ rateLimits: TranscriptState['rateLimits']
233
+ /** How many transcript rows exist right now — the number an embedder stores
234
+ * as its "seen" watermark while the panel is actually on screen, and compares
235
+ * against later to know what is new. */
236
+ itemCount: number
237
+ }
238
+
22
239
  /**
23
240
  * The all-in-one embeddable session surface: status bar, streaming transcript,
24
241
  * permission prompts, composer. Attaches via useClaudeSession; remount (key) to switch
25
242
  * sessions.
243
+ *
244
+ * Every affordance is gated on the session's **capability record** rather than on
245
+ * the engine name — an absent capability hides the control instead of offering
246
+ * one that can only fail.
26
247
  */
27
- export function SessionPanel({ client, sessionId, header, className }: SessionPanelProps) {
248
+ export function SessionPanel({
249
+ client,
250
+ sessionId,
251
+ header,
252
+ panelSurface = 'internal',
253
+ statusSurface = 'internal',
254
+ onOpenPanel,
255
+ onVitals,
256
+ transcriptVariant = 'cards',
257
+ transcriptDensity = 'comfortable',
258
+ controlsSurface = 'internal',
259
+ onControls,
260
+ focusComposerOnClick = false,
261
+ unseen,
262
+ className,
263
+ }: SessionPanelProps) {
264
+ const external = panelSurface === 'external'
265
+ const statusExternal = statusSurface === 'external'
266
+ const controlsExternal = controlsSurface === 'external'
28
267
  // Rejected commands (the CLI refusing a permission-mode switch, say) render INSIDE
29
268
  // the panel rather than through `toast`. The panel does not mount a `Toaster`, and
30
269
  // an embedder that doesn't either would drop the only signal that a command failed
31
270
  // — the select would just "not stick". An error channel a host can lose by omission
32
271
  // is not an error channel.
33
272
  const [protocolError, setProtocolError] = useState<string | undefined>(undefined)
34
- const { state, connected, handle, send, approve, deny, interrupt, setModel, setPermissionMode } =
35
- useClaudeSession(client, sessionId, { onProtocolError: setProtocolError })
273
+ const [panel, setPanel] = useState<Panel | undefined>()
274
+ const {
275
+ state,
276
+ connection,
277
+ protocolMismatch,
278
+ models,
279
+ effectiveModel,
280
+ handle,
281
+ send,
282
+ approve,
283
+ deny,
284
+ interrupt,
285
+ setModel,
286
+ setPermissionMode,
287
+ reconnectNow,
288
+ } = useClaudeSession(client, sessionId, { onProtocolError: setProtocolError })
36
289
  // Callers are told to remount on a session switch, but a changed prop must not leave
37
290
  // the previous session's failure on screen.
38
291
  useEffect(() => setProtocolError(undefined), [sessionId])
292
+
293
+ // Catch-up is entered once, from the watermark the embedder handed over, and
294
+ // left when dismissed or when the user sends anything (they are plainly
295
+ // caught up at that point). Snapshotted into state rather than read from the
296
+ // prop each render: the embedder keeps updating the watermark while the panel
297
+ // is on screen, and a boundary that crept forward under the reader would
298
+ // un-dim the very rows they came back to read.
299
+ const [caughtUp, setCaughtUp] = useState(false)
300
+ useEffect(() => {
301
+ setCaughtUp(false)
302
+ }, [sessionId])
303
+ const [catchUpMark] = useState(unseen)
304
+ const catchUp = caughtUp ? undefined : catchUpMark
305
+ const newCount = catchUp ? Math.max(0, state.items.length - catchUp.itemCount) : 0
306
+
307
+ // One router for every panel-opening affordance: internal surface opens the
308
+ // dialog, external hands the intent to the embedder (or drops it, absent a
309
+ // handler — inert beats half-working).
310
+ const openPanel = useCallback(
311
+ (target: SessionSurfacePanel) => {
312
+ if (external) onOpenPanel?.(target)
313
+ else setPanel(target)
314
+ },
315
+ [external, onOpenPanel],
316
+ )
317
+ // A tab that was in the background has been sitting out the reconnect backoff;
318
+ // coming back to it is exactly when waiting the rest of it out is wrong.
319
+ useEffect(() => {
320
+ const onVisible = () => {
321
+ if (document.visibilityState === 'visible') reconnectNow()
322
+ }
323
+ document.addEventListener('visibilitychange', onVisible)
324
+ return () => document.removeEventListener('visibilitychange', onVisible)
325
+ }, [reconnectNow])
39
326
  // Host server-bridged tool calls (provider-engine sessions) in this tab, on the
40
327
  // SAME handle the panel attached with — the bridge asks the first attached
41
328
  // client. Free for Claude sessions: the guest loads lazily on the first call,
42
329
  // which for them never comes.
43
330
  useToolCallHost(handle)
331
+ const capabilities = state.capabilities
332
+
333
+ // Vitals out to the embedder, keyed on the readings themselves so an inline
334
+ // closure prop doesn't retrigger it every render.
335
+ const onVitalsRef = useRef(onVitals)
336
+ onVitalsRef.current = onVitals
337
+ const vitalsModel = effectiveModel ?? state.model
338
+ // The choices, not just the current values: an external picker has no second
339
+ // attach to learn them from.
340
+ const permissionModes = useMemo(
341
+ () => permissionModeChoices(capabilities.permissionModes, state.session?.canBypassPermissions),
342
+ [capabilities.permissionModes, state.session?.canBypassPermissions],
343
+ )
344
+ useEffect(() => {
345
+ onVitalsRef.current?.({
346
+ status: state.status,
347
+ connection,
348
+ engine: state.engine,
349
+ capabilities: state.capabilities,
350
+ model: vitalsModel,
351
+ models,
352
+ permissionMode: state.permissionMode,
353
+ permissionModes,
354
+ cwd: state.cwd,
355
+ contextUsage: state.contextUsage,
356
+ rateLimits: state.rateLimits,
357
+ itemCount: state.items.length,
358
+ })
359
+ }, [
360
+ state.status,
361
+ connection,
362
+ state.engine,
363
+ state.capabilities,
364
+ vitalsModel,
365
+ models,
366
+ state.permissionMode,
367
+ permissionModes,
368
+ state.cwd,
369
+ state.contextUsage,
370
+ state.rateLimits,
371
+ state.items.length,
372
+ ])
373
+
374
+ // The commands back in. One stable object reading through refs, so an
375
+ // embedder can stash it and a re-render never invalidates what it holds.
376
+ const onControlsRef = useRef(onControls)
377
+ onControlsRef.current = onControls
378
+ const setters = useRef({ setModel, setPermissionMode, interrupt })
379
+ setters.current = { setModel, setPermissionMode, interrupt }
380
+ const controls = useRef<SessionControls>({
381
+ setModel: (model) => setters.current.setModel(model),
382
+ setPermissionMode: (mode) => setters.current.setPermissionMode(mode),
383
+ interrupt: () => setters.current.interrupt(),
384
+ focusComposer: () => composerRef.current?.focus(),
385
+ })
386
+ useEffect(() => {
387
+ const handler = onControlsRef.current
388
+ handler?.(controls.current)
389
+ return () => handler?.(undefined)
390
+ }, [sessionId])
44
391
  const busy = state.status === 'running' || state.status === 'awaiting_approval'
45
392
  const ended = state.status === 'failed' || state.status === 'closed'
393
+ const attachments = useAttachments(client, sessionId, {
394
+ capabilities,
395
+ engine: state.engine,
396
+ })
397
+ // Rooted at the session's cwd, which arrives with the snapshot — so `@` is
398
+ // inert for the moment before it does, and stays inert on a gateway that
399
+ // serves no host files.
400
+ const hostFiles = useHostFileSearch(client, state.cwd)
401
+ const windows = useMemo(() => rateLimitWindows(state), [state])
402
+ // Reads a picture the engine left on the host (codex's `image_gen` reports a
403
+ // path, never bytes). Stable and memoized per path: transcript rows re-render
404
+ // on every delta, and a fresh function would re-fetch each time.
405
+ const hostImage = useHostImage(client, sessionId, state.producedFiles)
406
+ const composerRef = useRef<ComposerHandle>(null)
407
+ // The catch-up strip's way of scrolling the (virtualized, usually unmounted)
408
+ // recap row into view — the transcript fills it in. See TranscriptProps.
409
+ const jumpToRecap = useRef<(() => void) | null>(null)
46
410
 
47
411
  // "/model" is handled panel-side (see handleSend) — surface it in the autocomplete
48
412
  // even though the CLI's command list doesn't include it.
@@ -55,96 +419,424 @@ export function SessionPanel({ client, sessionId, header, className }: SessionPa
55
419
  ]
56
420
  }, [state.commands])
57
421
 
58
- // "/model <id>" switches the model directly instead of going to the CLI.
59
- const handleSend = (text: string) => {
60
- const modelCommand = /^\/model\s+(\S+)$/.exec(text)
61
- if (modelCommand) {
62
- setModel(modelCommand[1])
63
- return
422
+ // Two things are answered here rather than sent, because sending them would
423
+ // spend a turn on a model reading the words back.
424
+ const handleSend = (text: string, attachmentIds: string[]) => {
425
+ if (attachmentIds.length === 0) {
426
+ // "/model <id>" switches the model directly instead of going to the CLI.
427
+ const modelCommand = /^\/model\s+(\S+)$/.exec(text)
428
+ if (modelCommand) {
429
+ setModel(modelCommand[1])
430
+ return
431
+ }
432
+ // The CLI's own `/mcp` is an interactive picker, not a prompt. Only where
433
+ // the capability exists: elsewhere it is ordinary message text, like any
434
+ // other slash command on an engine without them.
435
+ if (capabilities.mcpStatus && text.trim() === '/mcp') {
436
+ openPanel('mcp')
437
+ return
438
+ }
64
439
  }
65
- send(text)
440
+ // Typing into a session is the clearest possible statement that you have
441
+ // read it — nothing left to catch up on.
442
+ setCaughtUp(true)
443
+ send(text, attachmentIds)
444
+ }
445
+
446
+ // Everything the panel can open, in one place — and each one is also
447
+ // reachable by clicking the thing on the bar that summarises it. Entries the
448
+ // capability record forswears are absent, not present-and-empty.
449
+ //
450
+ // Built once and placed once: either the embedder's header takes it (see the
451
+ // `header` render-prop) or the status bar does. Never both — two `⋯` menus on
452
+ // one screen is worse than either position.
453
+ const actionsMenu = (
454
+ <Menu>
455
+ <MenuTrigger
456
+ render={
457
+ <Button variant='ghost' size='icon-sm' aria-label='Session actions'>
458
+ <MoreHorizontal className='size-4' />
459
+ </Button>
460
+ }
461
+ />
462
+ <MenuContent>
463
+ {capabilities.contextUsage ? (
464
+ <MenuItem onClick={() => openPanel('context')}>
465
+ <ChartPie className='size-3.5 text-fg-3' /> Context
466
+ </MenuItem>
467
+ ) : null}
468
+ {capabilities.rateLimits ? (
469
+ <MenuItem onClick={() => openPanel('usage')}>
470
+ <Gauge className='size-3.5 text-fg-3' /> Usage
471
+ </MenuItem>
472
+ ) : null}
473
+ <MenuItem onClick={() => openPanel('info')}>
474
+ <Info className='size-3.5 text-fg-3' /> Session info
475
+ </MenuItem>
476
+ {capabilities.mcpStatus ? (
477
+ <MenuItem onClick={() => openPanel('mcp')}>
478
+ <Plug className='size-3.5 text-fg-3' /> MCP servers
479
+ </MenuItem>
480
+ ) : null}
481
+ {/* On the capability alone, like MCP's entry. Codex answers
482
+ `skills/list` only over a live child, so before the first turn there
483
+ is no list yet — but hiding the entry until then made the dialog's
484
+ own explanation of that unreachable, which read as the feature being
485
+ missing. The empty state says it instead. */}
486
+ {capabilities.skillsList ? (
487
+ <MenuItem onClick={() => openPanel('skills')}>
488
+ <Sparkles className='size-3.5 text-fg-3' /> Skills
489
+ </MenuItem>
490
+ ) : null}
491
+ {hostFiles.available ? (
492
+ <MenuItem onClick={() => openPanel('files')}>
493
+ <FolderTree className='size-3.5 text-fg-3' /> Project files
494
+ </MenuItem>
495
+ ) : null}
496
+ </MenuContent>
497
+ </Menu>
498
+ )
499
+
500
+ // A function header claims the menu; anything else leaves it on the status bar.
501
+ // The external surface has no menu to claim — those entries live in the
502
+ // embedder's own chrome, reached through onOpenPanel.
503
+ const menu = external ? null : actionsMenu
504
+ const headerTakesActions = typeof header === 'function'
505
+
506
+ // Dead-space clicks land in the composer. Anything the user actually aimed at
507
+ // — a control, a link, the end of a drag-selection — keeps its own meaning;
508
+ // this only claims what was left over.
509
+ const handleClick = (event: ReactMouseEvent<HTMLDivElement>) => {
510
+ if (!focusComposerOnClick) return
511
+ const target = event.target as HTMLElement | null
512
+ if (target?.closest(INTERACTIVE)) return
513
+ if (window.getSelection()?.isCollapsed === false) return
514
+ composerRef.current?.focus()
66
515
  }
67
516
 
68
517
  return (
69
- <div
70
- data-slot='session-panel'
71
- className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}>
72
- {header}
73
- <StatusBar state={state} connected={connected} />
74
- {protocolError ? (
75
- <div className='px-3 pt-2'>
76
- <div
77
- role='alert'
78
- className='mx-auto flex w-full max-w-3xl items-start gap-2 rounded-md border border-danger/40 bg-danger-bg px-3 py-2 text-body-sm text-danger'>
79
- <span className='min-w-0 flex-1 break-words'>{protocolError}</span>
80
- <button
81
- type='button'
82
- onClick={() => setProtocolError(undefined)}
83
- aria-label='Dismiss error'
84
- className='shrink-0 opacity-70 transition-opacity hover:opacity-100'>
85
-
86
- </button>
518
+ // The variant is a panel-wide fact, not a transcript-only one: the approval
519
+ // and question prompts live outside the scroller but are line items in the
520
+ // same run, and they read `useLines()` like every other row.
521
+ <TranscriptVariantProvider value={transcriptVariant}>
522
+ <TranscriptDensityProvider value={transcriptDensity}>
523
+ <div
524
+ data-slot='session-panel'
525
+ onClick={handleClick}
526
+ className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}>
527
+ {headerTakesActions ? header({ actions: menu }) : header}
528
+ {statusExternal ? null : (
529
+ <StatusBar
530
+ state={state}
531
+ connection={connection}
532
+ onOpenStatus={external && !onOpenPanel ? undefined : () => openPanel('info')}
533
+ onOpenContext={external && !onOpenPanel ? undefined : () => openPanel('context')}
534
+ onOpenUsage={external && !onOpenPanel ? undefined : () => openPanel('usage')}
535
+ actions={headerTakesActions ? undefined : menu}
536
+ />
537
+ )}
538
+ {protocolMismatch !== undefined ? (
539
+ <Notice level='warning'>
540
+ Server speaks protocol v{protocolMismatch}, this build renders v{PROTOCOL_VERSION}. Some
541
+ events may not render.
542
+ </Notice>
543
+ ) : null}
544
+ {protocolError ? (
545
+ <Notice level='error' onDismiss={() => setProtocolError(undefined)}>
546
+ {protocolError}
547
+ </Notice>
548
+ ) : null}
549
+ <Transcript
550
+ state={state}
551
+ fileUrl={sessionId ? (path) => client.sessionFileUrl(sessionId, path) : undefined}
552
+ attachmentUrl={sessionId ? (id) => client.attachmentUrl(sessionId, id) : undefined}
553
+ canBrowseFiles={hostFiles.available}
554
+ hostImage={hostImage}
555
+ variant={transcriptVariant}
556
+ density={transcriptDensity}
557
+ catchUp={
558
+ catchUp && newCount > 0
559
+ ? { from: catchUp.itemCount, since: catchUp.since }
560
+ : undefined
561
+ }
562
+ jumpToRecapRef={jumpToRecap}
563
+ />
564
+ {/* The way back into what you missed. Above the composer because that is
565
+ where the eye already is on returning, and because the transcript
566
+ itself opens pinned to the newest row. */}
567
+ {catchUp && newCount > 0 ? (
568
+ <div className='px-3 pb-1'>
569
+ <div
570
+ data-slot='catch-up'
571
+ className='mx-auto flex w-full max-w-[var(--wd-content-max-w,48rem)] items-center gap-2 text-label text-fg-3'>
572
+ <span aria-hidden className='select-none text-accent'>
573
+
574
+ </span>
575
+ <span className='min-w-0 flex-1 truncate'>
576
+ {newCount} new {newCount === 1 ? 'row' : 'rows'}
577
+ {catchUp.since !== undefined ? ` since you were last here` : ''}
578
+ </span>
579
+ <button
580
+ type='button'
581
+ onClick={() => jumpToRecap.current?.()}
582
+ className='shrink-0 underline-offset-2 hover:text-fg-1 hover:underline'>
583
+ jump
584
+ </button>
585
+ <button
586
+ type='button'
587
+ onClick={() => setCaughtUp(true)}
588
+ className='shrink-0 underline-offset-2 hover:text-fg-1 hover:underline'>
589
+ dismiss
590
+ </button>
591
+ </div>
87
592
  </div>
88
- </div>
89
- ) : null}
90
- <Transcript
91
- state={state}
92
- fileUrl={sessionId ? (path) => client.sessionFileUrl(sessionId, path) : undefined}
93
- attachmentUrl={sessionId ? (id) => client.attachmentUrl(sessionId, id) : undefined}
94
- />
95
- {state.pendingApprovals.length > 0 ? (
96
- <div className='px-3 pb-2'>
97
- <div className='mx-auto flex w-full max-w-3xl flex-col gap-2'>
98
- {state.pendingApprovals.map((request) =>
99
- request.toolName === 'AskUserQuestion' &&
100
- parseUserQuestions(request.input).length > 0 ? (
101
- <QuestionPrompt
102
- key={request.id}
103
- request={request}
104
- onAnswer={approve}
105
- onDismiss={deny}
593
+ ) : null}
594
+ {/* An engine with no approval channel never raises these, but a stale
595
+ pending request from a replayed log would still render — the record is
596
+ the authority on whether an approval UI means anything here. */}
597
+ {capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? (
598
+ <div className='px-3 pb-2'>
599
+ <div className='mx-auto flex w-full max-w-[var(--wd-content-max-w,48rem)] flex-col gap-2'>
600
+ {state.pendingApprovals.map((request) =>
601
+ request.toolName === 'AskUserQuestion' &&
602
+ parseUserQuestions(request.input).length > 0 ? (
603
+ <QuestionPrompt
604
+ key={request.id}
605
+ request={request}
606
+ onAnswer={approve}
607
+ onDismiss={(id) => deny(id, 'Question dismissed by user')}
608
+ />
609
+ ) : (
610
+ <PermissionPrompt
611
+ key={request.id}
612
+ request={request}
613
+ onApprove={approve}
614
+ onDeny={deny}
615
+ />
616
+ ),
617
+ )}
618
+ </div>
619
+ </div>
620
+ ) : null}
621
+ <Composer
622
+ ref={composerRef}
623
+ onSend={handleSend}
624
+ onInterrupt={interrupt}
625
+ busy={busy}
626
+ disabled={ended || !sessionId}
627
+ commands={capabilities.slashCommands ? commands : undefined}
628
+ skills={capabilities.skillsList ? state.skills : undefined}
629
+ attachments={attachments}
630
+ onSearchFiles={
631
+ hostFiles.available
632
+ ? (query, options) => hostFiles.search(query, { ...options, limit: 8 })
633
+ : undefined
634
+ }
635
+ layout={controlsExternal ? 'inline' : 'stacked'}
636
+ toolbar={
637
+ controlsExternal ? undefined : (
638
+ <>
639
+ {/* Codex reports no `capabilities` event, so its models arrive from
640
+ the profile catalog instead — without that fallback its picker
641
+ would be permanently empty and the session unswitchable. */}
642
+ {models.length ? (
643
+ <ModelSelect
644
+ models={models}
645
+ model={effectiveModel}
646
+ onModelChange={setModel}
647
+ disabled={ended}
106
648
  />
107
- ) : (
108
- <PermissionPrompt
109
- key={request.id}
110
- request={request}
111
- onApprove={approve}
112
- onDeny={deny}
649
+ ) : null}
650
+ {state.permissionMode ? (
651
+ <PermissionModeSelect
652
+ mode={state.permissionMode}
653
+ onModeChange={setPermissionMode}
654
+ // Only what this engine implements — the rest would come back as
655
+ // a protocol_error.
656
+ modes={capabilities.permissionModes}
657
+ canBypass={state.session?.canBypassPermissions}
658
+ disabled={ended}
113
659
  />
114
- ),
115
- )}
116
- </div>
117
- </div>
118
- ) : null}
119
- <Composer
120
- onSend={handleSend}
121
- onInterrupt={interrupt}
122
- busy={busy}
123
- disabled={ended || !sessionId}
124
- commands={commands}
125
- toolbar={
660
+ ) : null}
661
+ </>
662
+ )
663
+ }
664
+ />
665
+
666
+ {/* The internal dialog surface. The external one renders none of these —
667
+ the embedder hosts equivalent surfaces and is handed the intents. */}
668
+ {!external ? (
126
669
  <>
127
- {state.models?.length ? (
128
- <ModelSelect
129
- models={state.models}
130
- model={state.model}
131
- onModelChange={setModel}
132
- disabled={ended}
133
- />
134
- ) : null}
135
- {state.permissionMode ? (
136
- <PermissionModeSelect
137
- mode={state.permissionMode}
138
- onModeChange={setPermissionMode}
139
- // A provider session rejects the CLI-only modes with a
140
- // protocol_error — don't offer what can only fail.
141
- modes={state.engine === 'provider' ? PROVIDER_PERMISSION_MODES : undefined}
142
- disabled={ended}
143
- />
144
- ) : null}
670
+ <SessionInfoDialog
671
+ state={state}
672
+ client={client}
673
+ sessionId={sessionId}
674
+ open={panel === 'info'}
675
+ onOpenChange={(next) => setPanel(next ? 'info' : undefined)}
676
+ />
677
+ <ContextDialog
678
+ usage={state.contextUsage}
679
+ open={panel === 'context'}
680
+ onOpenChange={(next) => setPanel(next ? 'context' : undefined)}
681
+ />
682
+ <UsageDialog
683
+ rateLimits={windows}
684
+ subscriptionType={state.subscriptionType}
685
+ engine={state.engine ?? 'claude'}
686
+ totalCostUsd={state.totalCostUsd}
687
+ updatedAt={state.rateLimitsUpdatedAt}
688
+ open={panel === 'usage'}
689
+ onOpenChange={(next) => setPanel(next ? 'usage' : undefined)}
690
+ />
691
+ <McpDialog
692
+ client={client}
693
+ sessionId={sessionId}
694
+ canManageServers={capabilities.mcpServerActions}
695
+ open={panel === 'mcp'}
696
+ onOpenChange={(next) => setPanel(next ? 'mcp' : undefined)}
697
+ />
698
+ <SkillsDialog
699
+ skills={state.skills}
700
+ open={panel === 'skills'}
701
+ onOpenChange={(next) => setPanel(next ? 'skills' : undefined)}
702
+ // Drafts into the composer; the operator sends it. There is no engine
703
+ // call that runs a skill, so there is nothing else this button could do.
704
+ onUse={(skill) => composerRef.current?.insertText(skillPrompt(skill))}
705
+ />
706
+ <HostFilesDialog
707
+ client={client}
708
+ cwd={state.cwd}
709
+ open={panel === 'files'}
710
+ onOpenChange={(next) => setPanel(next ? 'files' : undefined)}
711
+ />
145
712
  </>
146
- }
147
- />
713
+ ) : null}
714
+ </div>
715
+ </TranscriptDensityProvider>
716
+ </TranscriptVariantProvider>
717
+ )
718
+ }
719
+
720
+ /**
721
+ * Turns a host path a tool card is holding into something an `<img>` can show.
722
+ *
723
+ * Two sources, tried in that order and for a reason:
724
+ *
725
+ * 1. **The session's produced files.** If this session's own runner announced
726
+ * writing that path (`file_produced`), the gateway will serve it from
727
+ * `/sessions/:id/produced/:fileId` — no host-file roots to declare, no byte
728
+ * cap to raise. This is the path codex's generated images take, and it is
729
+ * why they now render out of the box.
730
+ * 2. **`/fs/read`.** For a path nothing produced — a picture the model looked
731
+ * at, an image already in the tree — where the operator's declared roots are
732
+ * the right gate and the answer is legitimately "no" outside them.
733
+ *
734
+ * The cache is what makes this usable from a transcript row: rows re-render on
735
+ * every streamed delta, and an uncached resolver would re-fetch the picture each
736
+ * time. A refusal is cached too, so it costs one request rather than one per
737
+ * render.
738
+ *
739
+ * Keyed by `fileId`-or-path so that a path which becomes produced *after* a
740
+ * failed `/fs/read` is retried under a different key rather than staying cached
741
+ * as a miss.
742
+ */
743
+ function useHostImage(
744
+ client: WorkerDeckClient,
745
+ sessionId: string | undefined,
746
+ producedFiles: Record<string, ProducedFileRef> | undefined,
747
+ ): (path: string) => Promise<string | undefined> {
748
+ const cache = useRef(new Map<string, Promise<string | undefined>>())
749
+ // Object URLs pin their blob until revoked, so a long session that generated
750
+ // a dozen images would hold a dozen megabytes past unmount.
751
+ const objectUrls = useRef<string[]>([])
752
+ useEffect(
753
+ () => () => {
754
+ for (const url of objectUrls.current) URL.revokeObjectURL(url)
755
+ objectUrls.current = []
756
+ },
757
+ [],
758
+ )
759
+ return useCallback(
760
+ (path: string) => {
761
+ const produced = producedFiles?.[path]
762
+ const key = produced ? `produced:${produced.fileId}` : `fs:${path}`
763
+ const hit = cache.current.get(key)
764
+ if (hit) return hit
765
+ const pending =
766
+ produced && sessionId
767
+ ? // Fetched rather than pointed at: the panel may be talking to a
768
+ // header-authenticated gateway, where a bare URL in an `<img src>`
769
+ // carries no credential.
770
+ client
771
+ .readProducedFile(sessionId, produced.fileId)
772
+ .then((blob) => {
773
+ if (blob.size === 0) return undefined
774
+ const url = URL.createObjectURL(blob)
775
+ objectUrls.current.push(url)
776
+ return url
777
+ })
778
+ .catch(() => undefined)
779
+ : client
780
+ .readHostFile(path)
781
+ .then((file) => {
782
+ if (file.encoding !== 'base64') return undefined
783
+ // The route reports bytes and an encoding but not a media type;
784
+ // the extension is what a browser needs to decode it.
785
+ const extension = path.slice(path.lastIndexOf('.') + 1).toLowerCase()
786
+ const mediaType = IMAGE_MEDIA_TYPES[extension]
787
+ return mediaType ? `data:${mediaType};base64,${file.content}` : undefined
788
+ })
789
+ .catch(() => undefined)
790
+ cache.current.set(key, pending)
791
+ return pending
792
+ },
793
+ [client, sessionId, producedFiles],
794
+ )
795
+ }
796
+
797
+ /** Extensions worth rendering inline, and what to call them. Anything else is
798
+ * left to the card's path text — guessing a media type is how an HTML file ends
799
+ * up in an `<img>`. */
800
+ const IMAGE_MEDIA_TYPES: Record<string, string> = {
801
+ png: 'image/png',
802
+ jpg: 'image/jpeg',
803
+ jpeg: 'image/jpeg',
804
+ gif: 'image/gif',
805
+ webp: 'image/webp',
806
+ }
807
+
808
+ /** A dismissible advisory strip above the transcript. */
809
+ function Notice({
810
+ level,
811
+ onDismiss,
812
+ children,
813
+ }: {
814
+ level: 'warning' | 'error'
815
+ onDismiss?: () => void
816
+ children: ReactNode
817
+ }) {
818
+ return (
819
+ <div className='px-3 pt-2'>
820
+ <div
821
+ role='alert'
822
+ className={cn(
823
+ 'mx-auto flex w-full max-w-[var(--wd-content-max-w,48rem)] items-start gap-2 rounded-md border px-3 py-2 text-body-sm',
824
+ level === 'error'
825
+ ? 'border-danger/40 bg-danger-bg text-danger'
826
+ : 'border-warning/40 bg-warning-bg text-warning',
827
+ )}>
828
+ <TriangleAlert className='mt-0.5 size-3.5 shrink-0' />
829
+ <span className='min-w-0 flex-1 break-words'>{children}</span>
830
+ {onDismiss ? (
831
+ <button
832
+ type='button'
833
+ onClick={onDismiss}
834
+ aria-label='Dismiss'
835
+ className='shrink-0 opacity-70 transition-opacity hover:opacity-100'>
836
+ <X className='size-3.5' />
837
+ </button>
838
+ ) : null}
839
+ </div>
148
840
  </div>
149
841
  )
150
842
  }