pmx-canvas 0.4.7 → 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.
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 +3069 -796
  6. package/dist/canvas/index.js +104 -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 +25 -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 +32 -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 +23 -1
  57. package/dist/types/server/canvas-operations.d.ts +1 -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 +54 -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 +1 -1
  93. package/skills/pmx-canvas/SKILL.md +109 -18
  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 +57 -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 +252 -0
  103. package/src/cli/index.ts +1 -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 +41 -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 +79 -2
  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 +273 -84
  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 +3069 -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 +237 -61
  172. package/src/server/canvas-operations.ts +17 -8
  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 +107 -4
  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 +65 -16
  196. package/src/server/operations/ops/query.ts +1 -0
  197. package/src/server/operations/ops/snapshots.ts +16 -0
  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,12 +1,25 @@
1
+ import { agentIdentityHue } from '../../shared/agent-presence.js';
1
2
  import type { Signal } from '@preact/signals';
2
- import { useSignalEffect } from '@preact/signals';
3
- import { useCallback, useEffect, useRef } from 'preact/hooks';
4
- import { canvasTheme } from '../state/canvas-store';
5
- import { getCanvasTokens } from '../theme/tokens';
3
+ import { useCallback, useRef } from 'preact/hooks';
4
+ import { fenceRectFromNodes } from '../../shared/scope-fence.js';
5
+ import { selectedNodeIds } from '../state/canvas-store';
6
+ import { agentPresences, presenceWorldPosition } from '../state/presence-store';
7
+ import { scopeFence } from '../state/session-store';
6
8
  import type { CanvasEdge, CanvasNodeState, ViewportState } from '../types';
7
-
8
- const MINIMAP_W = 180;
9
- const MINIMAP_H = 120;
9
+ import { KIND_COLOR } from './kind-colors';
10
+
11
+ /**
12
+ * Minimap v2 (rail-chrome-v2 phase 7, design item 19): a true-scale node map
13
+ * rendered from the store — each node a scaled rect in its kind color, groups
14
+ * and the scope fence as dashed outlines, the viewport frame with a grab
15
+ * cursor, the zoom % in the corner, selection outlines mirrored, and a pulsing
16
+ * violet dot where an attached agent is. 168×112 at rest; hovering magnifies
17
+ * the whole map ×1.7 from the bottom-right corner (CSS). Click jumps the
18
+ * viewport; dragging pans.
19
+ */
20
+
21
+ export const MINIMAP_W = 168;
22
+ export const MINIMAP_H = 112;
10
23
  const PADDING = 20;
11
24
 
