@workerdeck/ui 0.11.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.
Files changed (35) hide show
  1. package/build/{SessionPanel-_U8tjX29.mjs → SessionPanel-CKQa4i0Y.mjs} +337 -269
  2. package/build/SessionPanel-CKQa4i0Y.mjs.map +1 -0
  3. package/build/{SessionPanel-CyhygZx_.d.mts → SessionPanel-CZMA44NM.d.mts} +73 -2
  4. package/build/format.d.mts +65 -1
  5. package/build/format.mjs +118 -1
  6. package/build/format.mjs.map +1 -0
  7. package/build/index.d.mts +129 -10
  8. package/build/index.mjs +461 -5
  9. package/build/index.mjs.map +1 -1
  10. package/build/workspace.d.mts +36 -1
  11. package/build/workspace.mjs +16 -2
  12. package/build/workspace.mjs.map +1 -1
  13. package/package.json +4 -4
  14. package/src/components/agent/Composer.tsx +18 -10
  15. package/src/components/agent/EngineIcon.tsx +97 -0
  16. package/src/components/agent/Loader.tsx +11 -46
  17. package/src/components/agent/Message.tsx +10 -6
  18. package/src/components/agent/QuestionPrompt.tsx +1 -1
  19. package/src/components/agent/SessionBrowser.tsx +546 -0
  20. package/src/components/agent/SessionPanel.tsx +89 -14
  21. package/src/components/agent/SessionWorkspace.tsx +45 -0
  22. package/src/components/agent/StatusBar.tsx +9 -2
  23. package/src/components/agent/ToolCallCard.tsx +11 -2
  24. package/src/components/agent/Transcript.tsx +28 -11
  25. package/src/components/agent/line-prompt.tsx +2 -2
  26. package/src/components/agent/pulse.tsx +60 -0
  27. package/src/components/agent/transcript-variant.tsx +62 -0
  28. package/src/components/ui/Menu.tsx +1 -1
  29. package/src/components/ui/Select.tsx +1 -1
  30. package/src/components/ui/Tooltip.tsx +1 -1
  31. package/src/format.ts +1 -0
  32. package/src/index.ts +12 -0
  33. package/src/lib/status.ts +124 -0
  34. package/src/styles/theme.css +45 -15
  35. package/build/SessionPanel-_U8tjX29.mjs.map +0 -1
