pmx-canvas 0.4.8 → 0.5.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.
- package/.github/extensions/pmx-canvas/extension.mjs +154 -5
- package/.github/extensions/pmx-canvas/steering-delivery.mjs +79 -0
- package/CHANGELOG.md +139 -0
- package/Readme.md +72 -23
- package/dist/canvas/global.css +3069 -796
- package/dist/canvas/index.js +104 -104
- package/dist/json-render/index.css +1 -1
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/pump.d.ts +9 -0
- package/dist/types/client/App.d.ts +1 -1
- package/dist/types/client/canvas/AgentPresenceLayer.d.ts +11 -0
- package/dist/types/client/canvas/BarHint.d.ts +22 -0
- package/dist/types/client/canvas/CanvasNode.d.ts +5 -0
- package/dist/types/client/canvas/CanvasViewport.d.ts +4 -4
- package/dist/types/client/canvas/CommandBar.d.ts +10 -0
- package/dist/types/client/canvas/ConnectionBanner.d.ts +13 -0
- package/dist/types/client/canvas/ContextMenu.d.ts +6 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +2 -1
- package/dist/types/client/canvas/EmptyState.d.ts +11 -0
- package/dist/types/client/canvas/ExternalWriters.d.ts +5 -0
- package/dist/types/client/canvas/HumanPresenceLayer.d.ts +8 -0
- package/dist/types/client/canvas/Minimap.d.ts +12 -1
- package/dist/types/client/canvas/ScopeFenceLayer.d.ts +8 -0
- package/dist/types/client/canvas/SelectionBar.d.ts +6 -0
- package/dist/types/client/canvas/SessionPanel.d.ts +1 -0
- package/dist/types/client/canvas/SessionReceipt.d.ts +17 -0
- package/dist/types/client/canvas/TextPrompt.d.ts +25 -0
- package/dist/types/client/canvas/ToolRail.d.ts +21 -0
- package/dist/types/client/canvas/TopBar.d.ts +7 -0
- package/dist/types/client/canvas/canvas-area.d.ts +25 -0
- package/dist/types/client/canvas/create-in-view.d.ts +18 -0
- package/dist/types/client/canvas/import-files.d.ts +8 -0
- package/dist/types/client/canvas/kind-colors.d.ts +3 -0
- package/dist/types/client/canvas/use-focus-trap.d.ts +14 -0
- package/dist/types/client/canvas/use-now.d.ts +6 -0
- package/dist/types/client/icons.d.ts +11 -4
- package/dist/types/client/nodes/GroupNode.d.ts +6 -1
- package/dist/types/client/nodes/MarkdownNode.d.ts +2 -0
- package/dist/types/client/nodes/inline-editor-commands.d.ts +3 -4
- package/dist/types/client/state/canvas-store.d.ts +49 -5
- package/dist/types/client/state/human-store.d.ts +26 -0
- package/dist/types/client/state/iframe-mode.d.ts +1 -0
- package/dist/types/client/state/intent-bridge.d.ts +37 -5
- package/dist/types/client/state/node-factory.d.ts +0 -1
- package/dist/types/client/state/presence-store.d.ts +78 -0
- package/dist/types/client/state/session-store.d.ts +176 -0
- package/dist/types/client/types.d.ts +2 -1
- package/dist/types/client/utils/platform.d.ts +2 -0
- package/dist/types/server/agent-presence.d.ts +192 -0
- package/dist/types/server/ax-gate-ttl.d.ts +6 -0
- package/dist/types/server/ax-interaction.d.ts +4 -10
- package/dist/types/server/ax-state-manager.d.ts +21 -2
- package/dist/types/server/ax-state.d.ts +49 -3
- package/dist/types/server/ax-wait.d.ts +13 -0
- package/dist/types/server/canvas-db.d.ts +9 -1
- package/dist/types/server/canvas-operations.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +27 -13
- package/dist/types/server/canvas-validation.d.ts +0 -14
- package/dist/types/server/html-surface.d.ts +1 -1
- package/dist/types/server/human-presence.d.ts +40 -0
- package/dist/types/server/index.d.ts +29 -3
- package/dist/types/server/intent-registry.d.ts +7 -0
- package/dist/types/server/mutation-history.d.ts +15 -2
- package/dist/types/server/operations/invoker.d.ts +13 -2
- package/dist/types/server/operations/ops/ax-presence.d.ts +2 -0
- package/dist/types/server/operations/ops/human-presence.d.ts +3 -0
- package/dist/types/server/operations/registry.d.ts +11 -0
- package/dist/types/server/operations/types.d.ts +1 -1
- package/dist/types/server/scope-fence.d.ts +62 -0
- package/dist/types/server/server.d.ts +0 -12
- package/dist/types/shared/agent-presence.d.ts +130 -0
- package/dist/types/shared/approval-gates.d.ts +19 -0
- package/dist/types/shared/auto-arrange.d.ts +0 -1
- package/dist/types/shared/ax-kinds.d.ts +11 -0
- package/dist/types/shared/human-presence.d.ts +31 -0
- package/dist/types/shared/iframe-probe.d.ts +8 -0
- package/dist/types/shared/scope-fence.d.ts +21 -0
- package/docs/RELEASE.md +6 -0
- package/docs/ax-host-adapter-contract.md +30 -14
- package/docs/cli.md +37 -1
- package/docs/environment.md +3 -0
- package/docs/http-api.md +222 -5
- package/docs/mcp.md +6 -5
- package/docs/node-types.md +3 -1
- package/docs/plans/plan-009-tech-debt-backlog.md +13 -0
- package/docs/screenshot.png +0 -0
- package/docs/screenshots/multi-agent-live.png +0 -0
- package/docs/screenshots/workbench-live-dark.png +0 -0
- package/docs/screenshots/workbench-live-light.png +0 -0
- package/docs/sdk.md +1 -1
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +103 -16
- package/skills/pmx-canvas/references/codex-app-adapter.md +28 -0
- package/skills/pmx-canvas/references/full-reference.md +55 -16
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +41 -24
- package/skills/pmx-canvas-orchestration/SKILL.md +56 -2
- package/skills/pmx-canvas-testing/SKILL.md +17 -0
- package/src/cli/agent.ts +1 -0
- package/src/cli/commands/copilot.ts +15 -8
- package/src/cli/commands/nodes.ts +1 -19
- package/src/cli/commands/pump.ts +252 -0
- package/src/cli/index.ts +1 -5
- package/src/cli/shared.ts +1 -1
- package/src/client/App.tsx +209 -585
- package/src/client/canvas/AgentPresenceLayer.tsx +74 -0
- package/src/client/canvas/AttentionHistory.tsx +0 -10
- package/src/client/canvas/BarHint.tsx +49 -0
- package/src/client/canvas/CanvasNode.tsx +488 -136
- package/src/client/canvas/CanvasViewport.tsx +190 -94
- package/src/client/canvas/CommandBar.tsx +140 -0
- package/src/client/canvas/CommandPalette.tsx +133 -58
- package/src/client/canvas/ConnectionBanner.tsx +65 -0
- package/src/client/canvas/ContextMenu.tsx +88 -27
- package/src/client/canvas/ContextPinBar.tsx +2 -2
- package/src/client/canvas/EdgeLayer.tsx +121 -38
- package/src/client/canvas/EmptyState.tsx +93 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +64 -118
- package/src/client/canvas/ExternalWriters.tsx +308 -0
- package/src/client/canvas/FocusFieldLayer.tsx +12 -1
- package/src/client/canvas/HumanPresenceLayer.tsx +45 -0
- package/src/client/canvas/IntentLayer.tsx +1 -1
- package/src/client/canvas/Minimap.tsx +117 -190
- package/src/client/canvas/ScopeFenceLayer.tsx +38 -0
- package/src/client/canvas/SelectionBar.tsx +175 -23
- package/src/client/canvas/SessionPanel.tsx +481 -0
- package/src/client/canvas/SessionReceipt.tsx +141 -0
- package/src/client/canvas/ShortcutOverlay.tsx +21 -6
- package/src/client/canvas/SnapshotPanel.tsx +166 -80
- package/src/client/canvas/TextPrompt.tsx +128 -0
- package/src/client/canvas/ToolRail.tsx +447 -0
- package/src/client/canvas/TopBar.tsx +308 -0
- package/src/client/canvas/auto-fit.ts +1 -5
- package/src/client/canvas/canvas-area.ts +41 -0
- package/src/client/canvas/create-in-view.ts +48 -0
- package/src/client/canvas/import-files.ts +64 -0
- package/src/client/canvas/kind-colors.ts +22 -0
- package/src/client/canvas/snap-guides.ts +1 -1
- package/src/client/canvas/use-focus-trap.ts +54 -0
- package/src/client/canvas/use-now.ts +17 -0
- package/src/client/canvas/use-pan-zoom.ts +13 -2
- package/src/client/icons.tsx +57 -18
- package/src/client/nodes/GroupNode.tsx +9 -28
- package/src/client/nodes/InlineFormatBar.tsx +4 -3
- package/src/client/nodes/InlineMarkdownEditor.tsx +27 -0
- package/src/client/nodes/MarkdownNode.tsx +59 -24
- package/src/client/nodes/MdFormatBar.tsx +2 -4
- package/src/client/nodes/inline-editor-commands.ts +17 -6
- package/src/client/nodes/use-surface-frame.ts +3 -1
- package/src/client/state/attention-bridge.ts +3 -0
- package/src/client/state/canvas-store.ts +273 -84
- package/src/client/state/human-store.ts +175 -0
- package/src/client/state/iframe-mode.ts +33 -9
- package/src/client/state/intent-bridge.ts +75 -13
- package/src/client/state/node-factory.ts +2 -8
- package/src/client/state/presence-store.ts +168 -0
- package/src/client/state/session-store.ts +419 -0
- package/src/client/state/sse-bridge.ts +93 -30
- package/src/client/theme/global.css +3069 -796
- package/src/client/types.ts +3 -1
- package/src/client/utils/platform.ts +6 -1
- package/src/mcp/canvas-access.ts +2 -2
- package/src/server/agent-presence.ts +755 -0
- package/src/server/ax-context.ts +45 -0
- package/src/server/ax-gate-ttl.ts +61 -0
- package/src/server/ax-interaction.ts +29 -1
- package/src/server/ax-state-manager.ts +78 -6
- package/src/server/ax-state.ts +82 -15
- package/src/server/ax-wait.ts +33 -0
- package/src/server/canvas-db.ts +103 -33
- package/src/server/canvas-operations.ts +17 -8
- package/src/server/canvas-state.ts +131 -58
- package/src/server/canvas-validation.ts +3 -53
- package/src/server/demo-state.json +2 -70
- package/src/server/html-surface.ts +11 -2
- package/src/server/human-presence.ts +160 -0
- package/src/server/index.ts +107 -4
- package/src/server/intent-registry.ts +13 -0
- package/src/server/mutation-history.ts +47 -2
- package/src/server/operations/composites.ts +6 -3
- package/src/server/operations/http.ts +10 -1
- package/src/server/operations/index.ts +4 -0
- package/src/server/operations/invoker.ts +33 -6
- package/src/server/operations/ops/ax-presence.ts +62 -0
- package/src/server/operations/ops/ax-read.ts +8 -0
- package/src/server/operations/ops/ax-state.ts +106 -1
- package/src/server/operations/ops/ax-timeline.ts +54 -5
- package/src/server/operations/ops/ax-work.ts +46 -0
- package/src/server/operations/ops/batch.ts +3 -1
- package/src/server/operations/ops/edges.ts +88 -2
- package/src/server/operations/ops/groups.ts +4 -3
- package/src/server/operations/ops/human-presence.ts +35 -0
- package/src/server/operations/ops/json-render.ts +11 -1
- package/src/server/operations/ops/nodes.ts +65 -16
- package/src/server/operations/ops/query.ts +1 -0
- package/src/server/operations/ops/snapshots.ts +16 -0
- package/src/server/operations/registry.ts +196 -1
- package/src/server/operations/types.ts +1 -1
- package/src/server/scope-fence.ts +207 -0
- package/src/server/server.ts +177 -79
- package/src/server/trace-manager.ts +0 -2
- package/src/server/web-artifacts.ts +0 -1
- package/src/shared/agent-presence.ts +162 -0
- package/src/shared/approval-gates.ts +35 -0
- package/src/shared/auto-arrange.ts +121 -13
- package/src/shared/ax-kinds.ts +23 -0
- package/src/shared/human-presence.ts +30 -0
- package/src/shared/iframe-probe.ts +8 -0
- package/src/shared/scope-fence.ts +31 -0
- package/dist/types/client/canvas/DockedNode.d.ts +0 -4
- package/src/client/canvas/DockedNode.tsx +0 -165
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
2
|
+
import { mutatingNodeIds, sessionActive } from '../state/presence-store';
|
|
2
3
|
import { attentionPrimaryNodeIds, attentionPulseNodeIds, attentionSecondaryNodeIds } from '../state/attention-store';
|
|
3
4
|
import {
|
|
4
5
|
activeNodeId,
|
|
5
6
|
activeNeighborNodeIds,
|
|
7
|
+
addContextPins,
|
|
6
8
|
bringToFront,
|
|
7
9
|
contextPinnedNodeIds,
|
|
10
|
+
dragDropTarget,
|
|
8
11
|
draggingEdge,
|
|
12
|
+
endDropTracking,
|
|
9
13
|
expandNode,
|
|
10
14
|
nodes,
|
|
11
15
|
persistLayout,
|
|
@@ -13,14 +17,25 @@ import {
|
|
|
13
17
|
resizeNode,
|
|
14
18
|
searchHighlightIds,
|
|
15
19
|
selectedNodeIds,
|
|
20
|
+
suppressDropForDrag,
|
|
16
21
|
toggleCollapsed,
|
|
17
22
|
toggleContextPin,
|
|
18
23
|
toggleSelected,
|
|
24
|
+
trackDragMembership,
|
|
19
25
|
updateNode,
|
|
20
26
|
updateNodeData,
|
|
21
27
|
viewport,
|
|
28
|
+
canvasTool,
|
|
22
29
|
} from '../state/canvas-store';
|
|
23
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
addToGroupFromClient,
|
|
32
|
+
removeNodeFromClient,
|
|
33
|
+
setGroupChildrenFromClient,
|
|
34
|
+
ungroupFromClient,
|
|
35
|
+
updateNodeFromClient,
|
|
36
|
+
} from '../state/intent-bridge';
|
|
37
|
+
import { KIND_COLOR } from './kind-colors';
|
|
38
|
+
import { reportHumanGrab, takeOverNode, yieldedNodes } from '../state/human-store';
|
|
24
39
|
import { AxStepControls } from '../nodes/AxStepControls';
|
|
25
40
|
import { canOpenAsSite, openNodeAsSite } from '../nodes/surface-url';
|
|
26
41
|
import { getNodeIcon } from '../icons';
|
|
@@ -31,6 +46,40 @@ import { activeGuides, buildSnapCache, clearSnapCache, snapToGuides } from './sn
|
|
|
31
46
|
import { useNodeDrag } from './use-node-drag';
|
|
32
47
|
import { useNodeResize } from './use-node-resize';
|
|
33
48
|
|
|
49
|
+
const ARROW_DIRS: Record<string, { dx: number; dy: number }> = {
|
|
50
|
+
ArrowLeft: { dx: -1, dy: 0 },
|
|
51
|
+
ArrowRight: { dx: 1, dy: 0 },
|
|
52
|
+
ArrowUp: { dx: 0, dy: -1 },
|
|
53
|
+
ArrowDown: { dx: 0, dy: 1 },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** The closest node whose centre lies in `dir` from `from` (a 90° cone, weighted toward the axis). */
|
|
57
|
+
export function nearestNodeInDirection(
|
|
58
|
+
from: CanvasNodeState,
|
|
59
|
+
dir: { dx: number; dy: number },
|
|
60
|
+
candidates: Iterable<CanvasNodeState>,
|
|
61
|
+
): CanvasNodeState | null {
|
|
62
|
+
const fx = from.position.x + from.size.width / 2;
|
|
63
|
+
const fy = from.position.y + from.size.height / 2;
|
|
64
|
+
let best: CanvasNodeState | null = null;
|
|
65
|
+
let bestScore = Number.POSITIVE_INFINITY;
|
|
66
|
+
for (const node of candidates) {
|
|
67
|
+
if (node.id === from.id) continue;
|
|
68
|
+
const dx = node.position.x + node.size.width / 2 - fx;
|
|
69
|
+
const dy = node.position.y + node.size.height / 2 - fy;
|
|
70
|
+
const along = dx * dir.dx + dy * dir.dy;
|
|
71
|
+
if (along <= 0) continue;
|
|
72
|
+
const across = Math.abs(dx * dir.dy) + Math.abs(dy * dir.dx);
|
|
73
|
+
if (across > along) continue; // outside the 90° cone
|
|
74
|
+
const score = along + across * 2;
|
|
75
|
+
if (score < bestScore) {
|
|
76
|
+
bestScore = score;
|
|
77
|
+
best = node;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return best;
|
|
81
|
+
}
|
|
82
|
+
|
|
34
83
|
/** Fraction of a node's height the title bar may occupy before it starts
|
|
35
84
|
* crowding out the content it is labelling. */
|
|
36
85
|
const MAX_TITLEBAR_HEIGHT_RATIO = 0.4;
|
|
@@ -89,7 +138,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
89
138
|
const [renaming, setRenaming] = useState(false);
|
|
90
139
|
const renameRef = useRef<HTMLInputElement>(null);
|
|
91
140
|
|
|
92
|
-
// ── Drag (with snap alignment)
|
|
141
|
+
// ── Drag (with snap alignment + group membership feedback) ──
|
|
93
142
|
const handleMove = useCallback(
|
|
94
143
|
(id: string, x: number, y: number) => {
|
|
95
144
|
const snap = snapToGuides(x, y, node.size.width, node.size.height);
|
|
@@ -100,15 +149,38 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
100
149
|
}
|
|
101
150
|
updateNode(id, { position: { x: snap.x, y: snap.y } });
|
|
102
151
|
activeGuides.value = snap.guides.length > 0 ? snap.guides : null;
|
|
152
|
+
// Groups v2: the release pill + live auto-grow of a fit-mode parent.
|
|
153
|
+
trackDragMembership(id);
|
|
103
154
|
},
|
|
104
155
|
[node.size.width, node.size.height],
|
|
105
156
|
);
|
|
106
157
|
|
|
158
|
+
// Esc while dragging keeps the node out of (or in) the group it hovers.
|
|
159
|
+
const escListener = useRef<((e: KeyboardEvent) => void) | null>(null);
|
|
160
|
+
|
|
107
161
|
const handleDragEnd = useCallback(() => {
|
|
108
162
|
clearSnapCache();
|
|
109
163
|
activeGuides.value = null;
|
|
164
|
+
reportHumanGrab(null);
|
|
165
|
+
if (escListener.current) {
|
|
166
|
+
document.removeEventListener('keydown', escListener.current);
|
|
167
|
+
escListener.current = null;
|
|
168
|
+
}
|
|
110
169
|
persistLayout();
|
|
111
|
-
|
|
170
|
+
// Membership changes ONLY here, and only while the pill was showing.
|
|
171
|
+
const target = dragDropTarget.value;
|
|
172
|
+
endDropTracking();
|
|
173
|
+
if (!target || target.nodeId !== node.id) return;
|
|
174
|
+
if (target.mode === 'add') {
|
|
175
|
+
void addToGroupFromClient(target.groupId, [node.id]);
|
|
176
|
+
} else {
|
|
177
|
+
const group = nodes.value.get(target.groupId);
|
|
178
|
+
const remaining = Array.isArray(group?.data.children)
|
|
179
|
+
? group.data.children.filter((id): id is string => typeof id === 'string' && id !== node.id)
|
|
180
|
+
: [];
|
|
181
|
+
void setGroupChildrenFromClient(target.groupId, remaining);
|
|
182
|
+
}
|
|
183
|
+
}, [node.id]);
|
|
112
184
|
|
|
113
185
|
const startDrag = useNodeDrag({
|
|
114
186
|
nodeId: node.id,
|
|
@@ -143,26 +215,70 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
143
215
|
});
|
|
144
216
|
|
|
145
217
|
// ── Title bar interactions ────────────────────────────
|
|
218
|
+
// Connect tool (item 15): a drag from anywhere on the node draws an edge.
|
|
219
|
+
const startEdgeDrag = useCallback(
|
|
220
|
+
(e: PointerEvent) => {
|
|
221
|
+
e.stopPropagation();
|
|
222
|
+
e.preventDefault();
|
|
223
|
+
draggingEdge.value = {
|
|
224
|
+
fromId: node.id,
|
|
225
|
+
fromX: node.position.x + node.size.width / 2,
|
|
226
|
+
fromY: node.position.y + node.size.height / 2,
|
|
227
|
+
cursorX: node.position.x + node.size.width / 2,
|
|
228
|
+
cursorY: node.position.y + node.size.height / 2,
|
|
229
|
+
targetId: null,
|
|
230
|
+
};
|
|
231
|
+
},
|
|
232
|
+
[node.id, node.position.x, node.position.y, node.size.width, node.size.height],
|
|
233
|
+
);
|
|
234
|
+
|
|
146
235
|
const handleTitlePointerDown = useCallback(
|
|
147
236
|
(e: PointerEvent) => {
|
|
148
237
|
if (renaming) return;
|
|
238
|
+
if (canvasTool.value === 'connect') {
|
|
239
|
+
startEdgeDrag(e);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
// Shift+click on the title bar / group edge row toggles the
|
|
243
|
+
// multi-selection exactly like the body does — for a group frame the
|
|
244
|
+
// edge row is the only clickable part, so this is how a frame gets
|
|
245
|
+
// into a selection (fence, group, delete) at all.
|
|
246
|
+
if (e.shiftKey) {
|
|
247
|
+
e.stopPropagation();
|
|
248
|
+
toggleSelected(node.id);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
149
251
|
bringToFront(node.id);
|
|
252
|
+
// User wins (phase 8): hold the node for the drag; an agent mid-edit yields.
|
|
253
|
+
reportHumanGrab(node.id);
|
|
254
|
+
takeOverNode(node.id, typeof node.data.title === 'string' && node.data.title ? node.data.title : node.type);
|
|
150
255
|
buildSnapCache(node.id, nodes.value.values());
|
|
256
|
+
if (node.type !== 'group') {
|
|
257
|
+
const onKey = (ev: KeyboardEvent) => {
|
|
258
|
+
if (ev.key === 'Escape') suppressDropForDrag();
|
|
259
|
+
};
|
|
260
|
+
escListener.current = onKey;
|
|
261
|
+
document.addEventListener('keydown', onKey);
|
|
262
|
+
}
|
|
151
263
|
startDrag(e, node.position.x, node.position.y);
|
|
152
264
|
},
|
|
153
|
-
[node.id, node.position.x, node.position.y, startDrag, renaming],
|
|
265
|
+
[node.id, node.type, node.data.title, node.position.x, node.position.y, startDrag, renaming, startEdgeDrag],
|
|
154
266
|
);
|
|
155
267
|
|
|
156
268
|
const handlePointerDown = useCallback(
|
|
157
269
|
(e: PointerEvent) => {
|
|
158
270
|
e.stopPropagation();
|
|
271
|
+
if (canvasTool.value === 'connect') {
|
|
272
|
+
startEdgeDrag(e);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
159
275
|
if (e.shiftKey) {
|
|
160
276
|
toggleSelected(node.id);
|
|
161
277
|
return;
|
|
162
278
|
}
|
|
163
279
|
bringToFront(node.id);
|
|
164
280
|
},
|
|
165
|
-
[node.id],
|
|
281
|
+
[node.id, startEdgeDrag],
|
|
166
282
|
);
|
|
167
283
|
|
|
168
284
|
const handleContextMenuEvent = useCallback(
|
|
@@ -172,6 +288,30 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
172
288
|
[onContextMenu, node.id],
|
|
173
289
|
);
|
|
174
290
|
|
|
291
|
+
// ── Keyboard (item 18): roving focus across nodes ─────
|
|
292
|
+
// Arrow keys move focus to the nearest node in that direction (world
|
|
293
|
+
// space); Enter opens the focused node in focus mode.
|
|
294
|
+
const handleNodeKeyDown = useCallback(
|
|
295
|
+
(e: KeyboardEvent) => {
|
|
296
|
+
const target = e.target as HTMLElement | null;
|
|
297
|
+
if (target && target !== e.currentTarget && target.closest('input, textarea, [contenteditable="true"]')) return;
|
|
298
|
+
const dir = ARROW_DIRS[e.key];
|
|
299
|
+
if (dir) {
|
|
300
|
+
e.preventDefault();
|
|
301
|
+
const next = nearestNodeInDirection(node, dir, nodes.value.values());
|
|
302
|
+
if (!next) return;
|
|
303
|
+
bringToFront(next.id);
|
|
304
|
+
requestAnimationFrame(() => {
|
|
305
|
+
document.querySelector<HTMLElement>(`.canvas-node[data-node-id="${CSS.escape(next.id)}"]`)?.focus();
|
|
306
|
+
});
|
|
307
|
+
} else if (e.key === 'Enter' && e.target === e.currentTarget && EXPANDABLE_TYPES.has(node.type)) {
|
|
308
|
+
e.preventDefault();
|
|
309
|
+
expandNode(node.id);
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
[node],
|
|
313
|
+
);
|
|
314
|
+
|
|
175
315
|
// ── Double-click rename ───────────────────────────────
|
|
176
316
|
const handleTitleDblClick = useCallback((e: MouseEvent) => {
|
|
177
317
|
e.stopPropagation();
|
|
@@ -242,16 +382,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
242
382
|
autoFitPersistTimer.current = null;
|
|
243
383
|
}
|
|
244
384
|
};
|
|
245
|
-
}, [
|
|
246
|
-
node.id,
|
|
247
|
-
node.type,
|
|
248
|
-
node.data.mode,
|
|
249
|
-
node.data.strictSize,
|
|
250
|
-
node.collapsed,
|
|
251
|
-
node.dockPosition,
|
|
252
|
-
node.size.width,
|
|
253
|
-
node.size.height,
|
|
254
|
-
]);
|
|
385
|
+
}, [node.id, node.type, node.data.mode, node.data.strictSize, node.collapsed, node.size.width, node.size.height]);
|
|
255
386
|
|
|
256
387
|
const isPinned = node.pinned;
|
|
257
388
|
const isTrace = node.type === 'trace';
|
|
@@ -271,6 +402,9 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
271
402
|
'--node-chrome-scale': chromeScale.toFixed(3),
|
|
272
403
|
...(groupColor ? { '--group-color': groupColor } : {}),
|
|
273
404
|
};
|
|
405
|
+
// Shimmer while an attached agent is mutating this node (rail-chrome-v2
|
|
406
|
+
// phase 3). Gated on sessionActive: the quiet board never shimmers.
|
|
407
|
+
const isAgentMutating = sessionActive.value && mutatingNodeIds.value.has(node.id);
|
|
274
408
|
const nodeClass = [
|
|
275
409
|
'canvas-node',
|
|
276
410
|
isActive ? 'active' : '',
|
|
@@ -287,115 +421,320 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
287
421
|
isTraceRunning ? 'trace-running' : '',
|
|
288
422
|
isGroup ? 'group-node' : '',
|
|
289
423
|
isStrictSize ? 'strict-size' : '',
|
|
424
|
+
isAgentMutating ? 'agent-mutating' : '',
|
|
290
425
|
]
|
|
291
426
|
.filter(Boolean)
|
|
292
427
|
.join(' ');
|
|
293
428
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
</span>
|
|
327
|
-
)}
|
|
328
|
-
<div class="node-controls">
|
|
329
|
-
{isPinned && (
|
|
330
|
-
<span class="pin-indicator" title="Pinned">
|
|
331
|
-
⊙
|
|
332
|
-
</span>
|
|
333
|
-
)}
|
|
429
|
+
// ── Groups v2 (rail-chrome-v2 phase 7, item 20) ──────────
|
|
430
|
+
const groupChildren = isGroup
|
|
431
|
+
? (Array.isArray(node.data.children) ? node.data.children : []).filter(
|
|
432
|
+
(id): id is string => typeof id === 'string' && nodes.value.has(id),
|
|
433
|
+
)
|
|
434
|
+
: [];
|
|
435
|
+
const [groupMenuOpen, setGroupMenuOpen] = useState(false);
|
|
436
|
+
const dropTarget = dragDropTarget.value;
|
|
437
|
+
const isDropTarget = isGroup && dropTarget?.groupId === node.id && dropTarget.mode === 'add';
|
|
438
|
+
const isDropSource = isGroup && dropTarget?.groupId === node.id && dropTarget.mode === 'remove';
|
|
439
|
+
const isEdgeTarget = draggingEdge.value?.targetId === node.id;
|
|
440
|
+
const groupKindDots = isGroup
|
|
441
|
+
? [
|
|
442
|
+
...new Set(
|
|
443
|
+
groupChildren.map((id) => nodes.value.get(id)?.type).filter((t): t is CanvasNodeState['type'] => !!t),
|
|
444
|
+
),
|
|
445
|
+
].slice(0, 4)
|
|
446
|
+
: [];
|
|
447
|
+
|
|
448
|
+
if (isGroup && node.collapsed) {
|
|
449
|
+
// Collapsed group: a compact chip. Children keep their positions for restore.
|
|
450
|
+
return (
|
|
451
|
+
<div
|
|
452
|
+
class={`${nodeClass} group-chip`}
|
|
453
|
+
data-node-type={node.type}
|
|
454
|
+
style={{ ...nodeStyle, width: 'auto', height: 'auto' }}
|
|
455
|
+
onPointerDown={handlePointerDown}
|
|
456
|
+
onContextMenu={handleContextMenuEvent}
|
|
457
|
+
title="Expand group"
|
|
458
|
+
data-testid="group-chip"
|
|
459
|
+
>
|
|
460
|
+
<div class="node-titlebar group-chip-inner" onPointerDown={handleTitlePointerDown}>
|
|
334
461
|
<button
|
|
335
462
|
type="button"
|
|
336
|
-
class=
|
|
463
|
+
class="group-chip-expand"
|
|
464
|
+
aria-label={`Expand group ${title}`}
|
|
337
465
|
onClick={(e) => {
|
|
338
466
|
e.stopPropagation();
|
|
339
|
-
|
|
467
|
+
toggleCollapsed(node.id);
|
|
340
468
|
}}
|
|
341
|
-
title={isContextPinned ? 'Remove from context' : 'Add to context'}
|
|
342
469
|
>
|
|
343
|
-
|
|
470
|
+
▸
|
|
344
471
|
</button>
|
|
345
|
-
|
|
472
|
+
<div class="group-chip-text">
|
|
473
|
+
<div class="group-chip-name">{title}</div>
|
|
474
|
+
<div class="group-chip-meta">
|
|
475
|
+
{groupKindDots.map((kind) => (
|
|
476
|
+
<span key={kind} class="group-kind-dot" style={{ background: KIND_COLOR[kind] }} aria-hidden="true" />
|
|
477
|
+
))}
|
|
478
|
+
<span class="group-chip-count">
|
|
479
|
+
{groupChildren.length} node{groupChildren.length === 1 ? '' : 's'}
|
|
480
|
+
</span>
|
|
481
|
+
</div>
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
484
|
+
</div>
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return (
|
|
489
|
+
<div
|
|
490
|
+
class={`${nodeClass}${isDropTarget ? ' is-drop-target' : ''}${isDropSource ? ' is-drop-source' : ''}${isEdgeTarget ? ' is-edge-target' : ''}${isGroup && groupMenuOpen ? ' group-menu-open' : ''}`}
|
|
491
|
+
data-node-type={node.type}
|
|
492
|
+
data-node-id={node.id}
|
|
493
|
+
style={nodeStyle}
|
|
494
|
+
tabIndex={isActive ? 0 : -1}
|
|
495
|
+
onKeyDown={handleNodeKeyDown}
|
|
496
|
+
onPointerDown={handlePointerDown}
|
|
497
|
+
// Shift+click toggles multi-selection; the browser would otherwise extend
|
|
498
|
+
// a text selection from the previous card across this one.
|
|
499
|
+
onMouseDown={(e) => {
|
|
500
|
+
if (e.shiftKey) e.preventDefault();
|
|
501
|
+
}}
|
|
502
|
+
onContextMenu={handleContextMenuEvent}
|
|
503
|
+
>
|
|
504
|
+
{isGroup && (
|
|
505
|
+
<div class="node-titlebar group-edge-row" onPointerDown={handleTitlePointerDown}>
|
|
506
|
+
<span class="group-name-pill">
|
|
507
|
+
{renaming ? (
|
|
508
|
+
<input
|
|
509
|
+
ref={renameRef}
|
|
510
|
+
class="node-title-input group-title-input"
|
|
511
|
+
value={title}
|
|
512
|
+
onBlur={(e) => commitRename((e.target as HTMLInputElement).value)}
|
|
513
|
+
onKeyDown={(e) => {
|
|
514
|
+
if (e.key === 'Enter') commitRename((e.target as HTMLInputElement).value);
|
|
515
|
+
if (e.key === 'Escape') setRenaming(false);
|
|
516
|
+
}}
|
|
517
|
+
onClick={(e) => e.stopPropagation()}
|
|
518
|
+
/>
|
|
519
|
+
) : (
|
|
520
|
+
<span class="group-name" onDblClick={handleTitleDblClick} title={`${title} — double-click to rename`}>
|
|
521
|
+
{title}
|
|
522
|
+
</span>
|
|
523
|
+
)}
|
|
524
|
+
<span class="group-count">{groupChildren.length}</span>
|
|
525
|
+
</span>
|
|
526
|
+
<span class="group-edge-spacer" />
|
|
527
|
+
<span class="group-actions" role="toolbar" aria-label={`${title} actions`}>
|
|
528
|
+
<button
|
|
529
|
+
type="button"
|
|
530
|
+
class="group-action"
|
|
531
|
+
title="Auto-arrange children"
|
|
532
|
+
aria-label="Auto-arrange children"
|
|
533
|
+
disabled={groupChildren.length === 0}
|
|
534
|
+
onClick={(e) => {
|
|
535
|
+
e.stopPropagation();
|
|
536
|
+
void addToGroupFromClient(node.id, groupChildren, 'grid');
|
|
537
|
+
}}
|
|
538
|
+
>
|
|
539
|
+
<svg
|
|
540
|
+
width="11"
|
|
541
|
+
height="11"
|
|
542
|
+
viewBox="0 0 16 16"
|
|
543
|
+
fill="none"
|
|
544
|
+
stroke="currentColor"
|
|
545
|
+
stroke-width="1.5"
|
|
546
|
+
aria-hidden="true"
|
|
547
|
+
>
|
|
548
|
+
<rect x="1.5" y="1.5" width="5" height="5" rx="1" />
|
|
549
|
+
<rect x="9.5" y="1.5" width="5" height="5" rx="1" />
|
|
550
|
+
<rect x="1.5" y="9.5" width="5" height="5" rx="1" />
|
|
551
|
+
<rect x="9.5" y="9.5" width="5" height="5" rx="1" />
|
|
552
|
+
</svg>
|
|
553
|
+
</button>
|
|
554
|
+
<button
|
|
555
|
+
type="button"
|
|
556
|
+
class="group-action"
|
|
557
|
+
title="Collapse group"
|
|
558
|
+
aria-label="Collapse group"
|
|
559
|
+
onClick={(e) => {
|
|
560
|
+
e.stopPropagation();
|
|
561
|
+
toggleCollapsed(node.id);
|
|
562
|
+
}}
|
|
563
|
+
>
|
|
564
|
+
<svg
|
|
565
|
+
width="11"
|
|
566
|
+
height="11"
|
|
567
|
+
viewBox="0 0 16 16"
|
|
568
|
+
fill="none"
|
|
569
|
+
stroke="currentColor"
|
|
570
|
+
stroke-width="1.8"
|
|
571
|
+
stroke-linecap="round"
|
|
572
|
+
stroke-linejoin="round"
|
|
573
|
+
aria-hidden="true"
|
|
574
|
+
>
|
|
575
|
+
<path d="M3 10 L8 5 L13 10" />
|
|
576
|
+
</svg>
|
|
577
|
+
</button>
|
|
578
|
+
<button
|
|
579
|
+
type="button"
|
|
580
|
+
class="group-action"
|
|
581
|
+
title="Rename · Ungroup · Pin all to context"
|
|
582
|
+
aria-label="Group menu"
|
|
583
|
+
aria-expanded={groupMenuOpen}
|
|
584
|
+
onClick={(e) => {
|
|
585
|
+
e.stopPropagation();
|
|
586
|
+
setGroupMenuOpen((open) => !open);
|
|
587
|
+
}}
|
|
588
|
+
>
|
|
589
|
+
⋯
|
|
590
|
+
</button>
|
|
591
|
+
{groupMenuOpen && (
|
|
592
|
+
<div class="group-menu" role="menu" onPointerDown={(e) => e.stopPropagation()}>
|
|
593
|
+
<button
|
|
594
|
+
type="button"
|
|
595
|
+
role="menuitem"
|
|
596
|
+
onClick={(e) => {
|
|
597
|
+
e.stopPropagation();
|
|
598
|
+
setGroupMenuOpen(false);
|
|
599
|
+
setRenaming(true);
|
|
600
|
+
requestAnimationFrame(() => renameRef.current?.focus());
|
|
601
|
+
}}
|
|
602
|
+
>
|
|
603
|
+
Rename
|
|
604
|
+
</button>
|
|
605
|
+
<button
|
|
606
|
+
type="button"
|
|
607
|
+
role="menuitem"
|
|
608
|
+
onClick={(e) => {
|
|
609
|
+
e.stopPropagation();
|
|
610
|
+
setGroupMenuOpen(false);
|
|
611
|
+
// Dissolve — the same op an agent's ungroup runs.
|
|
612
|
+
removeNode(node.id);
|
|
613
|
+
void ungroupFromClient(node.id);
|
|
614
|
+
}}
|
|
615
|
+
>
|
|
616
|
+
Ungroup
|
|
617
|
+
</button>
|
|
618
|
+
<button
|
|
619
|
+
type="button"
|
|
620
|
+
role="menuitem"
|
|
621
|
+
disabled={groupChildren.length === 0}
|
|
622
|
+
onClick={(e) => {
|
|
623
|
+
e.stopPropagation();
|
|
624
|
+
setGroupMenuOpen(false);
|
|
625
|
+
addContextPins(groupChildren);
|
|
626
|
+
}}
|
|
627
|
+
>
|
|
628
|
+
Pin all to context
|
|
629
|
+
</button>
|
|
630
|
+
</div>
|
|
631
|
+
)}
|
|
632
|
+
</span>
|
|
633
|
+
</div>
|
|
634
|
+
)}
|
|
635
|
+
{!isGroup && (
|
|
636
|
+
<div class="node-titlebar" onPointerDown={handleTitlePointerDown}>
|
|
637
|
+
<span class="node-type-icon" aria-hidden="true">
|
|
638
|
+
{(() => {
|
|
639
|
+
const NodeIcon = getNodeIcon(node.type);
|
|
640
|
+
return <NodeIcon size={Math.round(14 * chromeScale)} />;
|
|
641
|
+
})()}
|
|
642
|
+
</span>
|
|
643
|
+
{/* The kind-colored icon says what type this is (rail-chrome-v2 card
|
|
644
|
+
shell: icon · title · controls, no type badge). Only the AX status
|
|
645
|
+
chip joins it, and only when there is a status to show. */}
|
|
646
|
+
{typeof node.data.axWorkStatus === 'string' && (
|
|
647
|
+
<span class={`node-ax-status node-ax-status-${node.data.axWorkStatus}`}>{node.data.axWorkStatus}</span>
|
|
648
|
+
)}
|
|
649
|
+
{renaming ? (
|
|
650
|
+
<input
|
|
651
|
+
ref={renameRef}
|
|
652
|
+
class="node-title-input"
|
|
653
|
+
value={title}
|
|
654
|
+
onBlur={(e) => commitRename((e.target as HTMLInputElement).value)}
|
|
655
|
+
onKeyDown={(e) => {
|
|
656
|
+
if (e.key === 'Enter') commitRename((e.target as HTMLInputElement).value);
|
|
657
|
+
if (e.key === 'Escape') setRenaming(false);
|
|
658
|
+
}}
|
|
659
|
+
onClick={(e) => e.stopPropagation()}
|
|
660
|
+
/>
|
|
661
|
+
) : (
|
|
662
|
+
<span class="node-title" onDblClick={handleTitleDblClick} title={`${title} — double-click to rename`}>
|
|
663
|
+
{title}
|
|
664
|
+
</span>
|
|
665
|
+
)}
|
|
666
|
+
<div class="node-controls">
|
|
667
|
+
{isPinned && (
|
|
668
|
+
<span class="pin-indicator" title="Pinned">
|
|
669
|
+
⊙
|
|
670
|
+
</span>
|
|
671
|
+
)}
|
|
672
|
+
<button
|
|
673
|
+
type="button"
|
|
674
|
+
class={`ctx-pin-btn${isContextPinned ? ' ctx-pin-active' : ''}`}
|
|
675
|
+
onClick={(e) => {
|
|
676
|
+
e.stopPropagation();
|
|
677
|
+
toggleContextPin(node.id);
|
|
678
|
+
}}
|
|
679
|
+
title={isContextPinned ? 'Remove from context' : 'Add to context'}
|
|
680
|
+
>
|
|
681
|
+
{'\u2726'}
|
|
682
|
+
</button>
|
|
683
|
+
{/* Open as site — full-page standalone view of this node's surface,
|
|
346
684
|
served from /api/canvas/surface/:id (same document as the canvas
|
|
347
685
|
iframe). Opens via the system browser so embedded hosts do not trap
|
|
348
686
|
it in their own webview. */}
|
|
349
|
-
|
|
687
|
+
{canOpenAsSite(node) && (
|
|
688
|
+
<button
|
|
689
|
+
type="button"
|
|
690
|
+
onClick={(e) => {
|
|
691
|
+
e.stopPropagation();
|
|
692
|
+
void openNodeAsSite(node);
|
|
693
|
+
}}
|
|
694
|
+
title="Open as site"
|
|
695
|
+
>
|
|
696
|
+
↗
|
|
697
|
+
</button>
|
|
698
|
+
)}
|
|
699
|
+
{/* Expand — opens node as full-viewport overlay for focused work */}
|
|
700
|
+
{EXPANDABLE_TYPES.has(node.type) && (
|
|
701
|
+
<button
|
|
702
|
+
type="button"
|
|
703
|
+
onClick={(e) => {
|
|
704
|
+
e.stopPropagation();
|
|
705
|
+
expandNode(node.id);
|
|
706
|
+
}}
|
|
707
|
+
title="Expand (focus mode)"
|
|
708
|
+
>
|
|
709
|
+
⤢
|
|
710
|
+
</button>
|
|
711
|
+
)}
|
|
350
712
|
<button
|
|
351
713
|
type="button"
|
|
352
714
|
onClick={(e) => {
|
|
353
715
|
e.stopPropagation();
|
|
354
|
-
|
|
716
|
+
toggleCollapsed(node.id);
|
|
355
717
|
}}
|
|
356
|
-
title=
|
|
718
|
+
title={node.collapsed ? 'Expand' : 'Collapse'}
|
|
357
719
|
>
|
|
358
|
-
|
|
720
|
+
{node.collapsed ? '▸' : '▾'}
|
|
359
721
|
</button>
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
{EXPANDABLE_TYPES.has(node.type) && (
|
|
722
|
+
{/* Report #64: status nodes get the same remove control as every other
|
|
723
|
+
node type (backend removal + undo/history handle status uniformly). */}
|
|
363
724
|
<button
|
|
364
725
|
type="button"
|
|
365
726
|
onClick={(e) => {
|
|
366
727
|
e.stopPropagation();
|
|
367
|
-
|
|
728
|
+
removeNode(node.id);
|
|
729
|
+
void removeNodeFromClient(node.id);
|
|
368
730
|
}}
|
|
369
|
-
title="
|
|
731
|
+
title="Close"
|
|
370
732
|
>
|
|
371
|
-
|
|
733
|
+
×
|
|
372
734
|
</button>
|
|
373
|
-
|
|
374
|
-
<button
|
|
375
|
-
type="button"
|
|
376
|
-
onClick={(e) => {
|
|
377
|
-
e.stopPropagation();
|
|
378
|
-
toggleCollapsed(node.id);
|
|
379
|
-
}}
|
|
380
|
-
title={node.collapsed ? 'Expand' : 'Collapse'}
|
|
381
|
-
>
|
|
382
|
-
{node.collapsed ? '▸' : '▾'}
|
|
383
|
-
</button>
|
|
384
|
-
{/* Report #64: status nodes get the same remove control as every other
|
|
385
|
-
node type (backend removal + undo/history handle status uniformly). */}
|
|
386
|
-
<button
|
|
387
|
-
type="button"
|
|
388
|
-
onClick={(e) => {
|
|
389
|
-
e.stopPropagation();
|
|
390
|
-
removeNode(node.id);
|
|
391
|
-
void removeNodeFromClient(node.id);
|
|
392
|
-
}}
|
|
393
|
-
title="Close"
|
|
394
|
-
>
|
|
395
|
-
×
|
|
396
|
-
</button>
|
|
735
|
+
</div>
|
|
397
736
|
</div>
|
|
398
|
-
|
|
737
|
+
)}
|
|
399
738
|
{!node.collapsed && (
|
|
400
739
|
<div ref={bodyRef} class="node-body">
|
|
401
740
|
{children}
|
|
@@ -410,47 +749,60 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
410
749
|
{!node.collapsed && (
|
|
411
750
|
<div class="node-resize-handle" onPointerDown={(e) => startResize(e, node.size.width, node.size.height)} />
|
|
412
751
|
)}
|
|
413
|
-
{
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
752
|
+
{isSelected &&
|
|
753
|
+
(['tl', 'tr', 'bl', 'br'] as const).map((corner) => (
|
|
754
|
+
<span key={corner} class={`node-selection-handle is-${corner}`} aria-hidden="true" />
|
|
755
|
+
))}
|
|
756
|
+
{yieldedNodes.value.has(node.id) && (
|
|
757
|
+
<span class="node-yield-pill" data-testid="yield-pill">
|
|
758
|
+
<span class="node-yield-dot" aria-hidden="true" />
|
|
759
|
+
{yieldedNodes.value.get(node.id)} took over — agent yielded
|
|
760
|
+
</span>
|
|
761
|
+
)}
|
|
762
|
+
{/* Connection port handles — visible on hover, drag to connect. Groups
|
|
763
|
+
are frames, not endpoints: no ports (and the edge row sits on the top
|
|
764
|
+
edge where the port would be). */}
|
|
765
|
+
{!isGroup &&
|
|
766
|
+
(['top', 'right', 'bottom', 'left'] as const).map((side) => (
|
|
767
|
+
<div
|
|
768
|
+
key={side}
|
|
769
|
+
class={`node-port node-port-${side}`}
|
|
770
|
+
onPointerDown={(e) => {
|
|
771
|
+
e.stopPropagation();
|
|
772
|
+
e.preventDefault();
|
|
773
|
+
const cx = node.position.x + node.size.width / 2;
|
|
774
|
+
const cy = node.position.y + node.size.height / 2;
|
|
775
|
+
const hw = node.size.width / 2;
|
|
776
|
+
const hh = node.size.height / 2;
|
|
777
|
+
let px: number, py: number;
|
|
778
|
+
switch (side) {
|
|
779
|
+
case 'top':
|
|
780
|
+
px = cx;
|
|
781
|
+
py = cy - hh;
|
|
782
|
+
break;
|
|
783
|
+
case 'bottom':
|
|
784
|
+
px = cx;
|
|
785
|
+
py = cy + hh;
|
|
786
|
+
break;
|
|
787
|
+
case 'left':
|
|
788
|
+
px = cx - hw;
|
|
789
|
+
py = cy;
|
|
790
|
+
break;
|
|
791
|
+
case 'right':
|
|
792
|
+
px = cx + hw;
|
|
793
|
+
py = cy;
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
draggingEdge.value = {
|
|
797
|
+
fromId: node.id,
|
|
798
|
+
fromX: px,
|
|
799
|
+
fromY: py,
|
|
800
|
+
cursorX: px,
|
|
801
|
+
cursorY: py,
|
|
802
|
+
};
|
|
803
|
+
}}
|
|
804
|
+
/>
|
|
805
|
+
))}
|
|
454
806
|
</div>
|
|
455
807
|
);
|
|
456
808
|
}
|