pmx-canvas 0.4.8 → 0.5.1

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 (212) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +154 -5
  2. package/.github/extensions/pmx-canvas/steering-delivery.mjs +79 -0
  3. package/CHANGELOG.md +156 -0
  4. package/Readme.md +72 -23
  5. package/dist/canvas/global.css +3071 -796
  6. package/dist/canvas/index.js +122 -104
  7. package/dist/json-render/index.css +1 -1
  8. package/dist/types/cli/agent.d.ts +1 -0
  9. package/dist/types/cli/commands/pump.d.ts +9 -0
  10. package/dist/types/client/App.d.ts +1 -1
  11. package/dist/types/client/canvas/AgentPresenceLayer.d.ts +11 -0
  12. package/dist/types/client/canvas/BarHint.d.ts +22 -0
  13. package/dist/types/client/canvas/CanvasNode.d.ts +5 -0
  14. package/dist/types/client/canvas/CanvasViewport.d.ts +4 -4
  15. package/dist/types/client/canvas/CommandBar.d.ts +10 -0
  16. package/dist/types/client/canvas/ConnectionBanner.d.ts +13 -0
  17. package/dist/types/client/canvas/ContextMenu.d.ts +6 -0
  18. package/dist/types/client/canvas/EdgeLayer.d.ts +2 -1
  19. package/dist/types/client/canvas/EmptyState.d.ts +11 -0
  20. package/dist/types/client/canvas/ExternalWriters.d.ts +5 -0
  21. package/dist/types/client/canvas/HumanPresenceLayer.d.ts +8 -0
  22. package/dist/types/client/canvas/Minimap.d.ts +12 -1
  23. package/dist/types/client/canvas/ScopeFenceLayer.d.ts +8 -0
  24. package/dist/types/client/canvas/SelectionBar.d.ts +6 -0
  25. package/dist/types/client/canvas/SessionPanel.d.ts +1 -0
  26. package/dist/types/client/canvas/SessionReceipt.d.ts +17 -0
  27. package/dist/types/client/canvas/TextPrompt.d.ts +25 -0
  28. package/dist/types/client/canvas/ToolRail.d.ts +21 -0
  29. package/dist/types/client/canvas/TopBar.d.ts +7 -0
  30. package/dist/types/client/canvas/canvas-area.d.ts +39 -0
  31. package/dist/types/client/canvas/create-in-view.d.ts +18 -0
  32. package/dist/types/client/canvas/import-files.d.ts +8 -0
  33. package/dist/types/client/canvas/kind-colors.d.ts +3 -0
  34. package/dist/types/client/canvas/use-focus-trap.d.ts +14 -0
  35. package/dist/types/client/canvas/use-now.d.ts +6 -0
  36. package/dist/types/client/icons.d.ts +11 -4
  37. package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
  38. package/dist/types/client/nodes/GroupNode.d.ts +6 -1
  39. package/dist/types/client/nodes/MarkdownNode.d.ts +2 -0
  40. package/dist/types/client/nodes/inline-editor-commands.d.ts +3 -4
  41. package/dist/types/client/state/canvas-store.d.ts +49 -5
  42. package/dist/types/client/state/human-store.d.ts +26 -0
  43. package/dist/types/client/state/iframe-mode.d.ts +1 -0
  44. package/dist/types/client/state/intent-bridge.d.ts +37 -5
  45. package/dist/types/client/state/node-factory.d.ts +0 -1
  46. package/dist/types/client/state/presence-store.d.ts +78 -0
  47. package/dist/types/client/state/session-store.d.ts +176 -0
  48. package/dist/types/client/types.d.ts +2 -1
  49. package/dist/types/client/utils/platform.d.ts +2 -0
  50. package/dist/types/server/agent-presence.d.ts +192 -0
  51. package/dist/types/server/ax-gate-ttl.d.ts +6 -0
  52. package/dist/types/server/ax-interaction.d.ts +4 -10
  53. package/dist/types/server/ax-state-manager.d.ts +21 -2
  54. package/dist/types/server/ax-state.d.ts +49 -3
  55. package/dist/types/server/ax-wait.d.ts +13 -0
  56. package/dist/types/server/canvas-db.d.ts +9 -1
  57. package/dist/types/server/canvas-operations.d.ts +11 -0
  58. package/dist/types/server/canvas-state.d.ts +27 -13
  59. package/dist/types/server/canvas-validation.d.ts +0 -14
  60. package/dist/types/server/html-surface.d.ts +1 -1
  61. package/dist/types/server/human-presence.d.ts +40 -0
  62. package/dist/types/server/index.d.ts +29 -3
  63. package/dist/types/server/intent-registry.d.ts +7 -0
  64. package/dist/types/server/mutation-history.d.ts +15 -2
  65. package/dist/types/server/operations/invoker.d.ts +13 -2
  66. package/dist/types/server/operations/ops/ax-presence.d.ts +2 -0
  67. package/dist/types/server/operations/ops/human-presence.d.ts +3 -0
  68. package/dist/types/server/operations/registry.d.ts +11 -0
  69. package/dist/types/server/operations/types.d.ts +1 -1
  70. package/dist/types/server/scope-fence.d.ts +62 -0
  71. package/dist/types/server/server.d.ts +0 -12
  72. package/dist/types/shared/agent-presence.d.ts +130 -0
  73. package/dist/types/shared/approval-gates.d.ts +19 -0
  74. package/dist/types/shared/auto-arrange.d.ts +0 -1
  75. package/dist/types/shared/ax-kinds.d.ts +11 -0
  76. package/dist/types/shared/human-presence.d.ts +31 -0
  77. package/dist/types/shared/iframe-probe.d.ts +8 -0
  78. package/dist/types/shared/scope-fence.d.ts +21 -0
  79. package/docs/RELEASE.md +20 -0
  80. package/docs/ax-host-adapter-contract.md +30 -14
  81. package/docs/cli.md +37 -1
  82. package/docs/environment.md +3 -0
  83. package/docs/http-api.md +222 -5
  84. package/docs/mcp.md +6 -5
  85. package/docs/node-types.md +3 -1
  86. package/docs/plans/plan-009-tech-debt-backlog.md +13 -0
  87. package/docs/screenshot.png +0 -0
  88. package/docs/screenshots/multi-agent-live.png +0 -0
  89. package/docs/screenshots/workbench-live-dark.png +0 -0
  90. package/docs/screenshots/workbench-live-light.png +0 -0
  91. package/docs/sdk.md +1 -1
  92. package/package.json +4 -4
  93. package/skills/pmx-canvas/SKILL.md +112 -17
  94. package/skills/pmx-canvas/references/codex-app-adapter.md +28 -0
  95. package/skills/pmx-canvas/references/full-reference.md +55 -16
  96. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +41 -24
  97. package/skills/pmx-canvas-orchestration/SKILL.md +56 -2
  98. package/skills/pmx-canvas-testing/SKILL.md +17 -0
  99. package/src/cli/agent.ts +1 -0
  100. package/src/cli/commands/copilot.ts +15 -8
  101. package/src/cli/commands/nodes.ts +1 -19
  102. package/src/cli/commands/pump.ts +263 -0
  103. package/src/cli/index.ts +2 -5
  104. package/src/cli/shared.ts +1 -1
  105. package/src/client/App.tsx +209 -585
  106. package/src/client/canvas/AgentPresenceLayer.tsx +74 -0
  107. package/src/client/canvas/AttentionHistory.tsx +0 -10
  108. package/src/client/canvas/BarHint.tsx +49 -0
  109. package/src/client/canvas/CanvasNode.tsx +488 -136
  110. package/src/client/canvas/CanvasViewport.tsx +190 -94
  111. package/src/client/canvas/CommandBar.tsx +140 -0
  112. package/src/client/canvas/CommandPalette.tsx +133 -58
  113. package/src/client/canvas/ConnectionBanner.tsx +65 -0
  114. package/src/client/canvas/ContextMenu.tsx +88 -27
  115. package/src/client/canvas/ContextPinBar.tsx +2 -2
  116. package/src/client/canvas/EdgeLayer.tsx +121 -38
  117. package/src/client/canvas/EmptyState.tsx +93 -0
  118. package/src/client/canvas/ExpandedNodeOverlay.tsx +64 -118
  119. package/src/client/canvas/ExternalWriters.tsx +308 -0
  120. package/src/client/canvas/FocusFieldLayer.tsx +12 -1
  121. package/src/client/canvas/HumanPresenceLayer.tsx +45 -0
  122. package/src/client/canvas/IntentLayer.tsx +1 -1
  123. package/src/client/canvas/Minimap.tsx +117 -190
  124. package/src/client/canvas/ScopeFenceLayer.tsx +38 -0
  125. package/src/client/canvas/SelectionBar.tsx +175 -23
  126. package/src/client/canvas/SessionPanel.tsx +481 -0
  127. package/src/client/canvas/SessionReceipt.tsx +141 -0
  128. package/src/client/canvas/ShortcutOverlay.tsx +21 -6
  129. package/src/client/canvas/SnapshotPanel.tsx +166 -80
  130. package/src/client/canvas/TextPrompt.tsx +128 -0
  131. package/src/client/canvas/ToolRail.tsx +447 -0
  132. package/src/client/canvas/TopBar.tsx +308 -0
  133. package/src/client/canvas/auto-fit.ts +1 -5
  134. package/src/client/canvas/canvas-area.ts +74 -0
  135. package/src/client/canvas/create-in-view.ts +48 -0
  136. package/src/client/canvas/import-files.ts +64 -0
  137. package/src/client/canvas/kind-colors.ts +22 -0
  138. package/src/client/canvas/snap-guides.ts +1 -1
  139. package/src/client/canvas/use-focus-trap.ts +54 -0
  140. package/src/client/canvas/use-now.ts +17 -0
  141. package/src/client/canvas/use-pan-zoom.ts +13 -2
  142. package/src/client/icons.tsx +57 -18
  143. package/src/client/nodes/ExtAppFrame.tsx +86 -20
  144. package/src/client/nodes/GroupNode.tsx +9 -28
  145. package/src/client/nodes/InlineFormatBar.tsx +4 -3
  146. package/src/client/nodes/InlineMarkdownEditor.tsx +27 -0
  147. package/src/client/nodes/MarkdownNode.tsx +59 -24
  148. package/src/client/nodes/MdFormatBar.tsx +2 -4
  149. package/src/client/nodes/inline-editor-commands.ts +17 -6
  150. package/src/client/nodes/use-surface-frame.ts +3 -1
  151. package/src/client/state/attention-bridge.ts +3 -0
  152. package/src/client/state/canvas-store.ts +285 -90
  153. package/src/client/state/human-store.ts +175 -0
  154. package/src/client/state/iframe-mode.ts +33 -9
  155. package/src/client/state/intent-bridge.ts +75 -13
  156. package/src/client/state/node-factory.ts +2 -8
  157. package/src/client/state/presence-store.ts +168 -0
  158. package/src/client/state/session-store.ts +419 -0
  159. package/src/client/state/sse-bridge.ts +93 -30
  160. package/src/client/theme/global.css +3071 -796
  161. package/src/client/types.ts +3 -1
  162. package/src/client/utils/platform.ts +6 -1
  163. package/src/mcp/canvas-access.ts +2 -2
  164. package/src/server/agent-presence.ts +755 -0
  165. package/src/server/ax-context.ts +45 -0
  166. package/src/server/ax-gate-ttl.ts +61 -0
  167. package/src/server/ax-interaction.ts +29 -1
  168. package/src/server/ax-state-manager.ts +78 -6
  169. package/src/server/ax-state.ts +82 -15
  170. package/src/server/ax-wait.ts +33 -0
  171. package/src/server/canvas-db.ts +103 -33
  172. package/src/server/canvas-operations.ts +58 -14
  173. package/src/server/canvas-state.ts +131 -58
  174. package/src/server/canvas-validation.ts +3 -53
  175. package/src/server/demo-state.json +2 -70
  176. package/src/server/html-surface.ts +11 -2
  177. package/src/server/human-presence.ts +160 -0
  178. package/src/server/index.ts +111 -6
  179. package/src/server/intent-registry.ts +13 -0
  180. package/src/server/mutation-history.ts +47 -2
  181. package/src/server/operations/composites.ts +6 -3
  182. package/src/server/operations/http.ts +10 -1
  183. package/src/server/operations/index.ts +4 -0
  184. package/src/server/operations/invoker.ts +33 -6
  185. package/src/server/operations/ops/ax-presence.ts +62 -0
  186. package/src/server/operations/ops/ax-read.ts +8 -0
  187. package/src/server/operations/ops/ax-state.ts +106 -1
  188. package/src/server/operations/ops/ax-timeline.ts +54 -5
  189. package/src/server/operations/ops/ax-work.ts +46 -0
  190. package/src/server/operations/ops/batch.ts +3 -1
  191. package/src/server/operations/ops/edges.ts +88 -2
  192. package/src/server/operations/ops/groups.ts +4 -3
  193. package/src/server/operations/ops/human-presence.ts +35 -0
  194. package/src/server/operations/ops/json-render.ts +11 -1
  195. package/src/server/operations/ops/nodes.ts +77 -16
  196. package/src/server/operations/ops/query.ts +1 -0
  197. package/src/server/operations/ops/snapshots.ts +7 -4
  198. package/src/server/operations/registry.ts +196 -1
  199. package/src/server/operations/types.ts +1 -1
  200. package/src/server/scope-fence.ts +207 -0
  201. package/src/server/server.ts +177 -79
  202. package/src/server/trace-manager.ts +0 -2
  203. package/src/server/web-artifacts.ts +0 -1
  204. package/src/shared/agent-presence.ts +162 -0
  205. package/src/shared/approval-gates.ts +35 -0
  206. package/src/shared/auto-arrange.ts +121 -13
  207. package/src/shared/ax-kinds.ts +23 -0
  208. package/src/shared/human-presence.ts +30 -0
  209. package/src/shared/iframe-probe.ts +8 -0
  210. package/src/shared/scope-fence.ts +31 -0
  211. package/dist/types/client/canvas/DockedNode.d.ts +0 -4
  212. 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 { removeNodeFromClient, updateNodeFromClient } from '../state/intent-bridge';
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
- return (
295
- <div class={nodeClass} style={nodeStyle} onPointerDown={handlePointerDown} onContextMenu={handleContextMenuEvent}>
296
- <div class="node-titlebar" onPointerDown={handleTitlePointerDown}>
297
- <span class="node-type-icon" aria-hidden="true">
298
- {(() => {
299
- const NodeIcon = getNodeIcon(node.type);
300
- return <NodeIcon size={Math.round(14 * chromeScale)} />;
301
- })()}
302
- </span>
303
- {/* The icon already says what type this is, so the type badge yields to
304
- the status chip rather than stacking two pills in front of the name —
305
- on a narrow node that combination pushed the title to an ellipsis. */}
306
- {typeof node.data.axWorkStatus === 'string' ? (
307
- <span class={`node-ax-status node-ax-status-${node.data.axWorkStatus}`}>{node.data.axWorkStatus}</span>
308
- ) : (
309
- <span class="node-type-badge">{TYPE_LABELS[node.type]}</span>
310
- )}
311
- {renaming ? (
312
- <input
313
- ref={renameRef}
314
- class="node-title-input"
315
- value={title}
316
- onBlur={(e) => commitRename((e.target as HTMLInputElement).value)}
317
- onKeyDown={(e) => {
318
- if (e.key === 'Enter') commitRename((e.target as HTMLInputElement).value);
319
- if (e.key === 'Escape') setRenaming(false);
320
- }}
321
- onClick={(e) => e.stopPropagation()}
322
- />
323
- ) : (
324
- <span class="node-title" onDblClick={handleTitleDblClick} title={`${title} — double-click to rename`}>
325
- {title}
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={`ctx-pin-btn${isContextPinned ? ' ctx-pin-active' : ''}`}
463
+ class="group-chip-expand"
464
+ aria-label={`Expand group ${title}`}
337
465
  onClick={(e) => {
338
466
  e.stopPropagation();
339
- toggleContextPin(node.id);
467
+ toggleCollapsed(node.id);
340
468
  }}
341
- title={isContextPinned ? 'Remove from context' : 'Add to context'}
342
469
  >
343
- {'\u2726'}
470
+ ▸
344
471
  </button>
345
- {/* Open as site — full-page standalone view of this node's surface,
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
- {canOpenAsSite(node) && (
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
- void openNodeAsSite(node);
716
+ toggleCollapsed(node.id);
355
717
  }}
356
- title="Open as site"
718
+ title={node.collapsed ? 'Expand' : 'Collapse'}
357
719
  >
358
- ↗
720
+ {node.collapsed ? '▸' : '▾'}
359
721
  </button>
360
- )}
361
- {/* Expand — opens node as full-viewport overlay for focused work */}
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
- expandNode(node.id);
728
+ removeNode(node.id);
729
+ void removeNodeFromClient(node.id);
368
730
  }}
369
- title="Expand (focus mode)"
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
- </div>
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
- {/* Connection port handles — visible on hover, drag to connect */}
414
- {(['top', 'right', 'bottom', 'left'] as const).map((side) => (
415
- <div
416
- key={side}
417
- class={`node-port node-port-${side}`}
418
- onPointerDown={(e) => {
419
- e.stopPropagation();
420
- e.preventDefault();
421
- const cx = node.position.x + node.size.width / 2;
422
- const cy = node.position.y + node.size.height / 2;
423
- const hw = node.size.width / 2;
424
- const hh = node.size.height / 2;
425
- let px: number, py: number;
426
- switch (side) {
427
- case 'top':
428
- px = cx;
429
- py = cy - hh;
430
- break;
431
- case 'bottom':
432
- px = cx;
433
- py = cy + hh;
434
- break;
435
- case 'left':
436
- px = cx - hw;
437
- py = cy;
438
- break;
439
- case 'right':
440
- px = cx + hw;
441
- py = cy;
442
- break;
443
- }
444
- draggingEdge.value = {
445
- fromId: node.id,
446
- fromX: px,
447
- fromY: py,
448
- cursorX: px,
449
- cursorY: py,
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
  }