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,4 +1,6 @@
1
+ import { effect } from '@preact/signals';
1
2
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
3
+ import { askText } from './TextPrompt';
2
4
  import { ContextNode } from '../nodes/ContextNode';
3
5
  import { DiffNode } from '../nodes/DiffNode';
4
6
  import { FileNode } from '../nodes/FileNode';
@@ -29,11 +31,20 @@ import {
29
31
  viewport,
30
32
  createAnnotationFromClient,
31
33
  removeAnnotationFromClient,
34
+ isPanModeActive,
35
+ dragDropTarget,
36
+ hiddenByCollapsedGroup,
37
+ canvasTool,
32
38
  } from '../state/canvas-store';
33
39
  import { createEdgeFromClient, createNodeFromClient } from '../state/intent-bridge';
34
- import type { CanvasAnnotation, CanvasNodeState } from '../types';
40
+ import type { AnnotationTool, CanvasAnnotation, CanvasNodeState } from '../types';
35
41
  import { FocusFieldLayer } from './FocusFieldLayer';
42
+ import { importFiles } from './import-files';
36
43
  import { IntentLayer } from './IntentLayer';
44
+ import { AgentPresenceLayer } from './AgentPresenceLayer';
45
+ import { HumanPresenceLayer } from './HumanPresenceLayer';
46
+ import { reportHumanCursor } from '../state/human-store';
47
+ import { ScopeFenceLayer } from './ScopeFenceLayer';
37
48
  import { CanvasNode } from './CanvasNode';
38
49
  import { EdgeLayer } from './EdgeLayer';
39
50
  import { AnnotationLayer } from './AnnotationLayer';
@@ -151,17 +162,14 @@ const ANNOTATION_WIDTH = 4;
151
162
  const TEXT_ANNOTATION_WIDTH = 24;
152
163
  const ERASER_HIT_RADIUS = 14;
153
164
 
154
- type AnnotationTool = 'pen' | 'eraser' | 'text' | null;
155
-
156
165
  interface CanvasViewportProps {
157
166
  onNodeContextMenu?: (e: MouseEvent, nodeId: string) => void;
167
+ onEdgeContextMenu?: (e: MouseEvent, edgeId: string) => void;
158
168
  onCanvasContextMenu?: (e: MouseEvent, canvasX: number, canvasY: number) => void;
159
169
  annotationMode?: boolean;
160
170
  annotationTool?: AnnotationTool;
161
171
  }
162
172
 
163
- const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico', 'avif']);
164
- const MD_EXTS = new Set(['md', 'mdx', 'markdown']);
165
173
  const WEBPAGE_NODE_SIZE = { width: 520, height: 420 };
166
174
 
