@workerdeck/ui 0.12.0 → 0.13.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.
@@ -1,5 +1,15 @@
1
1
  import { useEffect, useMemo, useRef, useState } from 'react'
2
- import { Pencil, Search, Trash2, X } from 'lucide-react'
2
+ import {
3
+ BellRing,
4
+ CircleAlert,
5
+ CircleSlash,
6
+ Moon,
7
+ PauseCircle,
8
+ Pencil,
9
+ Search,
10
+ Trash2,
11
+ X,
12
+ } from 'lucide-react'
3
13
  import {
4
14
  STATE_LABELS,
5
15
  STATE_ORDER,
@@ -19,13 +29,12 @@ import type {
19
29
  ViewConfig,
20
30
  WorkspaceScope,
21
31
  } from '@workerdeck/protocol'
22
- import { Badge } from '../ui/Badge.tsx'
23
32
  import { Button } from '../ui/Button.tsx'
24
33
  import { Input } from '../ui/Input.tsx'
25
34
  import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger, SelectValue } from '../ui/Select.tsx'
35
+ import { Spinner } from '../ui/Spinner.tsx'
26
36
  import { cn } from '../../lib/utils.ts'
27
- import { formatCost, formatRelativeTime } from '../../lib/format.ts'
28
- import { STATUS_META } from './status.ts'
37
+ import { formatCost, formatRelativeTime, friendlyModel } from '../../lib/format.ts'
29
38
 
30
39
  /**
31
40
  * A sessions list with the affordances a list of thirty needs: search, facets,
@@ -64,9 +73,44 @@ export interface SessionBrowserProps {
64
73
  onRename?: (row: SessionRow, title: string) => void
65
74
  /** Rendered when nothing at all exists (as opposed to nothing matching). */
66
75
  emptyState?: React.ReactNode
76
+ /**
77
+ * Whether the search + facet bar is shown. Defaults to `true` — a list that
78
+ * *is* the screen shows its controls.
79
+ *
80
+ * A host with somewhere better to put the toggle (a view title bar) passes
81
+ * `false` and owns the boolean itself, the way the VS Code extension does: the
82
+ * key lives where the commands do. Two rules come with it, and they are why
83
+ * this hides only the bar and nothing else — **closing the bar never clears
84
+ * the filters**, and the subset line below it renders either way, so a list
85
+ * filtered by a control you can't currently see still says so.
86
+ */
87
+ showControls?: boolean
67
88
  className?: string
68
89
  }
69
90
 
