@workerdeck/ui 0.20.0 → 0.22.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 (43) hide show
  1. package/README.md +3 -2
  2. package/build/{SessionPanel-CnU_IJ3-.d.mts → SessionPanel-13l25ubU.d.mts} +55 -7
  3. package/build/{SessionPanel-CHktI2CF.mjs → SessionPanel-DgwjH4Ve.mjs} +642 -231
  4. package/build/SessionPanel-DgwjH4Ve.mjs.map +1 -0
  5. package/build/index.d.mts +309 -53
  6. package/build/index.mjs +534 -397
  7. package/build/index.mjs.map +1 -1
  8. package/build/scoped.css +3547 -0
  9. package/build/workspace.d.mts +10 -1
  10. package/build/workspace.mjs +4 -2
  11. package/build/workspace.mjs.map +1 -1
  12. package/package.json +15 -7
  13. package/src/components/agent/Composer.tsx +10 -10
  14. package/src/components/agent/ContextDialog.tsx +3 -2
  15. package/src/components/agent/Conversation.tsx +1 -1
  16. package/src/components/agent/McpDialog.tsx +3 -1
  17. package/src/components/agent/Scrubber.tsx +248 -0
  18. package/src/components/agent/SessionBrowser.tsx +113 -293
  19. package/src/components/agent/SessionItem.tsx +538 -0
  20. package/src/components/agent/SessionList.tsx +3 -1
  21. package/src/components/agent/SessionPanel.tsx +112 -21
  22. package/src/components/agent/SessionStatusIcon.tsx +43 -0
  23. package/src/components/agent/SessionSteps.tsx +118 -61
  24. package/src/components/agent/SessionWorkspace.tsx +11 -0
  25. package/src/components/agent/SkillsDialog.tsx +3 -2
  26. package/src/components/agent/StatusBar.tsx +1 -1
  27. package/src/components/agent/Transcript.tsx +147 -73
  28. package/src/components/agent/UsageDialog.tsx +3 -1
  29. package/src/components/agent/scrubber-marks.ts +277 -0
  30. package/src/components/terminal/PermissionPrompt.tsx +3 -0
  31. package/src/components/terminal/QuestionPrompt.tsx +3 -0
  32. package/src/components/terminal/StatusLine.tsx +3 -1
  33. package/src/components/ui/AlertDialog.tsx +23 -20
  34. package/src/components/ui/Dialog.tsx +26 -23
  35. package/src/components/ui/Menu.tsx +20 -17
  36. package/src/components/ui/PortalScope.tsx +27 -0
  37. package/src/components/ui/Select.tsx +19 -16
  38. package/src/components/ui/Tooltip.tsx +13 -10
  39. package/src/index.ts +5 -1
  40. package/src/styles/scoped.entry.css +16 -0
  41. package/src/styles/terminal.css +1 -1
  42. package/src/styles/theme.css +223 -8
  43. package/build/SessionPanel-CHktI2CF.mjs.map +0 -1
