@workerdeck/ui 0.13.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-CKQa4i0Y.mjs → SessionPanel-DI1NO4l8.mjs} +118 -61
- package/build/SessionPanel-DI1NO4l8.mjs.map +1 -0
- package/build/{SessionPanel-CZMA44NM.d.mts → SessionPanel-J2U8v88q.d.mts} +57 -8
- package/build/index.d.mts +54 -2
- package/build/index.mjs +79 -24
- package/build/index.mjs.map +1 -1
- package/build/workspace.d.mts +5 -1
- package/build/workspace.mjs +5 -2
- package/build/workspace.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/agent/Composer.tsx +108 -41
- package/src/components/agent/Message.tsx +4 -1
- package/src/components/agent/SessionBrowser.tsx +35 -25
- package/src/components/agent/SessionPanel.tsx +88 -35
- package/src/components/agent/SessionWorkspace.tsx +7 -0
- package/src/components/agent/StatusBar.tsx +50 -11
- 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/Splitter.tsx +14 -0
- package/src/index.ts +2 -0
- package/src/styles/theme.css +32 -0
- package/build/SessionPanel-CKQa4i0Y.mjs.map +0 -1
|
@@ -3,10 +3,12 @@ import {
|
|
|
3
3
|
BellRing,
|
|
4
4
|
CircleAlert,
|
|
5
5
|
CircleSlash,
|
|
6
|
+
Layers,
|
|
6
7
|
Moon,
|
|
7
8
|
PauseCircle,
|
|
8
9
|
Pencil,
|
|
9
10
|
Search,
|
|
11
|
+
SearchX,
|
|
10
12
|
Trash2,
|
|
11
13
|
X,
|
|
12
14
|
} from 'lucide-react'
|
|
@@ -30,6 +32,7 @@ import type {
|
|
|
30
32
|
WorkspaceScope,
|
|
31
33
|
} from '@workerdeck/protocol'
|
|
32
34
|
import { Button } from '../ui/Button.tsx'
|
|
35
|
+
import { Empty } from '../ui/Empty.tsx'
|
|
33
36
|
import { Input } from '../ui/Input.tsx'
|
|
34
37
|
import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger, SelectValue } from '../ui/Select.tsx'
|
|
35
38
|
import { Spinner } from '../ui/Spinner.tsx'
|
|
@@ -228,25 +231,23 @@ export function SessionBrowser({
|
|
|
228
231
|
) : null}
|
|
229
232
|
|
|
230
233
|
{rows.length === 0 ? (
|
|
231
|
-
(emptyState ?? <Empty
|
|
234
|
+
(emptyState ?? <Empty icon={<Layers />} title='No sessions yet' />)
|
|
232
235
|
) : visible.length === 0 ? (
|
|
233
236
|
// Two different dead ends, two different ways out. "Nothing matches" is
|
|
234
|
-
// a filter someone set; anything else is the state of the world
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
)}
|
|
249
|
-
</Empty>
|
|
237
|
+
// a filter someone set; anything else is the state of the world — and
|
|
238
|
+
// only the first has a button, because an action that does nothing is
|
|
239
|
+
// worse than none.
|
|
240
|
+
hasFacetFilter(config) ? (
|
|
241
|
+
<Empty
|
|
242
|
+
icon={<SearchX />}
|
|
243
|
+
title='No matches'
|
|
244
|
+
description='No session matches the current search and filters.'
|
|
245
|
+
action='Clear filters'
|
|
246
|
+
onAction={() => onConfigChange(clearFilters(config))}
|
|
247
|
+
/>
|
|
248
|
+
) : (
|
|
249
|
+
<Empty icon={<Layers />} title='Nothing here' description='No session to show.' />
|
|
250
|
+
)
|
|
250
251
|
) : (
|
|
251
252
|
<div className='flex flex-col gap-4'>
|
|
252
253
|
{groups.map((group) => (
|
|
@@ -276,10 +277,6 @@ export function SessionBrowser({
|
|
|
276
277
|
)
|
|
277
278
|
}
|
|
278
279
|
|
|
279
|
-
function Empty({ children }: { children: React.ReactNode }) {
|
|
280
|
-
return <div className='px-3 py-6 text-center text-body-sm text-fg-4'>{children}</div>
|
|
281
|
-
}
|
|
282
|
-
|
|
283
280
|
interface SessionRowItemProps {
|
|
284
281
|
row: SessionRow
|
|
285
282
|
active?: boolean
|
|
@@ -315,6 +312,14 @@ function SessionRowItem({
|
|
|
315
312
|
<div
|
|
316
313
|
data-slot='session-row'
|
|
317
314
|
data-active={active || undefined}
|
|
315
|
+
// Selection lives on the whole row, not on the two text buttons inside
|
|
316
|
+
// it: the age, the unread badge and the state glyph sit outside them, and
|
|
317
|
+
// a row where a third of the surface silently does nothing is a row that
|
|
318
|
+
// feels broken. The buttons stay — they are what makes the row reachable
|
|
319
|
+
// by keyboard — but their activation now reaches this handler by
|
|
320
|
+
// bubbling, so there is one code path and no double-fire. Anything that
|
|
321
|
+
// is its own action (rename, close, the name editor) stops the event.
|
|
322
|
+
onClick={() => !editing && onSelect?.(row)}
|
|
318
323
|
className={cn(
|
|
319
324
|
'group flex cursor-pointer flex-col gap-0.5 text-left transition-colors',
|
|
320
325
|
rowShapeClass(active === true),
|
|
@@ -337,7 +342,6 @@ function SessionRowItem({
|
|
|
337
342
|
) : (
|
|
338
343
|
<button
|
|
339
344
|
type='button'
|
|
340
|
-
onClick={() => onSelect?.(row)}
|
|
341
345
|
className={cn(
|
|
342
346
|
'min-w-0 flex-1 truncate text-left text-body-sm outline-none',
|
|
343
347
|
active ? 'font-medium text-fg-1' : 'text-fg-2',
|
|
@@ -365,7 +369,7 @@ function SessionRowItem({
|
|
|
365
369
|
<div className='flex items-center gap-1 text-label text-fg-4'>
|
|
366
370
|
<button
|
|
367
371
|
type='button'
|
|
368
|
-
|
|
372
|
+
tabIndex={-1}
|
|
369
373
|
className='min-w-0 flex-1 truncate text-left font-mono outline-none'>
|
|
370
374
|
{details.join(' · ')}
|
|
371
375
|
</button>
|
|
@@ -375,7 +379,10 @@ function SessionRowItem({
|
|
|
375
379
|
size='icon-sm'
|
|
376
380
|
aria-label='Rename session'
|
|
377
381
|
className='size-5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100'
|
|
378
|
-
onClick={() =>
|
|
382
|
+
onClick={(e) => {
|
|
383
|
+
e.stopPropagation()
|
|
384
|
+
setEditing(true)
|
|
385
|
+
}}>
|
|
379
386
|
<Pencil className='size-3 text-fg-3' />
|
|
380
387
|
</Button>
|
|
381
388
|
) : null}
|
|
@@ -385,7 +392,10 @@ function SessionRowItem({
|
|
|
385
392
|
size='icon-sm'
|
|
386
393
|
aria-label='Close session'
|
|
387
394
|
className='size-5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100'
|
|
388
|
-
onClick={() =>
|
|
395
|
+
onClick={(e) => {
|
|
396
|
+
e.stopPropagation()
|
|
397
|
+
onDelete(row)
|
|
398
|
+
}}>
|
|
389
399
|
<Trash2 className='size-3 text-fg-3' />
|
|
390
400
|
</Button>
|
|
391
401
|
) : null}
|
|
@@ -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'
|
|
@@ -131,18 +133,36 @@ export interface SessionPanelProps {
|
|
|
131
133
|
* dock is allowed to be roomy.
|
|
132
134
|
*/
|
|
133
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
|
|
134
146
|
/**
|
|
135
147
|
* Where the session's own controls — model and permission mode — live.
|
|
136
148
|
* `'internal'` (default) draws them in the composer's toolbar row.
|
|
137
|
-
* `'
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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.
|
|
141
161
|
*
|
|
142
162
|
* The options themselves ride {@link SessionVitals} — an embedder must not
|
|
143
163
|
* attach a second time to learn what the models are.
|
|
144
164
|
*/
|
|
145
|
-
controlsSurface?: 'internal' | 'external'
|
|
165
|
+
controlsSurface?: 'internal' | 'external' | 'status'
|
|
146
166
|
/**
|
|
147
167
|
* Handed the session's setters once the panel is live, and `undefined` on
|
|
148
168
|
* unmount. The counterpart to `controlsSurface: 'external'`: vitals carry the
|
|
@@ -188,6 +208,22 @@ export interface SessionPanelProps {
|
|
|
188
208
|
* enforcement lives on the gateway.
|
|
189
209
|
*/
|
|
190
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
|
|
191
227
|
className?: string
|
|
192
228
|
}
|
|
193
229
|
|
|
@@ -283,16 +319,21 @@ export function SessionPanel({
|
|
|
283
319
|
onVitals,
|
|
284
320
|
transcriptVariant = 'cards',
|
|
285
321
|
transcriptDensity = 'comfortable',
|
|
322
|
+
transcriptFont = 'sans',
|
|
286
323
|
controlsSurface = 'internal',
|
|
287
324
|
onControls,
|
|
288
325
|
focusComposerOnClick = false,
|
|
289
326
|
unseen,
|
|
290
327
|
readOnly = false,
|
|
328
|
+
toolHost,
|
|
291
329
|
className,
|
|
292
330
|
}: SessionPanelProps) {
|
|
293
331
|
const external = panelSurface === 'external'
|
|
294
332
|
const statusExternal = statusSurface === 'external'
|
|
295
|
-
|
|
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
|
|
296
337
|
// Rejected commands (the CLI refusing a permission-mode switch, say) render INSIDE
|
|
297
338
|
// the panel rather than through `toast`. The panel does not mount a `Toaster`, and
|
|
298
339
|
// an embedder that doesn't either would drop the only signal that a command failed
|
|
@@ -356,7 +397,7 @@ export function SessionPanel({
|
|
|
356
397
|
// SAME handle the panel attached with — the bridge asks the first attached
|
|
357
398
|
// client. Free for Claude sessions: the guest loads lazily on the first call,
|
|
358
399
|
// which for them never comes.
|
|
359
|
-
useToolCallHost(handle)
|
|
400
|
+
useToolCallHost(handle, toolHost === false ? { enabled: false } : toolHost)
|
|
360
401
|
const capabilities = state.capabilities
|
|
361
402
|
|
|
362
403
|
// Vitals out to the embedder, keyed on the readings themselves so an inline
|
|
@@ -535,11 +576,46 @@ export function SessionPanel({
|
|
|
535
576
|
// Built once and placed at one end or the other — the bar has a `⋯` menu and
|
|
536
577
|
// three open handlers, and two copies of that in the tree would be two things
|
|
537
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
|
+
|
|
538
613
|
const statusBar = statusExternal ? null : (
|
|
539
614
|
<StatusBar
|
|
540
615
|
state={state}
|
|
541
616
|
connection={connection}
|
|
542
617
|
placement={statusPlacement}
|
|
618
|
+
controls={controlsInStatus && !readOnly ? sessionControls : undefined}
|
|
543
619
|
onOpenStatus={external && !onOpenPanel ? undefined : () => openPanel('info')}
|
|
544
620
|
onOpenContext={external && !onOpenPanel ? undefined : () => openPanel('context')}
|
|
545
621
|
onOpenUsage={external && !onOpenPanel ? undefined : () => openPanel('usage')}
|
|
@@ -566,6 +642,10 @@ export function SessionPanel({
|
|
|
566
642
|
<TranscriptDensityProvider value={transcriptDensity}>
|
|
567
643
|
<div
|
|
568
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}
|
|
569
649
|
onClick={handleClick}
|
|
570
650
|
className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}>
|
|
571
651
|
{headerTakesActions ? header({ actions: menu }) : header}
|
|
@@ -677,34 +757,7 @@ export function SessionPanel({
|
|
|
677
757
|
: undefined
|
|
678
758
|
}
|
|
679
759
|
layout={controlsExternal ? 'inline' : 'stacked'}
|
|
680
|
-
toolbar={
|
|
681
|
-
controlsExternal ? undefined : (
|
|
682
|
-
<>
|
|
683
|
-
{/* Codex reports no `capabilities` event, so its models arrive from
|
|
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
|
-
}
|
|
760
|
+
toolbar={controlsExternal ? undefined : sessionControls}
|
|
708
761
|
/>
|
|
709
762
|
)}
|
|
710
763
|
{/* Below the composer, along the foot of the panel — the editor
|
|
@@ -34,8 +34,10 @@ export interface SessionWorkspaceProps {
|
|
|
34
34
|
*/
|
|
35
35
|
transcriptVariant?: SessionPanelProps['transcriptVariant']
|
|
36
36
|
transcriptDensity?: SessionPanelProps['transcriptDensity']
|
|
37
|
+
transcriptFont?: SessionPanelProps['transcriptFont']
|
|
37
38
|
/** Which end of the panel the status bar sits at — see `SessionPanel`. */
|
|
38
39
|
statusPlacement?: SessionPanelProps['statusPlacement']
|
|
40
|
+
controlsSurface?: SessionPanelProps['controlsSurface']
|
|
39
41
|
unseen?: SessionPanelProps['unseen']
|
|
40
42
|
/** Viewer mode — no composer, no approval prompts. Forwarded verbatim to
|
|
41
43
|
* {@link SessionPanel}; the file tree and editor are unaffected, since reading
|
|
@@ -91,7 +93,9 @@ export function SessionWorkspace({
|
|
|
91
93
|
header,
|
|
92
94
|
transcriptVariant,
|
|
93
95
|
transcriptDensity,
|
|
96
|
+
transcriptFont,
|
|
94
97
|
statusPlacement,
|
|
98
|
+
controlsSurface,
|
|
95
99
|
unseen,
|
|
96
100
|
readOnly,
|
|
97
101
|
onVitals,
|
|
@@ -225,6 +229,7 @@ export function SessionWorkspace({
|
|
|
225
229
|
onValueChange={setRailWidth}
|
|
226
230
|
min={RAIL_MIN}
|
|
227
231
|
max={RAIL_MAX}
|
|
232
|
+
defaultValue={defaultRailWidth}
|
|
228
233
|
aria-label='Resize the file tree'
|
|
229
234
|
/>
|
|
230
235
|
) : null}
|
|
@@ -272,6 +277,8 @@ export function SessionWorkspace({
|
|
|
272
277
|
header={hoisted}
|
|
273
278
|
transcriptVariant={transcriptVariant}
|
|
274
279
|
transcriptDensity={transcriptDensity}
|
|
280
|
+
transcriptFont={transcriptFont}
|
|
281
|
+
controlsSurface={controlsSurface}
|
|
275
282
|
statusPlacement={statusPlacement}
|
|
276
283
|
unseen={unseen}
|
|
277
284
|
readOnly={readOnly}
|
|
@@ -28,6 +28,13 @@ export interface StatusBarProps {
|
|
|
28
28
|
onOpenStatus?: () => void
|
|
29
29
|
onOpenContext?: () => void
|
|
30
30
|
onOpenUsage?: () => void
|
|
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
|
|
31
38
|
/** Trailing slot — the session-actions menu, at the bar's trailing edge. */
|
|
32
39
|
actions?: ReactNode
|
|
33
40
|
/** Which edge the bar sits on, so its separating rule goes on the other side.
|
|
@@ -113,16 +120,22 @@ function RateLimitMeter({ label, info, now }: { label: string; info: RateLimitIn
|
|
|
113
120
|
{info.status === 'rejected' ? <div className='text-danger'>Limit reached</div> : null}
|
|
114
121
|
</div>
|
|
115
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. */}
|
|
116
129
|
<span
|
|
117
130
|
className={cn(
|
|
118
|
-
'
|
|
131
|
+
'cursor-default font-mono text-label whitespace-nowrap',
|
|
119
132
|
info.status === 'rejected' ? 'text-danger' : utilizationColor(pct ?? 0),
|
|
120
133
|
)}>
|
|
121
|
-
<ProgressRing value={pct ?? 0} />
|
|
134
|
+
<ProgressRing value={pct ?? 0} className='mr-1 inline-block align-middle' />
|
|
122
135
|
{label}
|
|
123
136
|
{pct !== undefined ? ` ${pct.toFixed(0)}%` : ''}
|
|
124
137
|
{resetsAtMs !== undefined ? (
|
|
125
|
-
<span className='text-fg-4'
|
|
138
|
+
<span className='text-fg-4'> · {formatCountdown(resetsAtMs, now)}</span>
|
|
126
139
|
) : null}
|
|
127
140
|
</span>
|
|
128
141
|
</Tip>
|
|
@@ -138,7 +151,15 @@ function Slot({ onClick, hint, children }: { onClick?: () => void; hint: string;
|
|
|
138
151
|
type='button'
|
|
139
152
|
onClick={onClick}
|
|
140
153
|
aria-label={hint}
|
|
141
|
-
|
|
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'>
|
|
142
163
|
{children}
|
|
143
164
|
</button>
|
|
144
165
|
)
|
|
@@ -151,6 +172,7 @@ export function StatusBar({
|
|
|
151
172
|
onOpenStatus,
|
|
152
173
|
onOpenContext,
|
|
153
174
|
onOpenUsage,
|
|
175
|
+
controls,
|
|
154
176
|
actions,
|
|
155
177
|
placement = 'top',
|
|
156
178
|
className,
|
|
@@ -164,7 +186,17 @@ export function StatusBar({
|
|
|
164
186
|
<div
|
|
165
187
|
data-slot='status-bar'
|
|
166
188
|
className={cn(
|
|
167
|
-
|
|
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',
|
|
168
200
|
// The rule goes between the bar and the content, so which edge it sits
|
|
169
201
|
// on follows the placement.
|
|
170
202
|
placement === 'bottom' ? 'border-t' : 'border-b',
|
|
@@ -176,16 +208,22 @@ export function StatusBar({
|
|
|
176
208
|
with which credentials — which is the question a status prompts. */}
|
|
177
209
|
<Slot onClick={onOpenStatus} hint='Session info'>
|
|
178
210
|
{link === 'live' ? (
|
|
179
|
-
|
|
180
|
-
|
|
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}
|
|
181
216
|
{meta.label}
|
|
182
217
|
</Badge>
|
|
183
218
|
) : (
|
|
184
|
-
<Badge
|
|
219
|
+
<Badge
|
|
220
|
+
variant={link === 'offline' ? 'danger' : 'warning'}
|
|
221
|
+
dot={false}
|
|
222
|
+
className='items-baseline'>
|
|
185
223
|
{link === 'offline' ? (
|
|
186
|
-
<WifiOff className='size-3 text-current' />
|
|
224
|
+
<WifiOff className='size-3 self-center text-current' />
|
|
187
225
|
) : (
|
|
188
|
-
<RefreshCw className='size-3 animate-spin text-current' />
|
|
226
|
+
<RefreshCw className='size-3 animate-spin self-center text-current' />
|
|
189
227
|
)}
|
|
190
228
|
{link === 'offline' ? 'Offline' : 'Reconnecting…'}
|
|
191
229
|
</Badge>
|
|
@@ -200,12 +238,13 @@ export function StatusBar({
|
|
|
200
238
|
) : null}
|
|
201
239
|
{session || weekly ? (
|
|
202
240
|
<Slot onClick={onOpenUsage} hint='Plan usage'>
|
|
203
|
-
<span className='inline-flex items-
|
|
241
|
+
<span className='inline-flex items-baseline gap-2'>
|
|
204
242
|
{session ? <RateLimitMeter label='Session' info={session} now={now} /> : null}
|
|
205
243
|
{weekly ? <RateLimitMeter label='Weekly' info={weekly} now={now} /> : null}
|
|
206
244
|
</span>
|
|
207
245
|
</Slot>
|
|
208
246
|
) : null}
|
|
247
|
+
{controls}
|
|
209
248
|
<span className='flex-1' />
|
|
210
249
|
<span className='font-mono text-label text-fg-3'>{formatCost(state.totalCostUsd)}</span>
|
|
211
250
|
{actions}
|
|
@@ -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
|
+
}
|
|
@@ -20,6 +20,12 @@ export interface SplitterProps {
|
|
|
20
20
|
max: number
|
|
21
21
|
/** Keyboard step. */
|
|
22
22
|
step?: number
|
|
23
|
+
/**
|
|
24
|
+
* Size to snap back to on a double-click — the convention every pane divider
|
|
25
|
+
* that can be dragged is expected to honour. Omit and a double-click does
|
|
26
|
+
* nothing, which is the honest behaviour when there is no default to mean.
|
|
27
|
+
*/
|
|
28
|
+
defaultValue?: number
|
|
23
29
|
/** Set when dragging the splitter *away* from the origin should shrink the
|
|
24
30
|
* controlled pane — i.e. the pane is on the right or the bottom. */
|
|
25
31
|
inverted?: boolean
|
|
@@ -52,6 +58,7 @@ export function Splitter({
|
|
|
52
58
|
min,
|
|
53
59
|
max,
|
|
54
60
|
step = 16,
|
|
61
|
+
defaultValue,
|
|
55
62
|
inverted,
|
|
56
63
|
'aria-label': label,
|
|
57
64
|
className,
|
|
@@ -84,6 +91,12 @@ export function Splitter({
|
|
|
84
91
|
}
|
|
85
92
|
}
|
|
86
93
|
|
|
94
|
+
// The second click of a double-click has already started a drag, so the reset
|
|
95
|
+
// has to land after `endDrag` — which it does: dblclick fires after pointerup.
|
|
96
|
+
const onDoubleClick = () => {
|
|
97
|
+
if (defaultValue !== undefined) onValueChange(clamp(defaultValue))
|
|
98
|
+
}
|
|
99
|
+
|
|
87
100
|
const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
88
101
|
// The arrows that move *along* this splitter's axis of travel; the other
|
|
89
102
|
// pair is left to the page, which is what a separator should do with them.
|
|
@@ -112,6 +125,7 @@ export function Splitter({
|
|
|
112
125
|
onPointerMove={onPointerMove}
|
|
113
126
|
onPointerUp={endDrag}
|
|
114
127
|
onPointerCancel={endDrag}
|
|
128
|
+
onDoubleClick={onDoubleClick}
|
|
115
129
|
onKeyDown={onKeyDown}
|
|
116
130
|
className={cn(
|
|
117
131
|
// A 1px line that reads as a border, with a larger invisible grab area
|
package/src/index.ts
CHANGED
|
@@ -42,6 +42,7 @@ export { CopyButton, type CopyButtonProps } from './components/ui/CopyButton.tsx
|
|
|
42
42
|
export { Spinner } from './components/ui/Spinner.tsx'
|
|
43
43
|
export { CodeBlock, type CodeBlockProps } from './components/ui/CodeBlock.tsx'
|
|
44
44
|
export { Splitter, type SplitterProps } from './components/ui/Splitter.tsx'
|
|
45
|
+
export { Empty, EmptyKey } from './components/ui/Empty.tsx'
|
|
45
46
|
export { ProgressRing, type ProgressRingProps } from './components/ui/ProgressRing.tsx'
|
|
46
47
|
// Prompt input (vendored just-marketing/prompt-area, themed to these tokens)
|
|
47
48
|
export {
|
|
@@ -88,6 +89,7 @@ export {
|
|
|
88
89
|
useTranscriptDensity,
|
|
89
90
|
useTranscriptVariant,
|
|
90
91
|
type TranscriptDensity,
|
|
92
|
+
type TranscriptFont,
|
|
91
93
|
type TranscriptVariant,
|
|
92
94
|
} from './components/agent/transcript-variant.tsx'
|
|
93
95
|
export { Response, type ResponseProps } from './components/agent/Response.tsx'
|