@workerdeck/ui 0.23.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/{SessionPanel-DUt2VzXG.mjs → SessionPanel-DDgBkHsi.mjs} +2151 -3357
- package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
- package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
- package/build/format-B8ZM9LPy.d.mts +16 -0
- package/build/format.d.mts +8 -75
- package/build/format.mjs +2 -2
- package/build/index.d.mts +63 -1138
- package/build/index.mjs +4 -300
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +15 -23
- package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
- package/build/status-C0HSLZas.mjs.map +1 -0
- package/build/workspace.d.mts +5 -126
- package/build/workspace.mjs +11 -143
- package/build/workspace.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/agent/CodeEditor.tsx +31 -103
- package/src/components/agent/Composer.tsx +114 -333
- package/src/components/agent/ContextDialog.tsx +20 -41
- package/src/components/agent/ContextRing.tsx +6 -28
- package/src/components/agent/Conversation.tsx +21 -33
- package/src/components/agent/EditorTabs.tsx +39 -67
- package/src/components/agent/EngineIcon.tsx +29 -72
- package/src/components/agent/EntryIcon.tsx +16 -0
- package/src/components/agent/FileCard.tsx +10 -16
- package/src/components/agent/FileTree.tsx +52 -125
- package/src/components/agent/FileViewer.tsx +30 -60
- package/src/components/agent/HostFilesDialog.tsx +40 -69
- package/src/components/agent/Loader.tsx +3 -24
- package/src/components/agent/McpDialog.tsx +64 -137
- package/src/components/agent/Message.tsx +2 -21
- package/src/components/agent/ModelSelect.tsx +33 -62
- package/src/components/agent/PermissionModeSelect.tsx +21 -79
- package/src/components/agent/PermissionPrompt.tsx +59 -63
- package/src/components/agent/ProjectIcon.tsx +228 -93
- package/src/components/agent/PromptTokenText.tsx +10 -16
- package/src/components/agent/QuestionPrompt.tsx +43 -64
- package/src/components/agent/Reasoning.tsx +13 -16
- package/src/components/agent/Response.tsx +3 -9
- package/src/components/agent/Scrubber.tsx +31 -80
- package/src/components/agent/SessionBrowser.tsx +64 -243
- package/src/components/agent/SessionEmptyState.tsx +10 -40
- package/src/components/agent/SessionInfoDialog.tsx +46 -78
- package/src/components/agent/SessionItem.tsx +53 -320
- package/src/components/agent/SessionList.tsx +23 -39
- package/src/components/agent/SessionPanel.tsx +315 -996
- package/src/components/agent/SessionStatusIcon.tsx +14 -28
- package/src/components/agent/SessionSteps.tsx +34 -187
- package/src/components/agent/SessionWorkspace.tsx +104 -219
- package/src/components/agent/SkillsDialog.tsx +35 -80
- package/src/components/agent/StatusBar.tsx +51 -136
- package/src/components/agent/SubagentStrip.tsx +12 -77
- package/src/components/agent/ToolCallCard.tsx +94 -135
- package/src/components/agent/Transcript.tsx +103 -1288
- package/src/components/agent/TranscriptItemView.tsx +155 -0
- package/src/components/agent/TranscriptRows.tsx +418 -0
- package/src/components/agent/UsageDialog.tsx +7 -29
- package/src/components/agent/UsageMeters.tsx +18 -73
- package/src/components/agent/pulse.tsx +9 -32
- package/src/components/agent/scrubber-marks.ts +33 -103
- package/src/components/agent/status.ts +1 -5
- package/src/components/agent/tool-result-fetch.tsx +3 -25
- package/src/components/agent/tool-result-image.tsx +32 -111
- package/src/components/agent/transcript-rows.ts +41 -118
- package/src/components/agent/transcript-variant.tsx +3 -80
- package/src/components/agent/use-height-epoch.ts +10 -24
- package/src/components/agent/use-path-links.ts +45 -63
- package/src/components/agent/use-subagent-frame.ts +106 -0
- package/src/components/agent/use-transcript-jumps.ts +49 -107
- package/src/components/prompt-area/animated-placeholder.tsx +6 -5
- package/src/components/prompt-area/clipboard-helpers.ts +26 -21
- package/src/components/prompt-area/cursor-helpers.ts +79 -37
- package/src/components/prompt-area/dom-helpers.ts +97 -69
- package/src/components/prompt-area/file-strip.tsx +90 -80
- package/src/components/prompt-area/html-to-markdown.ts +67 -46
- package/src/components/prompt-area/image-strip.tsx +8 -18
- package/src/components/prompt-area/index.ts +2 -15
- package/src/components/prompt-area/prompt-area-engine.ts +100 -115
- package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
- package/src/components/prompt-area/prompt-area.tsx +31 -38
- package/src/components/prompt-area/remove-button.tsx +3 -9
- package/src/components/prompt-area/segment-helpers.ts +4 -12
- package/src/components/prompt-area/trigger-popover.tsx +15 -27
- package/src/components/prompt-area/trigger-presets.ts +0 -10
- package/src/components/prompt-area/types.ts +3 -8
- package/src/components/prompt-area/use-chip-editing.ts +305 -0
- package/src/components/prompt-area/use-markdown-mode.ts +7 -17
- package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
- package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
- package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
- package/src/components/prompt-area/use-prompt-area.ts +170 -896
- package/src/components/prompt-area/use-trigger-search.ts +21 -19
- package/src/components/terminal/PermissionPrompt.tsx +56 -59
- package/src/components/terminal/QuestionPrompt.tsx +28 -79
- package/src/components/terminal/StatusLine.tsx +13 -63
- package/src/components/terminal/TerminalTranscript.tsx +44 -160
- package/src/components/terminal/affordances.tsx +68 -98
- package/src/components/terminal/blocks.ts +20 -156
- package/src/components/terminal/diff.tsx +19 -67
- package/src/components/terminal/height.ts +163 -313
- package/src/components/terminal/image-box.ts +0 -44
- package/src/components/terminal/items.tsx +150 -305
- package/src/components/terminal/markdown.tsx +36 -95
- package/src/components/terminal/press.tsx +26 -53
- package/src/components/terminal/prompt.tsx +40 -170
- package/src/components/terminal/result-preview.ts +8 -56
- package/src/components/terminal/row.tsx +11 -98
- package/src/components/terminal/scrubber.tsx +108 -401
- package/src/components/terminal/surface.tsx +6 -54
- package/src/components/terminal/todos.ts +66 -0
- package/src/components/terminal/tool-run.ts +21 -192
- package/src/components/ui/AlertDialog.tsx +8 -23
- package/src/components/ui/Badge.tsx +18 -29
- package/src/components/ui/Button.tsx +7 -15
- package/src/components/ui/Card.tsx +2 -13
- package/src/components/ui/CodeBlock.tsx +10 -37
- package/src/components/ui/CopyButton.tsx +10 -23
- package/src/components/ui/Dialog.tsx +23 -36
- package/src/components/ui/Empty.tsx +6 -29
- package/src/components/ui/Input.tsx +14 -16
- package/src/components/ui/Menu.tsx +6 -18
- package/src/components/ui/PortalScope.tsx +1 -21
- package/src/components/ui/ProgressRing.tsx +8 -19
- package/src/components/ui/Select.tsx +18 -31
- package/src/components/ui/Sonner.tsx +1 -2
- package/src/components/ui/Spinner.tsx +1 -1
- package/src/components/ui/Splitter.tsx +30 -64
- package/src/components/ui/Textarea.tsx +2 -5
- package/src/components/ui/Tooltip.tsx +3 -13
- package/src/format.ts +0 -9
- package/src/index.ts +16 -113
- package/src/lib/clipboard.ts +8 -28
- package/src/lib/css.ts +8 -0
- package/src/lib/format.ts +101 -86
- package/src/lib/plan-request.ts +16 -0
- package/src/lib/status.ts +45 -95
- package/src/lib/tool-icon.ts +39 -47
- package/src/lib/utils.ts +1 -3
- package/src/styles/terminal.css +81 -49
- package/src/styles/theme.css +96 -32
- package/src/workspace.ts +1 -24
- package/build/SessionPanel-BobynUo4.d.mts +0 -669
- package/build/SessionPanel-DUt2VzXG.mjs.map +0 -1
- package/build/format-ljc3lKpA.d.mts +0 -59
- package/build/status-BE-zg88x.mjs.map +0 -1
|
@@ -1,45 +1,29 @@
|
|
|
1
|
-
import {
|
|
2
|
-
useCallback,
|
|
3
|
-
useEffect,
|
|
4
|
-
useMemo,
|
|
5
|
-
useRef,
|
|
6
|
-
useState,
|
|
7
|
-
type MouseEvent as ReactMouseEvent,
|
|
8
|
-
type ReactNode,
|
|
9
|
-
} from 'react'
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'
|
|
10
2
|
import type { WorkerDeckClient } from '@workerdeck/client'
|
|
11
3
|
import {
|
|
12
4
|
PROTOCOL_VERSION,
|
|
13
5
|
mergeUsage,
|
|
14
6
|
orderUsageWindows,
|
|
15
|
-
subagentLabel,
|
|
16
7
|
usageInfos,
|
|
17
8
|
type ModelOption,
|
|
18
9
|
type PermissionMode,
|
|
19
10
|
type RateLimitInfo,
|
|
11
|
+
type SkillInfo,
|
|
20
12
|
} from '@workerdeck/protocol'
|
|
21
13
|
import {
|
|
22
14
|
useAttachments,
|
|
23
15
|
useClaudeSession,
|
|
16
|
+
useDraft,
|
|
24
17
|
useHostFileSearch,
|
|
25
18
|
useProfileUsage,
|
|
26
19
|
useToolCallHost,
|
|
20
|
+
type ClientToolHandler,
|
|
27
21
|
type ConnectionState,
|
|
28
22
|
type ProducedFileRef,
|
|
29
23
|
type TranscriptState,
|
|
30
24
|
type UseToolCallHostOptions,
|
|
31
25
|
} from '@workerdeck/react'
|
|
32
|
-
import {
|
|
33
|
-
ChartPie,
|
|
34
|
-
FolderTree,
|
|
35
|
-
Gauge,
|
|
36
|
-
Info,
|
|
37
|
-
MoreHorizontal,
|
|
38
|
-
Plug,
|
|
39
|
-
Sparkles,
|
|
40
|
-
TriangleAlert,
|
|
41
|
-
X,
|
|
42
|
-
} from 'lucide-react'
|
|
26
|
+
import { ChartPie, FolderTree, Gauge, Info, MoreHorizontal, Plug, Sparkles, TriangleAlert, X } from 'lucide-react'
|
|
43
27
|
import { cn } from '../../lib/utils.ts'
|
|
44
28
|
import { Button } from '../ui/Button.tsx'
|
|
45
29
|
import { Menu, MenuContent, MenuItem, MenuTrigger } from '../ui/Menu.tsx'
|
|
@@ -49,19 +33,15 @@ import { HostFilesDialog } from './HostFilesDialog.tsx'
|
|
|
49
33
|
import { McpDialog } from './McpDialog.tsx'
|
|
50
34
|
import { SkillsDialog } from './SkillsDialog.tsx'
|
|
51
35
|
import { ModelSelect } from './ModelSelect.tsx'
|
|
52
|
-
import {
|
|
53
|
-
PermissionModeSelect,
|
|
54
|
-
permissionModeChoices,
|
|
55
|
-
type PermissionModeChoice,
|
|
56
|
-
} from './PermissionModeSelect.tsx'
|
|
36
|
+
import { PermissionModeSelect, permissionModeChoices, type PermissionModeChoice } from './PermissionModeSelect.tsx'
|
|
57
37
|
import { PermissionPrompt } from './PermissionPrompt.tsx'
|
|
58
38
|
import { SubagentStrip } from './SubagentStrip.tsx'
|
|
59
|
-
import {
|
|
39
|
+
import { useSubagentFrame } from './use-subagent-frame.ts'
|
|
60
40
|
import { QuestionPrompt, parseUserQuestions } from './QuestionPrompt.tsx'
|
|
61
41
|
import { TerminalPermissionPrompt } from '../terminal/PermissionPrompt.tsx'
|
|
62
42
|
import { TerminalQuestionPrompt } from '../terminal/QuestionPrompt.tsx'
|
|
63
43
|
import { TerminalSurface } from '../terminal/surface.tsx'
|
|
64
|
-
import type
|
|
44
|
+
import { BookmarkProvider, type BookmarkHandle, type TerminalAffordances } from '../terminal/affordances.tsx'
|
|
65
45
|
|
|
66
46
|
import { SessionInfoDialog } from './SessionInfoDialog.tsx'
|
|
67
47
|
import { StatusBar } from './StatusBar.tsx'
|
|
@@ -77,14 +57,6 @@ import {
|
|
|
77
57
|
} from './transcript-variant.tsx'
|
|
78
58
|
import { UsageDialog } from './UsageDialog.tsx'
|
|
79
59
|
|
|
80
|
-
/**
|
|
81
|
-
* The box the pending prompts sit in.
|
|
82
|
-
*
|
|
83
|
-
* Under the terminal theme they are rows of the same run as the transcript, so
|
|
84
|
-
* they need that theme's cell — and its `--term-bleed`, so an approval's diff
|
|
85
|
-
* bands reach the same edges the transcript's do. Every other variant keeps the
|
|
86
|
-
* centred content column the panel uses everywhere else.
|
|
87
|
-
*/
|
|
88
60
|
function PromptSurface({
|
|
89
61
|
terminal,
|
|
90
62
|
metrics,
|
|
@@ -97,403 +69,67 @@ function PromptSurface({
|
|
|
97
69
|
children: ReactNode
|
|
98
70
|
}) {
|
|
99
71
|
if (!terminal) {
|
|
100
|
-
return (
|
|
101
|
-
<div className='mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] flex-col gap-2'>
|
|
102
|
-
{children}
|
|
103
|
-
</div>
|
|
104
|
-
)
|
|
72
|
+
return <div className="mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] flex-col gap-2">{children}</div>
|
|
105
73
|
}
|
|
106
74
|
return (
|
|
107
75
|
<TerminalSurface
|
|
108
76
|
fontSize={metrics?.fontSize}
|
|
109
77
|
lineHeight={metrics?.lineHeight}
|
|
110
78
|
affordances={affordances}
|
|
111
|
-
bleed=
|
|
112
|
-
className=
|
|
79
|
+
bleed="1ch"
|
|
80
|
+
className="term-transcript"
|
|
81
|
+
>
|
|
113
82
|
{children}
|
|
114
83
|
</TerminalSurface>
|
|
115
84
|
)
|
|
116
85
|
}
|
|
117
86
|
|
|
118
|
-
/**
|
|
119
|
-
* The character cell the terminal theme draws on, in **whole pixels**.
|
|
120
|
-
*
|
|
121
|
-
* One object rather than two props because the panel mounts three separate
|
|
122
|
-
* `TerminalSurface`s — the transcript, the pending prompts, the composer — and
|
|
123
|
-
* they must agree: a prompt drawn at a different line height from the rows above
|
|
124
|
-
* it is three surfaces on three grids, which is the failure this theme is built
|
|
125
|
-
* to make impossible. Passing one value through one prop is what keeps them from
|
|
126
|
-
* drifting.
|
|
127
|
-
*
|
|
128
|
-
* Absent means the CLI's own 13/18. A host that follows an editor font size
|
|
129
|
-
* (VS Code) hands that down instead.
|
|
130
|
-
*/
|
|
131
87
|
export type TerminalMetrics = { fontSize?: number; lineHeight?: number }
|
|
132
88
|
|
|
133
89
|
export interface SessionPanelProps {
|
|
134
90
|
client: WorkerDeckClient
|
|
135
91
|
sessionId: string | undefined
|
|
136
|
-
/**
|
|
137
|
-
* Optional slot rendered at the top, above the status bar.
|
|
138
|
-
*
|
|
139
|
-
* Pass a **function** to take the session-actions (`⋯`) menu into your own
|
|
140
|
-
* chrome: it is called with the menu element, and wherever you put it is
|
|
141
|
-
* where it lives — the status bar then renders without it, so it never
|
|
142
|
-
* appears twice. Pass a plain node (or nothing) and the menu stays in the
|
|
143
|
-
* status bar's trailing slot.
|
|
144
|
-
*
|
|
145
|
-
* The seam exists because the menu can only be *built* here — it needs the
|
|
146
|
-
* capability record, the host-file verdict and the panel's own dialog state —
|
|
147
|
-
* but an embedder with a real header usually wants it up there with the rest
|
|
148
|
-
* of the session's controls, not stranded on the status line.
|
|
149
|
-
*/
|
|
150
92
|
header?: ReactNode | ((slots: { actions: ReactNode }) => ReactNode)
|
|
151
|
-
/**
|
|
152
|
-
* Where the info/context/usage/MCP/skills/files surfaces live. `'internal'`
|
|
153
|
-
* (default) renders them as dialogs inside the panel. `'external'` renders
|
|
154
|
-
* NO dialogs and no `⋯` menu: every affordance that would open one calls
|
|
155
|
-
* {@link onOpenPanel} instead, so an embedder can host those surfaces in its
|
|
156
|
-
* own chrome (a VS Code sidebar, a drawer) and keep the panel purely a
|
|
157
|
-
* conversation surface.
|
|
158
|
-
*/
|
|
159
93
|
panelSurface?: 'internal' | 'external'
|
|
160
|
-
/**
|
|
161
|
-
* Where the status bar lives. `'internal'` (default) draws it across the top
|
|
162
|
-
* of the panel. `'external'` draws none — the readings still leave through
|
|
163
|
-
* {@link onVitals}, so an embedder with a status line of its own (VS Code's
|
|
164
|
-
* window status bar) renders them there instead of stacking a second bar
|
|
165
|
-
* inside a panel that already sits in one.
|
|
166
|
-
*
|
|
167
|
-
* Deliberately independent of {@link panelSurface}: hosting the dialogs and
|
|
168
|
-
* hosting the bar are separate decisions. One coupling to know about — the
|
|
169
|
-
* `⋯` menu lives in the bar's trailing slot, so `statusSurface: 'external'`
|
|
170
|
-
* with `panelSurface: 'internal'` must pass a **function** {@link header} to
|
|
171
|
-
* take the menu, or it has nowhere left to go.
|
|
172
|
-
*/
|
|
173
94
|
statusSurface?: 'internal' | 'external'
|
|
174
|
-
/**
|
|
175
|
-
* Which end of the panel the status bar sits at. Default `top`.
|
|
176
|
-
*
|
|
177
|
-
* `bottom` is the editor convention — VS Code's status bar runs along the
|
|
178
|
-
* foot of the window — and suits a host where the panel *is* the editor area
|
|
179
|
-
* and the chrome above it already belongs to the app. Placement only; the bar
|
|
180
|
-
* is the same bar, with the same `⋯` menu in its trailing slot, so this
|
|
181
|
-
* composes with {@link statusSurface} rather than competing with it (external
|
|
182
|
-
* still means "there isn't one").
|
|
183
|
-
*/
|
|
184
95
|
statusPlacement?: 'top' | 'bottom'
|
|
185
|
-
/** Where `panelSurface: 'external'` routes opens. Absent = the affordances
|
|
186
|
-
* (status-bar clicks, `/mcp`) become inert rather than half-working. */
|
|
187
96
|
onOpenPanel?: (panel: SessionSurfacePanel) => void
|
|
188
|
-
/** Live session vitals, fired whenever they change — for embedders mirroring
|
|
189
|
-
* status/context/usage into chrome outside the panel (identity-stable via an
|
|
190
|
-
* internal ref, so an inline closure is fine). */
|
|
191
97
|
onVitals?: (vitals: SessionVitals) => void
|
|
192
|
-
/**
|
|
193
|
-
* How the transcript draws a turn — `'cards'` (default, the chat convention)
|
|
194
|
-
* or `'terminal'`, the CLI's own form: every row on a character cell, no boxes
|
|
195
|
-
* anywhere, diffs as full-width bands. An embedder in a dock (the VS Code
|
|
196
|
-
* panel) wants `'terminal'`; a full-width dashboard may prefer cards.
|
|
197
|
-
*/
|
|
198
98
|
transcriptVariant?: TranscriptVariant
|
|
199
|
-
/**
|
|
200
|
-
* Terminal theme only: the pointer affordances a real terminal cannot offer —
|
|
201
|
-
* the hover fill, the hover-revealed copy. `false` for none. None of them
|
|
202
|
-
* costs layout, so turning them off changes no glyph's position. See
|
|
203
|
-
* {@link TerminalAffordances}.
|
|
204
|
-
*/
|
|
205
99
|
affordances?: TerminalAffordances | boolean
|
|
206
|
-
/**
|
|
207
|
-
* Terminal theme only: the character cell, in whole pixels. See
|
|
208
|
-
* {@link TerminalMetrics} — it reaches all three of the panel's terminal
|
|
209
|
-
* surfaces, which is why it is one prop and not two per surface.
|
|
210
|
-
*/
|
|
211
100
|
terminalMetrics?: TerminalMetrics
|
|
212
|
-
/**
|
|
213
|
-
* Terminal theme only: replace the scrollbar with the **overview ruler** — a
|
|
214
|
-
* `2ch` rail of coloured marks in three lanes (what you typed, the answer and
|
|
215
|
-
* its turn end, errors and a waiting approval), which you can hover to peek,
|
|
216
|
-
* click to jump, and drag to scrub.
|
|
217
|
-
*
|
|
218
|
-
* Its premise is the terminal theme's own: one line height and one cell make
|
|
219
|
-
* every row's height derivable, so a mark's position is *computed* rather than
|
|
220
|
-
* guessed from rows that have not mounted. That is why it is not offered under
|
|
221
|
-
* `cards` — there the flag is inert.
|
|
222
|
-
*
|
|
223
|
-
* `false` keeps the native scrollbar. So does `affordances={false}`, which
|
|
224
|
-
* leaves the marks painted but inert rather than removing a reader's only way
|
|
225
|
-
* to scroll.
|
|
226
|
-
*/
|
|
227
101
|
scrubber?: boolean
|
|
228
|
-
/**
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
* owns that store is the embedder's question (a private pin belongs with the
|
|
232
|
-
* client's watermarks; a shared one is session metadata on the gateway).
|
|
233
|
-
*/
|
|
234
|
-
scrubberMarks?: readonly number[]
|
|
235
|
-
/**
|
|
236
|
-
* Scroll a tool call into view; bump `nonce` to ask again for the same one.
|
|
237
|
-
* See {@link TranscriptProps.reveal} — this is the panel's pass-through, and
|
|
238
|
-
* exists so a surface *outside* the panel (a sessions list showing a session's
|
|
239
|
-
* running sub-agents) can say "take me to that one" without opening a second
|
|
240
|
-
* attach to find out where it is.
|
|
241
|
-
*/
|
|
102
|
+
/** Bookmarked transcript item ids — the host owns membership and persistence. */
|
|
103
|
+
bookmarks?: readonly string[]
|
|
104
|
+
onToggleBookmark?: (itemId: string) => void
|
|
242
105
|
reveal?: { toolUseId: string; nonce: number }
|
|
243
|
-
/**
|
|
244
|
-
* Open a **sub-agent takeover**: the panel body becomes that agent's own work,
|
|
245
|
-
* with a way back. Bump `nonce` to ask again for the same one.
|
|
246
|
-
*
|
|
247
|
-
* A *request*, not a controlled value — the panel owns which agent is open,
|
|
248
|
-
* exactly as it owns `panel`. Dismissal has to work with zero host wiring
|
|
249
|
-
* (Back and Escape are the panel's own affordances), and the two hosts in
|
|
250
|
-
* scope reach this across a postMessage bridge where a controlled prop would
|
|
251
|
-
* need a live closure at the far end. Same shape and same reason as
|
|
252
|
-
* {@link SessionPanelProps.reveal}.
|
|
253
|
-
*
|
|
254
|
-
* **Withdrawing the request closes the frame.** The prop going away without a
|
|
255
|
-
* remount is itself a request — "the conversation, plainly" — and it leaves
|
|
256
|
-
* through the same path Back takes, so the reader lands on the Task row they
|
|
257
|
-
* came from. It has to mean that, because a host that keeps its request in
|
|
258
|
-
* route state (the dashboard's `?subagent=`) has exactly one way to say it:
|
|
259
|
-
* clear the search — which is what the sidebar's plain session click already
|
|
260
|
-
* navigated with, and what the browser's Back button re-arrives on. Before
|
|
261
|
-
* this, both were silently ignored and the frame outlived the address that
|
|
262
|
-
* claimed it was gone. Still not a controlled value: the panel enters and
|
|
263
|
-
* leaves frames on its own and *reports* through
|
|
264
|
-
* {@link SessionPanelProps.onSubagentChange}; only a **change** of the prop
|
|
265
|
-
* is a request.
|
|
266
|
-
*
|
|
267
|
-
* Hosts must still clear their request on a session switch: a stale one
|
|
268
|
-
* replayed at remount would open a frame the new transcript cannot answer.
|
|
269
|
-
*
|
|
270
|
-
* Claude-only in practice, and gated by data rather than by a flag — codex and
|
|
271
|
-
* provider sessions have no `parentToolUseId`, so they grow no task blocks and
|
|
272
|
-
* no sub-agent rows, and nothing can raise this.
|
|
273
|
-
*/
|
|
274
106
|
openSubagent?: { toolUseId: string; nonce: number }
|
|
275
|
-
/**
|
|
276
|
-
* Which sub-agent the panel now has framed, or `undefined` for the session's
|
|
277
|
-
* own conversation — the outward half of
|
|
278
|
-
* {@link SessionPanelProps.openSubagent}, and a *statement* where that one is
|
|
279
|
-
* a *request*. Deliberately not an echo: the panel enters frames the host
|
|
280
|
-
* never asked for (a Task row pressed in the transcript) and leaves them on
|
|
281
|
-
* its own (Back, Escape, a reveal), so a host that tracked only its own
|
|
282
|
-
* requests would be wrong within one click. No nonce, for the same reason —
|
|
283
|
-
* a state that arrives twice is the same state, where a request that arrives
|
|
284
|
-
* twice is two requests.
|
|
285
|
-
*
|
|
286
|
-
* Never fired for a fresh mount's initial `undefined`, and never fired from
|
|
287
|
-
* an unmount. The first would be a lie with consequences: the seeding effect
|
|
288
|
-
* consumes `openSubagent` in the same commit, so for one commit the state is
|
|
289
|
-
* `undefined` even though a frame is about to open, and a host folding
|
|
290
|
-
* reports into route state would clear the very `?subagent=` request the
|
|
291
|
-
* panel is in the middle of honouring. The second lets a panel keyed away on
|
|
292
|
-
* a session switch stomp what the host already believes about the next one.
|
|
293
|
-
* See the notify effect for the mechanics.
|
|
294
|
-
*
|
|
295
|
-
* What the sessions list's secondary selection feeds on: the row of the agent
|
|
296
|
-
* on screen takes the blue and its session's card steps back to grey.
|
|
297
|
-
*/
|
|
298
107
|
onSubagentChange?: (toolUseId: string | undefined) => void
|
|
299
|
-
/**
|
|
300
|
-
* Hold the prompt of the turn you are reading at the top of the transcript.
|
|
301
|
-
* Works in both variants: the terminal clips to one line (as the CLI does),
|
|
302
|
-
* cards shows a frosted bar. The **real row** is pinned rather than a copy
|
|
303
|
-
* drawn above it, so it lines up with the rows beneath by construction.
|
|
304
|
-
*/
|
|
305
108
|
stickyPrompt?: boolean
|
|
306
|
-
/**
|
|
307
|
-
* How much air the transcript gives each row — `'comfortable'` (default: a
|
|
308
|
-
* blank line between messages, as the Claude Code CLI leaves) or `'compact'`
|
|
309
|
-
* (rows tight against one another). Independent of `transcriptVariant`: the
|
|
310
|
-
* variant follows from the surface, density is the reader's preference, and a
|
|
311
|
-
* dock is allowed to be roomy.
|
|
312
|
-
*/
|
|
313
109
|
transcriptDensity?: TranscriptDensity
|
|
314
|
-
/**
|
|
315
|
-
* The panel's typeface — `'sans'` (default, the host's UI font) or `'mono'`,
|
|
316
|
-
* which repoints the sans token at the mono stack **for this subtree only**.
|
|
317
|
-
*
|
|
318
|
-
* The third reader preference beside variant and density, and the same kind of
|
|
319
|
-
* thing: how a transcript should read is a property of the person reading it.
|
|
320
|
-
* Scoped to the panel because that is the whole claim — a monospace agent view
|
|
321
|
-
* next to an ordinary app, not a monospace app.
|
|
322
|
-
*/
|
|
323
110
|
transcriptFont?: TranscriptFont
|
|
324
|
-
/**
|
|
325
|
-
* Where the session's own controls — model and permission mode — live.
|
|
326
|
-
* `'internal'` (default) draws them in the composer's toolbar row.
|
|
327
|
-
* `'status'` draws them in the panel's OWN status bar, beside the readings
|
|
328
|
-
* they act on; the composer collapses to a single line either way. That is
|
|
329
|
-
* VS Code's arrangement without VS Code — a host whose panel carries a status
|
|
330
|
-
* bar of its own (`statusPlacement: 'bottom'`) gets the same streamlined
|
|
331
|
-
* shape without having to host the pickers itself.
|
|
332
|
-
* `'external'` draws neither: the embedder renders the pickers in its own
|
|
333
|
-
* chrome (VS Code's window status bar, where a click opens a QuickPick) and
|
|
334
|
-
* drives them through {@link onControls}.
|
|
335
|
-
*
|
|
336
|
-
* `'status'` needs a status bar to put them in — with
|
|
337
|
-
* `statusSurface: 'external'` there is none, and the two together would hide
|
|
338
|
-
* the controls entirely, so that combination falls back to the composer.
|
|
339
|
-
*
|
|
340
|
-
* The options themselves ride {@link SessionVitals} — an embedder must not
|
|
341
|
-
* attach a second time to learn what the models are.
|
|
342
|
-
*/
|
|
343
111
|
controlsSurface?: 'internal' | 'external' | 'status'
|
|
344
|
-
/**
|
|
345
|
-
* Handed the session's setters once the panel is live, and `undefined` on
|
|
346
|
-
* unmount. The counterpart to `controlsSurface: 'external'`: vitals carry the
|
|
347
|
-
* readings out, this carries the commands back in. Stable identity — safe to
|
|
348
|
-
* stash in a ref.
|
|
349
|
-
*/
|
|
350
112
|
onControls?: (controls: SessionControls | undefined) => void
|
|
351
|
-
/**
|
|
352
|
-
* Click anywhere the panel isn't already doing something and the caret lands
|
|
353
|
-
* in the composer — the terminal/chat convention, and what a docked panel
|
|
354
|
-
* wants: the field is why the panel is focussed at all.
|
|
355
|
-
*
|
|
356
|
-
* Only dead space. A click that hits a control (a tool row expanding, a link,
|
|
357
|
-
* a button) or that ends a text selection is that action, not a request for
|
|
358
|
-
* the input. Off by default: a full-page surface has plenty of dead space that
|
|
359
|
-
* means nothing in particular.
|
|
360
|
-
*/
|
|
361
113
|
focusComposerOnClick?: boolean
|
|
362
|
-
/**
|
|
363
|
-
* What this session looked like when it was last looked at: how many
|
|
364
|
-
* transcript items had been seen, and when. Present and behind the current
|
|
365
|
-
* transcript → **catch-up**: a recap row at the boundary, everything above it
|
|
366
|
-
* dimmed, and a bar offering to jump there or to dismiss.
|
|
367
|
-
*
|
|
368
|
-
* The embedder owns the watermark because only it knows what "looked at"
|
|
369
|
-
* means in its own chrome — a hidden dock is not being read. The panel reports
|
|
370
|
-
* the number to remember through `SessionVitals.itemCount`.
|
|
371
|
-
*/
|
|
372
114
|
unseen?: { itemCount: number; since?: number }
|
|
373
|
-
/**
|
|
374
|
-
* A viewer, not a seat at the session: transcript, status bar and panels as
|
|
375
|
-
* usual, but no composer and no approval prompts.
|
|
376
|
-
*
|
|
377
|
-
* For a surface that is *about* a run rather than in it — the dashboard's job
|
|
378
|
-
* detail, where the session belongs to the queue and typing into it would be a
|
|
379
|
-
* second operator arriving mid-run. Deliberately not "disabled controls": a
|
|
380
|
-
* greyed-out composer says the session is busy, an absent one says this screen
|
|
381
|
-
* does not drive it. The attach is still live and read paths are untouched,
|
|
382
|
-
* so the transcript streams and the file tree browses.
|
|
383
|
-
*
|
|
384
|
-
* It does **not** claim to be an authorization boundary. Anything holding this
|
|
385
|
-
* client can still send; what it removes is the affordance, and the honest
|
|
386
|
-
* enforcement lives on the gateway.
|
|
387
|
-
*/
|
|
388
115
|
readOnly?: boolean
|
|
389
|
-
/**
|
|
390
|
-
* Options for the browser tool host this panel runs on its own attach — or
|
|
391
|
-
* `false` to run none at all.
|
|
392
|
-
*
|
|
393
|
-
* The panel hosts server-bridged tool calls itself, because the bridge asks
|
|
394
|
-
* the *first attached client* and the panel owns the session's one attach: an
|
|
395
|
-
* embedder subscribing to the same handle separately would find this host
|
|
396
|
-
* already answering, and refusing, anything outside its allow-list. So the
|
|
397
|
-
* options come through here.
|
|
398
|
-
*
|
|
399
|
-
* Merged over the defaults, which host `eval_script` alone. Widening `tools`
|
|
400
|
-
* is a real grant — this tab will execute what the gateway asks it to for
|
|
401
|
-
* every name in the list — so it names them explicitly rather than accepting
|
|
402
|
-
* a wildcard.
|
|
403
|
-
*/
|
|
404
116
|
toolHost?: UseToolCallHostOptions | false
|
|
405
|
-
/**
|
|
406
|
-
* Keep this session's transcript warm across remounts (default true), so
|
|
407
|
-
* switching back to a recently viewed session paints instantly and replays
|
|
408
|
-
* only what it missed — `UseClaudeSessionOptions.cacheTranscript`. Set
|
|
409
|
-
* `false` for an embedder whose principal varies on one gateway URL by means
|
|
410
|
-
* the client cannot see.
|
|
411
|
-
*/
|
|
412
117
|
cacheTranscript?: boolean
|
|
413
|
-
/**
|
|
414
|
-
* Replaces the default empty state when the transcript has no messages. Pass
|
|
415
|
-
* your product's own onboarding content instead of WorkerDeck's generic
|
|
416
|
-
* "`>_` Tell the agent what to do." placeholder.
|
|
417
|
-
*/
|
|
418
118
|
emptyState?: ReactNode
|
|
419
|
-
/**
|
|
420
|
-
* Called when a link in the transcript is clicked. The embedder decides what
|
|
421
|
-
* happens: navigate in-app, open a browser tab, show a confirmation, or
|
|
422
|
-
* suppress.
|
|
423
|
-
*
|
|
424
|
-
* Return `true` (or a truthy value) to indicate the click was handled — the
|
|
425
|
-
* default action (`window.open(href, '_blank')`) is suppressed. Return
|
|
426
|
-
* `false` / `undefined` / nothing to let the browser open the link normally.
|
|
427
|
-
*
|
|
428
|
-
* Absent means "browser default" — links open in a new tab as Streamdown's
|
|
429
|
-
* `target="_blank"` intends. VS Code's webview overrides this through its own
|
|
430
|
-
* native handler (the "allow once / add to allowlist" dialog) and does not
|
|
431
|
-
* need this prop.
|
|
432
|
-
*
|
|
433
|
-
* **Typical embedder patterns:**
|
|
434
|
-
* - Relative URLs → in-app navigation, no confirmation
|
|
435
|
-
* - External URLs → confirmation dialog, or open unconditionally
|
|
436
|
-
* - Suppress all links → `() => true`
|
|
437
|
-
*/
|
|
438
119
|
onLinkClick?: (href: string) => boolean | void
|
|
439
|
-
|
|
440
|
-
* Client-side tool handlers. Each key is a tool name the model can call; the
|
|
441
|
-
* handler receives the model's input and returns a result. The tool's
|
|
442
|
-
* **schema** must be registered server-side (via `tools` on
|
|
443
|
-
* `ProviderRunnerOptions`), but the handler runs here — right where the data
|
|
444
|
-
* the tool needs lives.
|
|
445
|
-
*
|
|
446
|
-
* Shorthand for `toolHost.clientTools`; when both are set, this wins for
|
|
447
|
-
* overlapping names.
|
|
448
|
-
*
|
|
449
|
-
* ```tsx
|
|
450
|
-
* <SessionPanel
|
|
451
|
-
* clientTools={{
|
|
452
|
-
* app_navigate: async (input) => {
|
|
453
|
-
* router.push((input as { path: string }).path)
|
|
454
|
-
* return { value: 'navigated' }
|
|
455
|
-
* },
|
|
456
|
-
* }}
|
|
457
|
-
* />
|
|
458
|
-
* ```
|
|
459
|
-
*/
|
|
460
|
-
clientTools?: Record<string, import('@workerdeck/react').ClientToolHandler>
|
|
461
|
-
/**
|
|
462
|
-
* Base font size in **whole pixels**. Drives the overall scale of everything
|
|
463
|
-
* the panel draws — prompt, output, markdown, status bar — in both variants.
|
|
464
|
-
*
|
|
465
|
-
* Under the terminal theme it sets `--term-font-size` and derives
|
|
466
|
-
* `--term-line` at the CLI's own 13 : 18 ratio (unless {@link terminalMetrics}
|
|
467
|
-
* overrides those individually). Under cards it sets the panel root's
|
|
468
|
-
* `font-size`, which scales every `rem`/`em`-based token the type scale uses.
|
|
469
|
-
*
|
|
470
|
-
* Absent means "platform default": 13 px for the terminal theme, the
|
|
471
|
-
* inherited body size for cards. That is the right choice for a host that has
|
|
472
|
-
* no preference — the panel reads at the size the rest of the app does.
|
|
473
|
-
*/
|
|
120
|
+
clientTools?: Record<string, ClientToolHandler>
|
|
474
121
|
fontSize?: number
|
|
475
122
|
className?: string
|
|
476
123
|
}
|
|
477
124
|
|
|
478
|
-
/** What an embedder needs to *change* a session it doesn't own the attach for. */
|
|
479
125
|
export type SessionControls = {
|
|
480
126
|
setModel: (model?: string) => void
|
|
481
127
|
setPermissionMode: (mode: PermissionMode) => void
|
|
482
128
|
interrupt: () => void
|
|
483
|
-
/**
|
|
484
|
-
* Put the caret in the composer.
|
|
485
|
-
*
|
|
486
|
-
* For an embedder whose own chrome is how you arrive at a session — clicking a
|
|
487
|
-
* row in VS Code's sidebar — where revealing the panel and being able to type
|
|
488
|
-
* are the same intention. The panel cannot infer it: from in here, a session
|
|
489
|
-
* appearing looks identical whether someone asked for it or it was restored.
|
|
490
|
-
*/
|
|
491
129
|
focusComposer: () => void
|
|
130
|
+
insertComposerText: (text: string) => void
|
|
492
131
|
}
|
|
493
132
|
|
|
494
|
-
/** Everything a click can mean other than "put the caret in the composer".
|
|
495
|
-
* Deliberately broad: mistaking a real target for dead space steals focus from
|
|
496
|
-
* whatever the user just opened. */
|
|
497
133
|
const INTERACTIVE = [
|
|
498
134
|
'button',
|
|
499
135
|
'a',
|
|
@@ -510,55 +146,32 @@ const INTERACTIVE = [
|
|
|
510
146
|
'[role="tab"]',
|
|
511
147
|
].join(',')
|
|
512
148
|
|
|
513
|
-
/** The panels the session surface can raise. One at a time, by identity: a bag
|
|
514
|
-
* of booleans would let two open at once. */
|
|
515
149
|
export type SessionSurfacePanel = 'info' | 'context' | 'usage' | 'mcp' | 'files' | 'skills'
|
|
516
150
|
type Panel = SessionSurfacePanel
|
|
517
151
|
|
|
518
|
-
/** What {@link SessionPanelProps.onVitals} reports: the live readings a host
|
|
519
|
-
* chrome outside the panel would otherwise have to attach a second time for —
|
|
520
|
-
* which the tool bridge forbids (it asks the first attached client). */
|
|
521
152
|
export type SessionVitals = {
|
|
522
153
|
status: TranscriptState['status']
|
|
523
|
-
/**
|
|
524
|
-
* How the client is reaching the gateway. Load-bearing for a host rendering
|
|
525
|
-
* these outside the panel: `status` is the last thing the session *said*, and
|
|
526
|
-
* over a dropped socket that is a stale reading. The panel's own bar gives
|
|
527
|
-
* the connection the status slot when it isn't `'live'` for exactly this
|
|
528
|
-
* reason — an embedder showing `status` alone would present stale as current.
|
|
529
|
-
*/
|
|
530
154
|
connection: ConnectionState
|
|
531
155
|
engine: TranscriptState['engine']
|
|
532
156
|
capabilities: TranscriptState['capabilities']
|
|
533
157
|
model: string | undefined
|
|
534
|
-
/** The models this session can switch to — the panel's own list, so an
|
|
535
|
-
* external picker offers exactly what the internal one would. */
|
|
536
158
|
models: ModelOption[]
|
|
537
159
|
permissionMode: TranscriptState['permissionMode']
|
|
538
|
-
/** The modes it can switch into, already filtered by the capability record
|
|
539
|
-
* and the session's bypass grant (see `permissionModeChoices`). */
|
|
540
160
|
permissionModes: PermissionModeChoice[]
|
|
161
|
+
skills: SkillInfo[] | undefined
|
|
541
162
|
cwd: TranscriptState['cwd']
|
|
542
163
|
contextUsage: TranscriptState['contextUsage']
|
|
543
164
|
rateLimits: TranscriptState['rateLimits']
|
|
544
|
-
/**
|
|
545
|
-
*
|
|
546
|
-
*
|
|
165
|
+
/**
|
|
166
|
+
* When the newest window in `rateLimits` was reported, as event time — not receive time. External chrome (the
|
|
167
|
+
* VS Code status bar, iOS) cannot otherwise tell a live reading from one a days-old session just replayed.
|
|
168
|
+
* Absent means unknown, which is not the same as fresh.
|
|
169
|
+
*/
|
|
170
|
+
rateLimitsUpdatedAt: number | undefined
|
|
547
171
|
itemCount: number
|
|
548
|
-
/** Session-cumulative cost in USD. The internal status bar renders this via
|
|
549
|
-
* `formatCost`; an external host needs it to reproduce that reading. */
|
|
550
172
|
totalCostUsd: number
|
|
551
173
|
}
|
|
552
174
|
|
|
553
|
-
/**
|
|
554
|
-
* The all-in-one embeddable session surface: status bar, streaming transcript,
|
|
555
|
-
* permission prompts, composer. Attaches via useClaudeSession; remount (key) to switch
|
|
556
|
-
* sessions.
|
|
557
|
-
*
|
|
558
|
-
* Every affordance is gated on the session's **capability record** rather than on
|
|
559
|
-
* the engine name — an absent capability hides the control instead of offering
|
|
560
|
-
* one that can only fail.
|
|
561
|
-
*/
|
|
562
175
|
export function SessionPanel({
|
|
563
176
|
client,
|
|
564
177
|
sessionId,
|
|
@@ -574,7 +187,8 @@ export function SessionPanel({
|
|
|
574
187
|
affordances,
|
|
575
188
|
terminalMetrics,
|
|
576
189
|
scrubber = false,
|
|
577
|
-
|
|
190
|
+
bookmarks,
|
|
191
|
+
onToggleBookmark,
|
|
578
192
|
reveal,
|
|
579
193
|
openSubagent,
|
|
580
194
|
onSubagentChange,
|
|
@@ -592,52 +206,15 @@ export function SessionPanel({
|
|
|
592
206
|
fontSize,
|
|
593
207
|
className,
|
|
594
208
|
}: SessionPanelProps) {
|
|
595
|
-
// ── Font size resolution ──────────────────────────────────────────────
|
|
596
|
-
// `fontSize` is the panel-wide knob; `terminalMetrics` is the terminal-
|
|
597
|
-
// specific override that predates it. The two compose: `fontSize` provides
|
|
598
|
-
// the default the terminal derives from (13 : 18 ratio), and individual
|
|
599
|
-
// terminalMetrics fields can still override each axis.
|
|
600
209
|
const effectiveTermFontSize = terminalMetrics?.fontSize ?? fontSize
|
|
601
|
-
const effectiveTermLineHeight =
|
|
602
|
-
terminalMetrics?.lineHeight ??
|
|
603
|
-
(fontSize !== undefined ? Math.round(fontSize * (18 / 13)) : undefined)
|
|
210
|
+
const effectiveTermLineHeight = terminalMetrics?.lineHeight ?? (fontSize !== undefined ? Math.round(fontSize * (18 / 13)) : undefined)
|
|
604
211
|
|
|
605
212
|
const external = panelSurface === 'external'
|
|
606
213
|
const statusExternal = statusSurface === 'external'
|
|
607
|
-
// Both non-internal surfaces take the pickers out of the composer, which is
|
|
608
|
-
// what collapses it to a single line.
|
|
609
214
|
const controlsInStatus = controlsSurface === 'status' && !statusExternal
|
|
610
215
|
const controlsExternal = controlsSurface === 'external' || controlsInStatus
|
|
611
|
-
// Rejected commands (the CLI refusing a permission-mode switch, say) render INSIDE
|
|
612
|
-
// the panel rather than through `toast`. The panel does not mount a `Toaster`, and
|
|
613
|
-
// an embedder that doesn't either would drop the only signal that a command failed
|
|
614
|
-
// — the select would just "not stick". An error channel a host can lose by omission
|
|
615
|
-
// is not an error channel.
|
|
616
216
|
const [protocolError, setProtocolError] = useState<string | undefined>(undefined)
|
|
617
217
|
const [panel, setPanel] = useState<Panel | undefined>()
|
|
618
|
-
/**
|
|
619
|
-
* The sub-agent takeover: which `Task` call the body is currently showing, or
|
|
620
|
-
* undefined for the conversation.
|
|
621
|
-
*
|
|
622
|
-
* Deliberately **not** a `Panel` member. Those route outward under
|
|
623
|
-
* `panelSurface: 'external'` so a host can draw them natively — and a host
|
|
624
|
-
* cannot draw this one: it is a transcript, and the transcript is in here. It
|
|
625
|
-
* is a mode of the conversation surface, not a screen over it.
|
|
626
|
-
*/
|
|
627
|
-
const [subagentId, setSubagentId] = useState<string | undefined>(undefined)
|
|
628
|
-
/**
|
|
629
|
-
* Where to land on the way back — the row of the Task you entered from,
|
|
630
|
-
* asked for through the ordinary reveal seam.
|
|
631
|
-
*
|
|
632
|
-
* The alternative was keeping the main transcript mounted-but-hidden to
|
|
633
|
-
* preserve its scroll offset, which costs two live virtualizers and an
|
|
634
|
-
* absolute-position layout on a panel whose height just changed (the composer
|
|
635
|
-
* came back). Re-revealing is both cheaper and more honest: you return to the
|
|
636
|
-
* row you left from rather than to wherever you happened to be scrolled.
|
|
637
|
-
*/
|
|
638
|
-
const [returnReveal, setReturnReveal] = useState<
|
|
639
|
-
{ toolUseId: string; nonce: number } | undefined
|
|
640
|
-
>(undefined)
|
|
641
218
|
const {
|
|
642
219
|
state,
|
|
643
220
|
connection,
|
|
@@ -655,103 +232,17 @@ export function SessionPanel({
|
|
|
655
232
|
reconnectNow,
|
|
656
233
|
loadFullResult,
|
|
657
234
|
} = useClaudeSession(client, sessionId, { onProtocolError: setProtocolError, cacheTranscript })
|
|
658
|
-
// Callers are told to remount on a session switch, but a changed prop must not leave
|
|
659
|
-
// the previous session's failure on screen.
|
|
660
235
|
useEffect(() => setProtocolError(undefined), [sessionId])
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
// reader on the Task row they entered from. A reveal is the one exit that
|
|
671
|
-
// doesn't: it brought its own destination.
|
|
672
|
-
const leaveSubagent = useCallback(() => {
|
|
673
|
-
setSubagentId((current) => {
|
|
674
|
-
if (current !== undefined) setReturnReveal({ toolUseId: current, nonce: Date.now() })
|
|
675
|
-
return undefined
|
|
676
|
-
})
|
|
677
|
-
}, [])
|
|
678
|
-
|
|
679
|
-
// The host asking — or withdrawing the ask. Keyed on the nonce, so asking
|
|
680
|
-
// twice for the same agent works — and so a request that arrives while
|
|
681
|
-
// another frame is open swaps it in place rather than being ignored.
|
|
682
|
-
//
|
|
683
|
-
// A withdrawn request (the prop going away without a remount) CLOSES the
|
|
684
|
-
// frame — see the prop's docblock for why a host keeping its request in
|
|
685
|
-
// route state needs that to be true. Two non-cases are worth naming because
|
|
686
|
-
// they look like hazards and are not: on a fresh mount with no request this
|
|
687
|
-
// fires once and `leaveSubagent` finds nothing framed, so first render
|
|
688
|
-
// cannot wipe anything; and a host *echoing* the panel's own report back
|
|
689
|
-
// (the dashboard folding `onSubagentChange` into `?subagent=`) re-arrives
|
|
690
|
-
// with the nonce unchanged, so this effect never re-runs for it — the echo
|
|
691
|
-
// is inert by construction, not by a same-value bail-out.
|
|
692
|
-
const openSubagentNonce = openSubagent?.nonce
|
|
693
|
-
const openSubagentId = openSubagent?.toolUseId
|
|
694
|
-
useEffect(() => {
|
|
695
|
-
if (openSubagentId === undefined) leaveSubagent()
|
|
696
|
-
else setSubagentId(openSubagentId)
|
|
697
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
698
|
-
}, [openSubagentNonce])
|
|
699
|
-
|
|
700
|
-
// A *reveal* asked for while framed means the reader wants the conversation:
|
|
701
|
-
// latest intent wins. Leaving the frame up and scrolling something invisible
|
|
702
|
-
// underneath it would be the worst of both.
|
|
703
|
-
const revealNonce = reveal?.nonce
|
|
704
|
-
useEffect(() => {
|
|
705
|
-
if (revealNonce === undefined) return
|
|
706
|
-
setSubagentId(undefined)
|
|
707
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
708
|
-
}, [revealNonce])
|
|
709
|
-
|
|
710
|
-
// Escape leaves the frame — the keyboard half of Back. `defaultPrevented`
|
|
711
|
-
// keeps a dialog's own Escape (and the composer's) ahead of it: this is the
|
|
712
|
-
// outermost thing Escape can mean here, so it goes last.
|
|
713
|
-
useEffect(() => {
|
|
714
|
-
if (subagentId === undefined) return
|
|
715
|
-
const onKey = (event: KeyboardEvent) => {
|
|
716
|
-
if (event.key !== 'Escape' || event.defaultPrevented) return
|
|
717
|
-
leaveSubagent()
|
|
718
|
-
}
|
|
719
|
-
window.addEventListener('keydown', onKey)
|
|
720
|
-
return () => window.removeEventListener('keydown', onKey)
|
|
721
|
-
}, [subagentId, leaveSubagent])
|
|
722
|
-
|
|
723
|
-
// The report out — see the prop's docblock for what it claims. Fired off the
|
|
724
|
-
// STATE rather than from each exit path, because the state is the only place
|
|
725
|
-
// all four ways out and both ways in already meet; a notification hung on
|
|
726
|
-
// the affordances would go stale the first time someone added a fifth.
|
|
727
|
-
//
|
|
728
|
-
// The ref pair is the mount guard, and it is load-bearing. On a deep-linked
|
|
729
|
-
// mount, `subagentId` is `undefined` for the whole first commit — the
|
|
730
|
-
// seeding effect above runs in that same commit and its setState lands in
|
|
731
|
-
// the next one — so a naive `useEffect(..., [subagentId])` reports
|
|
732
|
-
// `undefined` first, the host clears the `?subagent=` it was asked with,
|
|
733
|
-
// and the withdrawal closes the frame the reader just requested. The ref
|
|
734
|
-
// starts at `undefined`, so the first commit is silent by construction —
|
|
735
|
-
// not by effect ordering, which is why this works wherever it sits relative
|
|
736
|
-
// to the seeding effect (and under StrictMode's double mount). No cleanup
|
|
737
|
-
// report on unmount, deliberately: hosts key this panel by session, and a
|
|
738
|
-
// departing panel announcing "nothing framed" would stomp whatever the host
|
|
739
|
-
// already holds for the next one.
|
|
740
|
-
const onSubagentChangeRef = useRef(onSubagentChange)
|
|
741
|
-
onSubagentChangeRef.current = onSubagentChange
|
|
742
|
-
const reportedSubagentId = useRef<string | undefined>(undefined)
|
|
743
|
-
useEffect(() => {
|
|
744
|
-
if (reportedSubagentId.current === subagentId) return
|
|
745
|
-
reportedSubagentId.current = subagentId
|
|
746
|
-
onSubagentChangeRef.current?.(subagentId)
|
|
747
|
-
}, [subagentId])
|
|
236
|
+
const {
|
|
237
|
+
subagentId,
|
|
238
|
+
enterSubagent,
|
|
239
|
+
leaveSubagent,
|
|
240
|
+
returnReveal,
|
|
241
|
+
frameItems: subagentFrameItems,
|
|
242
|
+
task: subagentTask,
|
|
243
|
+
fallbackLabel: subagentFallbackLabel,
|
|
244
|
+
} = useSubagentFrame({ sessionId, items: state.items, session: state.session, reveal, openSubagent, onSubagentChange })
|
|
748
245
|
|
|
749
|
-
// Catch-up is entered once, from the watermark the embedder handed over, and
|
|
750
|
-
// left when dismissed or when the user sends anything (they are plainly
|
|
751
|
-
// caught up at that point). Snapshotted into state rather than read from the
|
|
752
|
-
// prop each render: the embedder keeps updating the watermark while the panel
|
|
753
|
-
// is on screen, and a boundary that crept forward under the reader would
|
|
754
|
-
// un-dim the very rows they came back to read.
|
|
755
246
|
const [caughtUp, setCaughtUp] = useState(false)
|
|
756
247
|
useEffect(() => {
|
|
757
248
|
setCaughtUp(false)
|
|
@@ -760,29 +251,25 @@ export function SessionPanel({
|
|
|
760
251
|
const catchUp = caughtUp ? undefined : catchUpMark
|
|
761
252
|
const newCount = catchUp ? Math.max(0, state.items.length - catchUp.itemCount) : 0
|
|
762
253
|
|
|
763
|
-
// One router for every panel-opening affordance: internal surface opens the
|
|
764
|
-
// dialog, external hands the intent to the embedder (or drops it, absent a
|
|
765
|
-
// handler — inert beats half-working).
|
|
766
254
|
const openPanel = useCallback(
|
|
767
255
|
(target: SessionSurfacePanel) => {
|
|
768
|
-
if (external)
|
|
769
|
-
|
|
256
|
+
if (external) {
|
|
257
|
+
onOpenPanel?.(target)
|
|
258
|
+
} else {
|
|
259
|
+
setPanel(target)
|
|
260
|
+
}
|
|
770
261
|
},
|
|
771
262
|
[external, onOpenPanel],
|
|
772
263
|
)
|
|
773
|
-
// A tab that was in the background has been sitting out the reconnect backoff;
|
|
774
|
-
// coming back to it is exactly when waiting the rest of it out is wrong.
|
|
775
264
|
useEffect(() => {
|
|
776
265
|
const onVisible = () => {
|
|
777
|
-
if (document.visibilityState === 'visible')
|
|
266
|
+
if (document.visibilityState === 'visible') {
|
|
267
|
+
reconnectNow()
|
|
268
|
+
}
|
|
778
269
|
}
|
|
779
270
|
document.addEventListener('visibilitychange', onVisible)
|
|
780
271
|
return () => document.removeEventListener('visibilitychange', onVisible)
|
|
781
272
|
}, [reconnectNow])
|
|
782
|
-
// Host server-bridged tool calls (provider-engine sessions) in this tab, on the
|
|
783
|
-
// SAME handle the panel attached with — the bridge asks the first attached
|
|
784
|
-
// client. Free for Claude sessions: the guest loads lazily on the first call,
|
|
785
|
-
// which for them never comes.
|
|
786
273
|
useToolCallHost(
|
|
787
274
|
handle,
|
|
788
275
|
toolHost === false
|
|
@@ -793,51 +280,15 @@ export function SessionPanel({
|
|
|
793
280
|
)
|
|
794
281
|
const terminal = transcriptVariant === 'terminal'
|
|
795
282
|
|
|
796
|
-
// What the strip reads. The frame's items and its spawning call both come from
|
|
797
|
-
// the transcript, which is the only complete source: `SessionInfo.subagents`
|
|
798
|
-
// keeps eight settled records and is explicitly not a session's Task history.
|
|
799
|
-
const subagentFrameItems = useMemo(
|
|
800
|
-
() => (subagentId === undefined ? [] : subagentItems(state.items, subagentId)),
|
|
801
|
-
[state.items, subagentId],
|
|
802
|
-
)
|
|
803
|
-
const subagentTask = useMemo(
|
|
804
|
-
() =>
|
|
805
|
-
subagentId === undefined
|
|
806
|
-
? undefined
|
|
807
|
-
: state.items.find(
|
|
808
|
-
(item): item is ToolCallItem => item.kind === 'tool_call' && item.id === subagentId,
|
|
809
|
-
),
|
|
810
|
-
[state.items, subagentId],
|
|
811
|
-
)
|
|
812
|
-
// The one thing the rollup is allowed to do: name an agent whose `Task` call
|
|
813
|
-
// the transcript does not have. A label is not content.
|
|
814
|
-
const subagentFallbackLabel = useMemo(() => {
|
|
815
|
-
const record = state.session?.subagents?.find((sub) => sub.toolUseId === subagentId)
|
|
816
|
-
return record ? subagentLabel(record) : 'Sub-agent'
|
|
817
|
-
}, [state.session, subagentId])
|
|
818
283
|
const capabilities = state.capabilities
|
|
819
284
|
|
|
820
|
-
// Plan usage as the *gateway* knows it, merged over this session's own
|
|
821
|
-
// reading — one derivation, feeding the bar, the terminal status line, the
|
|
822
|
-
// Usage panel and the vitals an external chrome renders from, because four
|
|
823
|
-
// surfaces disagreeing about the same percentage is worse than any of them
|
|
824
|
-
// being stale. A session's own `rate_limit` readings land only at a turn's
|
|
825
|
-
// edges, so an idle session's numbers age silently and a sibling session's
|
|
826
|
-
// spend never shows up here at all; `mergeUsage` is where that rule is
|
|
827
|
-
// written down. Skipped entirely for an engine that reports no windows.
|
|
828
285
|
const { usage: profileUsage } = useProfileUsage(client, state.session?.profile, {
|
|
829
286
|
enabled: capabilities.rateLimits,
|
|
830
287
|
})
|
|
831
288
|
const usage = useMemo(
|
|
832
|
-
() =>
|
|
833
|
-
mergeUsage(
|
|
834
|
-
{ rateLimits: state.rateLimits, updatedAt: state.rateLimitsUpdatedAt },
|
|
835
|
-
profileUsage,
|
|
836
|
-
),
|
|
289
|
+
() => mergeUsage({ rateLimits: state.rateLimits, updatedAt: state.rateLimitsUpdatedAt }, profileUsage),
|
|
837
290
|
[state.rateLimits, state.rateLimitsUpdatedAt, profileUsage],
|
|
838
291
|
)
|
|
839
|
-
// Undefined rather than an empty map when nothing has been reported: absent is
|
|
840
|
-
// *unknown*, and a surface that can't tell the two apart draws 0%.
|
|
841
292
|
const rateLimits: Record<string, RateLimitInfo> | undefined = useMemo(
|
|
842
293
|
() => (Object.keys(usage).length > 0 ? usageInfos(usage) : undefined),
|
|
843
294
|
[usage],
|
|
@@ -847,13 +298,9 @@ export function SessionPanel({
|
|
|
847
298
|
return stamps.length > 0 ? Math.max(...stamps) : undefined
|
|
848
299
|
}, [usage])
|
|
849
300
|
|
|
850
|
-
// Vitals out to the embedder, keyed on the readings themselves so an inline
|
|
851
|
-
// closure prop doesn't retrigger it every render.
|
|
852
301
|
const onVitalsRef = useRef(onVitals)
|
|
853
302
|
onVitalsRef.current = onVitals
|
|
854
303
|
const vitalsModel = effectiveModel ?? state.model
|
|
855
|
-
// The choices, not just the current values: an external picker has no second
|
|
856
|
-
// attach to learn them from.
|
|
857
304
|
const permissionModes = useMemo(
|
|
858
305
|
() => permissionModeChoices(capabilities.permissionModes, state.session?.canBypassPermissions),
|
|
859
306
|
[capabilities.permissionModes, state.session?.canBypassPermissions],
|
|
@@ -868,9 +315,11 @@ export function SessionPanel({
|
|
|
868
315
|
models,
|
|
869
316
|
permissionMode: state.permissionMode,
|
|
870
317
|
permissionModes,
|
|
318
|
+
skills: state.skills,
|
|
871
319
|
cwd: state.cwd,
|
|
872
320
|
contextUsage: state.contextUsage,
|
|
873
321
|
rateLimits,
|
|
322
|
+
rateLimitsUpdatedAt: usageUpdatedAt,
|
|
874
323
|
itemCount: state.items.length,
|
|
875
324
|
totalCostUsd: state.totalCostUsd,
|
|
876
325
|
})
|
|
@@ -883,15 +332,15 @@ export function SessionPanel({
|
|
|
883
332
|
models,
|
|
884
333
|
state.permissionMode,
|
|
885
334
|
permissionModes,
|
|
335
|
+
state.skills,
|
|
886
336
|
state.cwd,
|
|
887
337
|
state.contextUsage,
|
|
888
338
|
rateLimits,
|
|
339
|
+
usageUpdatedAt,
|
|
889
340
|
state.items.length,
|
|
890
341
|
state.totalCostUsd,
|
|
891
342
|
])
|
|
892
343
|
|
|
893
|
-
// The commands back in. One stable object reading through refs, so an
|
|
894
|
-
// embedder can stash it and a re-render never invalidates what it holds.
|
|
895
344
|
const onControlsRef = useRef(onControls)
|
|
896
345
|
onControlsRef.current = onControls
|
|
897
346
|
const setters = useRef({ setModel, setPermissionMode, interrupt })
|
|
@@ -901,161 +350,121 @@ export function SessionPanel({
|
|
|
901
350
|
setPermissionMode: (mode) => setters.current.setPermissionMode(mode),
|
|
902
351
|
interrupt: () => setters.current.interrupt(),
|
|
903
352
|
focusComposer: () => composerRef.current?.focus(),
|
|
353
|
+
insertComposerText: (text) => composerRef.current?.insertText(text),
|
|
904
354
|
})
|
|
905
355
|
useEffect(() => {
|
|
906
356
|
const handler = onControlsRef.current
|
|
907
357
|
handler?.(controls.current)
|
|
908
358
|
return () => handler?.(undefined)
|
|
909
359
|
}, [sessionId])
|
|
360
|
+
const bookmarkSet = useMemo(() => new Set(bookmarks ?? []), [bookmarks])
|
|
361
|
+
const bookmarkHandle = useMemo<BookmarkHandle | undefined>(
|
|
362
|
+
() => (onToggleBookmark ? { has: (id) => bookmarkSet.has(id), toggle: onToggleBookmark } : undefined),
|
|
363
|
+
[bookmarkSet, onToggleBookmark],
|
|
364
|
+
)
|
|
365
|
+
|
|
910
366
|
const busy = state.status === 'running' || state.status === 'awaiting_approval'
|
|
911
367
|
const ended = state.status === 'failed' || state.status === 'closed'
|
|
912
368
|
const attachments = useAttachments(client, sessionId, {
|
|
913
369
|
capabilities,
|
|
914
370
|
engine: state.engine,
|
|
915
371
|
})
|
|
916
|
-
// Rooted at the session's cwd, which arrives with the snapshot — so `@` is
|
|
917
|
-
// inert for the moment before it does, and stays inert on a gateway that
|
|
918
|
-
// serves no host files.
|
|
919
372
|
const hostFiles = useHostFileSearch(client, state.cwd)
|
|
920
|
-
|
|
921
|
-
//
|
|
373
|
+
const draft = useDraft(client, sessionId)
|
|
374
|
+
// Stable identity: an inline arrow here would bust the Composer's `triggers`
|
|
375
|
+
// memo on every streaming re-render, and with it every prompt-area callback
|
|
376
|
+
// keyed on the triggers.
|
|
377
|
+
const searchComposerFiles = useCallback(
|
|
378
|
+
(query: string, options: { signal: AbortSignal }) => hostFiles.search(query, { ...options, limit: 8 }),
|
|
379
|
+
[hostFiles.search],
|
|
380
|
+
)
|
|
922
381
|
const windows = useMemo(() => orderUsageWindows(usage), [usage])
|
|
923
|
-
// Reads a picture the engine left on the host (codex's `image_gen` reports a
|
|
924
|
-
// path, never bytes). Stable and memoized per path: transcript rows re-render
|
|
925
|
-
// on every delta, and a fresh function would re-fetch each time.
|
|
926
382
|
const hostImage = useHostImage(client, sessionId, state.producedFiles)
|
|
927
|
-
// The other picture route, and the other store: an image *part* of a tool
|
|
928
|
-
// result, which an opted-in replay delivered as a reference rather than half a
|
|
929
|
-
// megabyte of base64. Bounded, unlike `useHostImage` — see the module.
|
|
930
383
|
const resultImages = useToolResultImages(client, sessionId)
|
|
931
384
|
const composerRef = useRef<ComposerHandle>(null)
|
|
932
|
-
// The catch-up strip's way of scrolling the (virtualized, usually unmounted)
|
|
933
|
-
// recap row into view — the transcript fills it in. See TranscriptProps.
|
|
934
385
|
const jumpToRecap = useRef<(() => void) | null>(null)
|
|
935
|
-
// Filled by the transcript; pressed on send. See `handleSend`.
|
|
936
386
|
const repinTranscript = useRef<(() => void) | null>(null)
|
|
937
387
|
|
|
938
|
-
// "/model" is handled panel-side (see handleSend) — surface it in the autocomplete
|
|
939
|
-
// even though the CLI's command list doesn't include it.
|
|
940
388
|
const commands = useMemo(() => {
|
|
941
|
-
if (!state.commands)
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
389
|
+
if (!state.commands) {
|
|
390
|
+
return undefined
|
|
391
|
+
}
|
|
392
|
+
if (state.commands.some((c) => c.name === 'model')) {
|
|
393
|
+
return state.commands
|
|
394
|
+
}
|
|
395
|
+
return [{ name: 'model', description: 'Switch the model for this session', argumentHint: '<model>' }, ...state.commands]
|
|
947
396
|
}, [state.commands])
|
|
948
397
|
|
|
949
|
-
// Two things are answered here rather than sent, because sending them would
|
|
950
|
-
// spend a turn on a model reading the words back.
|
|
951
398
|
const handleSend = (text: string, attachmentIds: string[]) => {
|
|
952
399
|
if (attachmentIds.length === 0) {
|
|
953
|
-
// "/model <id>" switches the model directly instead of going to the CLI.
|
|
954
400
|
const modelCommand = /^\/model\s+(\S+)$/.exec(text)
|
|
955
401
|
if (modelCommand) {
|
|
956
402
|
setModel(modelCommand[1])
|
|
957
403
|
return
|
|
958
404
|
}
|
|
959
|
-
// The CLI's own `/mcp` is an interactive picker, not a prompt. Only where
|
|
960
|
-
// the capability exists: elsewhere it is ordinary message text, like any
|
|
961
|
-
// other slash command on an engine without them.
|
|
962
405
|
if (capabilities.mcpStatus && text.trim() === '/mcp') {
|
|
963
406
|
openPanel('mcp')
|
|
964
407
|
return
|
|
965
408
|
}
|
|
966
409
|
}
|
|
967
|
-
// Typing into a session is the clearest possible statement that you have
|
|
968
|
-
// read it — nothing left to catch up on.
|
|
969
410
|
setCaughtUp(true)
|
|
970
|
-
// ...and sending is the statement that you want to watch what happens next,
|
|
971
|
-
// so following resumes here too. Scrolling up escapes the bottom lock, and
|
|
972
|
-
// without this the reply streams in off screen: the transcript stays parked
|
|
973
|
-
// where you were reading and sending looks like it did nothing. Ordered
|
|
974
|
-
// before `send` only for readability — the re-pin is instant and the first
|
|
975
|
-
// row is a round trip away either way.
|
|
976
411
|
repinTranscript.current?.()
|
|
977
412
|
send(text, attachmentIds)
|
|
978
413
|
}
|
|
979
414
|
|
|
980
|
-
// Everything the panel can open, in one place — and each one is also
|
|
981
|
-
// reachable by clicking the thing on the bar that summarises it. Entries the
|
|
982
|
-
// capability record forswears are absent, not present-and-empty.
|
|
983
|
-
//
|
|
984
|
-
// Built once and placed once: either the embedder's header takes it (see the
|
|
985
|
-
// `header` render-prop) or the status bar does. Never both — two `⋯` menus on
|
|
986
|
-
// one screen is worse than either position.
|
|
987
415
|
const actionsMenu = (
|
|
988
416
|
<Menu>
|
|
989
417
|
<MenuTrigger
|
|
990
418
|
render={
|
|
991
|
-
<Button variant=
|
|
992
|
-
<MoreHorizontal className=
|
|
419
|
+
<Button variant="ghost" size="icon-sm" aria-label="Session actions">
|
|
420
|
+
<MoreHorizontal className="size-4" />
|
|
993
421
|
</Button>
|
|
994
422
|
}
|
|
995
423
|
/>
|
|
996
424
|
<MenuContent>
|
|
997
425
|
{capabilities.contextUsage ? (
|
|
998
426
|
<MenuItem onClick={() => openPanel('context')}>
|
|
999
|
-
<ChartPie className=
|
|
427
|
+
<ChartPie className="size-3.5 text-fg-3" /> Context
|
|
1000
428
|
</MenuItem>
|
|
1001
429
|
) : null}
|
|
1002
430
|
{capabilities.rateLimits ? (
|
|
1003
431
|
<MenuItem onClick={() => openPanel('usage')}>
|
|
1004
|
-
<Gauge className=
|
|
432
|
+
<Gauge className="size-3.5 text-fg-3" /> Usage
|
|
1005
433
|
</MenuItem>
|
|
1006
434
|
) : null}
|
|
1007
435
|
<MenuItem onClick={() => openPanel('info')}>
|
|
1008
|
-
<Info className=
|
|
436
|
+
<Info className="size-3.5 text-fg-3" /> Session info
|
|
1009
437
|
</MenuItem>
|
|
1010
438
|
{capabilities.mcpStatus ? (
|
|
1011
439
|
<MenuItem onClick={() => openPanel('mcp')}>
|
|
1012
|
-
<Plug className=
|
|
440
|
+
<Plug className="size-3.5 text-fg-3" /> MCP servers
|
|
1013
441
|
</MenuItem>
|
|
1014
442
|
) : null}
|
|
1015
|
-
{/* On the capability alone, like MCP's entry. Codex answers
|
|
1016
|
-
`skills/list` only over a live child, so before the first turn there
|
|
1017
|
-
is no list yet — but hiding the entry until then made the dialog's
|
|
1018
|
-
own explanation of that unreachable, which read as the feature being
|
|
1019
|
-
missing. The empty state says it instead. */}
|
|
1020
443
|
{capabilities.skillsList ? (
|
|
1021
444
|
<MenuItem onClick={() => openPanel('skills')}>
|
|
1022
|
-
<Sparkles className=
|
|
445
|
+
<Sparkles className="size-3.5 text-fg-3" /> Skills
|
|
1023
446
|
</MenuItem>
|
|
1024
447
|
) : null}
|
|
1025
448
|
{hostFiles.available ? (
|
|
1026
449
|
<MenuItem onClick={() => openPanel('files')}>
|
|
1027
|
-
<FolderTree className=
|
|
450
|
+
<FolderTree className="size-3.5 text-fg-3" /> Project files
|
|
1028
451
|
</MenuItem>
|
|
1029
452
|
) : null}
|
|
1030
453
|
</MenuContent>
|
|
1031
454
|
</Menu>
|
|
1032
455
|
)
|
|
1033
456
|
|
|
1034
|
-
// A function header claims the menu; anything else leaves it on the status bar.
|
|
1035
|
-
// The external surface has no menu to claim — those entries live in the
|
|
1036
|
-
// embedder's own chrome, reached through onOpenPanel.
|
|
1037
457
|
const menu = external ? null : actionsMenu
|
|
1038
458
|
const headerTakesActions = typeof header === 'function'
|
|
1039
459
|
|
|
1040
|
-
// Built once and placed at one end or the other — the bar has a `⋯` menu and
|
|
1041
|
-
// three open handlers, and two copies of that in the tree would be two things
|
|
1042
|
-
// to keep in step.
|
|
1043
|
-
// Built once, placed by `controlsSurface`: the composer's toolbar row, or the
|
|
1044
|
-
// status bar beside the readings they act on, or nowhere at all when the host
|
|
1045
|
-
// draws them in its own chrome.
|
|
1046
460
|
const sessionControls = (
|
|
1047
461
|
<>
|
|
1048
|
-
{/* Codex reports no `capabilities` event, so its models arrive from the
|
|
1049
|
-
profile catalog instead — without that fallback its picker would be
|
|
1050
|
-
permanently empty and the session unswitchable. */}
|
|
1051
462
|
{models.length ? (
|
|
1052
463
|
<ModelSelect
|
|
1053
464
|
models={models}
|
|
1054
465
|
model={effectiveModel}
|
|
1055
466
|
onModelChange={setModel}
|
|
1056
467
|
disabled={ended}
|
|
1057
|
-
// The bar is one line of label-sized text; a 24px trigger would set
|
|
1058
|
-
// its height instead of fitting in it.
|
|
1059
468
|
className={controlsInStatus ? 'h-5' : undefined}
|
|
1060
469
|
/>
|
|
1061
470
|
) : null}
|
|
@@ -1063,8 +472,6 @@ export function SessionPanel({
|
|
|
1063
472
|
<PermissionModeSelect
|
|
1064
473
|
mode={state.permissionMode}
|
|
1065
474
|
onModeChange={setPermissionMode}
|
|
1066
|
-
// Only what this engine implements — the rest would come back as a
|
|
1067
|
-
// protocol_error.
|
|
1068
475
|
modes={capabilities.permissionModes}
|
|
1069
476
|
canBypass={state.session?.canBypassPermissions}
|
|
1070
477
|
disabled={ended}
|
|
@@ -1088,22 +495,24 @@ export function SessionPanel({
|
|
|
1088
495
|
/>
|
|
1089
496
|
)
|
|
1090
497
|
|
|
1091
|
-
// ── Link click handler ────────────────────────────────────────────────
|
|
1092
|
-
// When onLinkClick is provided, intercept <a> clicks on the panel root so
|
|
1093
|
-
// the embedder controls navigation. Capture phase so it fires before any
|
|
1094
|
-
// default handling. Only installed when the prop is present — a host that
|
|
1095
|
-
// does not provide it (VS Code, the dashboard) gets the browser / webview
|
|
1096
|
-
// default, which is the right thing in both cases.
|
|
1097
498
|
const panelRef = useRef<HTMLDivElement>(null)
|
|
1098
499
|
useEffect(() => {
|
|
1099
|
-
if (!onLinkClick)
|
|
500
|
+
if (!onLinkClick) {
|
|
501
|
+
return
|
|
502
|
+
}
|
|
1100
503
|
const el = panelRef.current
|
|
1101
|
-
if (!el)
|
|
504
|
+
if (!el) {
|
|
505
|
+
return
|
|
506
|
+
}
|
|
1102
507
|
const handler = (e: MouseEvent) => {
|
|
1103
508
|
const anchor = (e.target as HTMLElement)?.closest?.('a[href]') as HTMLAnchorElement | null
|
|
1104
|
-
if (!anchor)
|
|
509
|
+
if (!anchor) {
|
|
510
|
+
return
|
|
511
|
+
}
|
|
1105
512
|
const href = anchor.getAttribute('href')
|
|
1106
|
-
if (!href)
|
|
513
|
+
if (!href) {
|
|
514
|
+
return
|
|
515
|
+
}
|
|
1107
516
|
if (onLinkClick(href)) {
|
|
1108
517
|
e.preventDefault()
|
|
1109
518
|
e.stopPropagation()
|
|
@@ -1113,311 +522,232 @@ export function SessionPanel({
|
|
|
1113
522
|
return () => el.removeEventListener('click', handler, true)
|
|
1114
523
|
}, [onLinkClick])
|
|
1115
524
|
|
|
1116
|
-
// Dead-space clicks land in the composer. Anything the user actually aimed at
|
|
1117
|
-
// — a control, a link, the end of a drag-selection — keeps its own meaning;
|
|
1118
|
-
// this only claims what was left over.
|
|
1119
525
|
const handleClick = (event: ReactMouseEvent<HTMLDivElement>) => {
|
|
1120
|
-
if (!focusComposerOnClick || readOnly)
|
|
526
|
+
if (!focusComposerOnClick || readOnly) {
|
|
527
|
+
return
|
|
528
|
+
}
|
|
1121
529
|
const target = event.target as HTMLElement | null
|
|
1122
|
-
if (target?.closest(INTERACTIVE))
|
|
1123
|
-
|
|
530
|
+
if (target?.closest(INTERACTIVE)) {
|
|
531
|
+
return
|
|
532
|
+
}
|
|
533
|
+
if (window.getSelection()?.isCollapsed === false) {
|
|
534
|
+
return
|
|
535
|
+
}
|
|
1124
536
|
composerRef.current?.focus()
|
|
1125
537
|
}
|
|
1126
538
|
|
|
1127
539
|
return (
|
|
1128
|
-
// The variant is a panel-wide fact, not a transcript-only one: the composer
|
|
1129
|
-
// and the pending prompts live outside the scroller but belong to the same
|
|
1130
|
-
// run, and they read it from this context rather than a prop chain.
|
|
1131
540
|
<TranscriptVariantProvider value={transcriptVariant}>
|
|
1132
541
|
<TranscriptDensityProvider value={transcriptDensity}>
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
rendered anywhere else fall back to the context's no-op, which is
|
|
1136
|
-
correct for them: nothing truncates a replay they never asked for. */}
|
|
1137
|
-
<ToolResultFetchProvider value={loadFullResult}>
|
|
1138
|
-
<ToolResultImageProvider value={resultImages}>
|
|
1139
|
-
<div
|
|
1140
|
-
ref={panelRef}
|
|
1141
|
-
data-slot='session-panel'
|
|
1142
|
-
// The typeface is a cascade fact, not a React one — one attribute here,
|
|
1143
|
-
// and the `[data-agent-font]` rule in theme.css does the rest. Nothing
|
|
1144
|
-
// outside this subtree can pick it up.
|
|
1145
|
-
data-agent-font={transcriptFont}
|
|
1146
|
-
onClick={handleClick}
|
|
1147
|
-
className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}
|
|
1148
|
-
style={fontSize !== undefined ? { '--wd-font-size': `${Math.round(fontSize)}px` } as React.CSSProperties : undefined}>
|
|
1149
|
-
{headerTakesActions ? header({ actions: menu }) : header}
|
|
1150
|
-
{statusPlacement === 'top' ? statusBar : null}
|
|
1151
|
-
{protocolMismatch !== undefined ? (
|
|
1152
|
-
<Notice level='warning'>
|
|
1153
|
-
Server speaks protocol v{protocolMismatch}, this build renders v{PROTOCOL_VERSION}. Some
|
|
1154
|
-
events may not render.
|
|
1155
|
-
</Notice>
|
|
1156
|
-
) : null}
|
|
1157
|
-
{protocolError ? (
|
|
1158
|
-
<Notice level='error' onDismiss={() => setProtocolError(undefined)}>
|
|
1159
|
-
{protocolError}
|
|
1160
|
-
</Notice>
|
|
1161
|
-
) : null}
|
|
1162
|
-
{/* The takeover's one line: who is on screen, and the way back. Above
|
|
1163
|
-
the transcript because it is a frame around it, not a row in it —
|
|
1164
|
-
and because Back must be reachable without scrolling a stream that
|
|
1165
|
-
is still growing. */}
|
|
1166
|
-
{subagentId !== undefined ? (
|
|
1167
|
-
<SubagentStrip
|
|
1168
|
-
task={subagentTask}
|
|
1169
|
-
items={subagentFrameItems}
|
|
1170
|
-
label={subagentFallbackLabel}
|
|
1171
|
-
onBack={leaveSubagent}
|
|
1172
|
-
terminal={terminal}
|
|
1173
|
-
fontSize={effectiveTermFontSize}
|
|
1174
|
-
lineHeight={effectiveTermLineHeight}
|
|
1175
|
-
/>
|
|
1176
|
-
) : null}
|
|
1177
|
-
<Transcript
|
|
1178
|
-
/* A remount, so the frame opens pinned to its own bottom with a fresh
|
|
1179
|
-
virtualizer and height epoch — which is the right landing for both
|
|
1180
|
-
cases: the live tail of a running agent, and the final report of a
|
|
1181
|
-
settled one. */
|
|
1182
|
-
key={subagentId ?? 'session'}
|
|
1183
|
-
state={state}
|
|
1184
|
-
fileUrl={sessionId ? (path) => client.sessionFileUrl(sessionId, path) : undefined}
|
|
1185
|
-
attachmentUrl={sessionId ? (id) => client.attachmentUrl(sessionId, id) : undefined}
|
|
1186
|
-
canBrowseFiles={hostFiles.available}
|
|
1187
|
-
hostImage={hostImage}
|
|
1188
|
-
variant={transcriptVariant}
|
|
1189
|
-
density={transcriptDensity}
|
|
1190
|
-
fontSize={effectiveTermFontSize}
|
|
1191
|
-
lineHeight={effectiveTermLineHeight}
|
|
1192
|
-
affordances={affordances}
|
|
1193
|
-
stickyPrompt={stickyPrompt}
|
|
1194
|
-
scrubber={scrubber}
|
|
1195
|
-
scrubberMarks={scrubberMarks}
|
|
1196
|
-
replaying={replaying}
|
|
1197
|
-
catchUp={
|
|
1198
|
-
catchUp && newCount > 0
|
|
1199
|
-
? { from: catchUp.itemCount, since: catchUp.since }
|
|
1200
|
-
: undefined
|
|
1201
|
-
}
|
|
1202
|
-
/* On the way back, land on the Task you came from — see `returnReveal`.
|
|
1203
|
-
The host's own reveal still wins when it is the newer intent. */
|
|
1204
|
-
reveal={returnReveal ?? reveal}
|
|
1205
|
-
frame={subagentId === undefined ? undefined : { parentToolUseId: subagentId }}
|
|
1206
|
-
onOpenSubagent={setSubagentId}
|
|
1207
|
-
emptyState={emptyState}
|
|
1208
|
-
jumpToRecapRef={jumpToRecap}
|
|
1209
|
-
repinRef={repinTranscript}
|
|
1210
|
-
/>
|
|
1211
|
-
{/* The way back into what you missed. Above the composer because that is
|
|
1212
|
-
where the eye already is on returning, and because the transcript
|
|
1213
|
-
itself opens pinned to the newest row. Held with the transcript
|
|
1214
|
-
while the replay lands — its count is `state.items.length`, which
|
|
1215
|
-
during the replay is a number visibly climbing toward its answer. */}
|
|
1216
|
-
{catchUp && newCount > 0 && !replaying && subagentId === undefined ? (
|
|
1217
|
-
<div className='px-3 pb-1'>
|
|
542
|
+
<ToolResultFetchProvider value={loadFullResult}>
|
|
543
|
+
<ToolResultImageProvider value={resultImages}>
|
|
1218
544
|
<div
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
545
|
+
ref={panelRef}
|
|
546
|
+
data-slot="session-panel"
|
|
547
|
+
data-agent-font={transcriptFont}
|
|
548
|
+
onClick={handleClick}
|
|
549
|
+
className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}
|
|
550
|
+
style={fontSize !== undefined ? ({ '--wd-font-size': `${Math.round(fontSize)}px` } as React.CSSProperties) : undefined}
|
|
551
|
+
>
|
|
552
|
+
{headerTakesActions ? header({ actions: menu }) : header}
|
|
553
|
+
{statusPlacement === 'top' ? statusBar : null}
|
|
554
|
+
{protocolMismatch !== undefined ? (
|
|
555
|
+
<Notice level="warning">
|
|
556
|
+
Server speaks protocol v{protocolMismatch}, this build renders v{PROTOCOL_VERSION}. Some events may not render.
|
|
557
|
+
</Notice>
|
|
558
|
+
) : null}
|
|
559
|
+
{protocolError ? (
|
|
560
|
+
<Notice level="error" onDismiss={() => setProtocolError(undefined)}>
|
|
561
|
+
{protocolError}
|
|
562
|
+
</Notice>
|
|
563
|
+
) : null}
|
|
564
|
+
{subagentId !== undefined ? (
|
|
565
|
+
<SubagentStrip
|
|
566
|
+
task={subagentTask}
|
|
567
|
+
items={subagentFrameItems}
|
|
568
|
+
label={subagentFallbackLabel}
|
|
569
|
+
onBack={leaveSubagent}
|
|
570
|
+
terminal={terminal}
|
|
571
|
+
fontSize={effectiveTermFontSize}
|
|
572
|
+
lineHeight={effectiveTermLineHeight}
|
|
573
|
+
/>
|
|
574
|
+
) : null}
|
|
575
|
+
<BookmarkProvider value={bookmarkHandle}>
|
|
576
|
+
<Transcript
|
|
577
|
+
key={subagentId ?? 'session'}
|
|
578
|
+
state={state}
|
|
579
|
+
fileUrl={sessionId ? (path) => client.sessionFileUrl(sessionId, path) : undefined}
|
|
580
|
+
attachmentUrl={sessionId ? (id) => client.attachmentUrl(sessionId, id) : undefined}
|
|
581
|
+
canBrowseFiles={hostFiles.available}
|
|
582
|
+
hostImage={hostImage}
|
|
583
|
+
variant={transcriptVariant}
|
|
584
|
+
density={transcriptDensity}
|
|
585
|
+
fontSize={effectiveTermFontSize}
|
|
586
|
+
lineHeight={effectiveTermLineHeight}
|
|
587
|
+
affordances={affordances}
|
|
588
|
+
stickyPrompt={stickyPrompt}
|
|
589
|
+
scrubber={scrubber}
|
|
590
|
+
bookmarks={bookmarks}
|
|
591
|
+
replaying={replaying}
|
|
592
|
+
catchUp={catchUp && newCount > 0 ? { from: catchUp.itemCount, since: catchUp.since } : undefined}
|
|
593
|
+
reveal={returnReveal ?? reveal}
|
|
594
|
+
frame={subagentId === undefined ? undefined : { parentToolUseId: subagentId }}
|
|
595
|
+
onOpenSubagent={enterSubagent}
|
|
596
|
+
emptyState={emptyState}
|
|
597
|
+
jumpToRecapRef={jumpToRecap}
|
|
598
|
+
repinRef={repinTranscript}
|
|
599
|
+
/>
|
|
600
|
+
</BookmarkProvider>
|
|
601
|
+
{catchUp && newCount > 0 && !replaying && subagentId === undefined ? (
|
|
602
|
+
<div className="px-3 pb-1">
|
|
603
|
+
<div
|
|
604
|
+
data-slot="catch-up"
|
|
605
|
+
className="mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] items-center gap-2 text-label text-fg-3"
|
|
606
|
+
>
|
|
607
|
+
<span aria-hidden className={cn('select-none', terminal ? 'text-fg-3' : 'text-accent')}>
|
|
608
|
+
※
|
|
609
|
+
</span>
|
|
610
|
+
<span className="min-w-0 flex-1 truncate">
|
|
611
|
+
{newCount} new {newCount === 1 ? 'row' : 'rows'}
|
|
612
|
+
{catchUp.since !== undefined ? ` since you were last here` : ''}
|
|
613
|
+
</span>
|
|
614
|
+
<button
|
|
615
|
+
type="button"
|
|
616
|
+
onClick={() => jumpToRecap.current?.()}
|
|
617
|
+
className="shrink-0 underline-offset-2 hover:text-fg-1 hover:underline"
|
|
618
|
+
>
|
|
619
|
+
jump
|
|
620
|
+
</button>
|
|
621
|
+
<button
|
|
622
|
+
type="button"
|
|
623
|
+
onClick={() => setCaughtUp(true)}
|
|
624
|
+
className="shrink-0 underline-offset-2 hover:text-fg-1 hover:underline"
|
|
625
|
+
>
|
|
626
|
+
dismiss
|
|
627
|
+
</button>
|
|
628
|
+
</div>
|
|
629
|
+
</div>
|
|
630
|
+
) : null}
|
|
631
|
+
{!readOnly && capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? (
|
|
632
|
+
<div className={cn(terminal ? 'pb-2' : 'px-3 pb-2')}>
|
|
633
|
+
<PromptSurface
|
|
634
|
+
terminal={terminal}
|
|
635
|
+
metrics={{ fontSize: effectiveTermFontSize, lineHeight: effectiveTermLineHeight }}
|
|
636
|
+
affordances={affordances}
|
|
637
|
+
>
|
|
638
|
+
{state.pendingApprovals.map((request) => {
|
|
639
|
+
const isQuestion = request.toolName === 'AskUserQuestion' && parseUserQuestions(request.input).length > 0
|
|
640
|
+
if (terminal) {
|
|
641
|
+
return isQuestion ? (
|
|
642
|
+
<TerminalQuestionPrompt
|
|
643
|
+
key={request.id}
|
|
644
|
+
request={request}
|
|
645
|
+
onAnswer={approve}
|
|
646
|
+
onDismiss={(id) => deny(id, 'Question dismissed by user')}
|
|
647
|
+
/>
|
|
648
|
+
) : (
|
|
649
|
+
<TerminalPermissionPrompt key={request.id} request={request} onApprove={approve} onDeny={deny} />
|
|
650
|
+
)
|
|
651
|
+
}
|
|
652
|
+
return isQuestion ? (
|
|
653
|
+
<QuestionPrompt
|
|
654
|
+
key={request.id}
|
|
655
|
+
request={request}
|
|
656
|
+
onAnswer={approve}
|
|
657
|
+
onDismiss={(id) => deny(id, 'Question dismissed by user')}
|
|
658
|
+
/>
|
|
659
|
+
) : (
|
|
660
|
+
<PermissionPrompt key={request.id} request={request} onApprove={approve} onDeny={deny} />
|
|
661
|
+
)
|
|
662
|
+
})}
|
|
663
|
+
</PromptSurface>
|
|
664
|
+
</div>
|
|
665
|
+
) : null}
|
|
666
|
+
{readOnly || subagentId !== undefined ? null : (
|
|
667
|
+
<Composer
|
|
668
|
+
ref={composerRef}
|
|
669
|
+
onSend={handleSend}
|
|
670
|
+
onInterrupt={interrupt}
|
|
671
|
+
busy={busy}
|
|
672
|
+
disabled={ended || !sessionId}
|
|
673
|
+
commands={capabilities.slashCommands ? commands : undefined}
|
|
674
|
+
skills={capabilities.skillsList ? state.skills : undefined}
|
|
675
|
+
attachments={attachments}
|
|
676
|
+
draft={draft}
|
|
677
|
+
onSearchFiles={hostFiles.available ? searchComposerFiles : undefined}
|
|
678
|
+
layout={controlsExternal ? 'inline' : 'stacked'}
|
|
679
|
+
toolbar={controlsExternal ? undefined : sessionControls}
|
|
680
|
+
fontSize={effectiveTermFontSize}
|
|
681
|
+
lineHeight={effectiveTermLineHeight}
|
|
682
|
+
affordances={affordances}
|
|
683
|
+
/>
|
|
684
|
+
)}
|
|
685
|
+
{statusPlacement === 'bottom' ? statusBar : null}
|
|
686
|
+
|
|
687
|
+
{!external ? (
|
|
688
|
+
<>
|
|
689
|
+
<SessionInfoDialog
|
|
690
|
+
state={state}
|
|
691
|
+
client={client}
|
|
692
|
+
sessionId={sessionId}
|
|
693
|
+
open={panel === 'info'}
|
|
694
|
+
onOpenChange={(next) => setPanel(next ? 'info' : undefined)}
|
|
1281
695
|
/>
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
onApprove={approve}
|
|
1287
|
-
onDeny={deny}
|
|
696
|
+
<ContextDialog
|
|
697
|
+
usage={state.contextUsage}
|
|
698
|
+
open={panel === 'context'}
|
|
699
|
+
onOpenChange={(next) => setPanel(next ? 'context' : undefined)}
|
|
1288
700
|
/>
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
lineHeight={effectiveTermLineHeight}
|
|
1323
|
-
affordances={affordances}
|
|
1324
|
-
/>
|
|
1325
|
-
)}
|
|
1326
|
-
{/* Below the composer, along the foot of the panel — the editor
|
|
1327
|
-
convention. Last in the flex column, so it is the bottom edge. */}
|
|
1328
|
-
{statusPlacement === 'bottom' ? statusBar : null}
|
|
1329
|
-
|
|
1330
|
-
{/* The internal dialog surface. The external one renders none of these —
|
|
1331
|
-
the embedder hosts equivalent surfaces and is handed the intents. */}
|
|
1332
|
-
{!external ? (
|
|
1333
|
-
<>
|
|
1334
|
-
<SessionInfoDialog
|
|
1335
|
-
state={state}
|
|
1336
|
-
client={client}
|
|
1337
|
-
sessionId={sessionId}
|
|
1338
|
-
open={panel === 'info'}
|
|
1339
|
-
onOpenChange={(next) => setPanel(next ? 'info' : undefined)}
|
|
1340
|
-
/>
|
|
1341
|
-
<ContextDialog
|
|
1342
|
-
usage={state.contextUsage}
|
|
1343
|
-
open={panel === 'context'}
|
|
1344
|
-
onOpenChange={(next) => setPanel(next ? 'context' : undefined)}
|
|
1345
|
-
/>
|
|
1346
|
-
<UsageDialog
|
|
1347
|
-
rateLimits={windows}
|
|
1348
|
-
subscriptionType={state.subscriptionType}
|
|
1349
|
-
engine={state.engine ?? 'claude'}
|
|
1350
|
-
totalCostUsd={state.totalCostUsd}
|
|
1351
|
-
updatedAt={usageUpdatedAt}
|
|
1352
|
-
open={panel === 'usage'}
|
|
1353
|
-
onOpenChange={(next) => setPanel(next ? 'usage' : undefined)}
|
|
1354
|
-
/>
|
|
1355
|
-
<McpDialog
|
|
1356
|
-
client={client}
|
|
1357
|
-
sessionId={sessionId}
|
|
1358
|
-
canManageServers={capabilities.mcpServerActions}
|
|
1359
|
-
open={panel === 'mcp'}
|
|
1360
|
-
onOpenChange={(next) => setPanel(next ? 'mcp' : undefined)}
|
|
1361
|
-
/>
|
|
1362
|
-
<SkillsDialog
|
|
1363
|
-
skills={state.skills}
|
|
1364
|
-
open={panel === 'skills'}
|
|
1365
|
-
onOpenChange={(next) => setPanel(next ? 'skills' : undefined)}
|
|
1366
|
-
// Drafts into the composer; the operator sends it. There is no engine
|
|
1367
|
-
// call that runs a skill, so there is nothing else this button could do.
|
|
1368
|
-
onUse={(skill) => composerRef.current?.insertText(skillPrompt(skill))}
|
|
1369
|
-
/>
|
|
1370
|
-
<HostFilesDialog
|
|
1371
|
-
client={client}
|
|
1372
|
-
cwd={state.cwd}
|
|
1373
|
-
open={panel === 'files'}
|
|
1374
|
-
onOpenChange={(next) => setPanel(next ? 'files' : undefined)}
|
|
1375
|
-
/>
|
|
1376
|
-
</>
|
|
1377
|
-
) : null}
|
|
1378
|
-
</div>
|
|
1379
|
-
</ToolResultImageProvider>
|
|
1380
|
-
</ToolResultFetchProvider>
|
|
701
|
+
<UsageDialog
|
|
702
|
+
rateLimits={windows}
|
|
703
|
+
subscriptionType={state.subscriptionType}
|
|
704
|
+
engine={state.engine ?? 'claude'}
|
|
705
|
+
totalCostUsd={state.totalCostUsd}
|
|
706
|
+
updatedAt={usageUpdatedAt}
|
|
707
|
+
open={panel === 'usage'}
|
|
708
|
+
onOpenChange={(next) => setPanel(next ? 'usage' : undefined)}
|
|
709
|
+
/>
|
|
710
|
+
<McpDialog
|
|
711
|
+
client={client}
|
|
712
|
+
sessionId={sessionId}
|
|
713
|
+
canManageServers={capabilities.mcpServerActions}
|
|
714
|
+
open={panel === 'mcp'}
|
|
715
|
+
onOpenChange={(next) => setPanel(next ? 'mcp' : undefined)}
|
|
716
|
+
/>
|
|
717
|
+
<SkillsDialog
|
|
718
|
+
skills={state.skills}
|
|
719
|
+
open={panel === 'skills'}
|
|
720
|
+
onOpenChange={(next) => setPanel(next ? 'skills' : undefined)}
|
|
721
|
+
onUse={(skill) => composerRef.current?.insertText(skillPrompt(skill))}
|
|
722
|
+
/>
|
|
723
|
+
<HostFilesDialog
|
|
724
|
+
client={client}
|
|
725
|
+
cwd={state.cwd}
|
|
726
|
+
open={panel === 'files'}
|
|
727
|
+
onOpenChange={(next) => setPanel(next ? 'files' : undefined)}
|
|
728
|
+
/>
|
|
729
|
+
</>
|
|
730
|
+
) : null}
|
|
731
|
+
</div>
|
|
732
|
+
</ToolResultImageProvider>
|
|
733
|
+
</ToolResultFetchProvider>
|
|
1381
734
|
</TranscriptDensityProvider>
|
|
1382
735
|
</TranscriptVariantProvider>
|
|
1383
736
|
)
|
|
1384
737
|
}
|
|
1385
738
|
|
|
1386
|
-
/**
|
|
1387
|
-
* Turns a host path a tool card is holding into something an `<img>` can show.
|
|
1388
|
-
*
|
|
1389
|
-
* Two sources, tried in that order and for a reason:
|
|
1390
|
-
*
|
|
1391
|
-
* 1. **The session's produced files.** If this session's own runner announced
|
|
1392
|
-
* writing that path (`file_produced`), the gateway will serve it from
|
|
1393
|
-
* `/sessions/:id/produced/:fileId` — no host-file roots to declare, no byte
|
|
1394
|
-
* cap to raise. This is the path codex's generated images take, and it is
|
|
1395
|
-
* why they now render out of the box.
|
|
1396
|
-
* 2. **`/fs/read`.** For a path nothing produced — a picture the model looked
|
|
1397
|
-
* at, an image already in the tree — where the operator's declared roots are
|
|
1398
|
-
* the right gate and the answer is legitimately "no" outside them.
|
|
1399
|
-
*
|
|
1400
|
-
* The cache is what makes this usable from a transcript row: rows re-render on
|
|
1401
|
-
* every streamed delta, and an uncached resolver would re-fetch the picture each
|
|
1402
|
-
* time. A refusal is cached too, so it costs one request rather than one per
|
|
1403
|
-
* render.
|
|
1404
|
-
*
|
|
1405
|
-
* Keyed by `fileId`-or-path so that a path which becomes produced *after* a
|
|
1406
|
-
* failed `/fs/read` is retried under a different key rather than staying cached
|
|
1407
|
-
* as a miss.
|
|
1408
|
-
*/
|
|
1409
739
|
function useHostImage(
|
|
1410
740
|
client: WorkerDeckClient,
|
|
1411
741
|
sessionId: string | undefined,
|
|
1412
742
|
producedFiles: Record<string, ProducedFileRef> | undefined,
|
|
1413
743
|
): (path: string) => Promise<string | undefined> {
|
|
1414
744
|
const cache = useRef(new Map<string, Promise<string | undefined>>())
|
|
1415
|
-
// Object URLs pin their blob until revoked, so a long session that generated
|
|
1416
|
-
// a dozen images would hold a dozen megabytes past unmount.
|
|
1417
745
|
const objectUrls = useRef<string[]>([])
|
|
1418
746
|
useEffect(
|
|
1419
747
|
() => () => {
|
|
1420
|
-
for (const url of objectUrls.current)
|
|
748
|
+
for (const url of objectUrls.current) {
|
|
749
|
+
URL.revokeObjectURL(url)
|
|
750
|
+
}
|
|
1421
751
|
objectUrls.current = []
|
|
1422
752
|
},
|
|
1423
753
|
[],
|
|
@@ -1427,16 +757,17 @@ function useHostImage(
|
|
|
1427
757
|
const produced = producedFiles?.[path]
|
|
1428
758
|
const key = produced ? `produced:${produced.fileId}` : `fs:${path}`
|
|
1429
759
|
const hit = cache.current.get(key)
|
|
1430
|
-
if (hit)
|
|
760
|
+
if (hit) {
|
|
761
|
+
return hit
|
|
762
|
+
}
|
|
1431
763
|
const pending =
|
|
1432
764
|
produced && sessionId
|
|
1433
|
-
?
|
|
1434
|
-
// header-authenticated gateway, where a bare URL in an `<img src>`
|
|
1435
|
-
// carries no credential.
|
|
1436
|
-
client
|
|
765
|
+
? client
|
|
1437
766
|
.readProducedFile(sessionId, produced.fileId)
|
|
1438
767
|
.then((blob) => {
|
|
1439
|
-
if (blob.size === 0)
|
|
768
|
+
if (blob.size === 0) {
|
|
769
|
+
return undefined
|
|
770
|
+
}
|
|
1440
771
|
const url = URL.createObjectURL(blob)
|
|
1441
772
|
objectUrls.current.push(url)
|
|
1442
773
|
return url
|
|
@@ -1445,9 +776,9 @@ function useHostImage(
|
|
|
1445
776
|
: client
|
|
1446
777
|
.readHostFile(path)
|
|
1447
778
|
.then((file) => {
|
|
1448
|
-
if (file.encoding !== 'base64')
|
|
1449
|
-
|
|
1450
|
-
|
|
779
|
+
if (file.encoding !== 'base64') {
|
|
780
|
+
return undefined
|
|
781
|
+
}
|
|
1451
782
|
const extension = path.slice(path.lastIndexOf('.') + 1).toLowerCase()
|
|
1452
783
|
const mediaType = IMAGE_MEDIA_TYPES[extension]
|
|
1453
784
|
return mediaType ? `data:${mediaType};base64,${file.content}` : undefined
|
|
@@ -1460,9 +791,6 @@ function useHostImage(
|
|
|
1460
791
|
)
|
|
1461
792
|
}
|
|
1462
793
|
|
|
1463
|
-
/** Extensions worth rendering inline, and what to call them. Anything else is
|
|
1464
|
-
* left to the card's path text — guessing a media type is how an HTML file ends
|
|
1465
|
-
* up in an `<img>`. */
|
|
1466
794
|
const IMAGE_MEDIA_TYPES: Record<string, string> = {
|
|
1467
795
|
png: 'image/png',
|
|
1468
796
|
jpg: 'image/jpeg',
|
|
@@ -1471,35 +799,26 @@ const IMAGE_MEDIA_TYPES: Record<string, string> = {
|
|
|
1471
799
|
webp: 'image/webp',
|
|
1472
800
|
}
|
|
1473
801
|
|
|
1474
|
-
|
|
1475
|
-
function Notice({
|
|
1476
|
-
level,
|
|
1477
|
-
onDismiss,
|
|
1478
|
-
children,
|
|
1479
|
-
}: {
|
|
1480
|
-
level: 'warning' | 'error'
|
|
1481
|
-
onDismiss?: () => void
|
|
1482
|
-
children: ReactNode
|
|
1483
|
-
}) {
|
|
802
|
+
function Notice({ level, onDismiss, children }: { level: 'warning' | 'error'; onDismiss?: () => void; children: ReactNode }) {
|
|
1484
803
|
return (
|
|
1485
|
-
<div className=
|
|
804
|
+
<div className="px-3 pt-2">
|
|
1486
805
|
<div
|
|
1487
|
-
role=
|
|
806
|
+
role="alert"
|
|
1488
807
|
className={cn(
|
|
1489
808
|
'mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] items-start gap-2 rounded-md border px-3 py-2 text-body-sm',
|
|
1490
|
-
level === 'error'
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
<
|
|
1495
|
-
<span className='min-w-0 flex-1 break-words'>{children}</span>
|
|
809
|
+
level === 'error' ? 'border-danger/40 bg-danger-bg text-danger' : 'border-warning/40 bg-warning-bg text-warning',
|
|
810
|
+
)}
|
|
811
|
+
>
|
|
812
|
+
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" />
|
|
813
|
+
<span className="min-w-0 flex-1 break-words">{children}</span>
|
|
1496
814
|
{onDismiss ? (
|
|
1497
815
|
<button
|
|
1498
|
-
type=
|
|
816
|
+
type="button"
|
|
1499
817
|
onClick={onDismiss}
|
|
1500
|
-
aria-label=
|
|
1501
|
-
className=
|
|
1502
|
-
|
|
818
|
+
aria-label="Dismiss"
|
|
819
|
+
className="shrink-0 opacity-70 transition-opacity hover:opacity-100"
|
|
820
|
+
>
|
|
821
|
+
<X className="size-3.5" />
|
|
1503
822
|
</button>
|
|
1504
823
|
) : null}
|
|
1505
824
|
</div>
|