@@ -1,19 +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
- Eraser,
8
- Layers,
9
- Moon,
10
- PauseCircle,
11
- Pencil,
12
- Search,
13
- SearchX,
14
- Trash2,
15
- X,
16
- } from 'lucide-react'
3
+ import { Eraser, Layers, Pencil, Search, SearchX, Trash2, X } from 'lucide-react'
17
4
  import {
18
5
  STATE_LABELS,
19
6
  STATE_ORDER,
@@ -22,10 +9,7 @@ import {
22
9
  filterRows,
23
10
  groupRows,
24
11
  hasFacetFilter,
25
- projectLabel,
26
- projectSubpath,
27
12
  projectsOf,
28
- sessionLabel,
29
13
  subsetSummary,
30
14
  } from '@workerdeck/protocol'
31
15
  import type {
@@ -40,13 +24,14 @@ import { Button } from '../ui/Button.tsx'
40
24
  import { Empty } from '../ui/Empty.tsx'
41
25
  import { Input } from '../ui/Input.tsx'
42
26
  import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger, SelectValue } from '../ui/Select.tsx'
43
- import { Spinner } from '../ui/Spinner.tsx'
44
- import { ContextRing } from './ContextRing.tsx'
45
- import { EngineIcon, vendorMarkClass, vendorTextClass } from './EngineIcon.tsx'
46
27
  import { ProjectIcon } from './ProjectIcon.tsx'
47
- import { StepRow, StepToggle, runningSteps, sessionSteps } from './SessionSteps.tsx'
28
+ import { SessionItem } from './SessionItem.tsx'
48
29
  import { cn } from '../../lib/utils.ts'
49
- 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'
50
35
 
51
36
  /**
52
37
  * A sessions list with the affordances a list of thirty needs: search, facets,
@@ -71,6 +56,13 @@ export interface SessionBrowserProps {
71
56
  * phone — makes the scope filter genuinely inert rather than empty. */
72
57
  scope?: WorkspaceScope
73
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
74
66
  onSelect?: (row: SessionRow) => void
75
67
  onDelete?: (row: SessionRow) => void
76
68
  /**
@@ -99,6 +91,11 @@ export interface SessionBrowserProps {
99
91
  * transcript to a `Task` row. Absent, the sub-agent list still expands and a
100
92
  * step just opens its session — see `SessionRowItemProps`. */
101
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
102
99
  /** Rendered when nothing at all exists (as opposed to nothing matching). */
103
100
  emptyState?: React.ReactNode
104
101
  /**
@@ -154,11 +151,13 @@ export function SessionBrowser({
154
151
  onConfigChange,
155
152
  scope,
156
153
  activeId,
154
+ activeSubagentId,
157
155
  onSelect,
158
156
  onDelete,
159
157
  onRename,
160
158
  onClearContext,
161
159
  onSelectSubagent,
160
+ onRevealStep,
162
161
  emptyState,
163
162
  showControls = true,
164
163
  projectIcons,
@@ -299,11 +298,18 @@ export function SessionBrowser({
299
298
  <Empty icon={<Layers />} title='Nothing here' description='No session to show.' />
300
299
  )
301
300
  ) : (
302
- <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'>
303
309
  {groups.map((group) => (
304
310
  <div key={group.key} className='flex flex-col gap-1'>
305
311
  {config.groupBy !== 'none' && group.label ? (
306
- <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'>
307
313
  {/* Only the project facet has a mark of its own, and a group
308
314
  IS one project root, so the first row is a fair source. */}