167
175
  function normalizeUrlCandidate(raw: string): string | null {
@@ -214,21 +222,16 @@ function isEditableElement(element: Element | null): boolean {
214
222
  return Boolean(element.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]'));
215
223
  }
216
224
 
217
- function nodeTypeFromFilename(name: string): 'image' | 'markdown' | 'file' {
218
- const ext = name.split('.').pop()?.toLowerCase() ?? '';
219
- if (IMAGE_EXTS.has(ext)) return 'image';
220
- if (MD_EXTS.has(ext)) return 'markdown';
221
- return 'file';
222
- }
223
-
224
225
  export function getRenderableWorldNodes(
225
226
  allNodes: Iterable<CanvasNodeState>,
226
227
  focusedNodeId: string | null,
228
+ hiddenIds: ReadonlySet<string> = new Set(),
227
229
  ): CanvasNodeState[] {
228
230
  const worldNodes: CanvasNodeState[] = [];
229
231
  let insertIdx = 0; // groups fill from the front
230
232
  for (const n of allNodes) {
231
- if (n.dockPosition !== null) continue;
233
+ // Children of a collapsed group are hidden behind its chip.
234
+ if (hiddenIds.has(n.id)) continue;
232
235
  // Focus mode renders the node inside the overlay. Skip the original world
233
236
  // instance so embedded apps do not mount twice.
234
237
  if (focusedNodeId && n.id === focusedNodeId) continue;
@@ -243,12 +246,14 @@ export function getRenderableWorldNodes(
243
246
 
244
247
  export function CanvasViewport({
245
248
  onNodeContextMenu,
249
+ onEdgeContextMenu,
246
250
  onCanvasContextMenu,
247
251
  annotationMode = false,
248
252
  annotationTool = null,
249
253
  }: CanvasViewportProps) {
250
254
  const v = viewport.value;
251
255
  const isLassoing = useRef(false);
256
+ const lastPointerScreen = useRef<{ x: number; y: number } | null>(null);
252
257
  const isAnnotating = useRef(false);
253
258
  const annotationPoints = useRef<Array<{ x: number; y: number }>>([]);
254
259
  const [lasso, setLasso] = useState<LassoRect | null>(null);
@@ -261,6 +266,30 @@ export function CanvasViewport({
261
266
  // without stale-closure issues from useCallback dependency capture.
262
267
  const lassoRef = useRef<LassoRect | null>(null);
263
268
 
269
+ // Raster hygiene: promote the world layer only WHILE the viewport moves and
270
+ // demote it once it settles. A permanent will-change pinned a cached raster
271
+ // the compositor merely stretched — at high zoom the node headline (12px
272
+ // text magnified by the matrix) went permanently blurry. Dropping the hint
273
+ // after the last change forces a crisp re-raster at the final scale.
274
+ const worldRef = useRef<HTMLDivElement>(null);
275
+ useEffect(() => {
276
+ let settleTimer: ReturnType<typeof setTimeout> | null = null;
277
+ const dispose = effect(() => {
278
+ void viewport.value;
279
+ const world = worldRef.current;
280
+ if (!world) return;
281
+ world.style.willChange = 'transform';
282
+ if (settleTimer) clearTimeout(settleTimer);
283
+ settleTimer = setTimeout(() => {
284
+ world.style.willChange = 'auto';
285
+ }, 180);
286
+ });
287
+ return () => {
288
+ if (settleTimer) clearTimeout(settleTimer);
289
+ dispose();
290
+ };
291
+ }, []);
292
+
264
293
  const containerRef = usePanZoom({
265
294
  viewport,
266
295
  disabled: annotationMode,
@@ -318,7 +347,7 @@ export function CanvasViewport({
318
347
 
319
348
  if (annotationTool === 'eraser') {
320
349
  const target = e.target instanceof Element ? e.target : null;
321
- if (target?.closest('.hud-layer, .snapshot-panel, .context-menu, .command-palette')) return;
350
+ if (target?.closest('.tool-rail, .top-bar, .snapshot-panel, .context-menu, .command-palette')) return;
322
351
  e.preventDefault();
323
352
  e.stopPropagation();
324
353
  const rect = container.getBoundingClientRect();
@@ -334,7 +363,7 @@ export function CanvasViewport({
334
363
 
335
364
  if (annotationTool === 'text') {
336
365
  const target = e.target instanceof Element ? e.target : null;
337
- if (target?.closest('.hud-layer, .snapshot-panel, .context-menu, .command-palette')) return;
366
+ if (target?.closest('.tool-rail, .top-bar, .snapshot-panel, .context-menu, .command-palette')) return;
338
367
  e.preventDefault();
339
368
  e.stopPropagation();
340
369
  activeNodeId.value = null;
@@ -351,7 +380,7 @@ export function CanvasViewport({
351
380
 
352
381
  if (annotationTool === 'pen') {
353
382
  const target = e.target instanceof Element ? e.target : null;
354
- if (target?.closest('.hud-layer, .snapshot-panel, .context-menu, .command-palette')) return;
383
+ if (target?.closest('.tool-rail, .top-bar, .snapshot-panel, .context-menu, .command-palette')) return;
355
384
  e.preventDefault();
356
385
  e.stopPropagation();
357
386
  activeNodeId.value = null;
@@ -379,14 +408,17 @@ export function CanvasViewport({
379
408
 
380
409
  if (e.target !== container) return;
381
410
 
382
- if (!e.shiftKey) {
383
- if (!lassoRef.current) {
411
+ // Pan tool / held Space: usePanZoom owns this drag.
412
+ if (isPanModeActive() || e.button === 1) {
413
+ if (!e.shiftKey && !lassoRef.current) {
384
414
  activeNodeId.value = null;
385
415
  clearSelection();
386
416
  }
387
417
  return;
388
418
  }
389
419
 
420
+ // Select tool: plain background drag draws the lasso. A sub-threshold
421
+ // drag (a click) falls through in handlePointerUp and clears selection.
390
422
  e.preventDefault();
391
423
  e.stopPropagation();
392
424
  isLassoing.current = true;
@@ -401,8 +433,39 @@ export function CanvasViewport({
401
433
  [annotationTool, containerRef],
402
434
  );
403
435
 
436
+ // Trackpad pans and zooms move the world under a motionless pointer — no
437
+ // pointermove fires — so other tabs would watch this cursor drift to a stale
438
+ // world point while this tab navigates. Re-derive the world position from
439
+ // the last screen point on every viewport change (throttled by the store).
440
+ useEffect(() => {
441
+ return effect(() => {
442
+ const vp = viewport.value;
443
+ const screen = lastPointerScreen.current;
444
+ const container = containerRef.current;
445
+ if (!screen || !container) return;
446
+ const rect = container.getBoundingClientRect();
447
+ reportHumanCursor({
448
+ x: (screen.x - rect.left - vp.x) / vp.scale,
449
+ y: (screen.y - rect.top - vp.y) / vp.scale,
450
+ });
451
+ });
452
+ }, []);
453
+
404
454
  const handlePointerMove = useCallback(
405
455
  (e: PointerEvent) => {
456
+ // Human presence (phase 8): other tabs see this pointer in world space.
457
+ {
458
+ const container = containerRef.current;
459
+ if (container) {
460
+ lastPointerScreen.current = { x: e.clientX, y: e.clientY };
461
+ const rect = container.getBoundingClientRect();
462
+ const vp = viewport.value;
463
+ reportHumanCursor({
464
+ x: (e.clientX - rect.left - vp.x) / vp.scale,
465
+ y: (e.clientY - rect.top - vp.y) / vp.scale,
466
+ });
467
+ }
468
+ }
406
469
  if (isAnnotating.current) {
407
470
  const container = containerRef.current;
408
471
  if (!container) return;
@@ -460,6 +523,12 @@ export function CanvasViewport({
460
523
  const minY = Math.min(current.startY, current.currentY);
461
524
  const maxY = Math.max(current.startY, current.currentY);
462
525
 
526
+ // A sub-threshold lasso is a background CLICK: clear the selection —
527
+ // pre-rail chrome this was the plain-pointerdown path.
528
+ if (maxX - minX <= 5 && maxY - minY <= 5) {
529
+ activeNodeId.value = null;
530
+ clearSelection();
531
+ }
463
532
  // Only commit if the lasso was dragged at least a few pixels
464
533
  if (maxX - minX > 5 || maxY - minY > 5) {
465
534
  // Convert screen lasso rect to world-space
@@ -472,7 +541,6 @@ export function CanvasViewport({
472
541
  // Find intersecting nodes (AABB intersection)
473
542
  const hits: string[] = [];
474
543
  for (const node of nodes.value.values()) {
475
- if (node.dockPosition !== null) continue;
476
544
  const nx = node.position.x;
477
545
  const ny = node.position.y;
478
546
  if (nx + node.size.width > worldMinX && nx < worldMaxX && ny + node.size.height > worldMinY && ny < worldMaxY) {
@@ -516,53 +584,78 @@ export function CanvasViewport({
516
584
  if (annotationTool !== 'text' && textDraft) setTextDraft(null);
517
585
  }, [annotationTool, setTextDraft, textDraft]);
518
586
 
519
- // ── Drag-to-connect: track cursor in world space, hit-test on drop ──
587
+ // ── Drag-to-connect (item 15): the target lights up under the cursor,
588
+ // Esc cancels, L asks for a label on release. ──
520
589
  useEffect(() => {
590
+ const nodeAt = (wx: number, wy: number, exceptId: string): string | null => {
591
+ let hit: string | null = null;
592
+ for (const node of nodes.value.values()) {
593
+ if (node.id === exceptId || node.type === 'group') continue;
594
+ if (
595
+ wx >= node.position.x &&
596
+ wx <= node.position.x + node.size.width &&
597
+ wy >= node.position.y &&
598
+ wy <= node.position.y + node.size.height
599
+ ) {
600
+ hit = node.id;
601
+ }
602
+ }
603
+ return hit;
604
+ };
605
+
521
606
  function handleMove(e: PointerEvent) {
522
607
  if (!draggingEdge.value) return;
523
608
  const container = containerRef.current;
524
609
  if (!container) return;
525
610
  const rect = container.getBoundingClientRect();
526
611
  const v = viewport.value;
612
+ const cursorX = (e.clientX - rect.left - v.x) / v.scale;
613
+ const cursorY = (e.clientY - rect.top - v.y) / v.scale;
527
614
  draggingEdge.value = {
528
615
  ...draggingEdge.value,
529
- cursorX: (e.clientX - rect.left - v.x) / v.scale,
530
- cursorY: (e.clientY - rect.top - v.y) / v.scale,
616
+ cursorX,
617
+ cursorY,
618
+ targetId: nodeAt(cursorX, cursorY, draggingEdge.value.fromId),
531
619
  };
532
620
  }
533
621
 
534
- function handleUp(e: PointerEvent) {
622
+ function handleUp() {
535
623
  const drag = draggingEdge.value;
536
624
  if (!drag) return;
537
625
  draggingEdge.value = null;
626
+ const targetId = drag.targetId;
627
+ if (!targetId) return;
628
+ if (drag.withLabel) {
629
+ // In-canvas prompt — `window.prompt` is a silent no-op in embedded
630
+ // panes (the L-labelled connect just dropped the label there).
631
+ // Cancelling still creates the edge, unlabelled, like prompt-cancel did.
632
+ void askText('Edge label', 'label text').then((label) => {
633
+ createEdgeFromClient(drag.fromId, targetId, 'relation', label ?? undefined);
634
+ });
635
+ return;
636
+ }
637
+ createEdgeFromClient(drag.fromId, targetId, 'relation', undefined);
638
+ }
538
639
 
539
- // Hit-test: find node under cursor
540
- const container = containerRef.current;
541
- if (!container) return;
542
- const rect = container.getBoundingClientRect();
543
- const v = viewport.value;
544
- const wx = (e.clientX - rect.left - v.x) / v.scale;
545
- const wy = (e.clientY - rect.top - v.y) / v.scale;
546
-
547
- for (const node of nodes.value.values()) {
548
- if (node.id === drag.fromId || node.dockPosition !== null) continue;
549
- if (
550
- wx >= node.position.x &&
551
- wx <= node.position.x + node.size.width &&
552
- wy >= node.position.y &&
553
- wy <= node.position.y + node.size.height
554
- ) {
555
- createEdgeFromClient(drag.fromId, node.id, 'relation');
556
- return;
557
- }
640
+ function handleKey(e: KeyboardEvent) {
641
+ const drag = draggingEdge.value;
642
+ if (!drag) return;
643
+ if (e.key === 'Escape') {
644
+ e.preventDefault();
645
+ draggingEdge.value = null;
646
+ } else if (e.key === 'l' || e.key === 'L') {
647
+ e.preventDefault();
648
+ draggingEdge.value = { ...drag, withLabel: !drag.withLabel };
558
649
  }
559
650
  }
560
651
 
561
652
  document.addEventListener('pointermove', handleMove);
562
653
  document.addEventListener('pointerup', handleUp);
654
+ document.addEventListener('keydown', handleKey);
563
655
  return () => {
564
656
  document.removeEventListener('pointermove', handleMove);
565
657
  document.removeEventListener('pointerup', handleUp);
658
+ document.removeEventListener('keydown', handleKey);
566
659
  };
567
660
  }, [containerRef]);
568
661
 
@@ -654,51 +747,7 @@ export function CanvasViewport({
654
747
  await createWebpageNodes(urls, baseWx, baseWy);
655
748
  return;
656
749
  }
657
-
658
- const nodeW = 400;
659
- const nodeH = 300;
660
- const spacing = 20;
661
- const cols = Math.ceil(Math.sqrt(files.length));
662
-
663
- for (let i = 0; i < files.length; i++) {
664
- const file = files[i];
665
- const col = i % cols;
666
- const row = Math.floor(i / cols);
667
- const wx = baseWx - (cols * (nodeW + spacing)) / 2 + col * (nodeW + spacing);
668
- const wy = baseWy - nodeH / 2 + row * (nodeH + spacing);
669
-
670
- const type = nodeTypeFromFilename(file.name);
671
- const fileName = file.name;
672
-
673
- if (type === 'image') {
674
- const reader = new FileReader();
675
- const dataUri: string = await new Promise((resolve) => {
676
- reader.onload = () => resolve(reader.result as string);
677
- reader.readAsDataURL(file);
678
- });
679
- await createNodeFromClient({
680
- type: 'image',
681
- title: fileName,
682
- content: dataUri,
683
- x: wx,
684
- y: wy,
685
- width: nodeW,
686
- height: nodeH,
687
- });
688
- } else {
689
- const text = await file.text();
690
- const isWide = type === 'markdown' || type === 'file';
691
- await createNodeFromClient({
692
- type,
693
- title: fileName,
694
- content: text,
695
- x: wx,
696
- y: wy,
697
- width: isWide ? 720 : nodeW,
698
- height: isWide ? 500 : nodeH,
699
- });
700
- }
701
- }
750
+ await importFiles(files, baseWx, baseWy);
702
751
  },
703
752
  [containerRef, createWebpageNodes],
704
753
  );
@@ -731,12 +780,18 @@ export function CanvasViewport({
731
780
  };
732
781
  }, [containerRef, createWebpageNodes]);
733
782
 
734
- // Only render world-space nodes (dockPosition === null); docked nodes are in the HUD layer.
735
783
  // Do NOT sort by zIndex here — CSS z-index handles visual stacking. Sorting would
736
784
  // reorder DOM children when bringToFront() changes zIndex, causing browsers to
737
785
  // detach/reattach iframe elements (which forces them to reload/reconnect).
738
786
  // Group nodes render first (behind) so they serve as visual containers.
739
- const worldNodes = getRenderableWorldNodes(nodes.value.values(), expandedNodeId.value);
787
+ const worldNodes = getRenderableWorldNodes(
788
+ nodes.value.values(),
789
+ expandedNodeId.value,
790
+ new Set(hiddenByCollapsedGroup.value.keys()),
791
+ );
792
+ const dropTarget = dragDropTarget.value;
793
+ const dropTargetGroup = dropTarget ? nodes.value.get(dropTarget.groupId) : undefined;
794
+ const draggedNode = dropTarget ? nodes.value.get(dropTarget.nodeId) : undefined;
740
795
 
741
796
  // Compute lasso overlay rect in screen space
742
797
  let lassoStyle: Record<string, string> | null = null;
@@ -757,11 +812,15 @@ export function CanvasViewport({
757
812
 
758
813
  return (
759
814
  <div
760
- class="canvas-viewport"
815
+ class={`canvas-viewport${isPanModeActive() ? ' pan-mode' : ''}`}
761
816
  ref={containerRef}
762
817
  tabIndex={0}
763
818
  onPointerDown={handlePointerDown}
764
819
  onPointerMove={handlePointerMove}
820
+ onPointerLeave={() => {
821
+ lastPointerScreen.current = null;
822
+ reportHumanCursor(null);
823
+ }}
765
824
  onPointerUp={handlePointerUp}
766
825
  onContextMenu={handleContextMenu}
767
826
  onDblClick={handleDblClick}
@@ -779,27 +838,64 @@ export function CanvasViewport({
779
838
  ? 'cell'
780
839
  : annotationTool === 'text'
781
840
  ? 'text'
782
- : annotationMode || draggingEdge.value || isLassoing.current
841
+ : annotationMode || draggingEdge.value || isLassoing.current || canvasTool.value === 'connect'
783
842
  ? 'crosshair'
784
- : 'grab',
843
+ : isPanModeActive()
844
+ ? 'grab'
845
+ : 'default',
785
846
  }}
786
847
  >
787
848
  {/* D4: CSS matrix(a,b,c,d,tx,ty) — scale uniformly (a=d=scale, b=c=0)
788
849
  then translate (tx=v.x, ty=v.y). transformOrigin: '0 0' ensures
789
850
  the scale pivot is the top-left corner of the world layer. */}
790
851
  <div
852
+ ref={worldRef}
853
+ class="canvas-world"
791
854
  style={{
792
855
  transform: `matrix(${v.scale}, 0, 0, ${v.scale}, ${v.x}, ${v.y})`,
793
856
  transformOrigin: '0 0',
794
- willChange: 'transform',
795
857
  position: 'absolute',
796
858
  top: 0,
797
859
  left: 0,
798
860
  }}
799
861
  >
800
862
  <FocusFieldLayer />
863
+ <ScopeFenceLayer />
801
864
  <IntentLayer />
802
- <EdgeLayer nodes={nodes} edges={edges} />
865
+ <AgentPresenceLayer />
866
+ <HumanPresenceLayer />
867
+ <EdgeLayer nodes={nodes} edges={edges} onEdgeContextMenu={onEdgeContextMenu} />
868
+ {draggingEdge.value && (
869
+ <div
870
+ class="edge-hint-pill"
871
+ data-testid="edge-hint"
872
+ style={{ left: `${draggingEdge.value.cursorX + 14}px`, top: `${draggingEdge.value.cursorY + 14}px` }}
873
+ >
874
+ <span class="edge-hint-main">
875
+ {draggingEdge.value.targetId ? 'release to connect' : 'drag onto a node'}
876
+ </span>
877
+ <span class="edge-hint-keys">
878
+ esc cancels · L labels{draggingEdge.value.withLabel ? ' · label on' : ''}
879
+ </span>
880
+ </div>
881
+ )}
882
+ {dropTarget && dropTargetGroup && draggedNode && (
883
+ <div
884
+ class="drop-pill"
885
+ data-testid="drop-pill"
886
+ style={{
887
+ left: `${draggedNode.position.x}px`,
888
+ top: `${draggedNode.position.y + draggedNode.size.height + 10}px`,
889
+ }}
890
+ >
891
+ <span class="drop-pill-text">
892
+ {dropTarget.mode === 'add'
893
+ ? `release to add to ${String(dropTargetGroup.data.title ?? 'group')}`
894
+ : `release to remove from ${String(dropTargetGroup.data.title ?? 'group')}`}
895
+ </span>
896
+ <span class="drop-pill-hint">{dropTarget.mode === 'add' ? 'esc keeps it out' : 'esc keeps it in'}</span>
897
+ </div>
898
+ )}
803
899
  <AnnotationLayer annotations={Array.from(annotations.value.values())} />
804
900
  {draftAnnotation && draftAnnotation.points.length >= 2 && <AnnotationLayer annotations={[draftAnnotation]} />}
805
901
  {worldNodes.map((node) => (
@@ -0,0 +1,140 @@
1
+ import { useState } from 'preact/hooks';
2
+ import { IconSteer } from '../icons';
3
+ import { contextPinnedNodeIds, nodes, toggleContextPin } from '../state/canvas-store';
4
+ import { steerableAgents } from '../state/presence-store';
5
+ import { sendSteering } from '../state/session-store';
6
+ import { modChord } from '../utils/platform';
7
+
8
+ /**
9
+ * Command bar (rail-chrome-v2 phase 5): the human's steering surface while a
10
+ * session is attached — the design's floating composer, centered at the
11
+ * bottom of the canvas region. Above it, the pinned context as gold ✦ chips
12
+ * (× unpins — the same pin the node's own control toggles); the composer row
13
+ * posts an AX steering message to the session. The context-budget meter lives
14
+ * in the top bar (`ContextBudget`), where the mockup puts it. Replaces the
15
+ * quiet board's pin bar only while attached.
16
+ */
17
+ export function CommandBar() {
18
+ const pinnedIds = [...contextPinnedNodeIds.value];
19
+ const nodeMap = nodes.value;
20
+ const [draft, setDraft] = useState('');
21
+ const [sending, setSending] = useState(false);
22
+ // Addressed steering: the picker lists the CONNECTED agents (live presences,
23
+ // sessions first) and shows only when there is a real choice. A picked agent
24
+ // that detaches or expires falls back to "All agents" — presence is the
25
+ // source of truth for who can still claim a delivery.
26
+ const agents = steerableAgents.value;
27
+ const [target, setTarget] = useState<string | null>(null);
28
+ // The picker only earns its place when the roster offers a real choice or
29
+ // real information: two or more rows. A LONE steerable agent needs no
30
+ // dropdown — steers auto-address it (the placeholder names it), which also
31
+ // keeps stale broadcasts from leaking to agents that join later.
32
+ const showPicker = agents.length >= 2;
33
+ const soloTarget = !showPicker && agents.length === 1 && agents[0]!.steerable ? agents[0]! : null;
34
+ // A picked agent that loses its inbox (or leaves) falls back to All —
35
+ // steers must never keep targeting a roster row that cannot receive.
36
+ const effective = showPicker
37
+ ? target
38
+ ? (agents.find((agent) => agent.value === target && agent.steerable) ?? null)
39
+ : null
40
+ : soloTarget;
41
+ const effectiveTarget = effective?.value ?? null;
42
+
43
+ const submit = async () => {
44
+ const message = draft.trim();
45
+ if (!message || sending) return;
46
+ setSending(true);
47
+ const ok = await sendSteering(message, effectiveTarget);
48
+ setSending(false);
49
+ if (ok) setDraft('');
50
+ };
51
+
52
+ return (
53
+ <div class="command-bar" data-testid="command-bar">
54
+ {pinnedIds.length > 0 && (
55
+ <div class="command-bar-chips" aria-label="Pinned context">
56
+ {pinnedIds.map((id) => {
57
+ const node = nodeMap.get(id);
58
+ const title =
59
+ typeof node?.data.title === 'string' && node.data.title.trim() ? node.data.title : (node?.type ?? id);
60
+ return (
61
+ <span key={id} class="command-bar-chip" data-node-id={id}>
62
+ <span class="command-bar-chip-glyph" aria-hidden="true">
63
+ ✦
64
+ </span>
65
+ <span class="command-bar-chip-label" title={title}>
66
+ {title}
67
+ </span>
68
+ <button
69
+ type="button"
70
+ class="command-bar-chip-unpin"
71
+ aria-label={`Unpin ${title}`}
72
+ title="Remove from context"
73
+ onClick={() => toggleContextPin(id)}
74
+ >
75
+ ×
76
+ </button>
77
+ </span>
78
+ );
79
+ })}
80
+ <span class="command-bar-chips-note">in agent context</span>
81
+ </div>
82
+ )}
83
+ <div class="command-bar-composer">
84
+ <IconSteer size={15} class="command-bar-icon" />
85
+ {showPicker && (
86
+ <select
87
+ class="command-bar-target"
88
+ aria-label="Steer which agent"
89
+ title="Connected agents — steering reaches all of them unless you address one"
90
+ value={effectiveTarget ?? ''}
91
+ onChange={(e) => setTarget((e.target as HTMLSelectElement).value || null)}
92
+ >
93
+ <option value="">All agents</option>
94
+ {agents.map((agent) => {
95
+ // Pump health at the point of choice: unclaimed steers already
96
+ // sitting in this agent's queue (a growing number means its loop
97
+ // is not polling), and proof-of-polling when its consumer key
98
+ // claimed recently — attached alone doesn't mean anyone reads
99
+ // the inbox.
100
+ const queued = agent.pendingSteers && agent.pendingSteers > 0 ? ` · ${agent.pendingSteers} queued` : '';
101
+ const polling = agent.polling ? ' · polling' : '';
102
+ return (
103
+ <option key={agent.value} value={agent.value} disabled={!agent.steerable}>
104
+ {agent.attached
105
+ ? `${agent.label}${polling}${queued}`
106
+ : agent.steerable
107
+ ? `${agent.label} · writer${polling}${queued}`
108
+ : `${agent.label} · no inbox`}
109
+ </option>
110
+ );
111
+ })}
112
+ </select>
113
+ )}
114
+ <input
115
+ class="command-bar-input"
116
+ type="text"
117
+ value={draft}
118
+ placeholder={`Steer ${effective?.label ?? 'the agent'}, or ${modChord('K')} to search the board…`}
119
+ aria-label="Steer the agent"
120
+ disabled={sending}
121
+ onInput={(e) => setDraft((e.target as HTMLInputElement).value)}
122
+ onKeyDown={(e) => {
123
+ if (e.key === 'Enter') {
124
+ e.preventDefault();
125
+ void submit();
126
+ }
127
+ }}
128
+ />
129
+ <button
130
+ type="button"
131
+ class="command-bar-send"
132
+ disabled={sending || !draft.trim()}
133
+ onClick={() => void submit()}
134
+ >
135
+ {sending ? 'Sending…' : 'Send'}
136
+ </button>
137
+ </div>
138
+ </div>
139
+ );
140
+ }