pmx-canvas 0.7.2 → 0.7.3

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 (36) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/Readme.md +25 -11
  3. package/dist/canvas/global.css +123 -40
  4. package/dist/canvas/index.js +84 -84
  5. package/dist/json-render/index.js +55 -55
  6. package/dist/types/client/canvas/ChoiceMenu.d.ts +13 -0
  7. package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
  8. package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -0
  9. package/dist/types/client/state/canvas-store.d.ts +2 -0
  10. package/dist/types/client/state/intent-bridge.d.ts +1 -1
  11. package/dist/types/json-render/renderer/index.d.ts +2 -1
  12. package/dist/types/json-render/server.d.ts +2 -0
  13. package/dist/types/shared/ax-surface-protocol.d.ts +2 -0
  14. package/docs/ax-host-adapter-contract.md +13 -2
  15. package/package.json +1 -1
  16. package/skills/pmx-canvas/SKILL.md +23 -11
  17. package/skills/pmx-canvas/evals/evals.json +69 -0
  18. package/skills/pmx-canvas/references/full-reference.md +3 -3
  19. package/skills/pmx-canvas/references/installing-pmx-canvas.md +76 -4
  20. package/skills/pmx-canvas-testing/SKILL.md +49 -0
  21. package/src/client/canvas/CanvasNode.tsx +200 -195
  22. package/src/client/canvas/CanvasViewport.tsx +6 -0
  23. package/src/client/canvas/ChoiceMenu.tsx +122 -0
  24. package/src/client/canvas/CommandBar.tsx +25 -23
  25. package/src/client/canvas/FeedbackDialog.tsx +10 -8
  26. package/src/client/canvas/ToolRail.tsx +46 -99
  27. package/src/client/nodes/McpAppNode.tsx +16 -0
  28. package/src/client/nodes/use-ax-surface-bridge.ts +4 -2
  29. package/src/client/nodes/viewer-runtime-state.ts +78 -0
  30. package/src/client/state/canvas-store.ts +3 -0
  31. package/src/client/state/intent-bridge.ts +14 -2
  32. package/src/client/theme/global.css +123 -40
  33. package/src/json-render/renderer/index.tsx +58 -38
  34. package/src/json-render/server.ts +8 -1
  35. package/src/server/server.ts +3 -1
  36. package/src/shared/ax-surface-protocol.ts +3 -0
@@ -414,6 +414,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
414
414
  height: node.collapsed ? 'auto' : `${node.size.height}px`,
415
415
  zIndex: node.zIndex,
416
416
  '--node-chrome-scale': chromeScale.toFixed(3),
417
+ '--node-port-scale': (1 / viewportScale).toFixed(3),
417
418
  ...(groupColor ? { '--group-color': groupColor } : {}),
418
419
  };
419
420
  // Shimmer while an attached agent is mutating this node (rail-chrome-v2
@@ -515,13 +516,156 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
515
516
  }}
516
517
  onDblClick={handleDblClick}
517
518
  >
