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.
- package/CHANGELOG.md +13 -0
- package/Readme.md +25 -11
- package/dist/canvas/global.css +123 -40
- package/dist/canvas/index.js +84 -84
- package/dist/json-render/index.js +55 -55
- package/dist/types/client/canvas/ChoiceMenu.d.ts +13 -0
- package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
- package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -0
- package/dist/types/client/state/canvas-store.d.ts +2 -0
- package/dist/types/client/state/intent-bridge.d.ts +1 -1
- package/dist/types/json-render/renderer/index.d.ts +2 -1
- package/dist/types/json-render/server.d.ts +2 -0
- package/dist/types/shared/ax-surface-protocol.d.ts +2 -0
- package/docs/ax-host-adapter-contract.md +13 -2
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +23 -11
- package/skills/pmx-canvas/evals/evals.json +69 -0
- package/skills/pmx-canvas/references/full-reference.md +3 -3
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +76 -4
- package/skills/pmx-canvas-testing/SKILL.md +49 -0
- package/src/client/canvas/CanvasNode.tsx +200 -195
- package/src/client/canvas/CanvasViewport.tsx +6 -0
- package/src/client/canvas/ChoiceMenu.tsx +122 -0
- package/src/client/canvas/CommandBar.tsx +25 -23
- package/src/client/canvas/FeedbackDialog.tsx +10 -8
- package/src/client/canvas/ToolRail.tsx +46 -99
- package/src/client/nodes/McpAppNode.tsx +16 -0
- package/src/client/nodes/use-ax-surface-bridge.ts +4 -2
- package/src/client/nodes/viewer-runtime-state.ts +78 -0
- package/src/client/state/canvas-store.ts +3 -0
- package/src/client/state/intent-bridge.ts +14 -2
- package/src/client/theme/global.css +123 -40
- package/src/json-render/renderer/index.tsx +58 -38
- package/src/json-render/server.ts +8 -1
- package/src/server/server.ts +3 -1
- 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
|
-
|
|
519
|
-
|
|
520
|
-
<
|
|
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
|
|
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="
|
|
678
|
+
<span class="node-title" title={title}>
|
|
535
679
|
{title}
|
|
536
680
|
</span>
|
|
537
681
|
)}
|
|
538
|
-
<
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
<
|
|
590
|
-
</
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
639
|
-
addContextPins(groupChildren);
|
|
721
|
+
expandNode(node.id);
|
|
640
722
|
}}
|
|
723
|
+
title="Expand (focus mode)"
|
|
641
724
|
>
|
|
642
|
-
|
|
725
|
+
<IconExpand size={14} />
|
|
643
726
|
</button>
|
|
644
|
-
|
|
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
|
-
|
|
732
|
+
toggleCollapsed(node.id);
|
|
707
733
|
}}
|
|
708
|
-
title=
|
|
734
|
+
title={node.collapsed ? 'Expand' : 'Collapse'}
|
|
709
735
|
>
|
|
710
|
-
<
|
|
736
|
+
{node.collapsed ? <IconChevronRight size={14} /> : <IconChevronDown size={14} />}
|
|
711
737
|
</button>
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
744
|
+
void removeNodeFromClient(node.id);
|
|
720
745
|
}}
|
|
721
|
-
title="
|
|
746
|
+
title="Close"
|
|
722
747
|
>
|
|
723
|
-
<
|
|
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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
760
|
-
|
|
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
|
-
<
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
110
|
-
)
|
|
111
|
-
}
|
|
112
|
-
|
|
110
|
+
: `${agent.label} · no inbox`,
|
|
111
|
+
};
|
|
112
|
+
}),
|
|
113
|
+
]}
|
|
114
|
+
/>
|
|
113
115
|
)}
|
|
114
116
|
<input
|
|
115
117
|
class="command-bar-input"
|