12
25
  interface MinimapBounds {
@@ -21,39 +34,6 @@ interface MinimapFrame {
21
34
  scale: number;
22
35
  }
23
36
 
24
- function getNodeColors(): Record<CanvasNodeState['type'], string> {
25
- const t = getCanvasTokens();
26
- return {
27
- markdown: t.accent,
28
- 'mcp-app': t.ok,
29
- webpage: t.warn,
30
- 'json-render': t.ok,
31
- graph: t.purple,
32
- prompt: t.accent,
33
- response: t.ok,
34
- status: t.warn,
35
- context: t.muted,
36
- ledger: t.dim,
37
- trace: t.purple,
38
- file: t.accent,
39
- diff: t.ok,
40
- mermaid: t.purple,
41
- image: t.ok,
42
- html: t.warn,
43
- group: t.dim,
44
- };
45
- }
46
-
47
- function getEdgeColors(): Record<CanvasEdge['type'], string> {
48
- const t = getCanvasTokens();
49
- return {
50
- relation: t.muted,
51
- 'depends-on': t.warn,
52
- flow: t.accent,
53
- references: t.dim,
54
- };
55
- }
56
-
57
37
  export function computeMinimapFrame(
58
38
  nodeMap: Map<string, CanvasNodeState>,
59
39
  currentViewport: ViewportState,
@@ -110,139 +90,32 @@ interface MinimapProps {
110
90
  containerHeight: number;
111
91
  }
112
92
 
113
- export function Minimap({ viewport, nodes, edges, onNavigate, containerWidth, containerHeight }: MinimapProps) {
114
- const canvasRef = useRef<HTMLCanvasElement>(null);
93
+ export function Minimap({ viewport, nodes, onNavigate, containerWidth, containerHeight }: MinimapProps) {
94
+ const mapRef = useRef<HTMLDivElement>(null);
115
95
  const isDragging = useRef(false);
116
- const frameRef = useRef<MinimapFrame | null>(null);
117
- const drawRafId = useRef<number | null>(null);
118
-
119
- const draw = useCallback(() => {
120
- const canvas = canvasRef.current;
121
- if (!canvas) return;
122
- const ctx = canvas.getContext('2d');
123
- if (!ctx) return;
124
- const nodeMap = nodes.value;
125
- const edgeMap = edges.value;
126
- const currentViewport = viewport.value;
127
-
128
- const dpr = window.devicePixelRatio || 1;
129
- canvas.width = MINIMAP_W * dpr;
130
- canvas.height = MINIMAP_H * dpr;
131
- ctx.scale(dpr, dpr);
132
-
133
- // Clear
134
- ctx.clearRect(0, 0, MINIMAP_W, MINIMAP_H);
135
- const t = getCanvasTokens();
136
- ctx.fillStyle = t.panel + 'd9'; // panel color with ~85% alpha
137
- ctx.fillRect(0, 0, MINIMAP_W, MINIMAP_H);
138
-
139
- const frame = computeMinimapFrame(nodeMap, currentViewport, containerWidth, containerHeight);
140
- frameRef.current = frame;
141
- const { bounds, scale } = frame;
142
-
143
- const toMiniX = (x: number) => (x - bounds.minX) * scale;
144
- const toMiniY = (y: number) => (y - bounds.minY) * scale;
145
-
146
- // Draw nodes
147
- const all = Array.from(nodeMap.values());
148
- const nodeColors = getNodeColors();
149
- for (const n of all) {
150
- ctx.fillStyle = nodeColors[n.type] ?? t.muted;
151
- ctx.globalAlpha = 0.6;
152
- ctx.fillRect(
153
- toMiniX(n.position.x),
154
- toMiniY(n.position.y),
155
- Math.max(4, n.size.width * scale),
156
- Math.max(3, n.size.height * scale),
157
- );
158
- }
159
-
160
- const edgeColors = getEdgeColors();
161
- for (const edge of edgeMap.values()) {
162
- const fromNode = nodeMap.get(edge.from);
163
- const toNode = nodeMap.get(edge.to);
164
- if (!fromNode || !toNode) continue;
165
- const fromCx = toMiniX(fromNode.position.x + fromNode.size.width / 2);
166
- const fromCy = toMiniY(fromNode.position.y + fromNode.size.height / 2);
167
- const toCx = toMiniX(toNode.position.x + toNode.size.width / 2);
168
- const toCy = toMiniY(toNode.position.y + toNode.size.height / 2);
169
- ctx.beginPath();
170
- ctx.moveTo(fromCx, fromCy);
171
- ctx.lineTo(toCx, toCy);
172
- ctx.strokeStyle = edgeColors[edge.type] ?? t.muted;
173
- ctx.globalAlpha = 0.3;
174
- ctx.lineWidth = 0.5;
175
- ctx.stroke();
176
- }
177
-
178
- // Draw viewport rectangle
179
- const vpLeft = -currentViewport.x / currentViewport.scale;
180
- const vpTop = -currentViewport.y / currentViewport.scale;
181
- const vpW = containerWidth / currentViewport.scale;
182
- const vpH = containerHeight / currentViewport.scale;
183
-
184
- ctx.globalAlpha = 1;
185
- ctx.strokeStyle = t.accent;
186
- ctx.lineWidth = 1.5;
187
- ctx.strokeRect(toMiniX(vpLeft), toMiniY(vpTop), vpW * scale, vpH * scale);
188
- }, [nodes, edges, viewport, containerWidth, containerHeight]);
189
-
190
- const drawRef = useRef(draw);
191
- drawRef.current = draw;
192
-
193
- const scheduleDraw = useCallback(() => {
194
- if (drawRafId.current !== null) return;
195
- drawRafId.current = window.requestAnimationFrame(() => {
196
- drawRafId.current = null;
197
- drawRef.current();
198
- });
199
- }, []);
200
96
 
201
- // Redraw on state changes (including theme)
202
- useSignalEffect(() => {
203
- void canvasTheme.value;
204
- void nodes.value;
205
- void edges.value;
206
- void viewport.value;
207
- scheduleDraw();
208
- });
209
-
210
- useEffect(() => {
211
- scheduleDraw();
212
- }, [containerWidth, containerHeight, scheduleDraw]);
213
-
214
- useEffect(
215
- () => () => {
216
- if (drawRafId.current !== null) {
217
- window.cancelAnimationFrame(drawRafId.current);
218
- drawRafId.current = null;
219
- }
220
- },
221
- [],
222
- );
97
+ const nodeMap = nodes.value;
98
+ const v = viewport.value;
99
+ const frame = computeMinimapFrame(nodeMap, v, containerWidth, containerHeight);
100
+ const { bounds, scale } = frame;
101
+ const toX = (x: number) => (x - bounds.minX) * scale;
102
+ const toY = (y: number) => (y - bounds.minY) * scale;
223
103
 
224
104
  const handleNavigateFromEvent = useCallback(
225
105
  (e: MouseEvent | PointerEvent) => {
226
- const canvas = canvasRef.current;
227
- if (!canvas) return;
228
-
229
- const rect = canvas.getBoundingClientRect();
230
- const mx = e.clientX - rect.left;
231
- const my = e.clientY - rect.top;
232
-
233
- const frame =
234
- frameRef.current ?? computeMinimapFrame(nodes.value, viewport.value, containerWidth, containerHeight);
235
- frameRef.current = frame;
236
- const { bounds, scale } = frame;
237
-
238
- const v = viewport.value;
239
- const vpW = containerWidth / v.scale;
240
- const vpH = containerHeight / v.scale;
241
-
242
- // Center viewport on clicked point
243
- const worldX = mx / scale + bounds.minX;
244
- const worldY = my / scale + bounds.minY;
245
- onNavigate(-(worldX - vpW / 2) * v.scale, -(worldY - vpH / 2) * v.scale);
106
+ const map = mapRef.current;
107
+ if (!map) return;
108
+ // The map may be magnified (hover): read the rendered size, not the logical one.
109
+ const rect = map.getBoundingClientRect();
110
+ const mx = ((e.clientX - rect.left) / rect.width) * MINIMAP_W;
111
+ const my = ((e.clientY - rect.top) / rect.height) * MINIMAP_H;
112
+ const current = computeMinimapFrame(nodes.value, viewport.value, containerWidth, containerHeight);
113
+ const vp = viewport.value;
114
+ const vpW = containerWidth / vp.scale;
115
+ const vpH = containerHeight / vp.scale;
116
+ const worldX = mx / current.scale + current.bounds.minX;
117
+ const worldY = my / current.scale + current.bounds.minY;
118
+ onNavigate(-(worldX - vpW / 2) * vp.scale, -(worldY - vpH / 2) * vp.scale);
246
119
  },
247
120
  [nodes, viewport, containerWidth, containerHeight, onNavigate],
248
121
  );
@@ -250,50 +123,104 @@ export function Minimap({ viewport, nodes, edges, onNavigate, containerWidth, co
250
123
  const handlePointerDown = useCallback(
251
124
  (e: PointerEvent) => {
252
125
  e.stopPropagation();
126
+ e.preventDefault();
253
127
  isDragging.current = true;
254
128
  handleNavigateFromEvent(e);
255
-
256
129
  const onPointerMove = (ev: PointerEvent) => {
257
130
  if (isDragging.current) handleNavigateFromEvent(ev);
258
131
  };
259
-
260
132
  const onPointerUp = () => {
261
133
  isDragging.current = false;
262
134
  document.removeEventListener('pointermove', onPointerMove);
263
135
  document.removeEventListener('pointerup', onPointerUp);
264
136
  };
265
-
266
137
  document.addEventListener('pointermove', onPointerMove);
267
138
  document.addEventListener('pointerup', onPointerUp);
268
139
  },
269
140
  [handleNavigateFromEvent],
270
141
  );
271
142
 
143
+ const selected = selectedNodeIds.value;
144
+ const fence = scopeFence.value;
145
+ const fenceRect = fence
146
+ ? fenceRectFromNodes(
147
+ fence.nodeIds.map((id) => nodeMap.get(id)).filter((node): node is CanvasNodeState => node !== undefined),
148
+ fence.padding,
149
+ )
150
+ : null;
151
+ // Every live writer shows on the map — attached sessions, external writers,
152
+ // and fleet workers alike (amended presence contract): the minimap is where
153
+ // the human looks first to find WHERE an agent is working.
154
+ const presenceDots = agentPresences.value
155
+ .map((presence) => ({
156
+ id: presence.sessionId,
157
+ phase: presence.phase,
158
+ worker: presence.parentAgentId != null,
159
+ external: !presence.attached,
160
+ at: presenceWorldPosition(presence, (id) => nodeMap.get(id)),
161
+ }))
162
+ .filter((dot): dot is typeof dot & { at: { x: number; y: number } } => dot.at !== null);
163
+
164
+ const vpLeft = -v.x / v.scale;
165
+ const vpTop = -v.y / v.scale;
166
+
272
167
  return (
273
168
  <div
274
- style={{
275
- position: 'fixed',
276
- bottom: '16px',
277
- right: '16px',
278
- zIndex: 9998,
279
- border: '1px solid var(--c-line)',
280
- borderRadius: 'var(--radius-sm)',
281
- overflow: 'hidden',
282
- boxShadow: '0 4px 16px var(--c-shadow)',
283
- }}
169
+ ref={mapRef}
170
+ class="minimap"
171
+ data-testid="minimap"
172
+ title="Click to jump · drag the frame to pan"
173
+ onPointerDown={handlePointerDown}
284
174
  >
285
- <canvas
286
- ref={canvasRef}
287
- width={MINIMAP_W}
288
- height={MINIMAP_H}
175
+ {Array.from(nodeMap.values()).map((node) => {
176
+ const isGroup = node.type === 'group';
177
+ return (
178
+ <span
179
+ key={node.id}
180
+ class={`minimap-node${isGroup ? ' is-group' : ''}${selected.has(node.id) ? ' is-selected' : ''}`}
181
+ data-kind={node.type}
182
+ style={{
183
+ left: `${toX(node.position.x)}px`,
184
+ top: `${toY(node.position.y)}px`,
185
+ width: `${Math.max(isGroup ? 6 : 4, node.size.width * scale)}px`,
186
+ height: `${Math.max(isGroup ? 5 : 3, node.size.height * scale)}px`,
187
+ '--kind': KIND_COLOR[node.type],
188
+ }}
189
+ />
190
+ );
191
+ })}
192
+ {fenceRect && (
193
+ <span
194
+ class="minimap-fence"
195
+ style={{
196
+ left: `${toX(fenceRect.x)}px`,
197
+ top: `${toY(fenceRect.y)}px`,
198
+ width: `${fenceRect.width * scale}px`,
199
+ height: `${fenceRect.height * scale}px`,
200
+ }}
201
+ />
202
+ )}
203
+ {presenceDots.map((dot) => (
204
+ <span
205
+ key={dot.id}
206
+ class={`minimap-presence phase-${dot.phase}${dot.worker ? ' is-worker' : ''}${dot.external ? ' is-external' : ''}`}
207
+ style={{
208
+ left: `${toX(dot.at.x) - 4}px`,
209
+ top: `${toY(dot.at.y) - 4}px`,
210
+ '--identity-color': `hsl(${agentIdentityHue(dot.id)} 65% 62%)`,
211
+ }}
212
+ />
213
+ ))}
214
+ <span
215
+ class="minimap-frame"
289
216
  style={{
290
- width: `${MINIMAP_W}px`,
291
- height: `${MINIMAP_H}px`,
292
- display: 'block',
293
- cursor: 'pointer',
217
+ left: `${toX(vpLeft)}px`,
218
+ top: `${toY(vpTop)}px`,
219
+ width: `${(containerWidth / v.scale) * scale}px`,
220
+ height: `${(containerHeight / v.scale) * scale}px`,
294
221
  }}
295
- onPointerDown={handlePointerDown}
296
222
  />
223
+ <span class="minimap-zoom">{Math.round(v.scale * 100)}%</span>
297
224
  </div>
298
225
  );
299
226
  }
@@ -0,0 +1,38 @@
1
+ import { fenceRectFromNodes } from '../../shared/scope-fence.js';
2
+ import { nodes, viewport } from '../state/canvas-store';
3
+ import { sessionActive } from '../state/presence-store';
4
+ import { scopeFence } from '../state/session-store';
5
+
6
+ /**
7
+ * Scope fence (rail-chrome-v2 phase 4, design item 4): the region the human
8
+ * granted the attached agent. Drawn in the world layer from the same geometry
9
+ * the server enforces (fenced nodes' bounding box + padding), so what the
10
+ * human sees is exactly what the agent is held to. Mounts only while a
11
+ * session is attached and a fence is set.
12
+ */
13
+ export function ScopeFenceLayer() {
14
+ if (!sessionActive.value) return null;
15
+ const fence = scopeFence.value;
16
+ if (!fence) return null;
17
+ const nodeMap = nodes.value;
18
+ const fenced = fence.nodeIds.map((id) => nodeMap.get(id)).filter((node) => node !== undefined);
19
+ const rect = fenceRectFromNodes(fenced, fence.padding);
20
+ if (!rect) return null;
21
+ const counterScale = 1 / Math.max(0.05, viewport.value.scale);
22
+
23
+ return (
24
+ <div
25
+ class="scope-fence"
26
+ data-fenced-count={fenced.length}
27
+ style={{ transform: `translate(${rect.x}px, ${rect.y}px)`, width: `${rect.width}px`, height: `${rect.height}px` }}
28
+ aria-hidden="true"
29
+ >
30
+ <span class="scope-fence-pill scope-fence-pill-top" style={{ transform: `scale(${counterScale})` }}>
31
+ Agent scope · {fenced.length} node{fenced.length === 1 ? '' : 's'}
32
+ </span>
33
+ <span class="scope-fence-pill scope-fence-pill-bottom" style={{ transform: `scale(${counterScale})` }}>
34
+ writes outside are blocked · reads allowed
35
+ </span>
36
+ </div>
37
+ );
38
+ }
@@ -1,7 +1,26 @@
1
1
  import { useCallback } from 'preact/hooks';
2
- import { addContextPins, clearSelection, selectedNodeIds } from '../state/canvas-store';
3
- import { createEdgeFromClient, createGroupFromClient } from '../state/intent-bridge';
2
+ import { IconArrange } from '../icons';
3
+ import { attentionHistoryOpen } from '../state/attention-store';
4
+ import {
5
+ addContextPins,
6
+ alignSelection,
7
+ arrangeSelection,
8
+ clearSelection,
9
+ contextPinnedNodeIds,
10
+ distributeSelection,
11
+ removeNode,
12
+ selectedNodeIds,
13
+ } from '../state/canvas-store';
14
+ import { createEdgeFromClient, createGroupFromClient, removeNodeFromClient } from '../state/intent-bridge';
15
+ import { sessionActive } from '../state/presence-store';
16
+ import { BarHint } from './BarHint';
4
17
 
18
+ /**
19
+ * Floating bottom-center bar for a multi-selection (rail-chrome-v2 phase 7,
20
+ * design item 13): count, align left/top, distribute, auto-arrange, Group (G),
21
+ * Connect, Pin to agent context, delete. Restyle over the existing handlers —
22
+ * the geometry actions live in the store and persist like a drag does.
23
+ */
5
24
  export function SelectionBar() {
6
25
  const count = selectedNodeIds.value.size;
7
26
 
@@ -29,34 +48,167 @@ export function SelectionBar() {
29
48
  clearSelection();
30
49
  }, []);
31
50
 
51
+ const handleDelete = useCallback(() => {
52
+ const ids = Array.from(selectedNodeIds.value);
53
+ for (const id of ids) {
54
+ removeNode(id);
55
+ void removeNodeFromClient(id);
56
+ }
57
+ clearSelection();
58
+ }, []);
59
+
32
60
  if (count === 0) return null;
61
+ const many = count >= 2;
62
+ // The quiet board's pin bar shares the bottom-center slot (same rule as
63
+ // `ContextPinBar`'s own visibility) — sit above it rather than under it.
64
+ const abovePinBar = !sessionActive.value && contextPinnedNodeIds.value.size > 0 && !attentionHistoryOpen.value;
33
65
 
34
66
  return (
35
- <div class="selection-bar">
67
+ <div class={`selection-bar${abovePinBar ? ' is-above-pin-bar' : ''}`} role="toolbar" aria-label="Selection">
36
68
  <span class="selection-bar-count">
37
- {'\u2726'} {count} node{count !== 1 ? 's' : ''} selected
69
+ {count} node{count !== 1 ? 's' : ''} selected
38
70
  </span>
39
- <button type="button" class="selection-bar-btn selection-bar-pin-ctx" onClick={handlePinContext}>
40
- Pin as context
41
- </button>
42
- {count >= 2 && (
43
- <button type="button" class="selection-bar-btn" onClick={handleGroup}>
44
- Group
45
- </button>
71
+ {many && (
72
+ <>
73
+ <span class="selection-bar-sep" />
74
+ <BarHint label="Align left" side="up">
75
+ <button
76
+ type="button"
77
+ class="selection-bar-icon"
78
+ aria-label="Align left"
79
+ onClick={() => alignSelection('left')}
80
+ >
81
+ <svg
82
+ width="14"
83
+ height="14"
84
+ viewBox="0 0 16 16"
85
+ fill="none"
86
+ stroke="currentColor"
87
+ stroke-width="1.5"
88
+ stroke-linecap="round"
89
+ aria-hidden="true"
90
+ >
91
+ <line x1="2.5" y1="2" x2="2.5" y2="14" />
92
+ <rect x="5" y="3.5" width="8" height="3.5" rx="1" />
93
+ <rect x="5" y="9" width="5" height="3.5" rx="1" />
94
+ </svg>
95
+ </button>
96
+ </BarHint>
97
+ <BarHint label="Align top" side="up">
98
+ <button
99
+ type="button"
100
+ class="selection-bar-icon"
101
+ aria-label="Align top"
102
+ onClick={() => alignSelection('top')}
103
+ >
104
+ <svg
105
+ width="14"
106
+ height="14"
107
+ viewBox="0 0 16 16"
108
+ fill="none"
109
+ stroke="currentColor"
110
+ stroke-width="1.5"
111
+ stroke-linecap="round"
112
+ aria-hidden="true"
113
+ >
114
+ <line x1="2" y1="2.5" x2="14" y2="2.5" />
115
+ <rect x="3.5" y="5" width="3.5" height="8" rx="1" />
116
+ <rect x="9" y="5" width="3.5" height="5" rx="1" />
117
+ </svg>
118
+ </button>
119
+ </BarHint>
120
+ <BarHint
121
+ label="Distribute horizontally"
122
+ body="Evens the gaps. A selection that cannot fit flows into a clean row instead of overlapping."
123
+ side="up"
124
+ >
125
+ <button
126
+ type="button"
127
+ class="selection-bar-icon"
128
+ aria-label="Distribute"
129
+ disabled={count < 3}
130
+ onClick={() => distributeSelection()}
131
+ >
132
+ <svg
133
+ width="14"
134
+ height="14"
135
+ viewBox="0 0 16 16"
136
+ fill="none"
137
+ stroke="currentColor"
138
+ stroke-width="1.5"
139
+ aria-hidden="true"
140
+ >
141
+ <rect x="1.5" y="5" width="3.5" height="6" rx="1" />
142
+ <rect x="6.25" y="5" width="3.5" height="6" rx="1" />
143
+ <rect x="11" y="5" width="3.5" height="6" rx="1" />
144
+ </svg>
145
+ </button>
146
+ </BarHint>
147
+ <BarHint label="Auto-arrange selection" side="up">
148
+ <button
149
+ type="button"
150
+ class="selection-bar-icon"
151
+ aria-label="Auto-arrange"
152
+ onClick={() => arrangeSelection()}
153
+ >
154
+ <IconArrange size={14} />
155
+ </button>
156
+ </BarHint>
157
+ <span class="selection-bar-sep" />
158
+ <BarHint label="Group the selection" shortcut="G" side="up">
159
+ <button type="button" class="selection-bar-action" onClick={handleGroup}>
160
+ <svg
161
+ width="13"
162
+ height="13"
163
+ viewBox="0 0 16 16"
164
+ fill="none"
165
+ stroke="currentColor"
166
+ stroke-width="1.5"
167
+ stroke-dasharray="3 2.5"
168
+ aria-hidden="true"
169
+ >
170
+ <rect x="1.5" y="1.5" width="13" height="13" rx="2" />
171
+ </svg>
172
+ Group
173
+ </button>
174
+ </BarHint>
175
+ <BarHint label="Connect every pair" side="up">
176
+ <button type="button" class="selection-bar-action" onClick={handleConnect}>
177
+ Connect
178
+ </button>
179
+ </BarHint>
180
+ </>
46
181
  )}
47
- {count >= 2 && (
48
- <button type="button" class="selection-bar-btn" onClick={handleConnect}>
49
- Connect
182
+ <BarHint label="Pin to agent context" side="up">
183
+ <button type="button" class="selection-bar-action is-pin" onClick={handlePinContext}>
184
+ <span aria-hidden="true">✦</span>
185
+ Pin as context
50
186
  </button>
51
- )}
52
- <button
53
- type="button"
54
- class="selection-bar-btn selection-bar-clear"
55
- onClick={clearSelection}
56
- title="Clear selection"
57
- >
58
- {'\u00d7'}
59
- </button>
187
+ </BarHint>
188
+ <span class="selection-bar-sep" />
189
+ <BarHint label="Delete selection" side="up">
190
+ <button type="button" class="selection-bar-icon is-danger" aria-label="Delete selection" onClick={handleDelete}>
191
+ <svg
192
+ width="14"
193
+ height="14"
194
+ viewBox="0 0 16 16"
195
+ fill="none"
196
+ stroke="currentColor"
197
+ stroke-width="1.5"
198
+ stroke-linecap="round"
199
+ aria-hidden="true"
200
+ >
201
+ <path d="M2.5 4 H13.5" />
202
+ <path d="M5.5 4 V2.5 H10.5 V4" />
203
+ <path d="M4 4 L4.8 13.5 H11.2 L12 4" />
204
+ </svg>
205
+ </button>
206
+ </BarHint>
207
+ <BarHint label="Clear selection" shortcut="Esc" side="up">
208
+ <button type="button" class="selection-bar-icon" onClick={clearSelection} aria-label="Clear selection">
209
+ ×
210
+ </button>
211
+ </BarHint>
60
212
  </div>
61
213
  );
62
214
  }