@workerdeck/ui 0.7.0 → 0.10.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 +44 -3
- package/build/index.d.mts +767 -21
- package/build/index.mjs +3268 -348
- package/build/index.mjs.map +1 -1
- package/package.json +5 -4
- package/src/components/agent/CodeEditor.tsx +300 -0
- package/src/components/agent/Composer.tsx +379 -56
- package/src/components/agent/ContextDialog.tsx +99 -0
- package/src/components/agent/EditorTabs.tsx +165 -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/McpDialog.tsx +363 -0
- package/src/components/agent/ModelSelect.tsx +34 -6
- package/src/components/agent/PermissionModeSelect.tsx +99 -22
- package/src/components/agent/PermissionPrompt.tsx +72 -6
- package/src/components/agent/PromptTokenText.tsx +39 -0
- package/src/components/agent/SessionEmptyState.tsx +65 -0
- package/src/components/agent/SessionInfoDialog.tsx +163 -0
- package/src/components/agent/SessionPanel.tsx +380 -40
- package/src/components/agent/SessionWorkspace.tsx +282 -0
- package/src/components/agent/SkillsDialog.tsx +195 -0
- package/src/components/agent/StatusBar.tsx +85 -18
- package/src/components/agent/ToolCallCard.tsx +80 -4
- package/src/components/agent/Transcript.tsx +109 -12
- package/src/components/agent/UsageDialog.tsx +168 -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/CopyButton.tsx +8 -1
- 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/index.ts +53 -1
- package/src/lib/clipboard.ts +56 -0
- package/src/lib/format.ts +48 -0
- package/src/lib/tool-icon.ts +74 -0
|
@@ -1,28 +1,75 @@
|
|
|
1
|
-
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
2
2
|
import type { WorkerDeckClient } from '@workerdeck/client'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { PROTOCOL_VERSION } from '@workerdeck/protocol'
|
|
4
|
+
import {
|
|
5
|
+
rateLimitWindows,
|
|
6
|
+
useAttachments,
|
|
7
|
+
useClaudeSession,
|
|
8
|
+
useHostFileSearch,
|
|
9
|
+
useToolCallHost,
|
|
10
|
+
type ProducedFileRef,
|
|
11
|
+
} from '@workerdeck/react'
|
|
12
|
+
import {
|
|
13
|
+
ChartPie,
|
|
14
|
+
FolderTree,
|
|
15
|
+
Gauge,
|
|
16
|
+
Info,
|
|
17
|
+
MoreHorizontal,
|
|
18
|
+
Plug,
|
|
19
|
+
Sparkles,
|
|
20
|
+
TriangleAlert,
|
|
21
|
+
X,
|
|
22
|
+
} from 'lucide-react'
|
|
5
23
|
import { cn } from '../../lib/utils.ts'
|
|
6
|
-
import {
|
|
24
|
+
import { Button } from '../ui/Button.tsx'
|
|
25
|
+
import { Menu, MenuContent, MenuItem, MenuTrigger } from '../ui/Menu.tsx'
|
|
26
|
+
import { Composer, skillPrompt, type ComposerHandle } from './Composer.tsx'
|
|
27
|
+
import { ContextDialog } from './ContextDialog.tsx'
|
|
28
|
+
import { HostFilesDialog } from './HostFilesDialog.tsx'
|
|
29
|
+
import { McpDialog } from './McpDialog.tsx'
|
|
30
|
+
import { SkillsDialog } from './SkillsDialog.tsx'
|
|
7
31
|
import { ModelSelect } from './ModelSelect.tsx'
|
|
8
32
|
import { PermissionModeSelect } from './PermissionModeSelect.tsx'
|
|
9
33
|
import { PermissionPrompt } from './PermissionPrompt.tsx'
|
|
10
34
|
import { QuestionPrompt, parseUserQuestions } from './QuestionPrompt.tsx'
|
|
35
|
+
import { SessionInfoDialog } from './SessionInfoDialog.tsx'
|
|
11
36
|
import { StatusBar } from './StatusBar.tsx'
|
|
12
37
|
import { Transcript } from './Transcript.tsx'
|
|
38
|
+
import { UsageDialog } from './UsageDialog.tsx'
|
|
13
39
|
|
|
14
40
|
export interface SessionPanelProps {
|
|
15
41
|
client: WorkerDeckClient
|
|
16
42
|
sessionId: string | undefined
|
|
17
|
-
/**
|
|
18
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Optional slot rendered at the top, above the status bar.
|
|
45
|
+
*
|
|
46
|
+
* Pass a **function** to take the session-actions (`⋯`) menu into your own
|
|
47
|
+
* chrome: it is called with the menu element, and wherever you put it is
|
|
48
|
+
* where it lives — the status bar then renders without it, so it never
|
|
49
|
+
* appears twice. Pass a plain node (or nothing) and the menu stays in the
|
|
50
|
+
* status bar's trailing slot.
|
|
51
|
+
*
|
|
52
|
+
* The seam exists because the menu can only be *built* here — it needs the
|
|
53
|
+
* capability record, the host-file verdict and the panel's own dialog state —
|
|
54
|
+
* but an embedder with a real header usually wants it up there with the rest
|
|
55
|
+
* of the session's controls, not stranded on the status line.
|
|
56
|
+
*/
|
|
57
|
+
header?: ReactNode | ((slots: { actions: ReactNode }) => ReactNode)
|
|
19
58
|
className?: string
|
|
20
59
|
}
|
|
21
60
|
|
|
61
|
+
/** The panels the session surface can raise. One at a time, by identity: a bag
|
|
62
|
+
* of booleans would let two open at once. */
|
|
63
|
+
type Panel = 'info' | 'context' | 'usage' | 'mcp' | 'files' | 'skills'
|
|
64
|
+
|
|
22
65
|
/**
|
|
23
66
|
* The all-in-one embeddable session surface: status bar, streaming transcript,
|
|
24
67
|
* permission prompts, composer. Attaches via useClaudeSession; remount (key) to switch
|
|
25
68
|
* sessions.
|
|
69
|
+
*
|
|
70
|
+
* Every affordance is gated on the session's **capability record** rather than on
|
|
71
|
+
* the engine name — an absent capability hides the control instead of offering
|
|
72
|
+
* one that can only fail.
|
|
26
73
|
*/
|
|
27
74
|
export function SessionPanel({ client, sessionId, header, className }: SessionPanelProps) {
|
|
28
75
|
// Rejected commands (the CLI refusing a permission-mode switch, say) render INSIDE
|
|
@@ -31,18 +78,56 @@ export function SessionPanel({ client, sessionId, header, className }: SessionPa
|
|
|
31
78
|
// — the select would just "not stick". An error channel a host can lose by omission
|
|
32
79
|
// is not an error channel.
|
|
33
80
|
const [protocolError, setProtocolError] = useState<string | undefined>(undefined)
|
|
34
|
-
const
|
|
35
|
-
|
|
81
|
+
const [panel, setPanel] = useState<Panel | undefined>()
|
|
82
|
+
const {
|
|
83
|
+
state,
|
|
84
|
+
connection,
|
|
85
|
+
protocolMismatch,
|
|
86
|
+
models,
|
|
87
|
+
effectiveModel,
|
|
88
|
+
handle,
|
|
89
|
+
send,
|
|
90
|
+
approve,
|
|
91
|
+
deny,
|
|
92
|
+
interrupt,
|
|
93
|
+
setModel,
|
|
94
|
+
setPermissionMode,
|
|
95
|
+
reconnectNow,
|
|
96
|
+
} = useClaudeSession(client, sessionId, { onProtocolError: setProtocolError })
|
|
36
97
|
// Callers are told to remount on a session switch, but a changed prop must not leave
|
|
37
98
|
// the previous session's failure on screen.
|
|
38
99
|
useEffect(() => setProtocolError(undefined), [sessionId])
|
|
100
|
+
// A tab that was in the background has been sitting out the reconnect backoff;
|
|
101
|
+
// coming back to it is exactly when waiting the rest of it out is wrong.
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const onVisible = () => {
|
|
104
|
+
if (document.visibilityState === 'visible') reconnectNow()
|
|
105
|
+
}
|
|
106
|
+
document.addEventListener('visibilitychange', onVisible)
|
|
107
|
+
return () => document.removeEventListener('visibilitychange', onVisible)
|
|
108
|
+
}, [reconnectNow])
|
|
39
109
|
// Host server-bridged tool calls (provider-engine sessions) in this tab, on the
|
|
40
110
|
// SAME handle the panel attached with — the bridge asks the first attached
|
|
41
111
|
// client. Free for Claude sessions: the guest loads lazily on the first call,
|
|
42
112
|
// which for them never comes.
|
|
43
113
|
useToolCallHost(handle)
|
|
114
|
+
const capabilities = state.capabilities
|
|
44
115
|
const busy = state.status === 'running' || state.status === 'awaiting_approval'
|
|
45
116
|
const ended = state.status === 'failed' || state.status === 'closed'
|
|
117
|
+
const attachments = useAttachments(client, sessionId, {
|
|
118
|
+
capabilities,
|
|
119
|
+
engine: state.engine,
|
|
120
|
+
})
|
|
121
|
+
// Rooted at the session's cwd, which arrives with the snapshot — so `@` is
|
|
122
|
+
// inert for the moment before it does, and stays inert on a gateway that
|
|
123
|
+
// serves no host files.
|
|
124
|
+
const hostFiles = useHostFileSearch(client, state.cwd)
|
|
125
|
+
const windows = useMemo(() => rateLimitWindows(state), [state])
|
|
126
|
+
// Reads a picture the engine left on the host (codex's `image_gen` reports a
|
|
127
|
+
// path, never bytes). Stable and memoized per path: transcript rows re-render
|
|
128
|
+
// on every delta, and a fresh function would re-fetch each time.
|
|
129
|
+
const hostImage = useHostImage(client, sessionId, state.producedFiles)
|
|
130
|
+
const composerRef = useRef<ComposerHandle>(null)
|
|
46
131
|
|
|
47
132
|
// "/model" is handled panel-side (see handleSend) — surface it in the autocomplete
|
|
48
133
|
// even though the CLI's command list doesn't include it.
|
|
@@ -55,43 +140,119 @@ export function SessionPanel({ client, sessionId, header, className }: SessionPa
|
|
|
55
140
|
]
|
|
56
141
|
}, [state.commands])
|
|
57
142
|
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
|
|
143
|
+
// Two things are answered here rather than sent, because sending them would
|
|
144
|
+
// spend a turn on a model reading the words back.
|
|
145
|
+
const handleSend = (text: string, attachmentIds: string[]) => {
|
|
146
|
+
if (attachmentIds.length === 0) {
|
|
147
|
+
// "/model <id>" switches the model directly instead of going to the CLI.
|
|
148
|
+
const modelCommand = /^\/model\s+(\S+)$/.exec(text)
|
|
149
|
+
if (modelCommand) {
|
|
150
|
+
setModel(modelCommand[1])
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
// The CLI's own `/mcp` is an interactive picker, not a prompt. Only where
|
|
154
|
+
// the capability exists: elsewhere it is ordinary message text, like any
|
|
155
|
+
// other slash command on an engine without them.
|
|
156
|
+
if (capabilities.mcpStatus && text.trim() === '/mcp') {
|
|
157
|
+
setPanel('mcp')
|
|
158
|
+
return
|
|
159
|
+
}
|
|
64
160
|
}
|
|
65
|
-
send(text)
|
|
161
|
+
send(text, attachmentIds)
|
|
66
162
|
}
|
|
67
163
|
|
|
164
|
+
// Everything the panel can open, in one place — and each one is also
|
|
165
|
+
// reachable by clicking the thing on the bar that summarises it. Entries the
|
|
166
|
+
// capability record forswears are absent, not present-and-empty.
|
|
167
|
+
//
|
|
168
|
+
// Built once and placed once: either the embedder's header takes it (see the
|
|
169
|
+
// `header` render-prop) or the status bar does. Never both — two `⋯` menus on
|
|
170
|
+
// one screen is worse than either position.
|
|
171
|
+
const actionsMenu = (
|
|
172
|
+
<Menu>
|
|
173
|
+
<MenuTrigger
|
|
174
|
+
render={
|
|
175
|
+
<Button variant='ghost' size='icon-sm' aria-label='Session actions'>
|
|
176
|
+
<MoreHorizontal className='size-4' />
|
|
177
|
+
</Button>
|
|
178
|
+
}
|
|
179
|
+
/>
|
|
180
|
+
<MenuContent>
|
|
181
|
+
{capabilities.contextUsage ? (
|
|
182
|
+
<MenuItem onClick={() => setPanel('context')}>
|
|
183
|
+
<ChartPie className='size-3.5 text-fg-3' /> Context
|
|
184
|
+
</MenuItem>
|
|
185
|
+
) : null}
|
|
186
|
+
{capabilities.rateLimits ? (
|
|
187
|
+
<MenuItem onClick={() => setPanel('usage')}>
|
|
188
|
+
<Gauge className='size-3.5 text-fg-3' /> Usage
|
|
189
|
+
</MenuItem>
|
|
190
|
+
) : null}
|
|
191
|
+
<MenuItem onClick={() => setPanel('info')}>
|
|
192
|
+
<Info className='size-3.5 text-fg-3' /> Session info
|
|
193
|
+
</MenuItem>
|
|
194
|
+
{capabilities.mcpStatus ? (
|
|
195
|
+
<MenuItem onClick={() => setPanel('mcp')}>
|
|
196
|
+
<Plug className='size-3.5 text-fg-3' /> MCP servers
|
|
197
|
+
</MenuItem>
|
|
198
|
+
) : null}
|
|
199
|
+
{/* On the capability alone, like MCP's entry. Codex answers
|
|
200
|
+
`skills/list` only over a live child, so before the first turn there
|
|
201
|
+
is no list yet — but hiding the entry until then made the dialog's
|
|
202
|
+
own explanation of that unreachable, which read as the feature being
|
|
203
|
+
missing. The empty state says it instead. */}
|
|
204
|
+
{capabilities.skillsList ? (
|
|
205
|
+
<MenuItem onClick={() => setPanel('skills')}>
|
|
206
|
+
<Sparkles className='size-3.5 text-fg-3' /> Skills
|
|
207
|
+
</MenuItem>
|
|
208
|
+
) : null}
|
|
209
|
+
{hostFiles.available ? (
|
|
210
|
+
<MenuItem onClick={() => setPanel('files')}>
|
|
211
|
+
<FolderTree className='size-3.5 text-fg-3' /> Project files
|
|
212
|
+
</MenuItem>
|
|
213
|
+
) : null}
|
|
214
|
+
</MenuContent>
|
|
215
|
+
</Menu>
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
// A function header claims the menu; anything else leaves it on the status bar.
|
|
219
|
+
const headerTakesActions = typeof header === 'function'
|
|
220
|
+
|
|
68
221
|
return (
|
|
69
222
|
<div
|
|
70
223
|
data-slot='session-panel'
|
|
71
224
|
className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}>
|
|
72
|
-
{header}
|
|
73
|
-
<StatusBar
|
|
225
|
+
{headerTakesActions ? header({ actions: actionsMenu }) : header}
|
|
226
|
+
<StatusBar
|
|
227
|
+
state={state}
|
|
228
|
+
connection={connection}
|
|
229
|
+
onOpenStatus={() => setPanel('info')}
|
|
230
|
+
onOpenContext={() => setPanel('context')}
|
|
231
|
+
onOpenUsage={() => setPanel('usage')}
|
|
232
|
+
actions={headerTakesActions ? undefined : actionsMenu}
|
|
233
|
+
/>
|
|
234
|
+
{protocolMismatch !== undefined ? (
|
|
235
|
+
<Notice level='warning'>
|
|
236
|
+
Server speaks protocol v{protocolMismatch}, this build renders v{PROTOCOL_VERSION}. Some
|
|
237
|
+
events may not render.
|
|
238
|
+
</Notice>
|
|
239
|
+
) : null}
|
|
74
240
|
{protocolError ? (
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
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>
|
|
87
|
-
</div>
|
|
88
|
-
</div>
|
|
241
|
+
<Notice level='error' onDismiss={() => setProtocolError(undefined)}>
|
|
242
|
+
{protocolError}
|
|
243
|
+
</Notice>
|
|
89
244
|
) : null}
|
|
90
245
|
<Transcript
|
|
91
246
|
state={state}
|
|
92
247
|
fileUrl={sessionId ? (path) => client.sessionFileUrl(sessionId, path) : undefined}
|
|
248
|
+
attachmentUrl={sessionId ? (id) => client.attachmentUrl(sessionId, id) : undefined}
|
|
249
|
+
canBrowseFiles={hostFiles.available}
|
|
250
|
+
hostImage={hostImage}
|
|
93
251
|
/>
|
|
94
|
-
{
|
|
252
|
+
{/* An engine with no approval channel never raises these, but a stale
|
|
253
|
+
pending request from a replayed log would still render — the record is
|
|
254
|
+
the authority on whether an approval UI means anything here. */}
|
|
255
|
+
{capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? (
|
|
95
256
|
<div className='px-3 pb-2'>
|
|
96
257
|
<div className='mx-auto flex w-full max-w-3xl flex-col gap-2'>
|
|
97
258
|
{state.pendingApprovals.map((request) =>
|
|
@@ -101,7 +262,7 @@ export function SessionPanel({ client, sessionId, header, className }: SessionPa
|
|
|
101
262
|
key={request.id}
|
|
102
263
|
request={request}
|
|
103
264
|
onAnswer={approve}
|
|
104
|
-
onDismiss={deny}
|
|
265
|
+
onDismiss={(id) => deny(id, 'Question dismissed by user')}
|
|
105
266
|
/>
|
|
106
267
|
) : (
|
|
107
268
|
<PermissionPrompt
|
|
@@ -116,17 +277,28 @@ export function SessionPanel({ client, sessionId, header, className }: SessionPa
|
|
|
116
277
|
</div>
|
|
117
278
|
) : null}
|
|
118
279
|
<Composer
|
|
280
|
+
ref={composerRef}
|
|
119
281
|
onSend={handleSend}
|
|
120
282
|
onInterrupt={interrupt}
|
|
121
283
|
busy={busy}
|
|
122
284
|
disabled={ended || !sessionId}
|
|
123
|
-
commands={commands}
|
|
285
|
+
commands={capabilities.slashCommands ? commands : undefined}
|
|
286
|
+
skills={capabilities.skillsList ? state.skills : undefined}
|
|
287
|
+
attachments={attachments}
|
|
288
|
+
onSearchFiles={
|
|
289
|
+
hostFiles.available
|
|
290
|
+
? (query, options) => hostFiles.search(query, { ...options, limit: 8 })
|
|
291
|
+
: undefined
|
|
292
|
+
}
|
|
124
293
|
toolbar={
|
|
125
294
|
<>
|
|
126
|
-
{
|
|
295
|
+
{/* Codex reports no `capabilities` event, so its models arrive from
|
|
296
|
+
the profile catalog instead — without that fallback its picker
|
|
297
|
+
would be permanently empty and the session unswitchable. */}
|
|
298
|
+
{models.length ? (
|
|
127
299
|
<ModelSelect
|
|
128
|
-
models={
|
|
129
|
-
model={
|
|
300
|
+
models={models}
|
|
301
|
+
model={effectiveModel}
|
|
130
302
|
onModelChange={setModel}
|
|
131
303
|
disabled={ended}
|
|
132
304
|
/>
|
|
@@ -135,15 +307,183 @@ export function SessionPanel({ client, sessionId, header, className }: SessionPa
|
|
|
135
307
|
<PermissionModeSelect
|
|
136
308
|
mode={state.permissionMode}
|
|
137
309
|
onModeChange={setPermissionMode}
|
|
138
|
-
//
|
|
139
|
-
// protocol_error
|
|
140
|
-
modes={
|
|
310
|
+
// Only what this engine implements — the rest would come back as
|
|
311
|
+
// a protocol_error.
|
|
312
|
+
modes={capabilities.permissionModes}
|
|
313
|
+
canBypass={state.session?.canBypassPermissions}
|
|
141
314
|
disabled={ended}
|
|
142
315
|
/>
|
|
143
316
|
) : null}
|
|
144
317
|
</>
|
|
145
318
|
}
|
|
146
319
|
/>
|
|
320
|
+
|
|
321
|
+
<SessionInfoDialog
|
|
322
|
+
state={state}
|
|
323
|
+
client={client}
|
|
324
|
+
sessionId={sessionId}
|
|
325
|
+
open={panel === 'info'}
|
|
326
|
+
onOpenChange={(next) => setPanel(next ? 'info' : undefined)}
|
|
327
|
+
/>
|
|
328
|
+
<ContextDialog
|
|
329
|
+
usage={state.contextUsage}
|
|
330
|
+
open={panel === 'context'}
|
|
331
|
+
onOpenChange={(next) => setPanel(next ? 'context' : undefined)}
|
|
332
|
+
/>
|
|
333
|
+
<UsageDialog
|
|
334
|
+
rateLimits={windows}
|
|
335
|
+
subscriptionType={state.subscriptionType}
|
|
336
|
+
engine={state.engine ?? 'claude'}
|
|
337
|
+
totalCostUsd={state.totalCostUsd}
|
|
338
|
+
updatedAt={state.rateLimitsUpdatedAt}
|
|
339
|
+
open={panel === 'usage'}
|
|
340
|
+
onOpenChange={(next) => setPanel(next ? 'usage' : undefined)}
|
|
341
|
+
/>
|
|
342
|
+
<McpDialog
|
|
343
|
+
client={client}
|
|
344
|
+
sessionId={sessionId}
|
|
345
|
+
canManageServers={capabilities.mcpServerActions}
|
|
346
|
+
open={panel === 'mcp'}
|
|
347
|
+
onOpenChange={(next) => setPanel(next ? 'mcp' : undefined)}
|
|
348
|
+
/>
|
|
349
|
+
<SkillsDialog
|
|
350
|
+
skills={state.skills}
|
|
351
|
+
open={panel === 'skills'}
|
|
352
|
+
onOpenChange={(next) => setPanel(next ? 'skills' : undefined)}
|
|
353
|
+
// Drafts into the composer; the operator sends it. There is no engine
|
|
354
|
+
// call that runs a skill, so there is nothing else this button could do.
|
|
355
|
+
onUse={(skill) => composerRef.current?.insertText(skillPrompt(skill))}
|
|
356
|
+
/>
|
|
357
|
+
<HostFilesDialog
|
|
358
|
+
client={client}
|
|
359
|
+
cwd={state.cwd}
|
|
360
|
+
open={panel === 'files'}
|
|
361
|
+
onOpenChange={(next) => setPanel(next ? 'files' : undefined)}
|
|
362
|
+
/>
|
|
363
|
+
</div>
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Turns a host path a tool card is holding into something an `<img>` can show.
|
|
369
|
+
*
|
|
370
|
+
* Two sources, tried in that order and for a reason:
|
|
371
|
+
*
|
|
372
|
+
* 1. **The session's produced files.** If this session's own runner announced
|
|
373
|
+
* writing that path (`file_produced`), the gateway will serve it from
|
|
374
|
+
* `/sessions/:id/produced/:fileId` — no host-file roots to declare, no byte
|
|
375
|
+
* cap to raise. This is the path codex's generated images take, and it is
|
|
376
|
+
* why they now render out of the box.
|
|
377
|
+
* 2. **`/fs/read`.** For a path nothing produced — a picture the model looked
|
|
378
|
+
* at, an image already in the tree — where the operator's declared roots are
|
|
379
|
+
* the right gate and the answer is legitimately "no" outside them.
|
|
380
|
+
*
|
|
381
|
+
* The cache is what makes this usable from a transcript row: rows re-render on
|
|
382
|
+
* every streamed delta, and an uncached resolver would re-fetch the picture each
|
|
383
|
+
* time. A refusal is cached too, so it costs one request rather than one per
|
|
384
|
+
* render.
|
|
385
|
+
*
|
|
386
|
+
* Keyed by `fileId`-or-path so that a path which becomes produced *after* a
|
|
387
|
+
* failed `/fs/read` is retried under a different key rather than staying cached
|
|
388
|
+
* as a miss.
|
|
389
|
+
*/
|
|
390
|
+
function useHostImage(
|
|
391
|
+
client: WorkerDeckClient,
|
|
392
|
+
sessionId: string | undefined,
|
|
393
|
+
producedFiles: Record<string, ProducedFileRef> | undefined,
|
|
394
|
+
): (path: string) => Promise<string | undefined> {
|
|
395
|
+
const cache = useRef(new Map<string, Promise<string | undefined>>())
|
|
396
|
+
// Object URLs pin their blob until revoked, so a long session that generated
|
|
397
|
+
// a dozen images would hold a dozen megabytes past unmount.
|
|
398
|
+
const objectUrls = useRef<string[]>([])
|
|
399
|
+
useEffect(
|
|
400
|
+
() => () => {
|
|
401
|
+
for (const url of objectUrls.current) URL.revokeObjectURL(url)
|
|
402
|
+
objectUrls.current = []
|
|
403
|
+
},
|
|
404
|
+
[],
|
|
405
|
+
)
|
|
406
|
+
return useCallback(
|
|
407
|
+
(path: string) => {
|
|
408
|
+
const produced = producedFiles?.[path]
|
|
409
|
+
const key = produced ? `produced:${produced.fileId}` : `fs:${path}`
|
|
410
|
+
const hit = cache.current.get(key)
|
|
411
|
+
if (hit) return hit
|
|
412
|
+
const pending =
|
|
413
|
+
produced && sessionId
|
|
414
|
+
? // Fetched rather than pointed at: the panel may be talking to a
|
|
415
|
+
// header-authenticated gateway, where a bare URL in an `<img src>`
|
|
416
|
+
// carries no credential.
|
|
417
|
+
client
|
|
418
|
+
.readProducedFile(sessionId, produced.fileId)
|
|
419
|
+
.then((blob) => {
|
|
420
|
+
if (blob.size === 0) return undefined
|
|
421
|
+
const url = URL.createObjectURL(blob)
|
|
422
|
+
objectUrls.current.push(url)
|
|
423
|
+
return url
|
|
424
|
+
})
|
|
425
|
+
.catch(() => undefined)
|
|
426
|
+
: client
|
|
427
|
+
.readHostFile(path)
|
|
428
|
+
.then((file) => {
|
|
429
|
+
if (file.encoding !== 'base64') return undefined
|
|
430
|
+
// The route reports bytes and an encoding but not a media type;
|
|
431
|
+
// the extension is what a browser needs to decode it.
|
|
432
|
+
const extension = path.slice(path.lastIndexOf('.') + 1).toLowerCase()
|
|
433
|
+
const mediaType = IMAGE_MEDIA_TYPES[extension]
|
|
434
|
+
return mediaType ? `data:${mediaType};base64,${file.content}` : undefined
|
|
435
|
+
})
|
|
436
|
+
.catch(() => undefined)
|
|
437
|
+
cache.current.set(key, pending)
|
|
438
|
+
return pending
|
|
439
|
+
},
|
|
440
|
+
[client, sessionId, producedFiles],
|
|
441
|
+
)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Extensions worth rendering inline, and what to call them. Anything else is
|
|
445
|
+
* left to the card's path text — guessing a media type is how an HTML file ends
|
|
446
|
+
* up in an `<img>`. */
|
|
447
|
+
const IMAGE_MEDIA_TYPES: Record<string, string> = {
|
|
448
|
+
png: 'image/png',
|
|
449
|
+
jpg: 'image/jpeg',
|
|
450
|
+
jpeg: 'image/jpeg',
|
|
451
|
+
gif: 'image/gif',
|
|
452
|
+
webp: 'image/webp',
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** A dismissible advisory strip above the transcript. */
|
|
456
|
+
function Notice({
|
|
457
|
+
level,
|
|
458
|
+
onDismiss,
|
|
459
|
+
children,
|
|
460
|
+
}: {
|
|
461
|
+
level: 'warning' | 'error'
|
|
462
|
+
onDismiss?: () => void
|
|
463
|
+
children: ReactNode
|
|
464
|
+
}) {
|
|
465
|
+
return (
|
|
466
|
+
<div className='px-3 pt-2'>
|
|
467
|
+
<div
|
|
468
|
+
role='alert'
|
|
469
|
+
className={cn(
|
|
470
|
+
'mx-auto flex w-full max-w-3xl items-start gap-2 rounded-md border px-3 py-2 text-body-sm',
|
|
471
|
+
level === 'error'
|
|
472
|
+
? 'border-danger/40 bg-danger-bg text-danger'
|
|
473
|
+
: 'border-warning/40 bg-warning-bg text-warning',
|
|
474
|
+
)}>
|
|
475
|
+
<TriangleAlert className='mt-0.5 size-3.5 shrink-0' />
|
|
476
|
+
<span className='min-w-0 flex-1 break-words'>{children}</span>
|
|
477
|
+
{onDismiss ? (
|
|
478
|
+
<button
|
|
479
|
+
type='button'
|
|
480
|
+
onClick={onDismiss}
|
|
481
|
+
aria-label='Dismiss'
|
|
482
|
+
className='shrink-0 opacity-70 transition-opacity hover:opacity-100'>
|
|
483
|
+
<X className='size-3.5' />
|
|
484
|
+
</button>
|
|
485
|
+
) : null}
|
|
486
|
+
</div>
|
|
147
487
|
</div>
|
|
148
488
|
)
|
|
149
489
|
}
|