@workerdeck/ui 0.13.0 → 0.16.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 +72 -0
- package/build/{SessionPanel-CZMA44NM.d.mts → SessionPanel-B9CHoq8x.d.mts} +213 -27
- package/build/{SessionPanel-CKQa4i0Y.mjs → SessionPanel-DII9MmQ8.mjs} +5192 -2542
- package/build/SessionPanel-DII9MmQ8.mjs.map +1 -0
- package/build/{format-ljc3lKpA.d.mts → format-DfI_je9S.d.mts} +1 -1
- package/build/format.d.mts +39 -4
- package/build/format.mjs +2 -118
- package/build/index.d.mts +494 -45
- package/build/index.mjs +182 -24
- package/build/index.mjs.map +1 -1
- package/build/status-Ydzi7n6j.mjs +143 -0
- package/build/status-Ydzi7n6j.mjs.map +1 -0
- package/build/workspace.d.mts +13 -1
- package/build/workspace.mjs +111 -5
- package/build/workspace.mjs.map +1 -1
- package/package.json +14 -7
- package/src/components/agent/Composer.tsx +251 -84
- package/src/components/agent/Conversation.tsx +12 -12
- package/src/components/agent/FileCard.tsx +0 -26
- package/src/components/agent/FileTree.tsx +9 -8
- package/src/components/agent/Loader.tsx +22 -72
- package/src/components/agent/Message.tsx +11 -43
- package/src/components/agent/PermissionPrompt.tsx +0 -92
- package/src/components/agent/QuestionPrompt.tsx +0 -122
- package/src/components/agent/Reasoning.tsx +5 -19
- package/src/components/agent/Response.tsx +1 -132
- package/src/components/agent/SessionBrowser.tsx +35 -25
- package/src/components/agent/SessionPanel.tsx +312 -63
- package/src/components/agent/SessionWorkspace.tsx +36 -0
- package/src/components/agent/StatusBar.tsx +70 -15
- package/src/components/agent/ToolCallCard.tsx +17 -111
- package/src/components/agent/Transcript.tsx +710 -203
- package/src/components/agent/UsageDialog.tsx +20 -106
- package/src/components/agent/UsageMeters.tsx +133 -0
- package/src/components/agent/pulse.tsx +3 -2
- package/src/components/agent/transcript-rows.ts +82 -0
- package/src/components/agent/transcript-variant.tsx +43 -51
- package/src/components/agent/use-height-epoch.ts +60 -0
- package/src/components/agent/use-path-links.ts +147 -0
- package/src/components/agent/use-transcript-jumps.ts +190 -0
- package/src/components/prompt-area/cursor-helpers.ts +65 -0
- package/src/components/prompt-area/use-prompt-area.ts +16 -10
- package/src/components/terminal/PermissionPrompt.tsx +119 -0
- package/src/components/terminal/QuestionPrompt.tsx +322 -0
- package/src/components/terminal/StatusLine.tsx +159 -0
- package/src/components/terminal/TerminalTranscript.tsx +147 -0
- package/src/components/terminal/affordances.tsx +118 -0
- package/src/components/terminal/diff.tsx +130 -0
- package/src/components/terminal/height.ts +727 -0
- package/src/components/terminal/items.tsx +449 -0
- package/src/components/terminal/markdown.tsx +191 -0
- package/src/components/terminal/press.tsx +120 -0
- package/src/components/terminal/prompt.tsx +343 -0
- package/src/components/terminal/result-preview.ts +72 -0
- package/src/components/terminal/row.tsx +132 -0
- package/src/components/terminal/scrubber.tsx +663 -0
- package/src/components/terminal/surface.tsx +80 -0
- package/src/components/terminal/tool-run.ts +91 -0
- package/src/components/ui/Badge.tsx +6 -1
- package/src/components/ui/Empty.tsx +56 -0
- package/src/components/ui/Splitter.tsx +14 -0
- package/src/index.ts +36 -0
- package/src/lib/status.ts +59 -3
- package/src/lib/tool-icon.ts +14 -0
- package/src/styles/terminal.css +1011 -0
- package/src/styles/theme.css +73 -0
- package/build/SessionPanel-CKQa4i0Y.mjs.map +0 -1
- package/build/format.mjs.map +0 -1
- package/src/components/agent/line-prompt.tsx +0 -249
|
@@ -8,16 +8,25 @@ import {
|
|
|
8
8
|
type ReactNode,
|
|
9
9
|
} from 'react'
|
|
10
10
|
import type { WorkerDeckClient } from '@workerdeck/client'
|
|
11
|
-
import { PROTOCOL_VERSION, type ModelOption, type PermissionMode } from '@workerdeck/protocol'
|
|
12
11
|
import {
|
|
13
|
-
|
|
12
|
+
PROTOCOL_VERSION,
|
|
13
|
+
mergeUsage,
|
|
14
|
+
orderUsageWindows,
|
|
15
|
+
usageInfos,
|
|
16
|
+
type ModelOption,
|
|
17
|
+
type PermissionMode,
|
|
18
|
+
type RateLimitInfo,
|
|
19
|
+
} from '@workerdeck/protocol'
|
|
20
|
+
import {
|
|
14
21
|
useAttachments,
|
|
15
22
|
useClaudeSession,
|
|
16
23
|
useHostFileSearch,
|
|
24
|
+
useProfileUsage,
|
|
17
25
|
useToolCallHost,
|
|
18
26
|
type ConnectionState,
|
|
19
27
|
type ProducedFileRef,
|
|
20
28
|
type TranscriptState,
|
|
29
|
+
type UseToolCallHostOptions,
|
|
21
30
|
} from '@workerdeck/react'
|
|
22
31
|
import {
|
|
23
32
|
ChartPie,
|
|
@@ -46,6 +55,11 @@ import {
|
|
|
46
55
|
} from './PermissionModeSelect.tsx'
|
|
47
56
|
import { PermissionPrompt } from './PermissionPrompt.tsx'
|
|
48
57
|
import { QuestionPrompt, parseUserQuestions } from './QuestionPrompt.tsx'
|
|
58
|
+
import { TerminalPermissionPrompt } from '../terminal/PermissionPrompt.tsx'
|
|
59
|
+
import { TerminalQuestionPrompt } from '../terminal/QuestionPrompt.tsx'
|
|
60
|
+
import { TerminalSurface } from '../terminal/surface.tsx'
|
|
61
|
+
import type { TerminalAffordances } from '../terminal/affordances.tsx'
|
|
62
|
+
|
|
49
63
|
import { SessionInfoDialog } from './SessionInfoDialog.tsx'
|
|
50
64
|
import { StatusBar } from './StatusBar.tsx'
|
|
51
65
|
import { Transcript } from './Transcript.tsx'
|
|
@@ -53,10 +67,64 @@ import {
|
|
|
53
67
|
TranscriptDensityProvider,
|
|
54
68
|
TranscriptVariantProvider,
|
|
55
69
|
type TranscriptDensity,
|
|
70
|
+
type TranscriptFont,
|
|
56
71
|
type TranscriptVariant,
|
|
57
72
|
} from './transcript-variant.tsx'
|
|
58
73
|
import { UsageDialog } from './UsageDialog.tsx'
|
|
59
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The box the pending prompts sit in.
|
|
77
|
+
*
|
|
78
|
+
* Under the terminal theme they are rows of the same run as the transcript, so
|
|
79
|
+
* they need that theme's cell — and its `--term-bleed`, so an approval's diff
|
|
80
|
+
* bands reach the same edges the transcript's do. Every other variant keeps the
|
|
81
|
+
* centred content column the panel uses everywhere else.
|
|
82
|
+
*/
|
|
83
|
+
function PromptSurface({
|
|
84
|
+
terminal,
|
|
85
|
+
metrics,
|
|
86
|
+
affordances,
|
|
87
|
+
children,
|
|
88
|
+
}: {
|
|
89
|
+
terminal: boolean
|
|
90
|
+
metrics?: TerminalMetrics
|
|
91
|
+
affordances?: TerminalAffordances | boolean
|
|
92
|
+
children: ReactNode
|
|
93
|
+
}) {
|
|
94
|
+
if (!terminal) {
|
|
95
|
+
return (
|
|
96
|
+
<div className='mx-auto flex w-full max-w-[var(--wd-content-max-w,48rem)] flex-col gap-2'>
|
|
97
|
+
{children}
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
return (
|
|
102
|
+
<TerminalSurface
|
|
103
|
+
fontSize={metrics?.fontSize}
|
|
104
|
+
lineHeight={metrics?.lineHeight}
|
|
105
|
+
affordances={affordances}
|
|
106
|
+
bleed='1ch'
|
|
107
|
+
className='term-transcript'>
|
|
108
|
+
{children}
|
|
109
|
+
</TerminalSurface>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The character cell the terminal theme draws on, in **whole pixels**.
|
|
115
|
+
*
|
|
116
|
+
* One object rather than two props because the panel mounts three separate
|
|
117
|
+
* `TerminalSurface`s — the transcript, the pending prompts, the composer — and
|
|
118
|
+
* they must agree: a prompt drawn at a different line height from the rows above
|
|
119
|
+
* it is three surfaces on three grids, which is the failure this theme is built
|
|
120
|
+
* to make impossible. Passing one value through one prop is what keeps them from
|
|
121
|
+
* drifting.
|
|
122
|
+
*
|
|
123
|
+
* Absent means the CLI's own 13/18. A host that follows an editor font size
|
|
124
|
+
* (VS Code) hands that down instead.
|
|
125
|
+
*/
|
|
126
|
+
export type TerminalMetrics = { fontSize?: number; lineHeight?: number }
|
|
127
|
+
|
|
60
128
|
export interface SessionPanelProps {
|
|
61
129
|
client: WorkerDeckClient
|
|
62
130
|
sessionId: string | undefined
|
|
@@ -117,12 +185,55 @@ export interface SessionPanelProps {
|
|
|
117
185
|
* internal ref, so an inline closure is fine). */
|
|
118
186
|
onVitals?: (vitals: SessionVitals) => void
|
|
119
187
|
/**
|
|
120
|
-
* How the transcript draws a turn — `'cards'` (default
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
188
|
+
* How the transcript draws a turn — `'cards'` (default, the chat convention)
|
|
189
|
+
* or `'terminal'`, the CLI's own form: every row on a character cell, no boxes
|
|
190
|
+
* anywhere, diffs as full-width bands. An embedder in a dock (the VS Code
|
|
191
|
+
* panel) wants `'terminal'`; a full-width dashboard may prefer cards.
|
|
124
192
|
*/
|
|
125
193
|
transcriptVariant?: TranscriptVariant
|
|
194
|
+
/**
|
|
195
|
+
* Terminal theme only: the pointer affordances a real terminal cannot offer —
|
|
196
|
+
* the hover fill, the hover-revealed copy. `false` for none. None of them
|
|
197
|
+
* costs layout, so turning them off changes no glyph's position. See
|
|
198
|
+
* {@link TerminalAffordances}.
|
|
199
|
+
*/
|
|
200
|
+
affordances?: TerminalAffordances | boolean
|
|
201
|
+
/**
|
|
202
|
+
* Terminal theme only: the character cell, in whole pixels. See
|
|
203
|
+
* {@link TerminalMetrics} — it reaches all three of the panel's terminal
|
|
204
|
+
* surfaces, which is why it is one prop and not two per surface.
|
|
205
|
+
*/
|
|
206
|
+
terminalMetrics?: TerminalMetrics
|
|
207
|
+
/**
|
|
208
|
+
* Terminal theme only: replace the scrollbar with the **overview ruler** — a
|
|
209
|
+
* `2ch` rail of coloured marks in three lanes (what you typed, the answer and
|
|
210
|
+
* its turn end, errors and a waiting approval), which you can hover to peek,
|
|
211
|
+
* click to jump, and drag to scrub.
|
|
212
|
+
*
|
|
213
|
+
* Its premise is the terminal theme's own: one line height and one cell make
|
|
214
|
+
* every row's height derivable, so a mark's position is *computed* rather than
|
|
215
|
+
* guessed from rows that have not mounted. That is why it is not offered under
|
|
216
|
+
* `cards` — there the flag is inert.
|
|
217
|
+
*
|
|
218
|
+
* `false` keeps the native scrollbar. So does `affordances={false}`, which
|
|
219
|
+
* leaves the marks painted but inert rather than removing a reader's only way
|
|
220
|
+
* to scroll.
|
|
221
|
+
*/
|
|
222
|
+
scrubber?: boolean
|
|
223
|
+
/**
|
|
224
|
+
* Bookmarked **item indices**, painted full-width on the rail. Paint only —
|
|
225
|
+
* the panel neither stores bookmarks nor offers a way to set one, because who
|
|
226
|
+
* owns that store is the embedder's question (a private pin belongs with the
|
|
227
|
+
* client's watermarks; a shared one is session metadata on the gateway).
|
|
228
|
+
*/
|
|
229
|
+
scrubberMarks?: readonly number[]
|
|
230
|
+
/**
|
|
231
|
+
* Terminal theme only: hold the prompt of the turn you are reading at the top
|
|
232
|
+
* of the transcript, as the Claude Code CLI does. The **real row** is pinned
|
|
233
|
+
* rather than a copy drawn above it, so it lines up with the rows beneath by
|
|
234
|
+
* construction — see `TranscriptRows`.
|
|
235
|
+
*/
|
|
236
|
+
stickyPrompt?: boolean
|
|
126
237
|
/**
|
|
127
238
|
* How much air the transcript gives each row — `'comfortable'` (default: a
|
|
128
239
|
* blank line between messages, as the Claude Code CLI leaves) or `'compact'`
|
|
@@ -131,18 +242,36 @@ export interface SessionPanelProps {
|
|
|
131
242
|
* dock is allowed to be roomy.
|
|
132
243
|
*/
|
|
133
244
|
transcriptDensity?: TranscriptDensity
|
|
245
|
+
/**
|
|
246
|
+
* The panel's typeface — `'sans'` (default, the host's UI font) or `'mono'`,
|
|
247
|
+
* which repoints the sans token at the mono stack **for this subtree only**.
|
|
248
|
+
*
|
|
249
|
+
* The third reader preference beside variant and density, and the same kind of
|
|
250
|
+
* thing: how a transcript should read is a property of the person reading it.
|
|
251
|
+
* Scoped to the panel because that is the whole claim — a monospace agent view
|
|
252
|
+
* next to an ordinary app, not a monospace app.
|
|
253
|
+
*/
|
|
254
|
+
transcriptFont?: TranscriptFont
|
|
134
255
|
/**
|
|
135
256
|
* Where the session's own controls — model and permission mode — live.
|
|
136
257
|
* `'internal'` (default) draws them in the composer's toolbar row.
|
|
137
|
-
* `'
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
258
|
+
* `'status'` draws them in the panel's OWN status bar, beside the readings
|
|
259
|
+
* they act on; the composer collapses to a single line either way. That is
|
|
260
|
+
* VS Code's arrangement without VS Code — a host whose panel carries a status
|
|
261
|
+
* bar of its own (`statusPlacement: 'bottom'`) gets the same streamlined
|
|
262
|
+
* shape without having to host the pickers itself.
|
|
263
|
+
* `'external'` draws neither: the embedder renders the pickers in its own
|
|
264
|
+
* chrome (VS Code's window status bar, where a click opens a QuickPick) and
|
|
265
|
+
* drives them through {@link onControls}.
|
|
266
|
+
*
|
|
267
|
+
* `'status'` needs a status bar to put them in — with
|
|
268
|
+
* `statusSurface: 'external'` there is none, and the two together would hide
|
|
269
|
+
* the controls entirely, so that combination falls back to the composer.
|
|
141
270
|
*
|
|
142
271
|
* The options themselves ride {@link SessionVitals} — an embedder must not
|
|
143
272
|
* attach a second time to learn what the models are.
|
|
144
273
|
*/
|
|
145
|
-
controlsSurface?: 'internal' | 'external'
|
|
274
|
+
controlsSurface?: 'internal' | 'external' | 'status'
|
|
146
275
|
/**
|
|
147
276
|
* Handed the session's setters once the panel is live, and `undefined` on
|
|
148
277
|
* unmount. The counterpart to `controlsSurface: 'external'`: vitals carry the
|
|
@@ -188,6 +317,30 @@ export interface SessionPanelProps {
|
|
|
188
317
|
* enforcement lives on the gateway.
|
|
189
318
|
*/
|
|
190
319
|
readOnly?: boolean
|
|
320
|
+
/**
|
|
321
|
+
* Options for the browser tool host this panel runs on its own attach — or
|
|
322
|
+
* `false` to run none at all.
|
|
323
|
+
*
|
|
324
|
+
* The panel hosts server-bridged tool calls itself, because the bridge asks
|
|
325
|
+
* the *first attached client* and the panel owns the session's one attach: an
|
|
326
|
+
* embedder subscribing to the same handle separately would find this host
|
|
327
|
+
* already answering, and refusing, anything outside its allow-list. So the
|
|
328
|
+
* options come through here.
|
|
329
|
+
*
|
|
330
|
+
* Merged over the defaults, which host `eval_script` alone. Widening `tools`
|
|
331
|
+
* is a real grant — this tab will execute what the gateway asks it to for
|
|
332
|
+
* every name in the list — so it names them explicitly rather than accepting
|
|
333
|
+
* a wildcard.
|
|
334
|
+
*/
|
|
335
|
+
toolHost?: UseToolCallHostOptions | false
|
|
336
|
+
/**
|
|
337
|
+
* Keep this session's transcript warm across remounts (default true), so
|
|
338
|
+
* switching back to a recently viewed session paints instantly and replays
|
|
339
|
+
* only what it missed — `UseClaudeSessionOptions.cacheTranscript`. Set
|
|
340
|
+
* `false` for an embedder whose principal varies on one gateway URL by means
|
|
341
|
+
* the client cannot see.
|
|
342
|
+
*/
|
|
343
|
+
cacheTranscript?: boolean
|
|
191
344
|
className?: string
|
|
192
345
|
}
|
|
193
346
|
|
|
@@ -283,16 +436,27 @@ export function SessionPanel({
|
|
|
283
436
|
onVitals,
|
|
284
437
|
transcriptVariant = 'cards',
|
|
285
438
|
transcriptDensity = 'comfortable',
|
|
439
|
+
transcriptFont = 'sans',
|
|
440
|
+
affordances,
|
|
441
|
+
terminalMetrics,
|
|
442
|
+
scrubber = false,
|
|
443
|
+
scrubberMarks,
|
|
444
|
+
stickyPrompt = false,
|
|
286
445
|
controlsSurface = 'internal',
|
|
287
446
|
onControls,
|
|
288
447
|
focusComposerOnClick = false,
|
|
289
448
|
unseen,
|
|
290
449
|
readOnly = false,
|
|
450
|
+
toolHost,
|
|
451
|
+
cacheTranscript,
|
|
291
452
|
className,
|
|
292
453
|
}: SessionPanelProps) {
|
|
293
454
|
const external = panelSurface === 'external'
|
|
294
455
|
const statusExternal = statusSurface === 'external'
|
|
295
|
-
|
|
456
|
+
// Both non-internal surfaces take the pickers out of the composer, which is
|
|
457
|
+
// what collapses it to a single line.
|
|
458
|
+
const controlsInStatus = controlsSurface === 'status' && !statusExternal
|
|
459
|
+
const controlsExternal = controlsSurface === 'external' || controlsInStatus
|
|
296
460
|
// Rejected commands (the CLI refusing a permission-mode switch, say) render INSIDE
|
|
297
461
|
// the panel rather than through `toast`. The panel does not mount a `Toaster`, and
|
|
298
462
|
// an embedder that doesn't either would drop the only signal that a command failed
|
|
@@ -303,6 +467,7 @@ export function SessionPanel({
|
|
|
303
467
|
const {
|
|
304
468
|
state,
|
|
305
469
|
connection,
|
|
470
|
+
replaying,
|
|
306
471
|
protocolMismatch,
|
|
307
472
|
models,
|
|
308
473
|
effectiveModel,
|
|
@@ -314,7 +479,7 @@ export function SessionPanel({
|
|
|
314
479
|
setModel,
|
|
315
480
|
setPermissionMode,
|
|
316
481
|
reconnectNow,
|
|
317
|
-
} = useClaudeSession(client, sessionId, { onProtocolError: setProtocolError })
|
|
482
|
+
} = useClaudeSession(client, sessionId, { onProtocolError: setProtocolError, cacheTranscript })
|
|
318
483
|
// Callers are told to remount on a session switch, but a changed prop must not leave
|
|
319
484
|
// the previous session's failure on screen.
|
|
320
485
|
useEffect(() => setProtocolError(undefined), [sessionId])
|
|
@@ -356,9 +521,40 @@ export function SessionPanel({
|
|
|
356
521
|
// SAME handle the panel attached with — the bridge asks the first attached
|
|
357
522
|
// client. Free for Claude sessions: the guest loads lazily on the first call,
|
|
358
523
|
// which for them never comes.
|
|
359
|
-
useToolCallHost(handle)
|
|
524
|
+
useToolCallHost(handle, toolHost === false ? { enabled: false } : toolHost)
|
|
525
|
+
const terminal = transcriptVariant === 'terminal'
|
|
360
526
|
const capabilities = state.capabilities
|
|
361
527
|
|
|
528
|
+
// Plan usage as the *gateway* knows it, merged over this session's own
|
|
529
|
+
// reading — one derivation, feeding the bar, the terminal status line, the
|
|
530
|
+
// Usage panel and the vitals an external chrome renders from, because four
|
|
531
|
+
// surfaces disagreeing about the same percentage is worse than any of them
|
|
532
|
+
// being stale. A session's own `rate_limit` readings land only at a turn's
|
|
533
|
+
// edges, so an idle session's numbers age silently and a sibling session's
|
|
534
|
+
// spend never shows up here at all; `mergeUsage` is where that rule is
|
|
535
|
+
// written down. Skipped entirely for an engine that reports no windows.
|
|
536
|
+
const { usage: profileUsage } = useProfileUsage(client, state.session?.profile, {
|
|
537
|
+
enabled: capabilities.rateLimits,
|
|
538
|
+
})
|
|
539
|
+
const usage = useMemo(
|
|
540
|
+
() =>
|
|
541
|
+
mergeUsage(
|
|
542
|
+
{ rateLimits: state.rateLimits, updatedAt: state.rateLimitsUpdatedAt },
|
|
543
|
+
profileUsage,
|
|
544
|
+
),
|
|
545
|
+
[state.rateLimits, state.rateLimitsUpdatedAt, profileUsage],
|
|
546
|
+
)
|
|
547
|
+
// Undefined rather than an empty map when nothing has been reported: absent is
|
|
548
|
+
// *unknown*, and a surface that can't tell the two apart draws 0%.
|
|
549
|
+
const rateLimits: Record<string, RateLimitInfo> | undefined = useMemo(
|
|
550
|
+
() => (Object.keys(usage).length > 0 ? usageInfos(usage) : undefined),
|
|
551
|
+
[usage],
|
|
552
|
+
)
|
|
553
|
+
const usageUpdatedAt = useMemo(() => {
|
|
554
|
+
const stamps = Object.values(usage).map((w) => w.updatedAt)
|
|
555
|
+
return stamps.length > 0 ? Math.max(...stamps) : undefined
|
|
556
|
+
}, [usage])
|
|
557
|
+
|
|
362
558
|
// Vitals out to the embedder, keyed on the readings themselves so an inline
|
|
363
559
|
// closure prop doesn't retrigger it every render.
|
|
364
560
|
const onVitalsRef = useRef(onVitals)
|
|
@@ -382,7 +578,7 @@ export function SessionPanel({
|
|
|
382
578
|
permissionModes,
|
|
383
579
|
cwd: state.cwd,
|
|
384
580
|
contextUsage: state.contextUsage,
|
|
385
|
-
rateLimits
|
|
581
|
+
rateLimits,
|
|
386
582
|
itemCount: state.items.length,
|
|
387
583
|
})
|
|
388
584
|
}, [
|
|
@@ -396,7 +592,7 @@ export function SessionPanel({
|
|
|
396
592
|
permissionModes,
|
|
397
593
|
state.cwd,
|
|
398
594
|
state.contextUsage,
|
|
399
|
-
|
|
595
|
+
rateLimits,
|
|
400
596
|
state.items.length,
|
|
401
597
|
])
|
|
402
598
|
|
|
@@ -427,7 +623,9 @@ export function SessionPanel({
|
|
|
427
623
|
// inert for the moment before it does, and stays inert on a gateway that
|
|
428
624
|
// serves no host files.
|
|
429
625
|
const hostFiles = useHostFileSearch(client, state.cwd)
|
|
430
|
-
|
|
626
|
+
// Protocol's ordering, over the merged readings — each row keeping its own
|
|
627
|
+
// date, since they no longer share one clock.
|
|
628
|
+
const windows = useMemo(() => orderUsageWindows(usage), [usage])
|
|
431
629
|
// Reads a picture the engine left on the host (codex's `image_gen` reports a
|
|
432
630
|
// path, never bytes). Stable and memoized per path: transcript rows re-render
|
|
433
631
|
// on every delta, and a fresh function would re-fetch each time.
|
|
@@ -436,6 +634,8 @@ export function SessionPanel({
|
|
|
436
634
|
// The catch-up strip's way of scrolling the (virtualized, usually unmounted)
|
|
437
635
|
// recap row into view — the transcript fills it in. See TranscriptProps.
|
|
438
636
|
const jumpToRecap = useRef<(() => void) | null>(null)
|
|
637
|
+
// Filled by the transcript; pressed on send. See `handleSend`.
|
|
638
|
+
const repinTranscript = useRef<(() => void) | null>(null)
|
|
439
639
|
|
|
440
640
|
// "/model" is handled panel-side (see handleSend) — surface it in the autocomplete
|
|
441
641
|
// even though the CLI's command list doesn't include it.
|
|
@@ -469,6 +669,13 @@ export function SessionPanel({
|
|
|
469
669
|
// Typing into a session is the clearest possible statement that you have
|
|
470
670
|
// read it — nothing left to catch up on.
|
|
471
671
|
setCaughtUp(true)
|
|
672
|
+
// ...and sending is the statement that you want to watch what happens next,
|
|
673
|
+
// so following resumes here too. Scrolling up escapes the bottom lock, and
|
|
674
|
+
// without this the reply streams in off screen: the transcript stays parked
|
|
675
|
+
// where you were reading and sending looks like it did nothing. Ordered
|
|
676
|
+
// before `send` only for readability — the re-pin is instant and the first
|
|
677
|
+
// row is a round trip away either way.
|
|
678
|
+
repinTranscript.current?.()
|
|
472
679
|
send(text, attachmentIds)
|
|
473
680
|
}
|
|
474
681
|
|
|
@@ -535,11 +742,47 @@ export function SessionPanel({
|
|
|
535
742
|
// Built once and placed at one end or the other — the bar has a `⋯` menu and
|
|
536
743
|
// three open handlers, and two copies of that in the tree would be two things
|
|
537
744
|
// to keep in step.
|
|
745
|
+
// Built once, placed by `controlsSurface`: the composer's toolbar row, or the
|
|
746
|
+
// status bar beside the readings they act on, or nowhere at all when the host
|
|
747
|
+
// draws them in its own chrome.
|
|
748
|
+
const sessionControls = (
|
|
749
|
+
<>
|
|
750
|
+
{/* Codex reports no `capabilities` event, so its models arrive from the
|
|
751
|
+
profile catalog instead — without that fallback its picker would be
|
|
752
|
+
permanently empty and the session unswitchable. */}
|
|
753
|
+
{models.length ? (
|
|
754
|
+
<ModelSelect
|
|
755
|
+
models={models}
|
|
756
|
+
model={effectiveModel}
|
|
757
|
+
onModelChange={setModel}
|
|
758
|
+
disabled={ended}
|
|
759
|
+
// The bar is one line of label-sized text; a 24px trigger would set
|
|
760
|
+
// its height instead of fitting in it.
|
|
761
|
+
className={controlsInStatus ? 'h-5' : undefined}
|
|
762
|
+
/>
|
|
763
|
+
) : null}
|
|
764
|
+
{state.permissionMode ? (
|
|
765
|
+
<PermissionModeSelect
|
|
766
|
+
mode={state.permissionMode}
|
|
767
|
+
onModeChange={setPermissionMode}
|
|
768
|
+
// Only what this engine implements — the rest would come back as a
|
|
769
|
+
// protocol_error.
|
|
770
|
+
modes={capabilities.permissionModes}
|
|
771
|
+
canBypass={state.session?.canBypassPermissions}
|
|
772
|
+
disabled={ended}
|
|
773
|
+
className={controlsInStatus ? 'h-5' : undefined}
|
|
774
|
+
/>
|
|
775
|
+
) : null}
|
|
776
|
+
</>
|
|
777
|
+
)
|
|
778
|
+
|
|
538
779
|
const statusBar = statusExternal ? null : (
|
|
539
780
|
<StatusBar
|
|
540
781
|
state={state}
|
|
782
|
+
rateLimits={rateLimits}
|
|
541
783
|
connection={connection}
|
|
542
784
|
placement={statusPlacement}
|
|
785
|
+
controls={controlsInStatus && !readOnly ? sessionControls : undefined}
|
|
543
786
|
onOpenStatus={external && !onOpenPanel ? undefined : () => openPanel('info')}
|
|
544
787
|
onOpenContext={external && !onOpenPanel ? undefined : () => openPanel('context')}
|
|
545
788
|
onOpenUsage={external && !onOpenPanel ? undefined : () => openPanel('usage')}
|
|
@@ -559,13 +802,17 @@ export function SessionPanel({
|
|
|
559
802
|
}
|
|
560
803
|
|
|
561
804
|
return (
|
|
562
|
-
// The variant is a panel-wide fact, not a transcript-only one: the
|
|
563
|
-
// and
|
|
564
|
-
//
|
|
805
|
+
// The variant is a panel-wide fact, not a transcript-only one: the composer
|
|
806
|
+
// and the pending prompts live outside the scroller but belong to the same
|
|
807
|
+
// run, and they read it from this context rather than a prop chain.
|
|
565
808
|
<TranscriptVariantProvider value={transcriptVariant}>
|
|
566
809
|
<TranscriptDensityProvider value={transcriptDensity}>
|
|
567
810
|
<div
|
|
568
811
|
data-slot='session-panel'
|
|
812
|
+
// The typeface is a cascade fact, not a React one — one attribute here,
|
|
813
|
+
// and the `[data-agent-font]` rule in theme.css does the rest. Nothing
|
|
814
|
+
// outside this subtree can pick it up.
|
|
815
|
+
data-agent-font={transcriptFont}
|
|
569
816
|
onClick={handleClick}
|
|
570
817
|
className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}>
|
|
571
818
|
{headerTakesActions ? header({ actions: menu }) : header}
|
|
@@ -589,27 +836,34 @@ export function SessionPanel({
|
|
|
589
836
|
hostImage={hostImage}
|
|
590
837
|
variant={transcriptVariant}
|
|
591
838
|
density={transcriptDensity}
|
|
839
|
+
fontSize={terminalMetrics?.fontSize}
|
|
840
|
+
lineHeight={terminalMetrics?.lineHeight}
|
|
841
|
+
affordances={affordances}
|
|
842
|
+
stickyPrompt={stickyPrompt}
|
|
843
|
+
scrubber={scrubber}
|
|
844
|
+
scrubberMarks={scrubberMarks}
|
|
845
|
+
replaying={replaying}
|
|
592
846
|
catchUp={
|
|
593
847
|
catchUp && newCount > 0
|
|
594
848
|
? { from: catchUp.itemCount, since: catchUp.since }
|
|
595
849
|
: undefined
|
|
596
850
|
}
|
|
597
851
|
jumpToRecapRef={jumpToRecap}
|
|
852
|
+
repinRef={repinTranscript}
|
|
598
853
|
/>
|
|
599
854
|
{/* The way back into what you missed. Above the composer because that is
|
|
600
855
|
where the eye already is on returning, and because the transcript
|
|
601
|
-
itself opens pinned to the newest row.
|
|
602
|
-
|
|
856
|
+
itself opens pinned to the newest row. Held with the transcript
|
|
857
|
+
while the replay lands — its count is `state.items.length`, which
|
|
858
|
+
during the replay is a number visibly climbing toward its answer. */}
|
|
859
|
+
{catchUp && newCount > 0 && !replaying ? (
|
|
603
860
|
<div className='px-3 pb-1'>
|
|
604
861
|
<div
|
|
605
862
|
data-slot='catch-up'
|
|
606
863
|
className='mx-auto flex w-full max-w-[var(--wd-content-max-w,48rem)] items-center gap-2 text-label text-fg-3'>
|
|
607
864
|
<span
|
|
608
865
|
aria-hidden
|
|
609
|
-
className={cn(
|
|
610
|
-
'select-none',
|
|
611
|
-
transcriptVariant === 'lines' ? 'text-fg-3' : 'text-accent',
|
|
612
|
-
)}>
|
|
866
|
+
className={cn('select-none', terminal ? 'text-fg-3' : 'text-accent')}>
|
|
613
867
|
※
|
|
614
868
|
</span>
|
|
615
869
|
<span className='min-w-0 flex-1 truncate'>
|
|
@@ -638,11 +892,30 @@ export function SessionPanel({
|
|
|
638
892
|
`awaiting approval`, and the place that can act on it is wherever the
|
|
639
893
|
session is actually driven. */}
|
|
640
894
|
{!readOnly && capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? (
|
|
641
|
-
<div className='px-3 pb-2'>
|
|
642
|
-
<
|
|
643
|
-
{state.pendingApprovals.map((request) =>
|
|
644
|
-
|
|
645
|
-
|
|
895
|
+
<div className={cn(terminal ? 'pb-2' : 'px-3 pb-2')}>
|
|
896
|
+
<PromptSurface terminal={terminal} metrics={terminalMetrics} affordances={affordances}>
|
|
897
|
+
{state.pendingApprovals.map((request) => {
|
|
898
|
+
const isQuestion =
|
|
899
|
+
request.toolName === 'AskUserQuestion' &&
|
|
900
|
+
parseUserQuestions(request.input).length > 0
|
|
901
|
+
if (terminal) {
|
|
902
|
+
return isQuestion ? (
|
|
903
|
+
<TerminalQuestionPrompt
|
|
904
|
+
key={request.id}
|
|
905
|
+
request={request}
|
|
906
|
+
onAnswer={approve}
|
|
907
|
+
onDismiss={(id) => deny(id, 'Question dismissed by user')}
|
|
908
|
+
/>
|
|
909
|
+
) : (
|
|
910
|
+
<TerminalPermissionPrompt
|
|
911
|
+
key={request.id}
|
|
912
|
+
request={request}
|
|
913
|
+
onApprove={approve}
|
|
914
|
+
onDeny={deny}
|
|
915
|
+
/>
|
|
916
|
+
)
|
|
917
|
+
}
|
|
918
|
+
return isQuestion ? (
|
|
646
919
|
<QuestionPrompt
|
|
647
920
|
key={request.id}
|
|
648
921
|
request={request}
|
|
@@ -656,9 +929,9 @@ export function SessionPanel({
|
|
|
656
929
|
onApprove={approve}
|
|
657
930
|
onDeny={deny}
|
|
658
931
|
/>
|
|
659
|
-
)
|
|
660
|
-
)}
|
|
661
|
-
</
|
|
932
|
+
)
|
|
933
|
+
})}
|
|
934
|
+
</PromptSurface>
|
|
662
935
|
</div>
|
|
663
936
|
) : null}
|
|
664
937
|
{readOnly ? null : (
|
|
@@ -677,34 +950,10 @@ export function SessionPanel({
|
|
|
677
950
|
: undefined
|
|
678
951
|
}
|
|
679
952
|
layout={controlsExternal ? 'inline' : 'stacked'}
|
|
680
|
-
toolbar={
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
the profile catalog instead — without that fallback its picker
|
|
685
|
-
would be permanently empty and the session unswitchable. */}
|
|
686
|
-
{models.length ? (
|
|
687
|
-
<ModelSelect
|
|
688
|
-
models={models}
|
|
689
|
-
model={effectiveModel}
|
|
690
|
-
onModelChange={setModel}
|
|
691
|
-
disabled={ended}
|
|
692
|
-
/>
|
|
693
|
-
) : null}
|
|
694
|
-
{state.permissionMode ? (
|
|
695
|
-
<PermissionModeSelect
|
|
696
|
-
mode={state.permissionMode}
|
|
697
|
-
onModeChange={setPermissionMode}
|
|
698
|
-
// Only what this engine implements — the rest would come back as
|
|
699
|
-
// a protocol_error.
|
|
700
|
-
modes={capabilities.permissionModes}
|
|
701
|
-
canBypass={state.session?.canBypassPermissions}
|
|
702
|
-
disabled={ended}
|
|
703
|
-
/>
|
|
704
|
-
) : null}
|
|
705
|
-
</>
|
|
706
|
-
)
|
|
707
|
-
}
|
|
953
|
+
toolbar={controlsExternal ? undefined : sessionControls}
|
|
954
|
+
fontSize={terminalMetrics?.fontSize}
|
|
955
|
+
lineHeight={terminalMetrics?.lineHeight}
|
|
956
|
+
affordances={affordances}
|
|
708
957
|
/>
|
|
709
958
|
)}
|
|
710
959
|
{/* Below the composer, along the foot of the panel — the editor
|
|
@@ -732,7 +981,7 @@ export function SessionPanel({
|
|
|
732
981
|
subscriptionType={state.subscriptionType}
|
|
733
982
|
engine={state.engine ?? 'claude'}
|
|
734
983
|
totalCostUsd={state.totalCostUsd}
|
|
735
|
-
updatedAt={
|
|
984
|
+
updatedAt={usageUpdatedAt}
|
|
736
985
|
open={panel === 'usage'}
|
|
737
986
|
onOpenChange={(next) => setPanel(next ? 'usage' : undefined)}
|
|
738
987
|
/>
|
|
@@ -17,6 +17,7 @@ import { EditorTabs } from './EditorTabs.tsx'
|
|
|
17
17
|
import { FileTree } from './FileTree.tsx'
|
|
18
18
|
import { FileViewer } from './FileViewer.tsx'
|
|
19
19
|
import { SessionPanel, type SessionPanelProps } from './SessionPanel.tsx'
|
|
20
|
+
import { resolveAgainstCwd, usePathLinks } from './use-path-links.ts'
|
|
20
21
|
|
|
21
22
|
export interface SessionWorkspaceProps {
|
|
22
23
|
client: WorkerDeckClient
|
|
@@ -34,8 +35,15 @@ export interface SessionWorkspaceProps {
|
|
|
34
35
|
*/
|
|
35
36
|
transcriptVariant?: SessionPanelProps['transcriptVariant']
|
|
36
37
|
transcriptDensity?: SessionPanelProps['transcriptDensity']
|
|
38
|
+
transcriptFont?: SessionPanelProps['transcriptFont']
|
|
39
|
+
/** The overview ruler in place of the scrollbar — see `SessionPanel`. */
|
|
40
|
+
scrubber?: SessionPanelProps['scrubber']
|
|
41
|
+
scrubberMarks?: SessionPanelProps['scrubberMarks']
|
|
42
|
+
/** The last prompt pinned above the transcript — see `SessionPanel`. */
|
|
43
|
+
stickyPrompt?: SessionPanelProps['stickyPrompt']
|
|
37
44
|
/** Which end of the panel the status bar sits at — see `SessionPanel`. */
|
|
38
45
|
statusPlacement?: SessionPanelProps['statusPlacement']
|
|
46
|
+
controlsSurface?: SessionPanelProps['controlsSurface']
|
|
39
47
|
unseen?: SessionPanelProps['unseen']
|
|
40
48
|
/** Viewer mode — no composer, no approval prompts. Forwarded verbatim to
|
|
41
49
|
* {@link SessionPanel}; the file tree and editor are unaffected, since reading
|
|
@@ -91,7 +99,12 @@ export function SessionWorkspace({
|
|
|
91
99
|
header,
|
|
92
100
|
transcriptVariant,
|
|
93
101
|
transcriptDensity,
|
|
102
|
+
transcriptFont,
|
|
103
|
+
scrubber,
|
|
104
|
+
scrubberMarks,
|
|
105
|
+
stickyPrompt,
|
|
94
106
|
statusPlacement,
|
|
107
|
+
controlsSurface,
|
|
95
108
|
unseen,
|
|
96
109
|
readOnly,
|
|
97
110
|
onVitals,
|
|
@@ -114,6 +127,7 @@ export function SessionWorkspace({
|
|
|
114
127
|
// editor asks before it offers to save anything.
|
|
115
128
|
const { canWrite } = useHostFileRoots(client)
|
|
116
129
|
|
|
130
|
+
|
|
117
131
|
// Nothing here can save on the user's behalf when the tab is going away, so
|
|
118
132
|
// the browser's own guard is the last line. Registered only while there is
|
|
119
133
|
// something to lose — an unconditional handler makes every navigation prompt.
|
|
@@ -168,6 +182,22 @@ export function SessionWorkspace({
|
|
|
168
182
|
|
|
169
183
|
const column = useRef<HTMLDivElement>(null)
|
|
170
184
|
const columnHeight = useElementHeight(column)
|
|
185
|
+
// Cmd/Ctrl+click a file the agent named and it opens in the editor above —
|
|
186
|
+
// the workspace is the only surface that *has* an editor, which is why this
|
|
187
|
+
// lives here and not in the panel. Gated on the tree being available: without
|
|
188
|
+
// a host filesystem there is nothing to open, and a link that cannot resolve
|
|
189
|
+
// is worse than plain text. Monaco is excluded because Cmd+click already
|
|
190
|
+
// means go-to-definition in there, and every identifier would match.
|
|
191
|
+
const openPath = useCallback(
|
|
192
|
+
({ path }: { path: string }) => files.open(resolveAgainstCwd(path, cwd)),
|
|
193
|
+
[files.open, cwd],
|
|
194
|
+
)
|
|
195
|
+
usePathLinks({
|
|
196
|
+
container: column,
|
|
197
|
+
onOpen: openPath,
|
|
198
|
+
enabled: tree.available,
|
|
199
|
+
ignore: '.monaco-editor',
|
|
200
|
+
})
|
|
171
201
|
const editorMax = Math.max(EDITOR_MIN, columnHeight - AGENT_MIN)
|
|
172
202
|
|
|
173
203
|
// The embedder's header is app chrome and belongs above everything — but only
|
|
@@ -225,6 +255,7 @@ export function SessionWorkspace({
|
|
|
225
255
|
onValueChange={setRailWidth}
|
|
226
256
|
min={RAIL_MIN}
|
|
227
257
|
max={RAIL_MAX}
|
|
258
|
+
defaultValue={defaultRailWidth}
|
|
228
259
|
aria-label='Resize the file tree'
|
|
229
260
|
/>
|
|
230
261
|
) : null}
|
|
@@ -272,6 +303,11 @@ export function SessionWorkspace({
|
|
|
272
303
|
header={hoisted}
|
|
273
304
|
transcriptVariant={transcriptVariant}
|
|
274
305
|
transcriptDensity={transcriptDensity}
|
|
306
|
+
transcriptFont={transcriptFont}
|
|
307
|
+
scrubber={scrubber}
|
|
308
|
+
scrubberMarks={scrubberMarks}
|
|
309
|
+
stickyPrompt={stickyPrompt}
|
|
310
|
+
controlsSurface={controlsSurface}
|
|
275
311
|
statusPlacement={statusPlacement}
|
|
276
312
|
unseen={unseen}
|
|
277
313
|
readOnly={readOnly}
|