@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.
Files changed (32) hide show
  1. package/README.md +19 -0
  2. package/build/{SessionPanel-NQ8ksCfj.mjs → SessionPanel-DI1NO4l8.mjs} +223 -163
  3. package/build/SessionPanel-DI1NO4l8.mjs.map +1 -0
  4. package/build/{SessionPanel-Dy9lQrOV.d.mts → SessionPanel-J2U8v88q.d.mts} +86 -8
  5. package/build/index.d.mts +118 -7
  6. package/build/index.mjs +355 -169
  7. package/build/index.mjs.map +1 -1
  8. package/build/workspace.d.mts +13 -1
  9. package/build/workspace.mjs +7 -2
  10. package/build/workspace.mjs.map +1 -1
  11. package/package.json +4 -4
  12. package/src/components/agent/Composer.tsx +112 -37
  13. package/src/components/agent/EngineIcon.tsx +97 -0
  14. package/src/components/agent/Loader.tsx +4 -1
  15. package/src/components/agent/Message.tsx +12 -5
  16. package/src/components/agent/QuestionPrompt.tsx +1 -1
  17. package/src/components/agent/SessionBrowser.tsx +266 -138
  18. package/src/components/agent/SessionPanel.tsx +149 -48
  19. package/src/components/agent/SessionWorkspace.tsx +17 -0
  20. package/src/components/agent/StatusBar.tsx +58 -12
  21. package/src/components/agent/Transcript.tsx +2 -3
  22. package/src/components/agent/line-prompt.tsx +2 -2
  23. package/src/components/agent/transcript-variant.tsx +14 -0
  24. package/src/components/ui/Badge.tsx +6 -1
  25. package/src/components/ui/Empty.tsx +56 -0
  26. package/src/components/ui/Menu.tsx +1 -1
  27. package/src/components/ui/Select.tsx +1 -1
  28. package/src/components/ui/Splitter.tsx +14 -0
  29. package/src/components/ui/Tooltip.tsx +1 -1
  30. package/src/index.ts +11 -1
  31. package/src/styles/theme.css +77 -15
  32. 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
- * `'external'` draws neither, and the composer collapses to a single line
127
- * with its attach/send buttons beside the field: the embedder renders the
128
- * pickers in its own chrome (VS Code's status bar, where a click opens a
129
- * 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.
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
- 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
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
- {statusExternal ? null : (
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 aria-hidden className='select-none text-accent'>
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
- {capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? (
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
- /** Trailing slot — the session-actions menu, in the panel's top-right. */
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
- 'inline-flex cursor-default items-center gap-1 font-mono text-label',
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' {formatCountdown(resetsAtMs, now)}</span>
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
- 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'>
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
- 'flex items-center gap-2 border-b 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',
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
- <Badge variant={meta.variant} dot={!meta.busy}>
173
- {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}
174
216
  {meta.label}
175
217
  </Badge>
176
218
  ) : (
177
- <Badge variant={link === 'offline' ? 'danger' : 'warning'} dot={false}>
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-center gap-2'>
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-accent'>※</LineGlyph>
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={cn('mb-1 flex flex-wrap gap-1.5', lines ? 'justify-start' : 'justify-end')}>
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-accent' : undefined}>
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-accent'>›</LineGlyph>
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 ? <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
+ }
@@ -14,7 +14,7 @@ export const MenuContent: FunctionComponent<
14
14
  align={align}
15
15
  side={side}
16
16
  sideOffset={sideOffset}
17
- className='isolate z-60 outline-none'>
17
+ className='isolate z-80 outline-none'>
18
18
  <MenuPrimitive.Popup
19
19
  data-slot='menu-content'
20
20
  className={cn(
@@ -45,7 +45,7 @@ export const SelectContent: FunctionComponent<
45
45
  alignItemWithTrigger={alignItemWithTrigger}
46
46
  side={side}
47
47
  sideOffset={sideOffset}
48
- className='isolate z-60 outline-none'>
48
+ className='isolate z-80 outline-none'>
49
49
  <SelectPrimitive.Popup
50
50
  data-slot='select-content'
51
51
  className={cn(