@workerdeck/ui 0.12.0 → 0.15.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 +19 -0
- package/build/{SessionPanel-NQ8ksCfj.mjs → SessionPanel-DI1NO4l8.mjs} +223 -163
- package/build/SessionPanel-DI1NO4l8.mjs.map +1 -0
- package/build/{SessionPanel-Dy9lQrOV.d.mts → SessionPanel-J2U8v88q.d.mts} +86 -8
- package/build/index.d.mts +118 -7
- package/build/index.mjs +355 -169
- package/build/index.mjs.map +1 -1
- package/build/workspace.d.mts +13 -1
- package/build/workspace.mjs +7 -2
- package/build/workspace.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/agent/Composer.tsx +112 -37
- package/src/components/agent/EngineIcon.tsx +97 -0
- package/src/components/agent/Loader.tsx +4 -1
- package/src/components/agent/Message.tsx +12 -5
- package/src/components/agent/QuestionPrompt.tsx +1 -1
- package/src/components/agent/SessionBrowser.tsx +266 -138
- package/src/components/agent/SessionPanel.tsx +149 -48
- package/src/components/agent/SessionWorkspace.tsx +17 -0
- package/src/components/agent/StatusBar.tsx +58 -12
- package/src/components/agent/Transcript.tsx +2 -3
- package/src/components/agent/line-prompt.tsx +2 -2
- package/src/components/agent/transcript-variant.tsx +14 -0
- package/src/components/ui/Badge.tsx +6 -1
- package/src/components/ui/Empty.tsx +56 -0
- package/src/components/ui/Menu.tsx +1 -1
- package/src/components/ui/Select.tsx +1 -1
- package/src/components/ui/Splitter.tsx +14 -0
- package/src/components/ui/Tooltip.tsx +1 -1
- package/src/index.ts +11 -1
- package/src/styles/theme.css +77 -15
- package/build/SessionPanel-NQ8ksCfj.mjs.map +0 -1
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type ConnectionState,
|
|
19
19
|
type ProducedFileRef,
|
|
20
20
|
type TranscriptState,
|
|
21
|
+
type UseToolCallHostOptions,
|
|
21
22
|
} from '@workerdeck/react'
|
|
22
23
|
import {
|
|
23
24
|
ChartPie,
|
|
@@ -53,6 +54,7 @@ import {
|
|
|
53
54
|
TranscriptDensityProvider,
|
|
54
55
|
TranscriptVariantProvider,
|
|
55
56
|
type TranscriptDensity,
|
|
57
|
+
type TranscriptFont,
|
|
56
58
|
type TranscriptVariant,
|
|
57
59
|
} from './transcript-variant.tsx'
|
|
58
60
|
import { UsageDialog } from './UsageDialog.tsx'
|
|
@@ -98,6 +100,17 @@ export interface SessionPanelProps {
|
|
|
98
100
|
* take the menu, or it has nowhere left to go.
|
|
99
101
|
*/
|
|
100
102
|
statusSurface?: 'internal' | 'external'
|
|
103
|
+
/**
|
|
104
|
+
* Which end of the panel the status bar sits at. Default `top`.
|
|
105
|
+
*
|
|
106
|
+
* `bottom` is the editor convention — VS Code's status bar runs along the
|
|
107
|
+
* foot of the window — and suits a host where the panel *is* the editor area
|
|
108
|
+
* and the chrome above it already belongs to the app. Placement only; the bar
|
|
109
|
+
* is the same bar, with the same `⋯` menu in its trailing slot, so this
|
|
110
|
+
* composes with {@link statusSurface} rather than competing with it (external
|
|
111
|
+
* still means "there isn't one").
|
|
112
|
+
*/
|
|
113
|
+
statusPlacement?: 'top' | 'bottom'
|
|
101
114
|
/** Where `panelSurface: 'external'` routes opens. Absent = the affordances
|
|
102
115
|
* (status-bar clicks, `/mcp`) become inert rather than half-working. */
|
|
103
116
|
onOpenPanel?: (panel: SessionSurfacePanel) => void
|
|
@@ -120,18 +133,36 @@ export interface SessionPanelProps {
|
|
|
120
133
|
* dock is allowed to be roomy.
|
|
121
134
|
*/
|
|
122
135
|
transcriptDensity?: TranscriptDensity
|
|
136
|
+
/**
|
|
137
|
+
* The panel's typeface — `'sans'` (default, the host's UI font) or `'mono'`,
|
|
138
|
+
* which repoints the sans token at the mono stack **for this subtree only**.
|
|
139
|
+
*
|
|
140
|
+
* The third reader preference beside variant and density, and the same kind of
|
|
141
|
+
* thing: how a transcript should read is a property of the person reading it.
|
|
142
|
+
* Scoped to the panel because that is the whole claim — a monospace agent view
|
|
143
|
+
* next to an ordinary app, not a monospace app.
|
|
144
|
+
*/
|
|
145
|
+
transcriptFont?: TranscriptFont
|
|
123
146
|
/**
|
|
124
147
|
* Where the session's own controls — model and permission mode — live.
|
|
125
148
|
* `'internal'` (default) draws them in the composer's toolbar row.
|
|
126
|
-
* `'
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
149
|
+
* `'status'` draws them in the panel's OWN status bar, beside the readings
|
|
150
|
+
* they act on; the composer collapses to a single line either way. That is
|
|
151
|
+
* VS Code's arrangement without VS Code — a host whose panel carries a status
|
|
152
|
+
* bar of its own (`statusPlacement: 'bottom'`) gets the same streamlined
|
|
153
|
+
* shape without having to host the pickers itself.
|
|
154
|
+
* `'external'` draws neither: the embedder renders the pickers in its own
|
|
155
|
+
* chrome (VS Code's window status bar, where a click opens a QuickPick) and
|
|
156
|
+
* drives them through {@link onControls}.
|
|
157
|
+
*
|
|
158
|
+
* `'status'` needs a status bar to put them in — with
|
|
159
|
+
* `statusSurface: 'external'` there is none, and the two together would hide
|
|
160
|
+
* the controls entirely, so that combination falls back to the composer.
|
|
130
161
|
*
|
|
131
162
|
* The options themselves ride {@link SessionVitals} — an embedder must not
|
|
132
163
|
* attach a second time to learn what the models are.
|
|
133
164
|
*/
|
|
134
|
-
controlsSurface?: 'internal' | 'external'
|
|
165
|
+
controlsSurface?: 'internal' | 'external' | 'status'
|
|
135
166
|
/**
|
|
136
167
|
* Handed the session's setters once the panel is live, and `undefined` on
|
|
137
168
|
* unmount. The counterpart to `controlsSurface: 'external'`: vitals carry the
|
|
@@ -161,6 +192,38 @@ export interface SessionPanelProps {
|
|
|
161
192
|
* the number to remember through `SessionVitals.itemCount`.
|
|
162
193
|
*/
|
|
163
194
|
unseen?: { itemCount: number; since?: number }
|
|
195
|
+
/**
|
|
196
|
+
* A viewer, not a seat at the session: transcript, status bar and panels as
|
|
197
|
+
* usual, but no composer and no approval prompts.
|
|
198
|
+
*
|
|
199
|
+
* For a surface that is *about* a run rather than in it — the dashboard's job
|
|
200
|
+
* detail, where the session belongs to the queue and typing into it would be a
|
|
201
|
+
* second operator arriving mid-run. Deliberately not "disabled controls": a
|
|
202
|
+
* greyed-out composer says the session is busy, an absent one says this screen
|
|
203
|
+
* does not drive it. The attach is still live and read paths are untouched,
|
|
204
|
+
* so the transcript streams and the file tree browses.
|
|
205
|
+
*
|
|
206
|
+
* It does **not** claim to be an authorization boundary. Anything holding this
|
|
207
|
+
* client can still send; what it removes is the affordance, and the honest
|
|
208
|
+
* enforcement lives on the gateway.
|
|
209
|
+
*/
|
|
210
|
+
readOnly?: boolean
|
|
211
|
+
/**
|
|
212
|
+
* Options for the browser tool host this panel runs on its own attach — or
|
|
213
|
+
* `false` to run none at all.
|
|
214
|
+
*
|
|
215
|
+
* The panel hosts server-bridged tool calls itself, because the bridge asks
|
|
216
|
+
* the *first attached client* and the panel owns the session's one attach: an
|
|
217
|
+
* embedder subscribing to the same handle separately would find this host
|
|
218
|
+
* already answering, and refusing, anything outside its allow-list. So the
|
|
219
|
+
* options come through here.
|
|
220
|
+
*
|
|
221
|
+
* Merged over the defaults, which host `eval_script` alone. Widening `tools`
|
|
222
|
+
* is a real grant — this tab will execute what the gateway asks it to for
|
|
223
|
+
* every name in the list — so it names them explicitly rather than accepting
|
|
224
|
+
* a wildcard.
|
|
225
|
+
*/
|
|
226
|
+
toolHost?: UseToolCallHostOptions | false
|
|
164
227
|
className?: string
|
|
165
228
|
}
|
|
166
229
|
|
|
@@ -251,19 +314,26 @@ export function SessionPanel({
|
|
|
251
314
|
header,
|
|
252
315
|
panelSurface = 'internal',
|
|
253
316
|
statusSurface = 'internal',
|
|
317
|
+
statusPlacement = 'top',
|
|
254
318
|
onOpenPanel,
|
|
255
319
|
onVitals,
|
|
256
320
|
transcriptVariant = 'cards',
|
|
257
321
|
transcriptDensity = 'comfortable',
|
|
322
|
+
transcriptFont = 'sans',
|
|
258
323
|
controlsSurface = 'internal',
|
|
259
324
|
onControls,
|
|
260
325
|
focusComposerOnClick = false,
|
|
261
326
|
unseen,
|
|
327
|
+
readOnly = false,
|
|
328
|
+
toolHost,
|
|
262
329
|
className,
|
|
263
330
|
}: SessionPanelProps) {
|
|
264
331
|
const external = panelSurface === 'external'
|
|
265
332
|
const statusExternal = statusSurface === 'external'
|
|
266
|
-
|
|
333
|
+
// Both non-internal surfaces take the pickers out of the composer, which is
|
|
334
|
+
// what collapses it to a single line.
|
|
335
|
+
const controlsInStatus = controlsSurface === 'status' && !statusExternal
|
|
336
|
+
const controlsExternal = controlsSurface === 'external' || controlsInStatus
|
|
267
337
|
// Rejected commands (the CLI refusing a permission-mode switch, say) render INSIDE
|
|
268
338
|
// the panel rather than through `toast`. The panel does not mount a `Toaster`, and
|
|
269
339
|
// an embedder that doesn't either would drop the only signal that a command failed
|
|
@@ -327,7 +397,7 @@ export function SessionPanel({
|
|
|
327
397
|
// SAME handle the panel attached with — the bridge asks the first attached
|
|
328
398
|
// client. Free for Claude sessions: the guest loads lazily on the first call,
|
|
329
399
|
// which for them never comes.
|
|
330
|
-
useToolCallHost(handle)
|
|
400
|
+
useToolCallHost(handle, toolHost === false ? { enabled: false } : toolHost)
|
|
331
401
|
const capabilities = state.capabilities
|
|
332
402
|
|
|
333
403
|
// Vitals out to the embedder, keyed on the readings themselves so an inline
|
|
@@ -503,11 +573,61 @@ export function SessionPanel({
|
|
|
503
573
|
const menu = external ? null : actionsMenu
|
|
504
574
|
const headerTakesActions = typeof header === 'function'
|
|
505
575
|
|
|
576
|
+
// Built once and placed at one end or the other — the bar has a `⋯` menu and
|
|
577
|
+
// three open handlers, and two copies of that in the tree would be two things
|
|
578
|
+
// to keep in step.
|
|
579
|
+
// Built once, placed by `controlsSurface`: the composer's toolbar row, or the
|
|
580
|
+
// status bar beside the readings they act on, or nowhere at all when the host
|
|
581
|
+
// draws them in its own chrome.
|
|
582
|
+
const sessionControls = (
|
|
583
|
+
<>
|
|
584
|
+
{/* Codex reports no `capabilities` event, so its models arrive from the
|
|
585
|
+
profile catalog instead — without that fallback its picker would be
|
|
586
|
+
permanently empty and the session unswitchable. */}
|
|
587
|
+
{models.length ? (
|
|
588
|
+
<ModelSelect
|
|
589
|
+
models={models}
|
|
590
|
+
model={effectiveModel}
|
|
591
|
+
onModelChange={setModel}
|
|
592
|
+
disabled={ended}
|
|
593
|
+
// The bar is one line of label-sized text; a 24px trigger would set
|
|
594
|
+
// its height instead of fitting in it.
|
|
595
|
+
className={controlsInStatus ? 'h-5' : undefined}
|
|
596
|
+
/>
|
|
597
|
+
) : null}
|
|
598
|
+
{state.permissionMode ? (
|
|
599
|
+
<PermissionModeSelect
|
|
600
|
+
mode={state.permissionMode}
|
|
601
|
+
onModeChange={setPermissionMode}
|
|
602
|
+
// Only what this engine implements — the rest would come back as a
|
|
603
|
+
// protocol_error.
|
|
604
|
+
modes={capabilities.permissionModes}
|
|
605
|
+
canBypass={state.session?.canBypassPermissions}
|
|
606
|
+
disabled={ended}
|
|
607
|
+
className={controlsInStatus ? 'h-5' : undefined}
|
|
608
|
+
/>
|
|
609
|
+
) : null}
|
|
610
|
+
</>
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
const statusBar = statusExternal ? null : (
|
|
614
|
+
<StatusBar
|
|
615
|
+
state={state}
|
|
616
|
+
connection={connection}
|
|
617
|
+
placement={statusPlacement}
|
|
618
|
+
controls={controlsInStatus && !readOnly ? sessionControls : undefined}
|
|
619
|
+
onOpenStatus={external && !onOpenPanel ? undefined : () => openPanel('info')}
|
|
620
|
+
onOpenContext={external && !onOpenPanel ? undefined : () => openPanel('context')}
|
|
621
|
+
onOpenUsage={external && !onOpenPanel ? undefined : () => openPanel('usage')}
|
|
622
|
+
actions={headerTakesActions ? undefined : menu}
|
|
623
|
+
/>
|
|
624
|
+
)
|
|
625
|
+
|
|
506
626
|
// Dead-space clicks land in the composer. Anything the user actually aimed at
|
|
507
627
|
// — a control, a link, the end of a drag-selection — keeps its own meaning;
|
|
508
628
|
// this only claims what was left over.
|
|
509
629
|
const handleClick = (event: ReactMouseEvent<HTMLDivElement>) => {
|
|
510
|
-
if (!focusComposerOnClick) return
|
|
630
|
+
if (!focusComposerOnClick || readOnly) return
|
|
511
631
|
const target = event.target as HTMLElement | null
|
|
512
632
|
if (target?.closest(INTERACTIVE)) return
|
|
513
633
|
if (window.getSelection()?.isCollapsed === false) return
|
|
@@ -522,19 +642,14 @@ export function SessionPanel({
|
|
|
522
642
|
<TranscriptDensityProvider value={transcriptDensity}>
|
|
523
643
|
<div
|
|
524
644
|
data-slot='session-panel'
|
|
645
|
+
// The typeface is a cascade fact, not a React one — one attribute here,
|
|
646
|
+
// and the `[data-agent-font]` rule in theme.css does the rest. Nothing
|
|
647
|
+
// outside this subtree can pick it up.
|
|
648
|
+
data-agent-font={transcriptFont}
|
|
525
649
|
onClick={handleClick}
|
|
526
650
|
className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}>
|
|
527
651
|
{headerTakesActions ? header({ actions: menu }) : header}
|
|
528
|
-
{
|
|
529
|
-
<StatusBar
|
|
530
|
-
state={state}
|
|
531
|
-
connection={connection}
|
|
532
|
-
onOpenStatus={external && !onOpenPanel ? undefined : () => openPanel('info')}
|
|
533
|
-
onOpenContext={external && !onOpenPanel ? undefined : () => openPanel('context')}
|
|
534
|
-
onOpenUsage={external && !onOpenPanel ? undefined : () => openPanel('usage')}
|
|
535
|
-
actions={headerTakesActions ? undefined : menu}
|
|
536
|
-
/>
|
|
537
|
-
)}
|
|
652
|
+
{statusPlacement === 'top' ? statusBar : null}
|
|
538
653
|
{protocolMismatch !== undefined ? (
|
|
539
654
|
<Notice level='warning'>
|
|
540
655
|
Server speaks protocol v{protocolMismatch}, this build renders v{PROTOCOL_VERSION}. Some
|
|
@@ -569,7 +684,12 @@ export function SessionPanel({
|
|
|
569
684
|
<div
|
|
570
685
|
data-slot='catch-up'
|
|
571
686
|
className='mx-auto flex w-full max-w-[var(--wd-content-max-w,48rem)] items-center gap-2 text-label text-fg-3'>
|
|
572
|
-
<span
|
|
687
|
+
<span
|
|
688
|
+
aria-hidden
|
|
689
|
+
className={cn(
|
|
690
|
+
'select-none',
|
|
691
|
+
transcriptVariant === 'lines' ? 'text-fg-3' : 'text-accent',
|
|
692
|
+
)}>
|
|
573
693
|
※
|
|
574
694
|
</span>
|
|
575
695
|
<span className='min-w-0 flex-1 truncate'>
|
|
@@ -594,7 +714,10 @@ export function SessionPanel({
|
|
|
594
714
|
{/* An engine with no approval channel never raises these, but a stale
|
|
595
715
|
pending request from a replayed log would still render — the record is
|
|
596
716
|
the authority on whether an approval UI means anything here. */}
|
|
597
|
-
{
|
|
717
|
+
{/* A read-only surface has no answer to give: the status bar still says
|
|
718
|
+
`awaiting approval`, and the place that can act on it is wherever the
|
|
719
|
+
session is actually driven. */}
|
|
720
|
+
{!readOnly && capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? (
|
|
598
721
|
<div className='px-3 pb-2'>
|
|
599
722
|
<div className='mx-auto flex w-full max-w-[var(--wd-content-max-w,48rem)] flex-col gap-2'>
|
|
600
723
|
{state.pendingApprovals.map((request) =>
|
|
@@ -618,6 +741,7 @@ export function SessionPanel({
|
|
|
618
741
|
</div>
|
|
619
742
|
</div>
|
|
620
743
|
) : null}
|
|
744
|
+
{readOnly ? null : (
|
|
621
745
|
<Composer
|
|
622
746
|
ref={composerRef}
|
|
623
747
|
onSend={handleSend}
|
|
@@ -633,35 +757,12 @@ export function SessionPanel({
|
|
|
633
757
|
: undefined
|
|
634
758
|
}
|
|
635
759
|
layout={controlsExternal ? 'inline' : 'stacked'}
|
|
636
|
-
toolbar={
|
|
637
|
-
controlsExternal ? undefined : (
|
|
638
|
-
<>
|
|
639
|
-
{/* Codex reports no `capabilities` event, so its models arrive from
|
|
640
|
-
the profile catalog instead — without that fallback its picker
|
|
641
|
-
would be permanently empty and the session unswitchable. */}
|
|
642
|
-
{models.length ? (
|
|
643
|
-
<ModelSelect
|
|
644
|
-
models={models}
|
|
645
|
-
model={effectiveModel}
|
|
646
|
-
onModelChange={setModel}
|
|
647
|
-
disabled={ended}
|
|
648
|
-
/>
|
|
649
|
-
) : null}
|
|
650
|
-
{state.permissionMode ? (
|
|
651
|
-
<PermissionModeSelect
|
|
652
|
-
mode={state.permissionMode}
|
|
653
|
-
onModeChange={setPermissionMode}
|
|
654
|
-
// Only what this engine implements — the rest would come back as
|
|
655
|
-
// a protocol_error.
|
|
656
|
-
modes={capabilities.permissionModes}
|
|
657
|
-
canBypass={state.session?.canBypassPermissions}
|
|
658
|
-
disabled={ended}
|
|
659
|
-
/>
|
|
660
|
-
) : null}
|
|
661
|
-
</>
|
|
662
|
-
)
|
|
663
|
-
}
|
|
760
|
+
toolbar={controlsExternal ? undefined : sessionControls}
|
|
664
761
|
/>
|
|
762
|
+
)}
|
|
763
|
+
{/* Below the composer, along the foot of the panel — the editor
|
|
764
|
+
convention. Last in the flex column, so it is the bottom edge. */}
|
|
765
|
+
{statusPlacement === 'bottom' ? statusBar : null}
|
|
665
766
|
|
|
666
767
|
{/* The internal dialog surface. The external one renders none of these —
|
|
667
768
|
the embedder hosts equivalent surfaces and is handed the intents. */}
|
|
@@ -34,7 +34,15 @@ export interface SessionWorkspaceProps {
|
|
|
34
34
|
*/
|
|
35
35
|
transcriptVariant?: SessionPanelProps['transcriptVariant']
|
|
36
36
|
transcriptDensity?: SessionPanelProps['transcriptDensity']
|
|
37
|
+
transcriptFont?: SessionPanelProps['transcriptFont']
|
|
38
|
+
/** Which end of the panel the status bar sits at — see `SessionPanel`. */
|
|
39
|
+
statusPlacement?: SessionPanelProps['statusPlacement']
|
|
40
|
+
controlsSurface?: SessionPanelProps['controlsSurface']
|
|
37
41
|
unseen?: SessionPanelProps['unseen']
|
|
42
|
+
/** Viewer mode — no composer, no approval prompts. Forwarded verbatim to
|
|
43
|
+
* {@link SessionPanel}; the file tree and editor are unaffected, since reading
|
|
44
|
+
* a run's files is the point of a read-only view. */
|
|
45
|
+
readOnly?: SessionPanelProps['readOnly']
|
|
38
46
|
onVitals?: SessionPanelProps['onVitals']
|
|
39
47
|
/** Rail width in pixels on first render. */
|
|
40
48
|
defaultRailWidth?: number
|
|
@@ -85,7 +93,11 @@ export function SessionWorkspace({
|
|
|
85
93
|
header,
|
|
86
94
|
transcriptVariant,
|
|
87
95
|
transcriptDensity,
|
|
96
|
+
transcriptFont,
|
|
97
|
+
statusPlacement,
|
|
98
|
+
controlsSurface,
|
|
88
99
|
unseen,
|
|
100
|
+
readOnly,
|
|
89
101
|
onVitals,
|
|
90
102
|
defaultRailWidth = 260,
|
|
91
103
|
defaultRailCollapsed,
|
|
@@ -217,6 +229,7 @@ export function SessionWorkspace({
|
|
|
217
229
|
onValueChange={setRailWidth}
|
|
218
230
|
min={RAIL_MIN}
|
|
219
231
|
max={RAIL_MAX}
|
|
232
|
+
defaultValue={defaultRailWidth}
|
|
220
233
|
aria-label='Resize the file tree'
|
|
221
234
|
/>
|
|
222
235
|
) : null}
|
|
@@ -264,7 +277,11 @@ export function SessionWorkspace({
|
|
|
264
277
|
header={hoisted}
|
|
265
278
|
transcriptVariant={transcriptVariant}
|
|
266
279
|
transcriptDensity={transcriptDensity}
|
|
280
|
+
transcriptFont={transcriptFont}
|
|
281
|
+
controlsSurface={controlsSurface}
|
|
282
|
+
statusPlacement={statusPlacement}
|
|
267
283
|
unseen={unseen}
|
|
284
|
+
readOnly={readOnly}
|
|
268
285
|
onVitals={onVitals}
|
|
269
286
|
className='min-h-0 flex-1'
|
|
270
287
|
/>
|
|
@@ -28,8 +28,18 @@ export interface StatusBarProps {
|
|
|
28
28
|
onOpenStatus?: () => void
|
|
29
29
|
onOpenContext?: () => void
|
|
30
30
|
onOpenUsage?: () => void
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* The session's own controls (model, permission mode), for
|
|
33
|
+
* `controlsSurface: 'status'`. They sit at the end of the readings cluster —
|
|
34
|
+
* status, context, usage, then what you can *change* — rather than out by the
|
|
35
|
+
* actions menu, because they belong with the session facts they act on.
|
|
36
|
+
*/
|
|
37
|
+
controls?: ReactNode
|
|
38
|
+
/** Trailing slot — the session-actions menu, at the bar's trailing edge. */
|
|
32
39
|
actions?: ReactNode
|
|
40
|
+
/** Which edge the bar sits on, so its separating rule goes on the other side.
|
|
41
|
+
* Placement is the panel's decision; this only styles it. */
|
|
42
|
+
placement?: 'top' | 'bottom'
|
|
33
43
|
className?: string
|
|
34
44
|
}
|
|
35
45
|
|
|
@@ -110,16 +120,22 @@ function RateLimitMeter({ label, info, now }: { label: string; info: RateLimitIn
|
|
|
110
120
|
{info.status === 'rejected' ? <div className='text-danger'>Limit reached</div> : null}
|
|
111
121
|
</div>
|
|
112
122
|
}>
|
|
123
|
+
{/* Inline, not `inline-flex`: a flex container contributes its FIRST
|
|
124
|
+
item's baseline, and the first item here is the ring — so a flex
|
|
125
|
+
version hands the row a 13px circle's baseline instead of the number's,
|
|
126
|
+
and every reading beside it sits on a different line. Inline text has
|
|
127
|
+
only one baseline to give. `align-middle` centres the ring on the
|
|
128
|
+
x-height, which is where it looked right anyway. */}
|
|
113
129
|
<span
|
|
114
130
|
className={cn(
|
|
115
|
-
'
|
|
131
|
+
'cursor-default font-mono text-label whitespace-nowrap',
|
|
116
132
|
info.status === 'rejected' ? 'text-danger' : utilizationColor(pct ?? 0),
|
|
117
133
|
)}>
|
|
118
|
-
<ProgressRing value={pct ?? 0} />
|
|
134
|
+
<ProgressRing value={pct ?? 0} className='mr-1 inline-block align-middle' />
|
|
119
135
|
{label}
|
|
120
136
|
{pct !== undefined ? ` ${pct.toFixed(0)}%` : ''}
|
|
121
137
|
{resetsAtMs !== undefined ? (
|
|
122
|
-
<span className='text-fg-4'
|
|
138
|
+
<span className='text-fg-4'> · {formatCountdown(resetsAtMs, now)}</span>
|
|
123
139
|
) : null}
|
|
124
140
|
</span>
|
|
125
141
|
</Tip>
|
|
@@ -135,7 +151,15 @@ function Slot({ onClick, hint, children }: { onClick?: () => void; hint: string;
|
|
|
135
151
|
type='button'
|
|
136
152
|
onClick={onClick}
|
|
137
153
|
aria-label={hint}
|
|
138
|
-
|
|
154
|
+
// `leading-4` matches the label metrics inside: a button's own line box is
|
|
155
|
+
// the page's 24px one, and the extra strut was padding the bar by 2.5px
|
|
156
|
+
// that nothing was using.
|
|
157
|
+
// No horizontal padding: the bar's own 6px is the inset, and a slot that
|
|
158
|
+
// added its own would start the first reading 10px in. The hover surface
|
|
159
|
+
// hugs the text instead, which is what a status line's items do anyway.
|
|
160
|
+
// `leading-4` matches the label metrics inside — a button's own line box
|
|
161
|
+
// is the page's 24px one, and the strut padded the bar with nothing.
|
|
162
|
+
className='rounded-md py-0.5 leading-4 transition-colors outline-none hover:bg-surface-hover focus-visible:bg-surface-hover'>
|
|
139
163
|
{children}
|
|
140
164
|
</button>
|
|
141
165
|
)
|
|
@@ -148,7 +172,9 @@ export function StatusBar({
|
|
|
148
172
|
onOpenStatus,
|
|
149
173
|
onOpenContext,
|
|
150
174
|
onOpenUsage,
|
|
175
|
+
controls,
|
|
151
176
|
actions,
|
|
177
|
+
placement = 'top',
|
|
152
178
|
className,
|
|
153
179
|
}: StatusBarProps) {
|
|
154
180
|
const meta = STATUS_META[state.status]
|
|
@@ -160,7 +186,20 @@ export function StatusBar({
|
|
|
160
186
|
<div
|
|
161
187
|
data-slot='status-bar'
|
|
162
188
|
className={cn(
|
|
163
|
-
|
|
189
|
+
// Baselines, not boxes. `items-center` centres each child's *box*, and
|
|
190
|
+
// this bar's children are boxes of different heights for reasons that
|
|
191
|
+
// have nothing to do with their text — a badge's pill padding, a ring
|
|
192
|
+
// beside a number, a select's border and chevron. Centring those lands
|
|
193
|
+
// their text on four slightly different lines. A flex item's baseline is
|
|
194
|
+
// its first line's baseline, so aligning on it puts every reading on one
|
|
195
|
+
// line by construction, whatever is drawn around it.
|
|
196
|
+
// One explicit height, shared with the docked composer above it (see
|
|
197
|
+
// `Composer.tsx`) — the two strips along the foot of the panel read as
|
|
198
|
+
// one piece of chrome, and a pixel of drift between them shows.
|
|
199
|
+
'flex h-[38px] items-baseline gap-2 border-border bg-surface p-1.5',
|
|
200
|
+
// The rule goes between the bar and the content, so which edge it sits
|
|
201
|
+
// on follows the placement.
|
|
202
|
+
placement === 'bottom' ? 'border-t' : 'border-b',
|
|
164
203
|
className,
|
|
165
204
|
)}>
|
|
166
205
|
{/* One slot, two meanings: connection trouble wins it, because a session
|
|
@@ -169,16 +208,22 @@ export function StatusBar({
|
|
|
169
208
|
with which credentials — which is the question a status prompts. */}
|
|
170
209
|
<Slot onClick={onOpenStatus} hint='Session info'>
|
|
171
210
|
{link === 'live' ? (
|
|
172
|
-
|
|
173
|
-
|
|
211
|
+
// `items-baseline` so the badge answers the row with its label's
|
|
212
|
+
// baseline rather than its pill's box; the dot and the spinner keep
|
|
213
|
+
// their own centring.
|
|
214
|
+
<Badge variant={meta.variant} dot={!meta.busy} className='items-baseline'>
|
|
215
|
+
{meta.busy ? <Spinner className='size-3 self-center text-current' /> : null}
|
|
174
216
|
{meta.label}
|
|
175
217
|
</Badge>
|
|
176
218
|
) : (
|
|
177
|
-
<Badge
|
|
219
|
+
<Badge
|
|
220
|
+
variant={link === 'offline' ? 'danger' : 'warning'}
|
|
221
|
+
dot={false}
|
|
222
|
+
className='items-baseline'>
|
|
178
223
|
{link === 'offline' ? (
|
|
179
|
-
<WifiOff className='size-3 text-current' />
|
|
224
|
+
<WifiOff className='size-3 self-center text-current' />
|
|
180
225
|
) : (
|
|
181
|
-
<RefreshCw className='size-3 animate-spin text-current' />
|
|
226
|
+
<RefreshCw className='size-3 animate-spin self-center text-current' />
|
|
182
227
|
)}
|
|
183
228
|
{link === 'offline' ? 'Offline' : 'Reconnecting…'}
|
|
184
229
|
</Badge>
|
|
@@ -193,12 +238,13 @@ export function StatusBar({
|
|
|
193
238
|
) : null}
|
|
194
239
|
{session || weekly ? (
|
|
195
240
|
<Slot onClick={onOpenUsage} hint='Plan usage'>
|
|
196
|
-
<span className='inline-flex items-
|
|
241
|
+
<span className='inline-flex items-baseline gap-2'>
|
|
197
242
|
{session ? <RateLimitMeter label='Session' info={session} now={now} /> : null}
|
|
198
243
|
{weekly ? <RateLimitMeter label='Weekly' info={weekly} now={now} /> : null}
|
|
199
244
|
</span>
|
|
200
245
|
</Slot>
|
|
201
246
|
) : null}
|
|
247
|
+
{controls}
|
|
202
248
|
<span className='flex-1' />
|
|
203
249
|
<span className='font-mono text-label text-fg-3'>{formatCost(state.totalCostUsd)}</span>
|
|
204
250
|
{actions}
|
|
@@ -171,7 +171,7 @@ function RecapRow({ line, since }: { line: string; since?: number }) {
|
|
|
171
171
|
if (lines) {
|
|
172
172
|
return (
|
|
173
173
|
<div data-slot='recap' className='flex items-baseline gap-2 py-0.5'>
|
|
174
|
-
<LineGlyph className='text-
|
|
174
|
+
<LineGlyph className='text-fg-3'>※</LineGlyph>
|
|
175
175
|
<span className='min-w-0 flex-1 text-label leading-5 text-fg-3'>
|
|
176
176
|
<span className='text-fg-2'>recap:</span> {text}
|
|
177
177
|
</span>
|
|
@@ -260,9 +260,8 @@ function SentAttachments({
|
|
|
260
260
|
attachments: MessageAttachment[]
|
|
261
261
|
attachmentUrl?: (attachmentId: string) => string
|
|
262
262
|
}) {
|
|
263
|
-
const lines = useLines()
|
|
264
263
|
return (
|
|
265
|
-
<div className=
|
|
264
|
+
<div className='mb-1 flex flex-wrap justify-start gap-1.5'>
|
|
266
265
|
{attachments.map((attachment) => {
|
|
267
266
|
const href = attachmentUrl?.(attachment.id)
|
|
268
267
|
return attachment.mediaType.startsWith('image/') && href ? (
|
|
@@ -128,7 +128,7 @@ export function LineOptionList({
|
|
|
128
128
|
'flex w-full items-baseline gap-2 text-left outline-none',
|
|
129
129
|
isFocused ? 'bg-surface-hover' : 'hover:bg-surface-hover/60',
|
|
130
130
|
)}>
|
|
131
|
-
<LineGlyph className={isFocused ? 'text-
|
|
131
|
+
<LineGlyph className={isFocused ? 'text-fg-1' : undefined}>
|
|
132
132
|
{isFocused ? '❯' : ' '}
|
|
133
133
|
</LineGlyph>
|
|
134
134
|
<span className='shrink-0 font-mono text-label leading-5 text-fg-4'>{index + 1}</span>
|
|
@@ -184,7 +184,7 @@ export function LineInput({
|
|
|
184
184
|
}) {
|
|
185
185
|
return (
|
|
186
186
|
<div className='flex items-baseline gap-2'>
|
|
187
|
-
<LineGlyph className='text-
|
|
187
|
+
<LineGlyph className='text-fg-3'>›</LineGlyph>
|
|
188
188
|
<input
|
|
189
189
|
autoFocus
|
|
190
190
|
value={value}
|
|
@@ -72,6 +72,20 @@ export function useTranscriptDensity(): TranscriptDensity {
|
|
|
72
72
|
return useContext(DensityContext)
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The typeface the panel runs in.
|
|
77
|
+
*
|
|
78
|
+
* `sans` is the host's UI font; `mono` repoints the sans token at the mono stack
|
|
79
|
+
* for the panel's subtree (see the `[data-agent-font='mono']` rule in
|
|
80
|
+
* `theme.css`), so the transcript reads as part of a terminal rather than as a
|
|
81
|
+
* web app beside one.
|
|
82
|
+
*
|
|
83
|
+
* No context and no hook, unlike variant and density: nothing branches on it in
|
|
84
|
+
* JS. It is one attribute on the panel root and the cascade does the rest, which
|
|
85
|
+
* is also what keeps it from leaking past the panel.
|
|
86
|
+
*/
|
|
87
|
+
export type TranscriptFont = 'sans' | 'mono'
|
|
88
|
+
|
|
75
89
|
/**
|
|
76
90
|
* The gap between two rows, per variant and density — the whole of the density
|
|
77
91
|
* feature, since it is the only vertical spacing between rows that exists.
|
|
@@ -33,7 +33,12 @@ export interface BadgeProps
|
|
|
33
33
|
export function Badge({ className, variant = 'neutral', mono, dot, children, ...props }: BadgeProps) {
|
|
34
34
|
return (
|
|
35
35
|
<span data-slot='badge' className={cn(badgeVariants({ variant, mono, className }))} {...props}>
|
|
36
|
-
{dot
|
|
36
|
+
{/* `self-center` so the dot stays put if a caller baseline-aligns the
|
|
37
|
+
badge — a no-op under the default `items-center`, and what lets the
|
|
38
|
+
badge's *text* be the baseline it contributes to a row (a status bar
|
|
39
|
+
aligning readings on one line needs that, and the dot's own box would
|
|
40
|
+
otherwise answer for it). */}
|
|
41
|
+
{dot ? <span aria-hidden className='size-1.5 shrink-0 self-center rounded-full bg-current' /> : null}
|
|
37
42
|
{children}
|
|
38
43
|
</span>
|
|
39
44
|
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { Button } from './Button.tsx'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A view with nothing in it: icon, title, description, and at most one action.
|
|
6
|
+
*
|
|
7
|
+
* Centered and given room, because an empty view is the first thing a new
|
|
8
|
+
* install shows and a flush-left sentence in the top corner reads like a bug
|
|
9
|
+
* report. It lives here rather than in one client because every panel that can
|
|
10
|
+
* be empty should be empty the same way — the extension's views and the
|
|
11
|
+
* dashboard's four sidebars are the same shape of thing.
|
|
12
|
+
*
|
|
13
|
+
* **At most one action, and never one the view header already offers.** Creating
|
|
14
|
+
* a session and adding a gateway are the `+` in the native title bar, exclusively
|
|
15
|
+
* — so those empty states point at it in words rather than growing a second
|
|
16
|
+
* button that does the same thing two inches lower. A button here is for the way
|
|
17
|
+
* out of a state the header has no answer to: clearing a filter, widening a
|
|
18
|
+
* scope.
|
|
19
|
+
*/
|
|
20
|
+
export function Empty({
|
|
21
|
+
icon,
|
|
22
|
+
title,
|
|
23
|
+
description,
|
|
24
|
+
action,
|
|
25
|
+
onAction,
|
|
26
|
+
}: {
|
|
27
|
+
icon: ReactNode
|
|
28
|
+
title: string
|
|
29
|
+
description?: ReactNode
|
|
30
|
+
action?: string
|
|
31
|
+
onAction?: () => void
|
|
32
|
+
}) {
|
|
33
|
+
return (
|
|
34
|
+
<div className='flex flex-col items-center gap-2 px-5 py-8 text-center'>
|
|
35
|
+
<div className='text-fg-4 opacity-60 [&_svg]:size-7'>{icon}</div>
|
|
36
|
+
<p className='text-body-sm font-medium text-fg-2'>{title}</p>
|
|
37
|
+
{description ? (
|
|
38
|
+
<p className='max-w-[26ch] text-balance text-label leading-relaxed text-fg-4'>
|
|
39
|
+
{description}
|
|
40
|
+
</p>
|
|
41
|
+
) : null}
|
|
42
|
+
{action && onAction ? (
|
|
43
|
+
<Button variant='outline' size='sm' className='mt-1' onClick={onAction}>
|
|
44
|
+
{action}
|
|
45
|
+
</Button>
|
|
46
|
+
) : null}
|
|
47
|
+
</div>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A key or icon named inline in an empty state's description — "use the + above". */
|
|
52
|
+
export function EmptyKey({ children }: { children: ReactNode }) {
|
|
53
|
+
return (
|
|
54
|
+
<span className='rounded border border-border px-1 font-mono text-fg-3'>{children}</span>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -45,7 +45,7 @@ export const SelectContent: FunctionComponent<
|
|
|
45
45
|
alignItemWithTrigger={alignItemWithTrigger}
|
|
46
46
|
side={side}
|
|
47
47
|
sideOffset={sideOffset}
|
|
48
|
-
className='isolate z-
|
|
48
|
+
className='isolate z-80 outline-none'>
|
|
49
49
|
<SelectPrimitive.Popup
|
|
50
50
|
data-slot='select-content'
|
|
51
51
|
className={cn(
|