@workerdeck/ui 0.19.0 → 0.21.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,18 +1,6 @@
1
- import { useEffect, useMemo, useRef, useState } from 'react'
1
+ import { useMemo, useState } from 'react'
2
2
  import type { ReactNode } from 'react'
3
- import {
4
- BellRing,
5
- CircleAlert,
6
- CircleSlash,
7
- Layers,
8
- Moon,
9
- PauseCircle,
10
- Pencil,
11
- Search,
12
- SearchX,
13
- Trash2,
14
- X,
15
- } from 'lucide-react'
3
+ import { Eraser, Layers, Pencil, Search, SearchX, Trash2, X } from 'lucide-react'
16
4
  import {
17
5
  STATE_LABELS,
18
6
  STATE_ORDER,
@@ -21,10 +9,7 @@ import {
21
9
  filterRows,
22
10
  groupRows,
23
11
  hasFacetFilter,
24
- projectLabel,
25
- projectSubpath,
26
12
  projectsOf,
27
- sessionLabel,
28
13
  subsetSummary,
29
14
  } from '@workerdeck/protocol'
30
15
  import type {
@@ -39,13 +24,14 @@ import { Button } from '../ui/Button.tsx'
39
24
  import { Empty } from '../ui/Empty.tsx'
40
25
  import { Input } from '../ui/Input.tsx'
41
26
  import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger, SelectValue } from '../ui/Select.tsx'
42
- import { Spinner } from '../ui/Spinner.tsx'
43
- import { ContextRing } from './ContextRing.tsx'
44
- import { EngineIcon, vendorMarkClass, vendorTextClass } from './EngineIcon.tsx'
45
27
  import { ProjectIcon } from './ProjectIcon.tsx'
46
- import { StepRow, StepToggle, runningSteps, sessionSteps } from './SessionSteps.tsx'
28
+ import { SessionItem } from './SessionItem.tsx'
47
29
  import { cn } from '../../lib/utils.ts'
48
- import { formatCost, formatRelativeTime, friendlyModel } from '../../lib/format.ts'
30
+
31
+ // The state glyph moved to its own module when the card did — the extension's
32
+ // list draws it too, and this file re-exports it only so the package's public
33
+ // surface does not shift under a host that imports it from here.
34
+ export { SessionStatusIcon } from './SessionStatusIcon.tsx'
49
35
 
50
36
  /**
51
37
  * A sessions list with the affordances a list of thirty needs: search, facets,
@@ -70,6 +56,13 @@ export interface SessionBrowserProps {
70
56
  * phone — makes the scope filter genuinely inert rather than empty. */
71
57
  scope?: WorkspaceScope
72
58
  activeId?: string
59
+ /**
60
+ * Which sub-agent the host currently has open, by tool-use id — the *finer*
61
+ * of the two selections. The card holding it drops to the secondary grey and
62
+ * that step takes the blue; see `SessionItem`. A host with no sub-agent
63
+ * surface leaves it undefined and nothing changes.
64
+ */
65
+ activeSubagentId?: string
73
66
  onSelect?: (row: SessionRow) => void
74
67
  onDelete?: (row: SessionRow) => void
75
68
  /**
@@ -82,10 +75,27 @@ export interface SessionBrowserProps {
82
75
  * double-click would have already left the page.
83
76
  */
84
77
  onRename?: (row: SessionRow, title: string) => void
78
+ /**
79
+ * Clear the conversation in place, from the row's eraser. Same session, empty
80
+ * context — **not** a delete: the engine keeps the old conversation and it
81
+ * stays resumable, which is why the row's copy never says "deleted".
82
+ *
83
+ * Rendered only where the session's own capability record allows it
84
+ * (`capabilities.clearContext`, absent = false), so a row that cannot be
85
+ * cleared has no eraser rather than one that errors. Omit the prop to have
86
+ * none at all — a host with no way to send a session command has nothing to
87
+ * hang it on (this is a WS command, not a REST route).
88
+ */
89
+ onClearContext?: (row: SessionRow) => void
85
90
  /** Open a session *at* one of its sub-agents, when the host can scroll a
86
91
  * transcript to a `Task` row. Absent, the sub-agent list still expands and a
87
92
  * step just opens its session — see `SessionRowItemProps`. */
88
93
  onSelectSubagent?: (row: SessionRow, toolUseId: string) => void
94
+ /** Open a session and travel to one of its **tasks** — see
95
+ * `SessionItem.onRevealStep`. A task has no agent behind it, so it is a place
96
+ * to go rather than a thing to open, and framing it as an agent drew an empty
97
+ * view. */
98
+ onRevealStep?: (row: SessionRow, toolUseId: string) => void
89
99
  /** Rendered when nothing at all exists (as opposed to nothing matching). */
90
100
  emptyState?: React.ReactNode
91
101
  /**
@@ -141,10 +151,13 @@ export function SessionBrowser({
141
151
  onConfigChange,
142
152
  scope,
143
153
  activeId,
154
+ activeSubagentId,
144
155
  onSelect,
145
156
  onDelete,
146
157
  onRename,
158
+ onClearContext,
147
159
  onSelectSubagent,
160
+ onRevealStep,
148
161
  emptyState,
149
162
  showControls = true,
150
163
  projectIcons,
@@ -285,11 +298,18 @@ export function SessionBrowser({
285
298
  <Empty icon={<Layers />} title='Nothing here' description='No session to show.' />
286
299
  )
287
300
  ) : (
288
- <div className='flex flex-col gap-4'>
301
+ /* The list's own 4px inset, as **padding on the list** rather than
302
+ margins on the cards. `SessionItem` is `w-full` — it fills the slot it
303
+ is given, which is what lets a host size it — and `w-full` plus
304
+ `mx-1` is `100% + 8px`: an overflow by construction, which is exactly
305
+ what it did. The cards ran past the sidebar's right edge and the
306
+ column grew a horizontal scrollbar. Padding here cannot do that,
307
+ whatever the card's own width rule turns out to be. */
308
+ <div className='flex flex-col gap-4 px-1'>
289
309
  {groups.map((group) => (
290
310
  <div key={group.key} className='flex flex-col gap-1'>
291
311
  {config.groupBy !== 'none' && group.label ? (
292
- <div className='flex items-center gap-2 px-3 text-label font-medium text-fg-4'>
312
+ <div className='flex items-center gap-2 px-2 text-label font-medium text-fg-4'>
293
313
  {/* Only the project facet has a mark of its own, and a group
294
314
  IS one project root, so the first row is a fair source. */}
295
315
  {config.groupBy === 'project' ? (
@@ -308,13 +328,16 @@ export function SessionBrowser({
308
328
  key={`${row.hostId}:${row.info.id}`}
309
329
  row={row}
310
330
  active={row.info.id === activeId}
331
+ activeSubagentId={activeSubagentId}
311
332
  showGateway={gateways.length > 1 && config.groupBy !== 'gateway'}
312
333
  showProject={config.groupBy !== 'project'}
313
334
  projectIcons={projectIcons}
314
335
  onSelect={onSelect}
315
336
  onDelete={onDelete}
316
337
  onRename={onRename}
338
+ onClearContext={onClearContext}
317
339
  onSelectSubagent={onSelectSubagent}
340
+ onRevealStep={onRevealStep}
318
341
  />
319
342
  ))}
320
343
  </div>
@@ -339,6 +362,8 @@ function iconSrcOf(
339
362
  interface SessionRowItemProps {
340
363
  row: SessionRow
341
364
  active?: boolean
365
+ /** See `SessionBrowserProps.activeSubagentId`. */
366
+ activeSubagentId?: string
342
367
  showGateway?: boolean
343
368
  /** False when the list is already grouped by project — the header has said the
344
369
  * name, so the row must not spend its metadata line repeating it. What takes
@@ -352,6 +377,9 @@ interface SessionRowItemProps {
352
377
  onSelect?: (row: SessionRow) => void
353
378
  onDelete?: (row: SessionRow) => void
354
379
  onRename?: (row: SessionRow, title: string) => void
380
+ /** See `SessionBrowserProps.onClearContext` — gated on the row's own
381
+ * capability record, not on the engine name. */
382
+ onClearContext?: (row: SessionRow) => void
355
383
  /** Open one of a session's sub-agents — which now means handing the session
356
384
  * panel over to that agent's own work (`SessionPanel.openSubagent`), and meant
357
385
  * "scroll to its `Task` row" before that surface existed. Both are honest; a
@@ -359,290 +387,115 @@ interface SessionRowItemProps {
359
387
  * feature: without it a step just opens the session, which is all a host with
360
388
  * neither can offer. Only *agent* steps ever call it — see `Step.kind`. */
361
389
  onSelectSubagent?: (row: SessionRow, toolUseId: string) => void
390
+ /** See `SessionBrowserProps.onRevealStep`. */
391
+ onRevealStep?: (row: SessionRow, toolUseId: string) => void
362
392
  }
363
393
 
364
394
  function SessionRowItem({
365
395
  row,
366
396
  active,
397
+ activeSubagentId,
367
398
  showGateway,
368
399
  showProject = true,
369
400
  projectIcons,
370
401
  onSelect,
371
402
  onDelete,
372
403
  onRename,
404
+ onClearContext,
373
405
  onSelectSubagent,
406
+ onRevealStep,
374
407
  }: SessionRowItemProps) {
375
408
  const { info } = row
409
+ // The rename affordance here is a pencil, not the card's own double-click:
410
+ // the dashboard already spends the row's hover on two other actions, and a
411
+ // gesture that only one of the three responds to is a gesture nobody finds.
412
+ // So the trigger is `external` and this owns the flag.
376
413
  const [editing, setEditing] = useState(false)
377
- // Expansion is the row's own state and deliberately not persisted: a list that
378
- // reopened yesterday's work on every reload would be showing a settled tail
379
- // nobody asked for.
380
- const [expanded, setExpanded] = useState(false)
381
-
382
- // What it is and what it has spent, in one line — the same set the extension
383
- // shows, joined the same way, so the two lists read as one product.
384
- // protocol's own spelling, so this row, the group header above it and the
385
- // project facet cannot disagree about what a project is called. Under a
386
- // project group it is the sub-path instead — see `showProject` — and a session
387
- // at the root contributes nothing at all rather than a name already on screen.
388
- const project = showProject ? projectLabel(row) : projectSubpath(row)
389
- const projectIcon = showProject ? info.project?.icon : undefined
390
- const engine = info.engine ?? 'claude'
391
- // Everything after the model. The model itself is drawn separately because it
392
- // is the one segment that wears a colour — see `vendorTextClass` — and a
393
- // coloured run inside a joined string would have to be spliced back out.
394
- const details = [
395
- project,
396
- showGateway ? row.hostName : undefined,
397
- info.profile ? `@${info.profile}` : undefined,
398
- formatCost(info.totalCostUsd),
399
- ].filter(Boolean)
400
- const steps = sessionSteps(info, (toolUseId) =>
401
- onSelectSubagent ? onSelectSubagent(row, toolUseId) : onSelect?.(row),
402
- )
403
- // Where the icon goes: immediately before the project's own name, wherever
404
- // that landed in the joined line. Split rather than interleaved as nodes,
405
- // because everything here is one truncating mono run and a flex of pieces
406
- // would each shrink a little and leave several half-words.
407
- const cut = project === undefined ? -1 : details.indexOf(project)
408
414
 
409
415
  return (
410
- <div
411
- data-slot='session-row'
412
- data-active={active || undefined}
413
- // Selection lives on the whole row, not on the two text buttons inside
414
- // it: the age, the unread badge and the state glyph sit outside them, and
415
- // a row where a third of the surface silently does nothing is a row that
416
- // feels broken. The buttons stay — they are what makes the row reachable
417
- // by keyboard — but their activation now reaches this handler by
418
- // bubbling, so there is one code path and no double-fire. Anything that
419
- // is its own action (rename, close, the name editor) stops the event.
420
- onClick={() => !editing && onSelect?.(row)}
421
- className={cn(
422
- 'group flex cursor-pointer flex-col gap-0.5 text-left transition-colors',
423
- rowShapeClass(active === true),
424
- )}>
425
- {/* Line one: how it is doing, then what you scan the list by, then how
426
- long since it moved.
427
-
428
- State leads. It used to sit last, on the argument that the title is
429
- what you read and the state is what you glance at — but a glance wants
430
- a *column*, and a trailing glyph has no fixed x: it lands wherever the
431
- age and the ring leave it. Leading, every row's state stacks into one
432
- scannable strip, and the mark on line two lands under it in the same
433
- gutter. This is the VS Code sidebar's shape, and all three clients are
434
- on it now. */}
435
- <div className='flex items-center gap-1.5'>
436
- <Gutter>
437
- <SessionStatusIcon row={row} />
438
- </Gutter>
439
- {/* The editor replaces the link rather than sitting inside it an
440
- input nested in a button is invalid, and disabling the button to
441
- protect the edit disables the field with it. */}
442
- {editing && onRename ? (
443
- <NameEditor
444
- initial={info.title ?? ''}
445
- onCommit={(title) => {
446
- setEditing(false)
447
- onRename(row, title)
448
- }}
449
- onCancel={() => setEditing(false)}
450
- />
451
- ) : (
452
- <button
453
- type='button'
454
- className={cn(
455
- 'min-w-0 flex-1 truncate text-left text-body-sm outline-none',
456
- active ? 'font-medium text-fg-1' : 'text-fg-2',
457
- )}>
458
- {sessionLabel(info)}
459
- </button>
460
- )}
461
- {row.unseen > 0 ? (
462
- // Transcript rows since this session was last on screen — the same
463
- // unit the VS Code badge counts, because turns undercount badly.
464
- <span
465
- title={`${row.unseen} new`}
466
- className='shrink-0 rounded-full bg-accent px-1.5 text-label text-accent-fg'>
467
- {row.unseen}
468
- </span>
469
- ) : null}
470
- <span className='shrink-0 text-label text-fg-4'>
471
- {formatRelativeTime(info.lastActivityAt ?? info.createdAt)}
472
- </span>
473
- <ContextRing usage={info.contextUsage} />
474
- </div>
475
-
476
- {/* Line two: what it is — the engine's mark and its model in the vendor's
477
- own colour, then where it runs — with the actions at the far right,
478
- away from the title you are actually reading. */}
479
- <div className='flex items-center gap-1 text-label text-fg-4'>
480
- <Gutter>
481
- {/* The colour goes on the icon, not on this cell: the svg ships its
482
- own `text-fg-3` and only a class on the element itself merges over
483
- it. */}
484
- <EngineIcon
485
- engine={engine}
486
- model={info.model}
487
- className={vendorMarkClass(engine, info.model)}
488
- />
489
- </Gutter>
490
- <button
491
- type='button'
492
- tabIndex={-1}
493
- className='min-w-0 flex-1 truncate text-left font-mono outline-none'>
494
- <span className={vendorTextClass(engine, info.model)}>
495
- {/* The model id as a person says it — `claude-opus-5[1m]` is a wire
496
- value, and a list line has no room for a context-window suffix. */}
497
- {friendlyModel(info.model)}
498
- </span>
499
- {details.length > 0 ? ' · ' : ''}
500
- {cut < 0 ? (
501
- details.join(' · ')
502
- ) : (
503
- <>
504
- {details.slice(0, cut).map((part) => `${part} · `)}
505
- <ProjectIcon
506
- icon={projectIcon}
507
- src={iconSrcOf(row, projectIcons)}
508
- name={project}
509
- /* Nudged onto the text baseline: a 12px glyph box against an
510
- 11px line sits a hair proud without it. */
511
- className='mr-1 align-[-0.2em]'
512
- />
513
- {details.slice(cut).join(' · ')}
514
- </>
515
- )}
516
- </button>
517
- {steps.length > 0 ? (
518
- <StepToggle
519
- expanded={expanded}
520
- running={runningSteps(steps)}
521
- total={steps.length}
522
- noun={steps[0]!.noun}
523
- onToggle={() => setExpanded((v) => !v)}
524
- />
525
- ) : null}
526
- {onRename && !editing ? (
527
- <Button
528
- variant='ghost'
529
- size='icon-sm'
530
- aria-label='Rename session'
531
- className='size-5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100'
532
- onClick={(e) => {
533
- e.stopPropagation()
534
- setEditing(true)
535
- }}>
536
- <Pencil className='size-3 text-fg-3' />
537
- </Button>
538
- ) : null}
539
- {onDelete ? (
540
- <Button
541
- variant='ghost'
542
- size='icon-sm'
543
- aria-label='Close session'
544
- className='size-5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100'
545
- onClick={(e) => {
546
- e.stopPropagation()
547
- onDelete(row)
548
- }}>
549
- <Trash2 className='size-3 text-fg-3' />
550
- </Button>
551
- ) : null}
552
- </div>
553
- {expanded ? steps.map((step) => <StepRow key={step.key} step={step} onSelect={step.onSelect} />) : null}
554
- </div>
416
+ <SessionItem
417
+ row={row}
418
+ active={active === true}
419
+ activeStepKey={activeSubagentId}
420
+ showGateway={showGateway}
421
+ showProject={showProject}
422
+ projectIcons={projectIcons}
423
+ onSelect={() => onSelect?.(row)}
424
+ onSelectSubagent={onSelectSubagent ? (id) => onSelectSubagent(row, id) : undefined}
425
+ onRevealStep={onRevealStep ? (id) => onRevealStep(row, id) : undefined}
426
+ onRename={onRename ? (title) => onRename(row, title) : undefined}
427
+ renameOn='external'
428
+ editing={editing}
429
+ onEditingChange={setEditing}
430
+ actions={
431
+ <>
432
+ {onRename && !editing ? (
433
+ <RowAction
434
+ label='Rename session'
435
+ onClick={() => setEditing(true)}>
436
+ <Pencil className='size-3 text-fg-3' />
437
+ </RowAction>
438
+ ) : null}
439
+ {/* Gated on the row's own capability record, not on the engine name —
440
+ an engine that grows the ability gets the button with no change
441
+ here, and one that loses it stops offering a control that fails. */}
442
+ {onClearContext && info.capabilities?.clearContext ? (
443
+ <RowAction
444
+ label='Clear context'
445
+ title='Clear the conversation the session keeps running and the old conversation stays resumable'
446
+ onClick={() => onClearContext(row)}>
447
+ <Eraser className='size-3 text-fg-3' />
448
+ </RowAction>
449
+ ) : null}
450
+ {onDelete ? (
451
+ <RowAction label='Close session' onClick={() => onDelete(row)}>
452
+ <Trash2 className='size-3 text-fg-3' />
453
+ </RowAction>
454
+ ) : null}
455
+ </>
456
+ }
457
+ />
555
458
  )
556
459
  }
557
460
 
558
461
  /**
559
- * The one 14px cell both lines hang their glyph in.
462
+ * One of the dashboard's hover actions.
560
463
  *
561
- * A shared column rather than a leading character: the state and the engine mark
562
- * are different widths and a bare glyph would let the two text columns start at
563
- * different x. Centring inside a fixed cell is what makes them agree
564
- * `packages/ui`'s terminal-gutter argument at row scale.
464
+ * Hover-revealed rather than always on, which is the opposite of the
465
+ * extension's single overflow glyph and the right call for each: there are
466
+ * three of them here and a sidebar card has room for one, so the extension
467
+ * spends that room on a menu and this spends the row's hover on the actions
468
+ * themselves. Both stop the click — the whole card is pressable underneath, and
469
+ * an action that also selected the session would do two things per press.
565
470
  */
566
- function Gutter({ children }: { children: ReactNode }) {
567
- return <span className='flex w-3.5 shrink-0 items-center justify-center'>{children}</span>
568
- }
569
-
570
- /**
571
- * State as one glyph — a ringing bell when it wants a human, a spinner while it
572
- * works, a moon when it is only sleeping. Replaces the text badge: in a sidebar
573
- * the word costs more room than it earns, and the states that matter are the two
574
- * you can recognise without reading.
575
- *
576
- * **It reads `row.state`, not `info.status`, and that distinction is the whole
577
- * point of the row model.** `sessionState` already folds in the arm this glyph
578
- * cannot see for itself: a *background* sub-agent outlives its turn by design, so
579
- * the turn ends, `status` comes to rest at `idle`, and the agent keeps working.
580
- * Reading the raw status drew a **moon on a row filed under the "Working"
581
- * header** — the list contradicting itself on one line, which is exactly what a
582
- * derived view model exists to prevent. The value was in scope and unread.
583
- *
584
- * The terminal statuses still come off `info.status`, because `ended` collapses
585
- * `failed` and `closed` into one bucket and those are worth telling apart here.
586
- */
587
- export function SessionStatusIcon({ row }: { row: SessionRow }) {
588
- const { info } = row
589
- if (row.state === 'attention') {
590
- return <BellRing className='size-3.5 shrink-0 animate-pulse text-warning' />
591
- }
592
- if (row.state === 'working') {
593
- return <Spinner className='size-3.5 shrink-0 text-info' />
594
- }
595
- switch (info.status) {
596
- case 'failed':
597
- return <CircleAlert className='size-3.5 shrink-0 text-danger' />
598
- case 'closed':
599
- return <CircleSlash className='size-3.5 shrink-0 text-fg-4' />
600
- case 'parked':
601
- return <PauseCircle className='size-3.5 shrink-0 text-fg-3' />
602
- default:
603
- return <Moon className='size-3.5 shrink-0 text-fg-4' />
604
- }
605
- }
606
-
607
- /**
608
- * Inline rename. Enter commits, Escape cancels, blur commits — but only a blur
609
- * that is still inside this document: switching windows must not silently commit,
610
- * nor kill the editor in the frame it opened.
611
- */
612
- function NameEditor({
613
- initial,
614
- onCommit,
615
- onCancel,
471
+ function RowAction({
472
+ label,
473
+ title,
474
+ onClick,
475
+ children,
616
476
  }: {
617
- initial: string
618
- onCommit: (title: string) => void
619
- onCancel: () => void
477
+ label: string
478
+ title?: string
479
+ onClick: () => void
480
+ children: ReactNode
620
481
  }) {
621
- const [value, setValue] = useState(initial)
622
- const ref = useRef<HTMLInputElement>(null)
623
- useEffect(() => {
624
- ref.current?.select()
625
- }, [])
626
482
  return (
627
- <input
628
- ref={ref}
629
- value={value}
630
- autoFocus
631
- aria-label='Session name'
632
- // The row is a button; a click in here must not select the session.
633
- onClick={(e) => e.stopPropagation()}
634
- onChange={(e) => setValue(e.target.value)}
635
- onBlur={() => (document.hasFocus() ? onCommit(value.trim()) : undefined)}
636
- onKeyDown={(e) => {
637
- if (e.key === 'Enter') onCommit(value.trim())
638
- else if (e.key === 'Escape') onCancel()
483
+ <Button
484
+ variant='ghost'
485
+ size='icon-sm'
486
+ aria-label={label}
487
+ title={title ?? label}
488
+ className='size-5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100'
489
+ onClick={(e) => {
639
490
  e.stopPropagation()
640
- }}
641
- 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'
642
- />
491
+ onClick()
492
+ }}>
493
+ {children}
494
+ </Button>
643
495
  )
644
496
  }
645
497
 
498
+
646
499
  /** A multi-select facet. Empty = no filter, which is why the trigger reads the
647
500
  * facet's name rather than "All": nothing is being excluded. */
648
501
  /**