@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.
@@ -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>No sessions yet.</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
- <Empty>
236
- {hasFacetFilter(config) ? (
237
- <>
238
- No sessions match.{' '}
239
- <button
240
- type='button'
241
- className='underline underline-offset-2 hover:text-fg-1'
242
- onClick={() => onConfigChange(clearFilters(config))}>
243
- Clear filters
244
- </button>
245
- </>
246
- ) : (
247
- 'No sessions here.'
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
- onClick={() => !editing && onSelect?.(row)}
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={() => setEditing(true)}>
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={() => onDelete(row)}>
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
- * `'external'` draws neither, and the composer collapses to a single line
138
- * with its attach/send buttons beside the field: the embedder renders the
139
- * pickers in its own chrome (VS Code's status bar, where a click opens a
140
- * QuickPick) and drives them through {@link onControls}.
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
- const controlsExternal = controlsSurface === 'external'
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
- 'inline-flex cursor-default items-center gap-1 font-mono text-label',
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' {formatCountdown(resetsAtMs, now)}</span>
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
- className='rounded-md px-1 py-0.5 transition-colors outline-none hover:bg-surface-hover focus-visible:bg-surface-hover'>
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
- 'flex items-center gap-2 border-border bg-surface px-3 py-1.5',
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
- <Badge variant={meta.variant} dot={!meta.busy}>
180
- {meta.busy ? <Spinner className='size-3 text-current' /> : null}
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 variant={link === 'offline' ? 'danger' : 'warning'} dot={false}>
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-center gap-2'>
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 ? <span aria-hidden className='size-1.5 rounded-full bg-current' /> : null}
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'