@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.
- package/README.md +81 -3
- package/build/SessionPanel-Dy9lQrOV.d.mts +319 -0
- package/build/SessionPanel-NQ8ksCfj.mjs +8474 -0
- package/build/SessionPanel-NQ8ksCfj.mjs.map +1 -0
- package/build/format-DqR56Y8l.mjs +162 -0
- package/build/format-DqR56Y8l.mjs.map +1 -0
- package/build/format-ljc3lKpA.d.mts +59 -0
- package/build/format.d.mts +66 -0
- package/build/format.mjs +119 -0
- package/build/format.mjs.map +1 -0
- package/build/index.d.mts +671 -88
- package/build/index.mjs +387 -5160
- package/build/index.mjs.map +1 -1
- package/build/workspace.d.mts +226 -0
- package/build/workspace.mjs +861 -0
- package/build/workspace.mjs.map +1 -0
- package/package.json +22 -4
- package/src/components/agent/CodeEditor.tsx +300 -0
- package/src/components/agent/Composer.tsx +522 -87
- package/src/components/agent/ContextDialog.tsx +99 -0
- package/src/components/agent/Conversation.tsx +11 -3
- package/src/components/agent/EditorTabs.tsx +165 -0
- package/src/components/agent/FileCard.tsx +26 -0
- package/src/components/agent/FileTree.tsx +287 -0
- package/src/components/agent/FileViewer.tsx +148 -0
- package/src/components/agent/HostFilesDialog.tsx +218 -0
- package/src/components/agent/Loader.tsx +82 -14
- package/src/components/agent/McpDialog.tsx +363 -0
- package/src/components/agent/Message.tsx +51 -17
- package/src/components/agent/ModelSelect.tsx +34 -6
- package/src/components/agent/PermissionModeSelect.tsx +133 -22
- package/src/components/agent/PermissionPrompt.tsx +164 -6
- package/src/components/agent/PromptTokenText.tsx +39 -0
- package/src/components/agent/QuestionPrompt.tsx +122 -0
- package/src/components/agent/Reasoning.tsx +20 -5
- package/src/components/agent/Response.tsx +128 -0
- package/src/components/agent/SessionBrowser.tsx +428 -0
- package/src/components/agent/SessionEmptyState.tsx +65 -0
- package/src/components/agent/SessionInfoDialog.tsx +163 -0
- package/src/components/agent/SessionPanel.tsx +783 -91
- package/src/components/agent/SessionWorkspace.tsx +317 -0
- package/src/components/agent/SkillsDialog.tsx +195 -0
- package/src/components/agent/StatusBar.tsx +85 -18
- package/src/components/agent/ToolCallCard.tsx +252 -30
- package/src/components/agent/Transcript.tsx +513 -30
- package/src/components/agent/UsageDialog.tsx +168 -0
- package/src/components/agent/line-prompt.tsx +249 -0
- package/src/components/agent/pulse.tsx +60 -0
- package/src/components/agent/transcript-variant.tsx +123 -0
- package/src/components/prompt-area/prompt-area-engine.ts +53 -0
- package/src/components/prompt-area/types.ts +15 -0
- package/src/components/prompt-area/use-prompt-area.ts +20 -0
- package/src/components/ui/CodeBlock.tsx +40 -2
- package/src/components/ui/CopyButton.tsx +28 -3
- package/src/components/ui/Dialog.tsx +92 -0
- package/src/components/ui/Menu.tsx +55 -0
- package/src/components/ui/Splitter.tsx +133 -0
- package/src/components/ui/Tooltip.tsx +22 -5
- package/src/format.ts +11 -0
- package/src/index.ts +67 -2
- package/src/lib/clipboard.ts +56 -0
- package/src/lib/format.ts +114 -0
- package/src/lib/status.ts +124 -0
- package/src/lib/tool-icon.ts +96 -0
- package/src/workspace.ts +28 -0
|
@@ -1,48 +1,412 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
4
|
-
import {
|
|
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 {
|
|
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 {
|
|
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
|
-
/**
|
|
18
|
-
|
|
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({
|
|
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
|
|
35
|
-
|
|
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
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
{state.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
}
|