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