518
- {isGroup && (
519
- <div class="node-titlebar group-edge-row" onPointerDown={handleTitlePointerDown}>
520
- <span class="group-name-pill">
519
+ <div class="node-content">
520
+ {isGroup && (
521
+ <div class="node-titlebar group-edge-row" onPointerDown={handleTitlePointerDown}>
522
+ <span class="group-name-pill">
523
+ {renaming ? (
524
+ <input
525
+ ref={renameRef}
526
+ class="node-title-input group-title-input"
527
+ value={title}
528
+ onBlur={(e) => commitRename((e.target as HTMLInputElement).value)}
529
+ onKeyDown={(e) => {
530
+ if (e.key === 'Enter') commitRename((e.target as HTMLInputElement).value);
531
+ if (e.key === 'Escape') setRenaming(false);
532
+ }}
533
+ onClick={(e) => e.stopPropagation()}
534
+ />
535
+ ) : (
536
+ <span class="group-name" title={title}>
537
+ {title}
538
+ </span>
539
+ )}
540
+ <span class="group-count">{groupChildren.length}</span>
541
+ </span>
542
+ <span class="group-edge-spacer" />
543
+ <span class="group-actions" role="toolbar" aria-label={`${title} actions`}>
544
+ <button
545
+ type="button"
546
+ class="group-action"
547
+ title="Auto-arrange children"
548
+ aria-label="Auto-arrange children"
549
+ disabled={groupChildren.length === 0}
550
+ onClick={(e) => {
551
+ e.stopPropagation();
552
+ void addToGroupFromClient(node.id, groupChildren, 'grid');
553
+ }}
554
+ >
555
+ <svg
556
+ width="11"
557
+ height="11"
558
+ viewBox="0 0 16 16"
559
+ fill="none"
560
+ stroke="currentColor"
561
+ stroke-width="1.5"
562
+ aria-hidden="true"
563
+ >
564
+ <rect x="1.5" y="1.5" width="5" height="5" rx="1" />
565
+ <rect x="9.5" y="1.5" width="5" height="5" rx="1" />
566
+ <rect x="1.5" y="9.5" width="5" height="5" rx="1" />
567
+ <rect x="9.5" y="9.5" width="5" height="5" rx="1" />
568
+ </svg>
569
+ </button>
570
+ <button
571
+ type="button"
572
+ class="group-action"
573
+ title="Collapse group"
574
+ aria-label="Collapse group"
575
+ onClick={(e) => {
576
+ e.stopPropagation();
577
+ toggleCollapsed(node.id);
578
+ }}
579
+ >
580
+ <svg
581
+ width="11"
582
+ height="11"
583
+ viewBox="0 0 16 16"
584
+ fill="none"
585
+ stroke="currentColor"
586
+ stroke-width="1.8"
587
+ stroke-linecap="round"
588
+ stroke-linejoin="round"
589
+ aria-hidden="true"
590
+ >
591
+ <path d="M3 10 L8 5 L13 10" />
592
+ </svg>
593
+ </button>
594
+ <button
595
+ type="button"
596
+ class="group-action"
597
+ title="Rename · Ungroup · Pin all to context"
598
+ aria-label="Group menu"
599
+ aria-expanded={groupMenuOpen}
600
+ onClick={(e) => {
601
+ e.stopPropagation();
602
+ setGroupMenuOpen((open) => !open);
603
+ }}
604
+ >
605
+ <IconMore size={14} />
606
+ </button>
607
+ {groupMenuOpen && (
608
+ <div class="group-menu" role="menu" onPointerDown={(e) => e.stopPropagation()}>
609
+ <button
610
+ type="button"
611
+ role="menuitem"
612
+ onClick={(e) => {
613
+ e.stopPropagation();
614
+ setGroupMenuOpen(false);
615
+ setRenaming(true);
616
+ requestAnimationFrame(() => renameRef.current?.focus());
617
+ }}
618
+ >
619
+ Rename
620
+ </button>
621
+ <button
622
+ type="button"
623
+ role="menuitem"
624
+ onClick={(e) => {
625
+ e.stopPropagation();
626
+ setGroupMenuOpen(false);
627
+ // Dissolve — the same op an agent's ungroup runs.
628
+ removeNode(node.id);
629
+ void ungroupFromClient(node.id);
630
+ }}
631
+ >
632
+ Ungroup
633
+ </button>
634
+ <button
635
+ type="button"
636
+ role="menuitem"
637
+ disabled={groupChildren.length === 0}
638
+ onClick={(e) => {
639
+ e.stopPropagation();
640
+ setGroupMenuOpen(false);
641
+ addContextPins(groupChildren);
642
+ }}
643
+ >
644
+ Pin all to context
645
+ </button>
646
+ </div>
647
+ )}
648
+ </span>
649
+ </div>
650
+ )}
651
+ {!isGroup && (
652
+ <div class="node-titlebar" onPointerDown={handleTitlePointerDown}>
653
+ <span class="node-type-icon" aria-hidden="true">
654
+ {(() => {
655
+ const NodeIcon = getNodeIcon(node.type);
656
+ return <NodeIcon size={Math.round(14 * chromeScale)} />;
657
+ })()}
658
+ </span>
659
+ {/* The kind-colored icon says what type this is (rail-chrome-v2 card
660
+ shell: icon · title · controls, no type badge). Only the AX status
661
+ chip joins it, and only when there is a status to show. */}
662
+ {typeof node.data.axWorkStatus === 'string' && (
663
+ <span class={`node-ax-status node-ax-status-${node.data.axWorkStatus}`}>{node.data.axWorkStatus}</span>
664
+ )}
521
665
  {renaming ? (
522
666
  <input
523
667
  ref={renameRef}
524
- class="node-title-input group-title-input"
668
+ class="node-title-input"
525
669
  value={title}
526
670
  onBlur={(e) => commitRename((e.target as HTMLInputElement).value)}
527
671
  onKeyDown={(e) => {
@@ -531,234 +675,93 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
531
675
  onClick={(e) => e.stopPropagation()}
532
676
  />
533
677
  ) : (
534
- <span class="group-name" title={title}>
678
+ <span class="node-title" title={title}>
535
679
  {title}
536
680
  </span>
537
681
  )}
538
- <span class="group-count">{groupChildren.length}</span>
539
- </span>
540
- <span class="group-edge-spacer" />
541
- <span class="group-actions" role="toolbar" aria-label={`${title} actions`}>
542
- <button
543
- type="button"
544
- class="group-action"
545
- title="Auto-arrange children"
546
- aria-label="Auto-arrange children"
547
- disabled={groupChildren.length === 0}
548
- onClick={(e) => {
549
- e.stopPropagation();
550
- void addToGroupFromClient(node.id, groupChildren, 'grid');
551
- }}
552
- >
553
- <svg
554
- width="11"
555
- height="11"
556
- viewBox="0 0 16 16"
557
- fill="none"
558
- stroke="currentColor"
559
- stroke-width="1.5"
560
- aria-hidden="true"
561
- >
562
- <rect x="1.5" y="1.5" width="5" height="5" rx="1" />
563
- <rect x="9.5" y="1.5" width="5" height="5" rx="1" />
564
- <rect x="1.5" y="9.5" width="5" height="5" rx="1" />
565
- <rect x="9.5" y="9.5" width="5" height="5" rx="1" />
566
- </svg>
567
- </button>
568
- <button
569
- type="button"
570
- class="group-action"
571
- title="Collapse group"
572
- aria-label="Collapse group"
573
- onClick={(e) => {
574
- e.stopPropagation();
575
- toggleCollapsed(node.id);
576
- }}
577
- >
578
- <svg
579
- width="11"
580
- height="11"
581
- viewBox="0 0 16 16"
582
- fill="none"
583
- stroke="currentColor"
584
- stroke-width="1.8"
585
- stroke-linecap="round"
586
- stroke-linejoin="round"
587
- aria-hidden="true"
682
+ <div class="node-controls">
683
+ {isPinned && (
684
+ <span class="pin-indicator" title="Pinned">
685
+ ⊙
686
+ </span>
687
+ )}
688
+ <button
689
+ type="button"
690
+ class={`ctx-pin-btn${isContextPinned ? ' ctx-pin-active' : ''}`}
691
+ onClick={(e) => {
692
+ e.stopPropagation();
693
+ toggleContextPin(node.id);
694
+ }}
695
+ title={isContextPinned ? 'Remove from context' : 'Add to context'}
588
696
  >
589
- <path d="M3 10 L8 5 L13 10" />
590
- </svg>
591
- </button>
592
- <button
593
- type="button"
594
- class="group-action"
595
- title="Rename · Ungroup · Pin all to context"
596
- aria-label="Group menu"
597
- aria-expanded={groupMenuOpen}
598
- onClick={(e) => {
599
- e.stopPropagation();
600
- setGroupMenuOpen((open) => !open);
601
- }}
602
- >
603
- <IconMore size={14} />
604
- </button>
605
- {groupMenuOpen && (
606
- <div class="group-menu" role="menu" onPointerDown={(e) => e.stopPropagation()}>
607
- <button
608
- type="button"
609
- role="menuitem"
610
- onClick={(e) => {
611
- e.stopPropagation();
612
- setGroupMenuOpen(false);
613
- setRenaming(true);
614
- requestAnimationFrame(() => renameRef.current?.focus());
615
- }}
616
- >
617
- Rename
618
- </button>
697
+ <IconSparkle size={14} />
698
+ </button>
699
+ {/* Open as site — full-page standalone view of this node's surface,
700
+ served from /api/canvas/surface/:id (same document as the canvas
701
+ iframe). Opens via the system browser so embedded hosts do not trap
702
+ it in their own webview. */}
703
+ {canOpenAsSite(node) && (
619
704
  <button
620
705
  type="button"
621
- role="menuitem"
622
706
  onClick={(e) => {
623
707
  e.stopPropagation();
624
- setGroupMenuOpen(false);
625
- // Dissolve — the same op an agent's ungroup runs.
626
- removeNode(node.id);
627
- void ungroupFromClient(node.id);
708
+ void openNodeAsSite(node);
628
709
  }}
710
+ title="Open as site"
629
711
  >
630
- Ungroup
712
+ <IconExternalLink size={14} />
631
713
  </button>
714
+ )}
715
+ {/* Expand — opens node as full-viewport overlay for focused work */}
716
+ {EXPANDABLE_TYPES.has(node.type) && (
632
717
  <button
633
718
  type="button"
634
- role="menuitem"
635
- disabled={groupChildren.length === 0}
636
719
  onClick={(e) => {
637
720
  e.stopPropagation();
638
- setGroupMenuOpen(false);
639
- addContextPins(groupChildren);
721
+ expandNode(node.id);
640
722
  }}
723
+ title="Expand (focus mode)"
641
724
  >
642
- Pin all to context
725
+ <IconExpand size={14} />
643
726
  </button>
644
- </div>
645
- )}
646
- </span>
647
- </div>
648
- )}
649
- {!isGroup && (
650
- <div class="node-titlebar" onPointerDown={handleTitlePointerDown}>
651
- <span class="node-type-icon" aria-hidden="true">
652
- {(() => {
653
- const NodeIcon = getNodeIcon(node.type);
654
- return <NodeIcon size={Math.round(14 * chromeScale)} />;
655
- })()}
656
- </span>
657
- {/* The kind-colored icon says what type this is (rail-chrome-v2 card
658
- shell: icon · title · controls, no type badge). Only the AX status
659
- chip joins it, and only when there is a status to show. */}
660
- {typeof node.data.axWorkStatus === 'string' && (
661
- <span class={`node-ax-status node-ax-status-${node.data.axWorkStatus}`}>{node.data.axWorkStatus}</span>
662
- )}
663
- {renaming ? (
664
- <input
665
- ref={renameRef}
666
- class="node-title-input"
667
- value={title}
668
- onBlur={(e) => commitRename((e.target as HTMLInputElement).value)}
669
- onKeyDown={(e) => {
670
- if (e.key === 'Enter') commitRename((e.target as HTMLInputElement).value);
671
- if (e.key === 'Escape') setRenaming(false);
672
- }}
673
- onClick={(e) => e.stopPropagation()}
674
- />
675
- ) : (
676
- <span class="node-title" title={title}>
677
- {title}
678
- </span>
679
- )}
680
- <div class="node-controls">
681
- {isPinned && (
682
- <span class="pin-indicator" title="Pinned">
683
- ⊙
684
- </span>
685
- )}
686
- <button
687
- type="button"
688
- class={`ctx-pin-btn${isContextPinned ? ' ctx-pin-active' : ''}`}
689
- onClick={(e) => {
690
- e.stopPropagation();
691
- toggleContextPin(node.id);
692
- }}
693
- title={isContextPinned ? 'Remove from context' : 'Add to context'}
694
- >
695
- <IconSparkle size={14} />
696
- </button>
697
- {/* Open as site — full-page standalone view of this node's surface,
698
- served from /api/canvas/surface/:id (same document as the canvas
699
- iframe). Opens via the system browser so embedded hosts do not trap
700
- it in their own webview. */}
701
- {canOpenAsSite(node) && (
727
+ )}
702
728
  <button
703
729
  type="button"
704
730
  onClick={(e) => {
705
731
  e.stopPropagation();
706
- void openNodeAsSite(node);
732
+ toggleCollapsed(node.id);
707
733
  }}
708
- title="Open as site"
734
+ title={node.collapsed ? 'Expand' : 'Collapse'}
709
735
  >
710
- <IconExternalLink size={14} />
736
+ {node.collapsed ? <IconChevronRight size={14} /> : <IconChevronDown size={14} />}
711
737
  </button>
712
- )}
713
- {/* Expand — opens node as full-viewport overlay for focused work */}
714
- {EXPANDABLE_TYPES.has(node.type) && (
738
+ {/* Report #64: status nodes get the same remove control as every other
739
+ node type (backend removal + undo/history handle status uniformly). */}
715
740
  <button
716
741
  type="button"
717
742
  onClick={(e) => {
718
743
  e.stopPropagation();
719
- expandNode(node.id);
744
+ void removeNodeFromClient(node.id);
720
745
  }}
721
- title="Expand (focus mode)"
746
+ title="Close"
722
747
  >
723
- <IconExpand size={14} />
748
+ <IconClose size={14} />
724
749
  </button>
725
- )}
726
- <button
727
- type="button"
728
- onClick={(e) => {
729
- e.stopPropagation();
730
- toggleCollapsed(node.id);
731
- }}
732
- title={node.collapsed ? 'Expand' : 'Collapse'}
733
- >
734
- {node.collapsed ? <IconChevronRight size={14} /> : <IconChevronDown size={14} />}
735
- </button>
736
- {/* Report #64: status nodes get the same remove control as every other
737
- node type (backend removal + undo/history handle status uniformly). */}
738
- <button
739
- type="button"
740
- onClick={(e) => {
741
- e.stopPropagation();
742
- void removeNodeFromClient(node.id);
743
- }}
744
- title="Close"
745
- >
746
- <IconClose size={14} />
747
- </button>
750
+ </div>
748
751
  </div>
749
- </div>
750
- )}
751
- {!node.collapsed && (
752
- <div ref={bodyRef} class="node-body">
753
- {children}
754
- {/* AX flow step controls — rendered here rather than in a node renderer
752
+ )}
753
+ {!node.collapsed && (
754
+ <div ref={bodyRef} class="node-body">
755
+ {children}
756
+ {/* AX flow step controls — rendered here rather than in a node renderer
755
757
  because a materialized step can be ANY node type, and because the
756
758
  body is what auto-fit measures (a footer outside it would be clipped
757
759
  on a content-fitted node). Renders nothing unless the node carries a
758
760
  `data.axStep` stamp. */}
759
- <AxStepControls node={node} />
760
- </div>
761
- )}
761
+ <AxStepControls node={node} />
762
+ </div>
763
+ )}
764
+ </div>
762
765
  {!node.collapsed && (
763
766
  <div class="node-resize-handle" onPointerDown={(e) => startResize(e, node.size.width, node.size.height)} />
764
767
  )}
@@ -781,8 +784,10 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
781
784
  key={side}
782
785
  class={`node-port node-port-${side}`}
783
786
  onPointerDown={(e) => {
787
+ if (e.button !== 0) return;
784
788
  e.stopPropagation();
785
789
  e.preventDefault();
790
+ e.currentTarget.setPointerCapture(e.pointerId);
786
791
  const cx = node.position.x + node.size.width / 2;
787
792
  const cy = node.position.y + node.size.height / 2;
788
793
  const hw = node.size.width / 2;
@@ -652,12 +652,18 @@ export function CanvasViewport({
652
652
  }
653
653
  }
654
654
 
655
+ function handleCancel() {
656
+ draggingEdge.value = null;
657
+ }
658
+
655
659
  document.addEventListener('pointermove', handleMove);
656
660
  document.addEventListener('pointerup', handleUp);
661
+ document.addEventListener('pointercancel', handleCancel);
657
662
  document.addEventListener('keydown', handleKey);
658
663
  return () => {
659
664
  document.removeEventListener('pointermove', handleMove);
660
665
  document.removeEventListener('pointerup', handleUp);
666
+ document.removeEventListener('pointercancel', handleCancel);
661
667
  document.removeEventListener('keydown', handleKey);
662
668
  };
663
669
  }, [containerRef]);
@@ -0,0 +1,122 @@
1
+ import { useLayoutEffect, useRef, useState } from 'preact/hooks';
2
+ import { IconChevronDown } from '../icons';
3
+
4
+ /** Themed single-choice menu shared by the feedback form and steering composer. */
5
+ export function ChoiceMenu({
6
+ label,
7
+ value,
8
+ options,
9
+ onChange,
10
+ className = '',
11
+ above = false,
12
+ }: {
13
+ label: string;
14
+ value: string;
15
+ options: Array<{ value: string; label: string; disabled?: boolean }>;
16
+ onChange: (value: string) => void;
17
+ className?: string;
18
+ above?: boolean;
19
+ }) {
20
+ const [open, setOpen] = useState(false);
21
+ const root = useRef<HTMLDivElement>(null);
22
+ const trigger = useRef<HTMLButtonElement>(null);
23
+ const menu = useRef<HTMLDivElement>(null);
24
+ const close = () => {
25
+ setOpen(false);
26
+ trigger.current?.focus();
27
+ };
28
+
29
+ useLayoutEffect(() => {
30
+ if (!open) return;
31
+ const selected = menu.current?.querySelector<HTMLButtonElement>('[aria-checked="true"]:not(:disabled)');
32
+ (selected ?? menu.current?.querySelector<HTMLButtonElement>('button:not(:disabled)'))?.focus();
33
+ const outside = (event: PointerEvent) => {
34
+ if (event.target instanceof Node && !root.current?.contains(event.target)) setOpen(false);
35
+ };
36
+ document.addEventListener('pointerdown', outside);
37
+ return () => document.removeEventListener('pointerdown', outside);
38
+ }, [open]);
39
+
40
+ return (
41
+ <div
42
+ ref={root}
43
+ class={`choice-menu ${className}`}
44
+ onBlur={(e) => {
45
+ if (e.relatedTarget instanceof Node && !root.current?.contains(e.relatedTarget)) setOpen(false);
46
+ }}
47
+ onKeyDown={(e) => {
48
+ // Menu typing/navigation must not trigger canvas tool shortcuts.
49
+ // Tab still reaches modal focus traps; closed Escape reaches the dialog.
50
+ if (e.key !== 'Tab' && (e.key !== 'Escape' || open)) e.stopPropagation();
51
+ if (e.key === 'Escape' && open) {
52
+ e.preventDefault();
53
+ e.stopPropagation();
54
+ close();
55
+ }
56
+ if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) {
57
+ e.preventDefault();
58
+ e.stopPropagation();
59
+ if (!open) {
60
+ setOpen(true);
61
+ return;
62
+ }
63
+ const items = [...(menu.current?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') ?? [])];
64
+ if (!items.length) return;
65
+ const index = items.findIndex((item) => item === document.activeElement);
66
+ const next =
67
+ e.key === 'Home'
68
+ ? 0
69
+ : e.key === 'End'
70
+ ? items.length - 1
71
+ : (index + (e.key === 'ArrowUp' ? -1 : 1) + items.length) % items.length;
72
+ items[next]?.focus();
73
+ }
74
+ }}
75
+ >
76
+ <button
77
+ ref={trigger}
78
+ type="button"
79
+ class="choice-menu-trigger"
80
+ aria-label={label}
81
+ aria-haspopup="menu"
82
+ aria-expanded={open}
83
+ onClick={() => setOpen(!open)}
84
+ >
85
+ <span>{options.find((option) => option.value === value)?.label ?? label}</span>
86
+ <IconChevronDown size={14} />
87
+ </button>
88
+ {open && (
89
+ <div
90
+ ref={menu}
91
+ role="menu"
92
+ aria-label={label}
93
+ class={`toolbar-menu choice-menu-options${above ? ' opens-above' : ''}`}
94
+ >
95
+ {options.map((option) => (
96
+ <button
97
+ key={option.value}
98
+ type="button"
99
+ role="menuitemradio"
100
+ class={`toolbar-menu-item${option.value === value ? ' active' : ''}`}
101
+ aria-checked={option.value === value}
102
+ disabled={option.disabled}
103
+ tabIndex={-1}
104
+ data-value={option.value}
105
+ onClick={() => {
106
+ onChange(option.value);
107
+ close();
108
+ }}
109
+ >
110
+ <span>{option.label}</span>
111
+ {option.value === value && (
112
+ <span class="toolbar-menu-check" aria-hidden="true">
113
+ ✓
114
+ </span>
115
+ )}
116
+ </button>
117
+ ))}
118
+ </div>
119
+ )}
120
+ </div>
121
+ );
122
+ }
@@ -4,6 +4,7 @@ import { contextPinnedNodeIds, nodes, toggleContextPin } from '../state/canvas-s
4
4
  import { steerableAgents } from '../state/presence-store';
5
5
  import { sendSteering } from '../state/session-store';
6
6
  import { modChord } from '../utils/platform';
7
+ import { ChoiceMenu } from './ChoiceMenu';
7
8
 
8
9
  /**
9
10
  * Command bar (rail-chrome-v2 phase 5): the human's steering surface while a
@@ -83,33 +84,34 @@ export function CommandBar() {
83
84
  <div class="command-bar-composer">
84
85
  <IconSteer size={15} class="command-bar-icon" />
85
86
  {showPicker && (
86
- <select
87
- class="command-bar-target"
88
- aria-label="Steer which agent"
89
- title="Connected agents — steering reaches all of them unless you address one"
87
+ <ChoiceMenu
88
+ className="command-bar-target"
89
+ label="Steer which agent"
90
90
  value={effectiveTarget ?? ''}
91
- onChange={(e) => setTarget((e.target as HTMLSelectElement).value || null)}
92
- >
93
- <option value="">All agents</option>
94
- {agents.map((agent) => {
95
- // Pump health at the point of choice: unclaimed steers already
96
- // sitting in this agent's queue (a growing number means its loop
97
- // is not polling), and proof-of-polling when its consumer key
98
- // claimed recently — attached alone doesn't mean anyone reads
99
- // the inbox.
100
- const queued = agent.pendingSteers && agent.pendingSteers > 0 ? ` · ${agent.pendingSteers} queued` : '';
101
- const polling = agent.polling ? ' · polling' : '';
102
- return (
103
- <option key={agent.value} value={agent.value} disabled={!agent.steerable}>
104
- {agent.attached
91
+ above
92
+ onChange={(value) => setTarget(value || null)}
93
+ options={[
94
+ { value: '', label: 'All agents' },
95
+ ...agents.map((agent) => {
96
+ // Pump health at the point of choice: unclaimed steers already
97
+ // sitting in this agent's queue (a growing number means its loop
98
+ // is not polling), and proof-of-polling when its consumer key
99
+ // claimed recently — attached alone doesn't mean anyone reads
100
+ // the inbox.
101
+ const queued = agent.pendingSteers && agent.pendingSteers > 0 ? ` · ${agent.pendingSteers} queued` : '';
102
+ const polling = agent.polling ? ' · polling' : '';
103
+ return {
104
+ value: agent.value,
105
+ disabled: !agent.steerable,
106
+ label: agent.attached
105
107
  ? `${agent.label}${polling}${queued}`
106
108
  : agent.steerable
107
109
  ? `${agent.label} · writer${polling}${queued}`
108
- : `${agent.label} · no inbox`}
109
- </option>
110
- );
111
- })}
112
- </select>
110
+ : `${agent.label} · no inbox`,
111
+ };
112
+ }),
113
+ ]}
114
+ />
113
115
  )}
114
116
  <input
115
117
  class="command-bar-input"