91
+ /**
92
+ * How a list row is drawn, in one place, so `SidebarRow` in `web` matches this
93
+ * exactly rather than approximating it — the dashboard's other three sidebars
94
+ * are that component, and a sessions list that hovered differently from the
95
+ * gateways list beside it would read as a different product.
96
+ *
97
+ * Two rules are load-bearing:
98
+ *
99
+ * - **Fill means hover, and only hover.** It stays on the row whether or not
100
+ * the row is selected, because a selected row still has to answer the
101
+ * pointer. Selection gets the gutter instead.
102
+ * - **`ml-0` on the selected row is not cosmetic.** It hands the accent border
103
+ * the 4px the margin was holding, so the text does not shift sideways as a
104
+ * row becomes the selected one. The squared left corners are what let the bar
105
+ * sit flush against the sidebar edge.
106
+ */
107
+ export function rowShapeClass(active: boolean): string {
108
+ return cn(
109
+ 'px-2 py-1.5 hover:bg-row-hover',
110
+ active ? 'mr-1 ml-0 rounded-r-md border-l-4 border-l-accent' : 'mx-1 rounded-md',
111
+ )
112
+ }
113
+
70
114
  export function SessionBrowser({
71
115
  rows,
72
116
  config,
@@ -77,6 +121,7 @@ export function SessionBrowser({
77
121
  onDelete,
78
122
  onRename,
79
123
  emptyState,
124
+ showControls = true,
80
125
  className,
81
126
  }: SessionBrowserProps) {
82
127
  const visible = useMemo(() => filterRows(rows, config, scope), [rows, config, scope])
@@ -95,8 +140,13 @@ export function SessionBrowser({
95
140
 
96
141
  return (
97
142
  <div data-slot='session-browser' className={cn('flex flex-col gap-3', className)}>
98
- <div className='flex flex-wrap items-center gap-2'>
99
- <div className='relative min-w-48 flex-1'>
143
+ {/* One control per row, label left, input right — the shape VS Code uses
144
+ for its own filter surfaces. A wrapping row of pill-shaped selects
145
+ reflows into an unpredictable number of lines as facets appear and
146
+ disappear; a column of labelled rows is the same height every time and
147
+ reads at sidebar width, which is the only width this has. */}
148
+ <div className={cn('flex flex-col gap-1.5 px-2', !showControls && 'hidden')}>
149
+ <div className='relative'>
100
150
  <Search className='pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-fg-4' />
101
151
  <Input
102
152
  value={config.search}
@@ -106,54 +156,64 @@ export function SessionBrowser({
106
156
  className='pl-8'
107
157
  />
108
158
  </div>
109
- <FacetSelect
110
- label='State'
111
- value={config.states}
112
- options={STATE_ORDER.map((s) => ({ value: s, label: STATE_LABELS[s] }))}
113
- onChange={(states) => set({ states: states as SessionState[] })}
114
- />
115
- {adapters.length > 1 ? (
159
+ <FilterRow label='State'>
116
160
  <FacetSelect
117
- label='Engine'
118
- value={config.adapters}
119
- options={adapters.map((a) => ({ value: a, label: a }))}
120
- onChange={(adapters) => set({ adapters })}
161
+ label='State'
162
+ value={config.states}
163
+ options={STATE_ORDER.map((s) => ({ value: s, label: STATE_LABELS[s] }))}
164
+ onChange={(states) => set({ states: states as SessionState[] })}
121
165
  />
166
+ </FilterRow>
167
+ {adapters.length > 1 ? (
168
+ <FilterRow label='Engine'>
169
+ <FacetSelect
170
+ label='Engine'
171
+ value={config.adapters}
172
+ options={adapters.map((a) => ({ value: a, label: a }))}
173
+ onChange={(adapters) => set({ adapters })}
174
+ />
175
+ </FilterRow>
122
176
  ) : null}
123
177
  {gateways.length > 1 ? (
124
- <FacetSelect
125
- label='Gateway'
126
- value={config.gateways}
127
- options={gateways.map((g) => ({ value: g.id, label: g.name }))}
128
- onChange={(gateways) => set({ gateways })}
129
- />
178
+ <FilterRow label='Gateway'>
179
+ <FacetSelect
180
+ label='Gateway'
181
+ value={config.gateways}
182
+ options={gateways.map((g) => ({ value: g.id, label: g.name }))}
183
+ onChange={(gateways) => set({ gateways })}
184
+ />
185
+ </FilterRow>
130
186
  ) : null}
131
- <OneOfSelect
132
- label='Group'
133
- value={config.groupBy}
134
- options={[
135
- { value: 'none', label: 'No grouping' },
136
- { value: 'state', label: 'By state' },
137
- { value: 'adapter', label: 'By engine' },
138
- ...(gateways.length > 1 ? [{ value: 'gateway' as const, label: 'By gateway' }] : []),
139
- ]}
140
- onChange={(groupBy) => set({ groupBy: groupBy as GroupBy })}
141
- />
142
- <OneOfSelect
143
- label='Sort'
144
- value={config.sortBy}
145
- options={[
146
- { value: 'recent', label: 'Recent' },
147
- { value: 'name', label: 'Name' },
148
- { value: 'state', label: 'State' },
149
- ...(gateways.length > 1 ? [{ value: 'gateway' as const, label: 'Gateway' }] : []),
150
- ]}
151
- onChange={(sortBy) => set({ sortBy: sortBy as SortBy })}
152
- />
187
+ <FilterRow label='Group'>
188
+ <OneOfSelect
189
+ label='Group'
190
+ value={config.groupBy}
191
+ options={[
192
+ { value: 'none', label: 'No grouping' },
193
+ { value: 'state', label: 'By state' },
194
+ { value: 'adapter', label: 'By engine' },
195
+ ...(gateways.length > 1 ? [{ value: 'gateway' as const, label: 'By gateway' }] : []),
196
+ ]}
197
+ onChange={(groupBy) => set({ groupBy: groupBy as GroupBy })}
198
+ />
199
+ </FilterRow>
200
+ <FilterRow label='Sort'>
201
+ <OneOfSelect
202
+ label='Sort'
203
+ value={config.sortBy}
204
+ options={[
205
+ { value: 'recent', label: 'Recent' },
206
+ { value: 'name', label: 'Name' },
207
+ { value: 'state', label: 'State' },
208
+ ...(gateways.length > 1 ? [{ value: 'gateway' as const, label: 'Gateway' }] : []),
209
+ ]}
210
+ onChange={(sortBy) => set({ sortBy: sortBy as SortBy })}
211
+ />
212
+ </FilterRow>
153
213
  </div>
154
214
 
155
215
  {subset ? (
156
- <div className='flex items-center gap-2 text-label text-fg-4'>
216
+ <div className='flex items-center gap-2 px-3 text-label text-fg-4'>
157
217
  <span>
158
218
  {subset.shown} of {subset.total}
159
219
  {subset.causes.length ? ` · ${subset.causes.join(' · ')}` : null}
@@ -192,7 +252,7 @@ export function SessionBrowser({
192
252
  {groups.map((group) => (
193
253
  <div key={group.key} className='flex flex-col gap-1'>
194
254
  {config.groupBy !== 'none' && group.label ? (
195
- <div className='flex items-baseline gap-2 px-2.5 text-label font-medium text-fg-4'>
255
+ <div className='flex items-baseline gap-2 px-3 text-label font-medium text-fg-4'>
196
256
  <span className='uppercase tracking-wide'>{group.label}</span>
197
257
  <span className='text-fg-4/70'>{group.rows.length}</span>
198
258
  </div>
@@ -217,7 +277,7 @@ export function SessionBrowser({
217
277
  }
218
278
 
219
279
  function Empty({ children }: { children: React.ReactNode }) {
220
- return <div className='px-2.5 py-6 text-center text-body-sm text-fg-4'>{children}</div>
280
+ return <div className='px-3 py-6 text-center text-body-sm text-fg-4'>{children}</div>
221
281
  }
222
282
 
223
283
  interface SessionRowItemProps {
@@ -238,87 +298,128 @@ function SessionRowItem({
238
298
  onRename,
239
299
  }: SessionRowItemProps) {
240
300
  const { info } = row
241
- const meta = STATUS_META[info.status]
242
301
  const [editing, setEditing] = useState(false)
243
302
 
303
+ // What it is and what it has spent, in one line — the same set the extension
304
+ // shows, joined the same way, so the two lists read as one product.
305
+ const folder = info.cwd.split('/').filter(Boolean).pop() ?? info.cwd
306
+ const details = [
307
+ showGateway ? row.hostName : undefined,
308
+ friendlyModel(info.model),
309
+ folder,
310
+ info.profile ? `@${info.profile}` : undefined,
311
+ formatCost(info.totalCostUsd),
312
+ ].filter(Boolean)
313
+
244
314
  return (
245
315
  <div
246
316
  data-slot='session-row'
247
317
  data-active={active || undefined}
248
318
  className={cn(
249
- 'group flex w-full items-center gap-2 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors',
250
- active ? 'border-border bg-surface' : 'hover:bg-surface-hover',
319
+ 'group flex cursor-pointer flex-col gap-0.5 text-left transition-colors',
320
+ rowShapeClass(active === true),
251
321
  )}>
252
- <div className='min-w-0 flex-1'>
253
- <div className='flex items-center gap-2'>
254
- {/* The editor replaces the link rather than sitting inside it — an
255
- input nested in a button is invalid, and disabling the button to
256
- protect the edit disables the field with it. */}
257
- {editing && onRename ? (
258
- <NameEditor
259
- initial={info.title ?? ''}
260
- onCommit={(title) => {
261
- setEditing(false)
262
- onRename(row, title)
263
- }}
264
- onCancel={() => setEditing(false)}
265
- />
266
- ) : (
267
- <button
268
- type='button'
269
- onClick={() => onSelect?.(row)}
270
- className='min-w-0 truncate text-left text-body-sm font-medium text-fg-1 outline-none'>
271
- {sessionLabel(info)}
272
- </button>
273
- )}
274
- <Badge variant={meta.variant} dot className='shrink-0'>
275
- {meta.label}
276
- </Badge>
277
- {row.unseen > 0 ? (
278
- // Transcript rows since this session was last on screen — the same
279
- // unit the VS Code badge counts, because turns undercount badly.
280
- <Badge variant='accent' className='shrink-0' title={`${row.unseen} new`}>
281
- {row.unseen}
282
- </Badge>
283
- ) : null}
284
- </div>
322
+ {/* Line one: what you scan the list by on the left, how it is doing on the
323
+ right, state last. */}
324
+ <div className='flex items-center gap-1.5'>
325
+ {/* The editor replaces the link rather than sitting inside it — an
326
+ input nested in a button is invalid, and disabling the button to
327
+ protect the edit disables the field with it. */}
328
+ {editing && onRename ? (
329
+ <NameEditor
330
+ initial={info.title ?? ''}
331
+ onCommit={(title) => {
332
+ setEditing(false)
333
+ onRename(row, title)
334
+ }}
335
+ onCancel={() => setEditing(false)}
336
+ />
337
+ ) : (
338
+ <button
339
+ type='button'
340
+ onClick={() => onSelect?.(row)}
341
+ className={cn(
342
+ 'min-w-0 flex-1 truncate text-left text-body-sm outline-none',
343
+ active ? 'font-medium text-fg-1' : 'text-fg-2',
344
+ )}>
345
+ {sessionLabel(info)}
346
+ </button>
347
+ )}
348
+ {row.unseen > 0 ? (
349
+ // Transcript rows since this session was last on screen — the same
350
+ // unit the VS Code badge counts, because turns undercount badly.
351
+ <span
352
+ title={`${row.unseen} new`}
353
+ className='shrink-0 rounded-full bg-accent px-1.5 text-label text-accent-fg'>
354
+ {row.unseen}
355
+ </span>
356
+ ) : null}
357
+ <span className='shrink-0 text-label text-fg-4'>
358
+ {formatRelativeTime(info.lastActivityAt ?? info.createdAt)}
359
+ </span>
360
+ <SessionStatusIcon row={row} />
361
+ </div>
362
+
363
+ {/* Line two: what it is, with the actions at the far right — away from the
364
+ state icon, and away from the title you are actually reading. */}
365
+ <div className='flex items-center gap-1 text-label text-fg-4'>
285
366
  <button
286
367
  type='button'
287
368
  onClick={() => !editing && onSelect?.(row)}
288
- className='mt-0.5 flex w-full items-center gap-2 text-left font-mono text-label text-fg-4 outline-none'>
289
- <span className='truncate'>{info.cwd}</span>
290
- {showGateway ? <span className='shrink-0'>{row.hostName}</span> : null}
291
- {info.profile ? <span className='shrink-0'>@{info.profile}</span> : null}
292
- <span className='shrink-0'>{formatCost(info.totalCostUsd)}</span>
293
- <span className='shrink-0'>
294
- {formatRelativeTime(info.lastActivityAt ?? info.createdAt)}
295
- </span>
369
+ className='min-w-0 flex-1 truncate text-left font-mono outline-none'>
370
+ {details.join(' · ')}
296
371
  </button>
372
+ {onRename && !editing ? (
373
+ <Button
374
+ variant='ghost'
375
+ size='icon-sm'
376
+ aria-label='Rename session'
377
+ className='size-5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100'
378
+ onClick={() => setEditing(true)}>
379
+ <Pencil className='size-3 text-fg-3' />
380
+ </Button>
381
+ ) : null}
382
+ {onDelete ? (
383
+ <Button
384
+ variant='ghost'
385
+ size='icon-sm'
386
+ aria-label='Close session'
387
+ className='size-5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100'
388
+ onClick={() => onDelete(row)}>
389
+ <Trash2 className='size-3 text-fg-3' />
390
+ </Button>
391
+ ) : null}
297
392
  </div>
298
- {onRename && !editing ? (
299
- <Button
300
- variant='ghost'
301
- size='icon-sm'
302
- aria-label='Rename session'
303
- className='opacity-0 transition-opacity group-hover:opacity-100'
304
- onClick={() => setEditing(true)}>
305
- <Pencil className='size-3.5 text-fg-3' />
306
- </Button>
307
- ) : null}
308
- {onDelete ? (
309
- <Button
310
- variant='ghost'
311
- size='icon-sm'
312
- aria-label='Close session'
313
- className='opacity-0 transition-opacity group-hover:opacity-100'
314
- onClick={() => onDelete(row)}>
315
- <Trash2 className='size-3.5 text-fg-3' />
316
- </Button>
317
- ) : null}
318
393
  </div>
319
394
  )
320
395
  }
321
396
 
397
+ /**
398
+ * State as one glyph on the right edge — a ringing bell when it wants a human, a
399
+ * spinner while it works, a moon when it is only sleeping. Replaces the text
400
+ * badge: in a sidebar the word costs more room than it earns, and the states
401
+ * that matter are the two you can recognise without reading.
402
+ */
403
+ export function SessionStatusIcon({ row }: { row: SessionRow }) {
404
+ const { info } = row
405
+ if (info.pendingPermissionCount > 0 || info.status === 'awaiting_approval') {
406
+ return <BellRing className='size-3.5 shrink-0 animate-pulse text-warning' />
407
+ }
408
+ if (info.status === 'running' || info.status === 'starting') {
409
+ return <Spinner className='size-3.5 shrink-0 text-info' />
410
+ }
411
+ switch (info.status) {
412
+ case 'failed':
413
+ return <CircleAlert className='size-3.5 shrink-0 text-danger' />
414
+ case 'closed':
415
+ return <CircleSlash className='size-3.5 shrink-0 text-fg-4' />
416
+ case 'parked':
417
+ return <PauseCircle className='size-3.5 shrink-0 text-fg-3' />
418
+ default:
419
+ return <Moon className='size-3.5 shrink-0 text-fg-4' />
420
+ }
421
+ }
422
+
322
423
  /**
323
424
  * Inline rename. Enter commits, Escape cancels, blur commits — but only a blur
324
425
  * that is still inside this document: switching windows must not silently commit,
@@ -360,6 +461,22 @@ function NameEditor({
360
461
 
361
462
  /** A multi-select facet. Empty = no filter, which is why the trigger reads the
362
463
  * facet's name rather than "All": nothing is being excluded. */
464
+ /**
465
+ * One labelled control row. The label column is fixed so every control starts
466
+ * on the same x — the thing that makes a stack of them read as a form rather
467
+ * than as five unrelated widgets.
468
+ */
469
+ function FilterRow({ label, children }: { label: string; children: React.ReactNode }) {
470
+ return (
471
+ <div className='flex items-center gap-2'>
472
+ <span aria-hidden className='w-14 shrink-0 truncate text-label text-fg-3'>
473
+ {label}
474
+ </span>
475
+ <div className='min-w-0 flex-1'>{children}</div>
476
+ </div>
477
+ )
478
+ }
479
+
363
480
  function FacetSelect({
364
481
  label,
365
482
  value,
@@ -374,13 +491,14 @@ function FacetSelect({
374
491
  return (
375
492
  <div className='flex items-center gap-1'>
376
493
  <Select multiple value={value} onValueChange={(v) => onChange(v as string[])}>
377
- <SelectTrigger aria-label={label} className='min-w-28'>
494
+ <SelectTrigger aria-label={label} className='min-w-0 flex-1'>
378
495
  <SelectValue>
496
+ {/* "All" rather than the label, which the row already carries. */}
379
497
  {value.length === 0
380
- ? label
498
+ ? 'All'
381
499
  : value.length === 1
382
- ? (options.find((o) => o.value === value[0])?.label ?? label)
383
- : `${label} · ${value.length}`}
500
+ ? (options.find((o) => o.value === value[0])?.label ?? 'All')
501
+ : `${value.length} selected`}
384
502
  </SelectValue>
385
503
  </SelectTrigger>
386
504
  <SelectContent>
@@ -413,7 +531,7 @@ function OneOfSelect({
413
531
  }) {
414
532
  return (
415
533
  <Select value={value} onValueChange={(v) => onChange(v as string)}>
416
- <SelectTrigger aria-label={label} className='min-w-28'>
534
+ <SelectTrigger aria-label={label} className='w-full min-w-0'>
417
535
  <SelectValue>{options.find((o) => o.value === value)?.label ?? label}</SelectValue>
418
536
  </SelectTrigger>
419
537
  <SelectContent>
@@ -98,6 +98,17 @@ export interface SessionPanelProps {
98
98
  * take the menu, or it has nowhere left to go.
99
99
  */
100
100
  statusSurface?: 'internal' | 'external'
101
+ /**
102
+ * Which end of the panel the status bar sits at. Default `top`.
103
+ *
104
+ * `bottom` is the editor convention — VS Code's status bar runs along the
105
+ * foot of the window — and suits a host where the panel *is* the editor area
106
+ * and the chrome above it already belongs to the app. Placement only; the bar
107
+ * is the same bar, with the same `⋯` menu in its trailing slot, so this
108
+ * composes with {@link statusSurface} rather than competing with it (external
109
+ * still means "there isn't one").
110
+ */
111
+ statusPlacement?: 'top' | 'bottom'
101
112
  /** Where `panelSurface: 'external'` routes opens. Absent = the affordances
102
113
  * (status-bar clicks, `/mcp`) become inert rather than half-working. */
103
114
  onOpenPanel?: (panel: SessionSurfacePanel) => void
@@ -161,6 +172,22 @@ export interface SessionPanelProps {
161
172
  * the number to remember through `SessionVitals.itemCount`.
162
173
  */
163
174
  unseen?: { itemCount: number; since?: number }
175
+ /**
176
+ * A viewer, not a seat at the session: transcript, status bar and panels as
177
+ * usual, but no composer and no approval prompts.
178
+ *
179
+ * For a surface that is *about* a run rather than in it — the dashboard's job
180
+ * detail, where the session belongs to the queue and typing into it would be a
181
+ * second operator arriving mid-run. Deliberately not "disabled controls": a
182
+ * greyed-out composer says the session is busy, an absent one says this screen
183
+ * does not drive it. The attach is still live and read paths are untouched,
184
+ * so the transcript streams and the file tree browses.
185
+ *
186
+ * It does **not** claim to be an authorization boundary. Anything holding this
187
+ * client can still send; what it removes is the affordance, and the honest
188
+ * enforcement lives on the gateway.
189
+ */
190
+ readOnly?: boolean
164
191
  className?: string
165
192
  }
166
193
 
@@ -251,6 +278,7 @@ export function SessionPanel({
251
278
  header,
252
279
  panelSurface = 'internal',
253
280
  statusSurface = 'internal',
281
+ statusPlacement = 'top',
254
282
  onOpenPanel,
255
283
  onVitals,
256
284
  transcriptVariant = 'cards',
@@ -259,6 +287,7 @@ export function SessionPanel({
259
287
  onControls,
260
288
  focusComposerOnClick = false,
261
289
  unseen,
290
+ readOnly = false,
262
291
  className,
263
292
  }: SessionPanelProps) {
264
293
  const external = panelSurface === 'external'
@@ -503,11 +532,26 @@ export function SessionPanel({
503
532
  const menu = external ? null : actionsMenu
504
533
  const headerTakesActions = typeof header === 'function'
505
534
 
535
+ // Built once and placed at one end or the other — the bar has a `⋯` menu and
536
+ // three open handlers, and two copies of that in the tree would be two things
537
+ // to keep in step.
538
+ const statusBar = statusExternal ? null : (
539
+ <StatusBar
540
+ state={state}
541
+ connection={connection}
542
+ placement={statusPlacement}
543
+ onOpenStatus={external && !onOpenPanel ? undefined : () => openPanel('info')}
544
+ onOpenContext={external && !onOpenPanel ? undefined : () => openPanel('context')}
545
+ onOpenUsage={external && !onOpenPanel ? undefined : () => openPanel('usage')}
546
+ actions={headerTakesActions ? undefined : menu}
547
+ />
548
+ )
549
+
506
550
  // Dead-space clicks land in the composer. Anything the user actually aimed at
507
551
  // — a control, a link, the end of a drag-selection — keeps its own meaning;
508
552
  // this only claims what was left over.
509
553
  const handleClick = (event: ReactMouseEvent<HTMLDivElement>) => {
510
- if (!focusComposerOnClick) return
554
+ if (!focusComposerOnClick || readOnly) return
511
555
  const target = event.target as HTMLElement | null
512
556
  if (target?.closest(INTERACTIVE)) return
513
557
  if (window.getSelection()?.isCollapsed === false) return
@@ -525,16 +569,7 @@ export function SessionPanel({
525
569
  onClick={handleClick}
526
570
  className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}>
527
571
  {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
- )}
572
+ {statusPlacement === 'top' ? statusBar : null}
538
573
  {protocolMismatch !== undefined ? (
539
574
  <Notice level='warning'>
540
575
  Server speaks protocol v{protocolMismatch}, this build renders v{PROTOCOL_VERSION}. Some
@@ -569,7 +604,12 @@ export function SessionPanel({
569
604
  <div
570
605
  data-slot='catch-up'
571
606
  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'>
607
+ <span
608
+ aria-hidden
609
+ className={cn(
610
+ 'select-none',
611
+ transcriptVariant === 'lines' ? 'text-fg-3' : 'text-accent',
612
+ )}>
573
613
 
574
614
  </span>
575
615
  <span className='min-w-0 flex-1 truncate'>
@@ -594,7 +634,10 @@ export function SessionPanel({
594
634
  {/* An engine with no approval channel never raises these, but a stale
595
635
  pending request from a replayed log would still render — the record is
596
636
  the authority on whether an approval UI means anything here. */}
597
- {capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? (
637
+ {/* A read-only surface has no answer to give: the status bar still says
638
+ `awaiting approval`, and the place that can act on it is wherever the
639
+ session is actually driven. */}
640
+ {!readOnly && capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? (
598
641
  <div className='px-3 pb-2'>
599
642
  <div className='mx-auto flex w-full max-w-[var(--wd-content-max-w,48rem)] flex-col gap-2'>
600
643
  {state.pendingApprovals.map((request) =>
@@ -618,6 +661,7 @@ export function SessionPanel({
618
661
  </div>
619
662
  </div>
620
663
  ) : null}
664
+ {readOnly ? null : (
621
665
  <Composer
622
666
  ref={composerRef}
623
667
  onSend={handleSend}
@@ -662,6 +706,10 @@ export function SessionPanel({
662
706
  )
663
707
  }
664
708
  />
709
+ )}
710
+ {/* Below the composer, along the foot of the panel — the editor
711
+ convention. Last in the flex column, so it is the bottom edge. */}
712
+ {statusPlacement === 'bottom' ? statusBar : null}
665
713
 
666
714
  {/* The internal dialog surface. The external one renders none of these —
667
715
  the embedder hosts equivalent surfaces and is handed the intents. */}
@@ -34,7 +34,13 @@ export interface SessionWorkspaceProps {
34
34
  */
35
35
  transcriptVariant?: SessionPanelProps['transcriptVariant']
36
36
  transcriptDensity?: SessionPanelProps['transcriptDensity']
37
+ /** Which end of the panel the status bar sits at — see `SessionPanel`. */
38
+ statusPlacement?: SessionPanelProps['statusPlacement']
37
39
  unseen?: SessionPanelProps['unseen']
40
+ /** Viewer mode — no composer, no approval prompts. Forwarded verbatim to
41
+ * {@link SessionPanel}; the file tree and editor are unaffected, since reading
42
+ * a run's files is the point of a read-only view. */
43
+ readOnly?: SessionPanelProps['readOnly']
38
44
  onVitals?: SessionPanelProps['onVitals']
39
45
  /** Rail width in pixels on first render. */
40
46
  defaultRailWidth?: number
@@ -85,7 +91,9 @@ export function SessionWorkspace({
85
91
  header,
86
92
  transcriptVariant,
87
93
  transcriptDensity,
94
+ statusPlacement,
88
95
  unseen,
96
+ readOnly,
89
97
  onVitals,
90
98
  defaultRailWidth = 260,
91
99
  defaultRailCollapsed,
@@ -264,7 +272,9 @@ export function SessionWorkspace({
264
272
  header={hoisted}
265
273
  transcriptVariant={transcriptVariant}
266
274
  transcriptDensity={transcriptDensity}
275
+ statusPlacement={statusPlacement}
267
276
  unseen={unseen}
277
+ readOnly={readOnly}
268
278
  onVitals={onVitals}
269
279
  className='min-h-0 flex-1'
270
280
  />