309
315
  {config.groupBy === 'project' ? (
@@ -322,6 +328,7 @@ export function SessionBrowser({
322
328
  key={`${row.hostId}:${row.info.id}`}
323
329
  row={row}
324
330
  active={row.info.id === activeId}
331
+ activeSubagentId={activeSubagentId}
325
332
  showGateway={gateways.length > 1 && config.groupBy !== 'gateway'}
326
333
  showProject={config.groupBy !== 'project'}
327
334
  projectIcons={projectIcons}
@@ -330,6 +337,7 @@ export function SessionBrowser({
330
337
  onRename={onRename}
331
338
  onClearContext={onClearContext}
332
339
  onSelectSubagent={onSelectSubagent}
340
+ onRevealStep={onRevealStep}
333
341
  />
334
342
  ))}
335
343
  </div>
@@ -354,6 +362,8 @@ function iconSrcOf(
354
362
  interface SessionRowItemProps {
355
363
  row: SessionRow
356
364
  active?: boolean
365
+ /** See `SessionBrowserProps.activeSubagentId`. */
366
+ activeSubagentId?: string
357
367
  showGateway?: boolean
358
368
  /** False when the list is already grouped by project — the header has said the
359
369
  * name, so the row must not spend its metadata line repeating it. What takes
@@ -377,11 +387,14 @@ interface SessionRowItemProps {
377
387
  * feature: without it a step just opens the session, which is all a host with
378
388
  * neither can offer. Only *agent* steps ever call it — see `Step.kind`. */
379
389
  onSelectSubagent?: (row: SessionRow, toolUseId: string) => void
390
+ /** See `SessionBrowserProps.onRevealStep`. */
391
+ onRevealStep?: (row: SessionRow, toolUseId: string) => void
380
392
  }
381
393
 
382
394
  function SessionRowItem({
383
395
  row,
384
396
  active,
397
+ activeSubagentId,
385
398
  showGateway,
386
399
  showProject = true,
387
400
  projectIcons,
@@ -390,292 +403,99 @@ function SessionRowItem({
390
403
  onRename,
391
404
  onClearContext,
392
405
  onSelectSubagent,
406
+ onRevealStep,
393
407
  }: SessionRowItemProps) {
394
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.
395
413
  const [editing, setEditing] = useState(false)
396
- // Expansion is the row's own state and deliberately not persisted: a list that
397
- // reopened yesterday's work on every reload would be showing a settled tail
398
- // nobody asked for.
399
- const [expanded, setExpanded] = useState(false)
400
-
401
- // What it is and what it has spent, in one line — the same set the extension
402
- // shows, joined the same way, so the two lists read as one product.
403
- // protocol's own spelling, so this row, the group header above it and the
404
- // project facet cannot disagree about what a project is called. Under a
405
- // project group it is the sub-path instead — see `showProject` — and a session
406
- // at the root contributes nothing at all rather than a name already on screen.
407
- const project = showProject ? projectLabel(row) : projectSubpath(row)
408
- const projectIcon = showProject ? info.project?.icon : undefined
409
- const engine = info.engine ?? 'claude'
410
- // Everything after the model. The model itself is drawn separately because it
411
- // is the one segment that wears a colour — see `vendorTextClass` — and a
412
- // coloured run inside a joined string would have to be spliced back out.
413
- const details = [
414
- project,
415
- showGateway ? row.hostName : undefined,
416
- info.profile ? `@${info.profile}` : undefined,
417
- formatCost(info.totalCostUsd),
418
- ].filter(Boolean)
419
- const steps = sessionSteps(info, (toolUseId) =>
420
- onSelectSubagent ? onSelectSubagent(row, toolUseId) : onSelect?.(row),
421
- )
422
- // Where the icon goes: immediately before the project's own name, wherever
423
- // that landed in the joined line. Split rather than interleaved as nodes,
424
- // because everything here is one truncating mono run and a flex of pieces
425
- // would each shrink a little and leave several half-words.
426
- const cut = project === undefined ? -1 : details.indexOf(project)
427
414
 
428
415
  return (
429
- <div
430
- data-slot='session-row'
431
- data-active={active || undefined}
432
- // Selection lives on the whole row, not on the two text buttons inside
433
- // it: the age, the unread badge and the state glyph sit outside them, and
434
- // a row where a third of the surface silently does nothing is a row that
435
- // feels broken. The buttons stay — they are what makes the row reachable
436
- // by keyboard — but their activation now reaches this handler by
437
- // bubbling, so there is one code path and no double-fire. Anything that
438
- // is its own action (rename, close, the name editor) stops the event.
439
- onClick={() => !editing && onSelect?.(row)}
440
- className={cn(
441
- 'group flex cursor-pointer flex-col gap-0.5 text-left transition-colors',
442
- rowShapeClass(active === true),
443
- )}>
444
- {/* Line one: how it is doing, then what you scan the list by, then how
445
- long since it moved.
446
-
447
- State leads. It used to sit last, on the argument that the title is
448
- what you read and the state is what you glance at — but a glance wants
449
- a *column*, and a trailing glyph has no fixed x: it lands wherever the
450
- age and the ring leave it. Leading, every row's state stacks into one
451
- scannable strip, and the mark on line two lands under it in the same
452
- gutter. This is the VS Code sidebar's shape, and all three clients are
453
- on it now. */}
454
- <div className='flex items-center gap-1.5'>
455
- <Gutter>
456
- <SessionStatusIcon row={row} />
457
- </Gutter>
458
- {/* The editor replaces the link rather than sitting inside it an
459
- input nested in a button is invalid, and disabling the button to
460
- protect the edit disables the field with it. */}
461
- {editing && onRename ? (
462
- <NameEditor
463
- initial={info.title ?? ''}
464
- onCommit={(title) => {
465
- setEditing(false)
466
- onRename(row, title)
467
- }}
468
- onCancel={() => setEditing(false)}
469
- />
470
- ) : (
471
- <button
472
- type='button'
473
- className={cn(
474
- 'min-w-0 flex-1 truncate text-left text-body-sm outline-none',
475
- active ? 'font-medium text-fg-1' : 'text-fg-2',
476
- )}>
477
- {sessionLabel(info)}
478
- </button>
479
- )}
480
- {row.unseen > 0 ? (
481
- // Transcript rows since this session was last on screen — the same
482
- // unit the VS Code badge counts, because turns undercount badly.
483
- <span
484
- title={`${row.unseen} new`}
485
- className='shrink-0 rounded-full bg-accent px-1.5 text-label text-accent-fg'>
486
- {row.unseen}
487
- </span>
488
- ) : null}
489
- <span className='shrink-0 text-label text-fg-4'>
490
- {formatRelativeTime(info.lastActivityAt ?? info.createdAt)}
491
- </span>
492
- <ContextRing usage={info.contextUsage} />
493
- </div>
494
-
495
- {/* Line two: what it is — the engine's mark and its model in the vendor's
496
- own colour, then where it runs — with the actions at the far right,
497
- away from the title you are actually reading. */}
498
- <div className='flex items-center gap-1 text-label text-fg-4'>
499
- <Gutter>
500
- {/* The colour goes on the icon, not on this cell: the svg ships its
501
- own `text-fg-3` and only a class on the element itself merges over
502
- it. */}
503
- <EngineIcon
504
- engine={engine}
505
- model={info.model}
506
- className={vendorMarkClass(engine, info.model)}
507
- />
508
- </Gutter>
509
- <button
510
- type='button'
511
- tabIndex={-1}
512
- className='min-w-0 flex-1 truncate text-left font-mono outline-none'>
513
- <span className={vendorTextClass(engine, info.model)}>
514
- {/* The model id as a person says it — `claude-opus-5[1m]` is a wire
515
- value, and a list line has no room for a context-window suffix. */}
516
- {friendlyModel(info.model)}
517
- </span>
518
- {details.length > 0 ? ' · ' : ''}
519
- {cut < 0 ? (
520
- details.join(' · ')
521
- ) : (
522
- <>
523
- {details.slice(0, cut).map((part) => `${part} · `)}
524
- <ProjectIcon
525
- icon={projectIcon}
526
- src={iconSrcOf(row, projectIcons)}
527
- name={project}
528
- /* Nudged onto the text baseline: a 12px glyph box against an
529
- 11px line sits a hair proud without it. */
530
- className='mr-1 align-[-0.2em]'
531
- />
532
- {details.slice(cut).join(' · ')}
533
- </>
534
- )}
535
- </button>
536
- {steps.length > 0 ? (
537
- <StepToggle
538
- expanded={expanded}
539
- running={runningSteps(steps)}
540
- total={steps.length}
541
- noun={steps[0]!.noun}
542
- onToggle={() => setExpanded((v) => !v)}
543
- />
544
- ) : null}
545
- {onRename && !editing ? (
546
- <Button
547
- variant='ghost'
548
- size='icon-sm'
549
- aria-label='Rename session'
550
- className='size-5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100'
551
- onClick={(e) => {
552
- e.stopPropagation()
553
- setEditing(true)
554
- }}>
555
- <Pencil className='size-3 text-fg-3' />
556
- </Button>
557
- ) : null}
558
- {onClearContext && info.capabilities?.clearContext ? (
559
- <Button
560
- variant='ghost'
561
- size='icon-sm'
562
- aria-label='Clear context'
563
- title='Clear the conversation — the session keeps running and the old conversation stays resumable'
564
- className='size-5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100'
565
- onClick={(e) => {
566
- e.stopPropagation()
567
- onClearContext(row)
568
- }}>
569
- <Eraser className='size-3 text-fg-3' />
570
- </Button>
571
- ) : null}
572
- {onDelete ? (
573
- <Button
574
- variant='ghost'
575
- size='icon-sm'
576
- aria-label='Close session'
577
- className='size-5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100'
578
- onClick={(e) => {
579
- e.stopPropagation()
580
- onDelete(row)
581
- }}>
582
- <Trash2 className='size-3 text-fg-3' />
583
- </Button>
584
- ) : null}
585
- </div>
586
- {expanded ? steps.map((step) => <StepRow key={step.key} step={step} onSelect={step.onSelect} />) : null}
587
- </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
+ />
588
458
  )
589
459
  }
590
460
 
591
461
  /**
592
- * The one 14px cell both lines hang their glyph in.
593
- *
594
- * A shared column rather than a leading character: the state and the engine mark
595
- * are different widths and a bare glyph would let the two text columns start at
596
- * different x. Centring inside a fixed cell is what makes them agree —
597
- * `packages/ui`'s terminal-gutter argument at row scale.
598
- */
599
- function Gutter({ children }: { children: ReactNode }) {
600
- return <span className='flex w-3.5 shrink-0 items-center justify-center'>{children}</span>
601
- }
602
-
603
- /**
604
- * State as one glyph — a ringing bell when it wants a human, a spinner while it
605
- * works, a moon when it is only sleeping. Replaces the text badge: in a sidebar
606
- * the word costs more room than it earns, and the states that matter are the two
607
- * you can recognise without reading.
462
+ * One of the dashboard's hover actions.
608
463
  *
609
- * **It reads `row.state`, not `info.status`, and that distinction is the whole
610
- * point of the row model.** `sessionState` already folds in the arm this glyph
611
- * cannot see for itself: a *background* sub-agent outlives its turn by design, so
612
- * the turn ends, `status` comes to rest at `idle`, and the agent keeps working.
613
- * Reading the raw status drew a **moon on a row filed under the "Working"
614
- * header** the list contradicting itself on one line, which is exactly what a
615
- * derived view model exists to prevent. The value was in scope and unread.
616
- *
617
- * The terminal statuses still come off `info.status`, because `ended` collapses
618
- * `failed` and `closed` into one bucket and those are worth telling apart here.
619
- */
620
- export function SessionStatusIcon({ row }: { row: SessionRow }) {
621
- const { info } = row
622
- if (row.state === 'attention') {
623
- return <BellRing className='size-3.5 shrink-0 animate-pulse text-warning' />
624
- }
625
- if (row.state === 'working') {
626
- return <Spinner className='size-3.5 shrink-0 text-info' />
627
- }
628
- switch (info.status) {
629
- case 'failed':
630
- return <CircleAlert className='size-3.5 shrink-0 text-danger' />
631
- case 'closed':
632
- return <CircleSlash className='size-3.5 shrink-0 text-fg-4' />
633
- case 'parked':
634
- return <PauseCircle className='size-3.5 shrink-0 text-fg-3' />
635
- default:
636
- return <Moon className='size-3.5 shrink-0 text-fg-4' />
637
- }
638
- }
639
-
640
- /**
641
- * Inline rename. Enter commits, Escape cancels, blur commits — but only a blur
642
- * that is still inside this document: switching windows must not silently commit,
643
- * nor kill the editor in the frame it opened.
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.
644
470
  */
645
- function NameEditor({
646
- initial,
647
- onCommit,
648
- onCancel,
471
+ function RowAction({
472
+ label,
473
+ title,
474
+ onClick,
475
+ children,
649
476
  }: {
650
- initial: string
651
- onCommit: (title: string) => void
652
- onCancel: () => void
477
+ label: string
478
+ title?: string
479
+ onClick: () => void
480
+ children: ReactNode
653
481
  }) {
654
- const [value, setValue] = useState(initial)
655
- const ref = useRef<HTMLInputElement>(null)
656
- useEffect(() => {
657
- ref.current?.select()
658
- }, [])
659
482
  return (
660
- <input
661
- ref={ref}
662
- value={value}
663
- autoFocus
664
- aria-label='Session name'
665
- // The row is a button; a click in here must not select the session.
666
- onClick={(e) => e.stopPropagation()}
667
- onChange={(e) => setValue(e.target.value)}
668
- onBlur={() => (document.hasFocus() ? onCommit(value.trim()) : undefined)}
669
- onKeyDown={(e) => {
670
- if (e.key === 'Enter') onCommit(value.trim())
671
- 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) => {
672
490
  e.stopPropagation()
673
- }}
674
- 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'
675
- />
491
+ onClick()
492
+ }}>
493
+ {children}
494
+ </Button>
676
495
  )
677
496
  }
678
497
 
498
+
679
499
  /** A multi-select facet. Empty = no filter, which is why the trigger reads the
680
500
  * facet's name rather than "All": nothing is being excluded. */
681
501
  /**