@@ -0,0 +1,546 @@
1
+ import { useEffect, useMemo, useRef, useState } from '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'
13
+ import {
14
+ STATE_LABELS,
15
+ STATE_ORDER,
16
+ adaptersOf,
17
+ clearFilters,
18
+ filterRows,
19
+ groupRows,
20
+ hasFacetFilter,
21
+ sessionLabel,
22
+ subsetSummary,
23
+ } from '@workerdeck/protocol'
24
+ import type {
25
+ GroupBy,
26
+ SessionRow,
27
+ SessionState,
28
+ SortBy,
29
+ ViewConfig,
30
+ WorkspaceScope,
31
+ } from '@workerdeck/protocol'
32
+ import { Button } from '../ui/Button.tsx'
33
+ import { Input } from '../ui/Input.tsx'
34
+ import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger, SelectValue } from '../ui/Select.tsx'
35
+ import { Spinner } from '../ui/Spinner.tsx'
36
+ import { cn } from '../../lib/utils.ts'
37
+ import { formatCost, formatRelativeTime, friendlyModel } from '../../lib/format.ts'
38
+
39
+ /**
40
+ * A sessions list with the affordances a list of thirty needs: search, facets,
41
+ * grouping, sorting, unread counts, and one honest line about what is hidden.
42
+ *
43
+ * The *rules* are `@workerdeck/protocol`'s (`filterRows`/`groupRows`/
44
+ * `subsetSummary`), not this component's — the VS Code sidebar renders the same
45
+ * model with workbench chrome, its activity-bar badge counts the same rows this
46
+ * would show, and iOS mirrors them in Swift. What lives here is the styled
47
+ * rendering of that model, so a host that wants the dashboard's look gets it
48
+ * without reimplementing the model behind it.
49
+ *
50
+ * `SessionList` remains beside this for the plain case (a fixed set of rows, no
51
+ * controls); this is what you reach for when the list is the screen.
52
+ */
53
+
54
+ export interface SessionBrowserProps {
55
+ rows: SessionRow[]
56
+ config: ViewConfig
57
+ onConfigChange: (config: ViewConfig) => void
58
+ /** The host's own folders, if it has such a notion. Absent — a dashboard, a
59
+ * phone — makes the scope filter genuinely inert rather than empty. */
60
+ scope?: WorkspaceScope
61
+ activeId?: string
62
+ onSelect?: (row: SessionRow) => void
63
+ onDelete?: (row: SessionRow) => void
64
+ /**
65
+ * Rename, from the row's pencil. Empty string restores the derived title. A
66
+ * gateway edit (`PATCH /sessions/:id`), never a local override — every client
67
+ * should see the same name. Omit to make titles read-only.
68
+ *
69
+ * A hover affordance rather than the extension's double-click-the-title,
70
+ * because here a single click on the row navigates: the *first* click of a
71
+ * double-click would have already left the page.
72
+ */
73
+ onRename?: (row: SessionRow, title: string) => void
74
+ /** Rendered when nothing at all exists (as opposed to nothing matching). */
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
88
+ className?: string
89
+ }
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
+
114
+ export function SessionBrowser({
115
+ rows,
116
+ config,
117
+ onConfigChange,
118
+ scope,
119
+ activeId,
120
+ onSelect,
121
+ onDelete,
122
+ onRename,
123
+ emptyState,
124
+ showControls = true,
125
+ className,
126
+ }: SessionBrowserProps) {
127
+ const visible = useMemo(() => filterRows(rows, config, scope), [rows, config, scope])
128
+ const groups = useMemo(() => groupRows(visible, config), [visible, config])
129
+ const subset = subsetSummary(config, scope, visible.length, rows.length)
130
+ // Derived, not enumerated: a new engine or a new gateway needs no change here,
131
+ // and a facet with one possible value is not a choice worth showing.
132
+ const adapters = useMemo(() => adaptersOf(rows), [rows])
133
+ const gateways = useMemo(() => {
134
+ const seen = new Map<string, string>()
135
+ for (const row of rows) seen.set(row.hostId, row.hostName)
136
+ return [...seen].map(([id, name]) => ({ id, name }))
137
+ }, [rows])
138
+
139
+ const set = (patch: Partial<ViewConfig>) => onConfigChange({ ...config, ...patch })
140
+
141
+ return (
142
+ <div data-slot='session-browser' className={cn('flex flex-col gap-3', className)}>
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'>
150
+ <Search className='pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-fg-4' />
151
+ <Input
152
+ value={config.search}
153
+ onChange={(e) => set({ search: e.target.value })}
154
+ placeholder='Search sessions'
155
+ aria-label='Search sessions'
156
+ className='pl-8'
157
+ />
158
+ </div>
159
+ <FilterRow label='State'>
160
+ <FacetSelect
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[] })}
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>
176
+ ) : null}
177
+ {gateways.length > 1 ? (
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>
186
+ ) : null}
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>
213
+ </div>
214
+
215
+ {subset ? (
216
+ <div className='flex items-center gap-2 px-3 text-label text-fg-4'>
217
+ <span>
218
+ {subset.shown} of {subset.total}
219
+ {subset.causes.length ? ` · ${subset.causes.join(' · ')}` : null}
220
+ </span>
221
+ <button
222
+ type='button'
223
+ className='text-fg-3 underline underline-offset-2 hover:text-fg-1'
224
+ onClick={() => onConfigChange(clearFilters(config))}>
225
+ Show all
226
+ </button>
227
+ </div>
228
+ ) : null}
229
+
230
+ {rows.length === 0 ? (
231
+ (emptyState ?? <Empty>No sessions yet.</Empty>)
232
+ ) : visible.length === 0 ? (
233
+ // 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>
250
+ ) : (
251
+ <div className='flex flex-col gap-4'>
252
+ {groups.map((group) => (
253
+ <div key={group.key} className='flex flex-col gap-1'>
254
+ {config.groupBy !== 'none' && group.label ? (
255
+ <div className='flex items-baseline gap-2 px-3 text-label font-medium text-fg-4'>
256
+ <span className='uppercase tracking-wide'>{group.label}</span>
257
+ <span className='text-fg-4/70'>{group.rows.length}</span>
258
+ </div>
259
+ ) : null}
260
+ {group.rows.map((row) => (
261
+ <SessionRowItem
262
+ key={`${row.hostId}:${row.info.id}`}
263
+ row={row}
264
+ active={row.info.id === activeId}
265
+ showGateway={gateways.length > 1}
266
+ onSelect={onSelect}
267
+ onDelete={onDelete}
268
+ onRename={onRename}
269
+ />
270
+ ))}
271
+ </div>
272
+ ))}
273
+ </div>
274
+ )}
275
+ </div>
276
+ )
277
+ }
278
+
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
+ interface SessionRowItemProps {
284
+ row: SessionRow
285
+ active?: boolean
286
+ showGateway?: boolean
287
+ onSelect?: (row: SessionRow) => void
288
+ onDelete?: (row: SessionRow) => void
289
+ onRename?: (row: SessionRow, title: string) => void
290
+ }
291
+
292
+ function SessionRowItem({
293
+ row,
294
+ active,
295
+ showGateway,
296
+ onSelect,
297
+ onDelete,
298
+ onRename,
299
+ }: SessionRowItemProps) {
300
+ const { info } = row
301
+ const [editing, setEditing] = useState(false)
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
+
314
+ return (
315
+ <div
316
+ data-slot='session-row'
317
+ data-active={active || undefined}
318
+ className={cn(
319
+ 'group flex cursor-pointer flex-col gap-0.5 text-left transition-colors',
320
+ rowShapeClass(active === true),
321
+ )}>
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'>
366
+ <button
367
+ type='button'
368
+ onClick={() => !editing && onSelect?.(row)}
369
+ className='min-w-0 flex-1 truncate text-left font-mono outline-none'>
370
+ {details.join(' · ')}
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}
392
+ </div>
393
+ </div>
394
+ )
395
+ }
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
+
423
+ /**
424
+ * Inline rename. Enter commits, Escape cancels, blur commits — but only a blur
425
+ * that is still inside this document: switching windows must not silently commit,
426
+ * nor kill the editor in the frame it opened.
427
+ */
428
+ function NameEditor({
429
+ initial,
430
+ onCommit,
431
+ onCancel,
432
+ }: {
433
+ initial: string
434
+ onCommit: (title: string) => void
435
+ onCancel: () => void
436
+ }) {
437
+ const [value, setValue] = useState(initial)
438
+ const ref = useRef<HTMLInputElement>(null)
439
+ useEffect(() => {
440
+ ref.current?.select()
441
+ }, [])
442
+ return (
443
+ <input
444
+ ref={ref}
445
+ value={value}
446
+ autoFocus
447
+ aria-label='Session name'
448
+ // The row is a button; a click in here must not select the session.
449
+ onClick={(e) => e.stopPropagation()}
450
+ onChange={(e) => setValue(e.target.value)}
451
+ onBlur={() => (document.hasFocus() ? onCommit(value.trim()) : undefined)}
452
+ onKeyDown={(e) => {
453
+ if (e.key === 'Enter') onCommit(value.trim())
454
+ else if (e.key === 'Escape') onCancel()
455
+ e.stopPropagation()
456
+ }}
457
+ className='-my-0.5 min-w-0 flex-1 rounded-sm border border-ring bg-bg px-1 py-0.5 text-body-sm font-medium text-fg-1 outline-none'
458
+ />
459
+ )
460
+ }
461
+
462
+ /** A multi-select facet. Empty = no filter, which is why the trigger reads the
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
+
480
+ function FacetSelect({
481
+ label,
482
+ value,
483
+ options,
484
+ onChange,
485
+ }: {
486
+ label: string
487
+ value: string[]
488
+ options: { value: string; label: string }[]
489
+ onChange: (value: string[]) => void
490
+ }) {
491
+ return (
492
+ <div className='flex items-center gap-1'>
493
+ <Select multiple value={value} onValueChange={(v) => onChange(v as string[])}>
494
+ <SelectTrigger aria-label={label} className='min-w-0 flex-1'>
495
+ <SelectValue>
496
+ {/* "All" rather than the label, which the row already carries. */}
497
+ {value.length === 0
498
+ ? 'All'
499
+ : value.length === 1
500
+ ? (options.find((o) => o.value === value[0])?.label ?? 'All')
501
+ : `${value.length} selected`}
502
+ </SelectValue>
503
+ </SelectTrigger>
504
+ <SelectContent>
505
+ {options.map((option) => (
506
+ <SelectItem key={option.value} value={option.value}>
507
+ <SelectItemText>{option.label}</SelectItemText>
508
+ </SelectItem>
509
+ ))}
510
+ </SelectContent>
511
+ </Select>
512
+ {value.length > 0 ? (
513
+ <Button variant='ghost' size='icon-sm' aria-label={`Clear ${label}`} onClick={() => onChange([])}>
514
+ <X className='size-3 text-fg-4' />
515
+ </Button>
516
+ ) : null}
517
+ </div>
518
+ )
519
+ }
520
+
521
+ function OneOfSelect({
522
+ label,
523
+ value,
524
+ options,
525
+ onChange,
526
+ }: {
527
+ label: string
528
+ value: string
529
+ options: readonly { value: string; label: string }[]
530
+ onChange: (value: string) => void
531
+ }) {
532
+ return (
533
+ <Select value={value} onValueChange={(v) => onChange(v as string)}>
534
+ <SelectTrigger aria-label={label} className='w-full min-w-0'>
535
+ <SelectValue>{options.find((o) => o.value === value)?.label ?? label}</SelectValue>
536
+ </SelectTrigger>
537
+ <SelectContent>
538
+ {options.map((option) => (
539
+ <SelectItem key={option.value} value={option.value}>
540
+ <SelectItemText>{option.label}</SelectItemText>
541
+ </SelectItem>
542
+ ))}
543
+ </SelectContent>
544
+ </Select>
545
+ )
546
+ }