pmx-canvas 0.6.4 → 0.7.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 (204) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +67 -3
  2. package/CHANGELOG.md +73 -0
  3. package/Readme.md +35 -5
  4. package/dist/canvas/global.css +498 -28
  5. package/dist/canvas/index.js +110 -95
  6. package/dist/canvas/mermaid-entry.js +258 -258
  7. package/dist/types/cli/agent.d.ts +4 -0
  8. package/dist/types/cli/commands/backup.d.ts +1 -0
  9. package/dist/types/cli/commands/boards.d.ts +1 -0
  10. package/dist/types/cli/commands/export.d.ts +1 -0
  11. package/dist/types/cli/commands/record.d.ts +12 -0
  12. package/dist/types/client/canvas/BoardCopyDialog.d.ts +5 -0
  13. package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
  14. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
  15. package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
  16. package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
  17. package/dist/types/client/canvas/HomeView.d.ts +9 -0
  18. package/dist/types/client/canvas/Presentation.d.ts +13 -0
  19. package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
  20. package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
  21. package/dist/types/client/nodes/BoardNode.d.ts +5 -0
  22. package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
  23. package/dist/types/client/state/boards-store.d.ts +79 -0
  24. package/dist/types/client/state/canvas-store.d.ts +2 -0
  25. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  26. package/dist/types/client/state/presentation.d.ts +3 -0
  27. package/dist/types/client/state/session-store.d.ts +7 -0
  28. package/dist/types/client/types.d.ts +2 -2
  29. package/dist/types/json-render/catalog.d.ts +3 -3
  30. package/dist/types/json-render/charts/definitions.d.ts +1 -1
  31. package/dist/types/json-render/server.d.ts +8 -0
  32. package/dist/types/mcp/canvas-access.d.ts +10 -3
  33. package/dist/types/mcp/context-uri-template.d.ts +6 -0
  34. package/dist/types/server/agent-presence.d.ts +1 -1
  35. package/dist/types/server/attribution.d.ts +11 -0
  36. package/dist/types/server/ax-context.d.ts +3 -1
  37. package/dist/types/server/ax-interaction.d.ts +2 -2
  38. package/dist/types/server/ax-state.d.ts +13 -3
  39. package/dist/types/server/backup.d.ts +36 -0
  40. package/dist/types/server/board-export.d.ts +45 -0
  41. package/dist/types/server/canvas-db.d.ts +65 -9
  42. package/dist/types/server/canvas-operations.d.ts +45 -0
  43. package/dist/types/server/canvas-provenance.d.ts +1 -1
  44. package/dist/types/server/canvas-schema.d.ts +1 -0
  45. package/dist/types/server/canvas-state.d.ts +146 -3
  46. package/dist/types/server/canvas-validation.d.ts +3 -0
  47. package/dist/types/server/context-brief.d.ts +111 -0
  48. package/dist/types/server/context-reads.d.ts +55 -0
  49. package/dist/types/server/document-import.d.ts +66 -0
  50. package/dist/types/server/html-surface.d.ts +1 -1
  51. package/dist/types/server/index.d.ts +107 -4
  52. package/dist/types/server/mutation-history.d.ts +1 -0
  53. package/dist/types/server/operations/invoker.d.ts +2 -0
  54. package/dist/types/server/operations/mcp.d.ts +7 -1
  55. package/dist/types/server/operations/ops/app.d.ts +2 -0
  56. package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
  57. package/dist/types/server/operations/ops/backup.d.ts +2 -0
  58. package/dist/types/server/operations/ops/boards.d.ts +10 -0
  59. package/dist/types/server/operations/ops/export.d.ts +2 -0
  60. package/dist/types/server/operations/ops/imports.d.ts +2 -0
  61. package/dist/types/server/operations/ops/json-render.d.ts +2 -0
  62. package/dist/types/server/operations/ops/nodes.d.ts +4 -2
  63. package/dist/types/server/operations/ops/query.d.ts +2 -0
  64. package/dist/types/server/operations/registry.d.ts +9 -1
  65. package/dist/types/server/operations/types.d.ts +1 -1
  66. package/dist/types/server/server.d.ts +1 -0
  67. package/dist/types/server/trace-manager.d.ts +7 -0
  68. package/dist/types/server/web-artifacts.d.ts +3 -0
  69. package/dist/types/server/webpage-node.d.ts +22 -1
  70. package/dist/types/server/workbench-auth.d.ts +3 -0
  71. package/dist/types/shared/boards.d.ts +6 -0
  72. package/dist/types/shared/tour.d.ts +72 -0
  73. package/docs/RELEASE.md +5 -0
  74. package/docs/cli.md +157 -0
  75. package/docs/http-api.md +277 -6
  76. package/docs/mcp.md +96 -7
  77. package/docs/node-types.md +106 -0
  78. package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
  79. package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
  80. package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
  81. package/docs/plans/plan-013-static-board-export.md +47 -0
  82. package/docs/plans/plan-014-connected-board-memory.md +140 -0
  83. package/docs/product-vision-2026-09.md +118 -18
  84. package/docs/sdk.md +50 -0
  85. package/package.json +1 -1
  86. package/skills/pmx-canvas/SKILL.md +62 -5
  87. package/skills/pmx-canvas/references/full-reference.md +44 -8
  88. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
  89. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  90. package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
  91. package/skills/pmx-canvas-testing/SKILL.md +4 -0
  92. package/src/cli/agent.ts +9 -0
  93. package/src/cli/commands/ax.ts +13 -0
  94. package/src/cli/commands/backup.ts +66 -0
  95. package/src/cli/commands/boards.ts +71 -0
  96. package/src/cli/commands/export.ts +30 -0
  97. package/src/cli/commands/nodes.ts +14 -1
  98. package/src/cli/commands/pins.ts +6 -2
  99. package/src/cli/commands/pump.ts +3 -0
  100. package/src/cli/commands/query.ts +33 -7
  101. package/src/cli/commands/record.ts +218 -0
  102. package/src/cli/commands/view.ts +7 -5
  103. package/src/cli/index.ts +21 -9
  104. package/src/client/App.tsx +28 -4
  105. package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
  106. package/src/client/canvas/BoardCopyDialog.tsx +153 -0
  107. package/src/client/canvas/BoardFolderDialog.tsx +120 -0
  108. package/src/client/canvas/BoardLinkDialog.tsx +93 -0
  109. package/src/client/canvas/BoardSwitcher.tsx +149 -0
  110. package/src/client/canvas/CanvasNode.tsx +8 -0
  111. package/src/client/canvas/CanvasViewport.tsx +3 -0
  112. package/src/client/canvas/CommandPalette.tsx +107 -4
  113. package/src/client/canvas/ContextMenu.tsx +4 -0
  114. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
  115. package/src/client/canvas/ExportDialog.tsx +197 -0
  116. package/src/client/canvas/HomeView.tsx +277 -0
  117. package/src/client/canvas/IntentLayer.tsx +9 -2
  118. package/src/client/canvas/Presentation.tsx +108 -0
  119. package/src/client/canvas/SessionPanel.tsx +28 -1
  120. package/src/client/canvas/ToolRail.tsx +38 -3
  121. package/src/client/canvas/TopBar.tsx +49 -7
  122. package/src/client/canvas/import-files.ts +19 -1
  123. package/src/client/canvas/kind-colors.ts +1 -0
  124. package/src/client/canvas/use-node-drag.ts +8 -3
  125. package/src/client/icons.tsx +2 -0
  126. package/src/client/mermaid-entry.ts +39 -0
  127. package/src/client/nodes/AttachmentNode.tsx +148 -0
  128. package/src/client/nodes/BoardNode.tsx +35 -0
  129. package/src/client/nodes/FileNode.tsx +6 -0
  130. package/src/client/nodes/HtmlNode.tsx +12 -13
  131. package/src/client/nodes/MarkdownNode.tsx +47 -19
  132. package/src/client/nodes/McpAppNode.tsx +105 -13
  133. package/src/client/nodes/MermaidNode.tsx +3 -2
  134. package/src/client/nodes/ax-node-actions.ts +2 -5
  135. package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
  136. package/src/client/state/boards-store.ts +167 -0
  137. package/src/client/state/canvas-store.ts +22 -0
  138. package/src/client/state/intent-bridge.ts +30 -2
  139. package/src/client/state/intent-store.ts +5 -1
  140. package/src/client/state/node-factory.ts +1 -0
  141. package/src/client/state/presentation.ts +7 -0
  142. package/src/client/state/session-store.ts +8 -0
  143. package/src/client/state/sse-bridge.ts +52 -5
  144. package/src/client/theme/global.css +498 -28
  145. package/src/client/types.ts +2 -0
  146. package/src/json-render/server.ts +45 -9
  147. package/src/mcp/canvas-access.ts +59 -22
  148. package/src/mcp/context-uri-template.ts +28 -0
  149. package/src/mcp/server.ts +122 -15
  150. package/src/server/agent-context.ts +116 -29
  151. package/src/server/agent-presence.ts +15 -9
  152. package/src/server/attribution.ts +29 -0
  153. package/src/server/ax-context.ts +4 -2
  154. package/src/server/ax-interaction.ts +1 -0
  155. package/src/server/ax-state-manager.ts +72 -3
  156. package/src/server/ax-state.ts +41 -0
  157. package/src/server/backup.ts +178 -0
  158. package/src/server/board-export.ts +686 -0
  159. package/src/server/canvas-db.ts +612 -105
  160. package/src/server/canvas-operations.ts +183 -31
  161. package/src/server/canvas-provenance.ts +1 -0
  162. package/src/server/canvas-schema.ts +22 -0
  163. package/src/server/canvas-state.ts +812 -48
  164. package/src/server/canvas-validation.ts +9 -1
  165. package/src/server/context-brief.ts +372 -0
  166. package/src/server/context-reads.ts +238 -0
  167. package/src/server/demo-state.json +670 -9
  168. package/src/server/document-import.ts +223 -0
  169. package/src/server/html-primitives.ts +199 -91
  170. package/src/server/html-surface.ts +8 -3
  171. package/src/server/index.ts +243 -5
  172. package/src/server/mutation-history.ts +10 -13
  173. package/src/server/operations/composites.ts +29 -5
  174. package/src/server/operations/http.ts +9 -3
  175. package/src/server/operations/index.ts +8 -0
  176. package/src/server/operations/invoker.ts +14 -3
  177. package/src/server/operations/mcp.ts +50 -11
  178. package/src/server/operations/ops/app.ts +15 -0
  179. package/src/server/operations/ops/ax-read.ts +10 -6
  180. package/src/server/operations/ops/ax-timeline.ts +75 -0
  181. package/src/server/operations/ops/backup.ts +134 -0
  182. package/src/server/operations/ops/batch.ts +20 -4
  183. package/src/server/operations/ops/boards.ts +351 -0
  184. package/src/server/operations/ops/export.ts +74 -0
  185. package/src/server/operations/ops/ext-app.ts +4 -0
  186. package/src/server/operations/ops/groups.ts +14 -7
  187. package/src/server/operations/ops/imports.ts +220 -0
  188. package/src/server/operations/ops/json-render.ts +37 -5
  189. package/src/server/operations/ops/nodes.ts +99 -21
  190. package/src/server/operations/ops/query.ts +206 -9
  191. package/src/server/operations/ops/viewport.ts +32 -0
  192. package/src/server/operations/registry.ts +129 -31
  193. package/src/server/operations/types.ts +1 -1
  194. package/src/server/scope-fence.ts +1 -1
  195. package/src/server/server.ts +155 -8
  196. package/src/server/spatial-analysis.ts +9 -17
  197. package/src/server/trace-manager.ts +19 -0
  198. package/src/server/web-artifacts.ts +11 -3
  199. package/src/server/webpage-node.ts +173 -13
  200. package/src/server/workbench-auth.ts +11 -0
  201. package/src/server/workboard.ts +3 -3
  202. package/src/shared/boards.ts +18 -0
  203. package/src/shared/node-sizes.ts +1 -0
  204. package/src/shared/tour.ts +87 -0
@@ -0,0 +1,120 @@
1
+ import { useEffect, useRef, useState } from 'preact/hooks';
2
+ import { normalizeBoardCategory } from '../../shared/boards.js';
3
+ import { boardList, updateBoard, type BoardSummary } from '../state/boards-store';
4
+ import { useFocusTrap } from './use-focus-trap';
5
+
6
+ /** A destination picker: select an existing folder, optionally create a child, then move. */
7
+ export function BoardFolderDialog({
8
+ board,
9
+ folders,
10
+ onClose,
11
+ }: {
12
+ board: BoardSummary;
13
+ folders: string[];
14
+ onClose: () => void;
15
+ }) {
16
+ const ref = useRef<HTMLDivElement>(null);
17
+ const [parent, setParent] = useState(board.category ?? '');
18
+ const [name, setName] = useState('');
19
+ const [busy, setBusy] = useState(false);
20
+ const [error, setError] = useState('');
21
+ useFocusTrap(ref, true);
22
+ useEffect(() => {
23
+ const onEscape = (event: KeyboardEvent) => {
24
+ if (event.key !== 'Escape') return;
25
+ event.preventDefault();
26
+ event.stopPropagation();
27
+ if (!busy) onClose();
28
+ };
29
+ window.addEventListener('keydown', onEscape, true);
30
+ return () => window.removeEventListener('keydown', onEscape, true);
31
+ }, [busy, onClose]);
32
+
33
+ const destination = name.trim() ? [parent, name.trim()].filter(Boolean).join('/') : parent;
34
+ const move = async () => {
35
+ let category: string | null;
36
+ try {
37
+ if (name.includes('/')) throw new Error('Use one folder name; choose its parent above.');
38
+ category = normalizeBoardCategory(destination);
39
+ } catch (cause) {
40
+ setError(cause instanceof Error ? cause.message : String(cause));
41
+ return;
42
+ }
43
+ setBusy(true);
44
+ setError('');
45
+ await updateBoard(board.id, { category });
46
+ if (boardList.value.find((entry) => entry.id === board.id)?.category === category) onClose();
47
+ else setError('Could not move this board. Try again.');
48
+ setBusy(false);
49
+ };
50
+
51
+ return (
52
+ <div class="text-prompt-backdrop" onPointerDown={() => !busy && onClose()}>
53
+ <div
54
+ ref={ref}
55
+ class="text-prompt home-folder-dialog"
56
+ role="dialog"
57
+ aria-modal="true"
58
+ aria-label={`Move ${board.name}`}
59
+ onPointerDown={(event) => event.stopPropagation()}
60
+ >
61
+ <div class="text-prompt-title">Move “{board.name}”</div>
62
+ <p class="home-folder-hint">Choose a folder, or create one inside it. The board keeps its content and links.</p>
63
+ <fieldset class="home-folder-options" disabled={busy}>
64
+ <legend>Destination folder</legend>
65
+ {['', ...folders].map((path) => (
66
+ <label
67
+ key={path}
68
+ class="home-folder-option"
69
+ style={{ paddingLeft: `${8 + Math.min(path.split('/').length - 1, 5) * 14}px` }}
70
+ >
71
+ <input
72
+ type="radio"
73
+ name="folder-destination"
74
+ value={path}
75
+ checked={parent === path}
76
+ onChange={() => {
77
+ setParent(path);
78
+ setError('');
79
+ }}
80
+ />
81
+ <span>{path || 'Home (no folder)'}</span>
82
+ </label>
83
+ ))}
84
+ </fieldset>
85
+ <label class="home-folder-new">
86
+ New folder inside {parent || 'Home'} <span class="home-folder-hint">(optional)</span>
87
+ <input
88
+ class="text-prompt-input"
89
+ aria-label="New folder name"
90
+ placeholder="e.g. Decisions"
91
+ maxLength={60}
92
+ value={name}
93
+ disabled={busy}
94
+ onInput={(event) => {
95
+ setName(event.currentTarget.value);
96
+ setError('');
97
+ }}
98
+ />
99
+ </label>
100
+ <div class="home-folder-hint">
101
+ Move to: <strong>{destination || 'Home'}</strong>
102
+ </div>
103
+ {error && <p role="alert">{error}</p>}
104
+ <div class="text-prompt-actions">
105
+ <button type="button" class="text-prompt-cancel" disabled={busy} onClick={onClose}>
106
+ Cancel
107
+ </button>
108
+ <button
109
+ type="button"
110
+ class="text-prompt-confirm"
111
+ disabled={busy || destination === (board.category ?? '')}
112
+ onClick={() => void move()}
113
+ >
114
+ {busy ? 'Moving…' : 'Move board'}
115
+ </button>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ );
120
+ }
@@ -0,0 +1,93 @@
1
+ import { useRef, useState } from 'preact/hooks';
2
+ import { activeBoardId, boardList } from '../state/boards-store';
3
+ import { requestJson } from '../state/intent-bridge';
4
+ import { canvasAreaCenter } from './canvas-area';
5
+ import { viewport } from '../state/canvas-store';
6
+ import { useFocusTrap } from './use-focus-trap';
7
+
8
+ export function BoardLinkDialog({ onClose }: { onClose: () => void }) {
9
+ const dialogRef = useRef<HTMLDivElement>(null);
10
+ const searchRef = useRef<HTMLInputElement>(null);
11
+ const [query, setQuery] = useState('');
12
+ const [error, setError] = useState('');
13
+ useFocusTrap(dialogRef, true, { initial: searchRef });
14
+ const choices = boardList.value.filter(
15
+ (board) => board.id !== activeBoardId.value && board.name.toLowerCase().includes(query.trim().toLowerCase()),
16
+ );
17
+ const create = async (boardId: string) => {
18
+ const target = boardList.value.find((board) => board.id === boardId);
19
+ if (!target) return setError('That board is no longer available.');
20
+ const center = canvasAreaCenter();
21
+ const v = viewport.value;
22
+ const result = await requestJson<{ ok?: boolean }>(
23
+ 'createBoardLink',
24
+ '/api/canvas/node',
25
+ {},
26
+ {
27
+ method: 'POST',
28
+ headers: { 'Content-Type': 'application/json' },
29
+ body: JSON.stringify({
30
+ type: 'board',
31
+ title: target.name,
32
+ x: Math.round((center.x - v.x) / v.scale - 180),
33
+ y: Math.round((center.y - v.y) / v.scale - 100),
34
+ width: 360,
35
+ height: 200,
36
+ data: { boardId },
37
+ }),
38
+ },
39
+ );
40
+ if (!result.ok) return setError('Could not create the board link.');
41
+ onClose();
42
+ };
43
+ return (
44
+ <div class="text-prompt-backdrop" onMouseDown={onClose}>
45
+ <div
46
+ ref={dialogRef}
47
+ class="text-prompt board-link-dialog"
48
+ role="dialog"
49
+ aria-modal="true"
50
+ aria-labelledby="board-link-title"
51
+ onMouseDown={(e) => e.stopPropagation()}
52
+ >
53
+ <h2 id="board-link-title">Link to a board</h2>
54
+ <input
55
+ ref={searchRef}
56
+ type="search"
57
+ class="text-prompt-input"
58
+ aria-label="Find a board to link"
59
+ placeholder="Find a board…"
60
+ value={query}
61
+ onInput={(e) => setQuery(e.currentTarget.value)}
62
+ />
63
+ {error && (
64
+ <div role="alert" class="board-copy-error">
65
+ {error}
66
+ </div>
67
+ )}
68
+ <ul class="board-link-results">
69
+ {choices.map((board) => (
70
+ <li key={board.id}>
71
+ <button type="button" onClick={() => void create(board.id)}>
72
+ <span>{board.name}</span>
73
+ <small>{board.nodeCount} nodes</small>
74
+ </button>
75
+ </li>
76
+ ))}
77
+ {choices.length === 0 && (
78
+ <li class="board-copy-status">
79
+ {boardList.value.length <= 1
80
+ ? 'Create another board first, then link it here.'
81
+ : 'No boards match your search.'}
82
+ </li>
83
+ )}
84
+ </ul>
85
+ <div class="text-prompt-actions">
86
+ <button type="button" onClick={onClose}>
87
+ Cancel
88
+ </button>
89
+ </div>
90
+ </div>
91
+ </div>
92
+ );
93
+ }
@@ -0,0 +1,149 @@
1
+ import { render, type VNode } from 'preact';
2
+ import { useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks';
3
+ import { activeBoard, activeBoardId, boardList, boardsLoaded, openBoard } from '../state/boards-store';
4
+ import { IconLogo, IconArrange } from '../icons';
5
+ import { promptNewBoard } from './HomeView';
6
+
7
+ const RECENT_LIMIT = 8;
8
+
9
+ /** Escape the top bar's filter/clip without installing React-compat event hooks. */
10
+ function BoardMenuPortal({ children }: { children: VNode }) {
11
+ const host = useMemo(() => document.createElement('div'), []);
12
+ useLayoutEffect(() => {
13
+ document.body.appendChild(host);
14
+ return () => {
15
+ render(null, host);
16
+ host.remove();
17
+ };
18
+ }, [host]);
19
+ useLayoutEffect(() => render(children, host), [children, host]);
20
+ return null;
21
+ }
22
+
23
+ /**
24
+ * The top bar's board identity (plan 012): the open board's name, or "Home".
25
+ * Clicking it lists recent boards, New board, and Home. The menu is fixed-
26
+ * positioned so the bar's overflow clip cannot cut it off.
27
+ */
28
+ export function BoardSwitcher({ fallbackName }: { fallbackName: string }) {
29
+ const [open, setOpen] = useState(false);
30
+ const rootRef = useRef<HTMLSpanElement>(null);
31
+ const buttonRef = useRef<HTMLButtonElement>(null);
32
+ const menuRef = useRef<HTMLDivElement>(null);
33
+
34
+ useLayoutEffect(() => {
35
+ if (!open) return;
36
+ const onPointerDown = (e: PointerEvent) => {
37
+ if (e.target instanceof Node && !rootRef.current?.contains(e.target) && !menuRef.current?.contains(e.target))
38
+ setOpen(false);
39
+ };
40
+ const onKeyDown = (e: KeyboardEvent) => {
41
+ if (e.key === 'Escape') {
42
+ e.stopPropagation();
43
+ setOpen(false);
44
+ buttonRef.current?.focus();
45
+ }
46
+ };
47
+ // Canvas gestures stop bubbling; dismiss before those handlers run.
48
+ document.addEventListener('pointerdown', onPointerDown, true);
49
+ document.addEventListener('keydown', onKeyDown, true);
50
+ return () => {
51
+ document.removeEventListener('pointerdown', onPointerDown, true);
52
+ document.removeEventListener('keydown', onKeyDown, true);
53
+ };
54
+ }, [open]);
55
+
56
+ const board = activeBoard();
57
+ const label = !boardsLoaded.value ? fallbackName : board ? board.name : 'Home';
58
+ const recent = boardList.value.filter((entry) => entry.id !== activeBoardId.value).slice(0, RECENT_LIMIT);
59
+ const anchor = buttonRef.current?.getBoundingClientRect();
60
+
61
+ const choose = (id: string | null) => {
62
+ setOpen(false);
63
+ void openBoard(id);
64
+ };
65
+
66
+ return (
67
+ <span class="board-switcher" ref={rootRef}>
68
+ <button
69
+ ref={buttonRef}
70
+ type="button"
71
+ class="top-bar-title board-switcher-button"
72
+ aria-haspopup="menu"
73
+ aria-expanded={open}
74
+ aria-label={`Board: ${label}. Switch board`}
75
+ onClick={() => setOpen((value) => !value)}
76
+ >
77
+ <span class="board-switcher-name">{label}</span>
78
+ <span class="board-switcher-caret" aria-hidden="true">
79
+ ▾
80
+ </span>
81
+ </button>
82
+ {open && anchor && (
83
+ <BoardMenuPortal>
84
+ <div
85
+ ref={menuRef}
86
+ class="toolbar-menu board-switcher-menu"
87
+ role="menu"
88
+ aria-label="Boards"
89
+ style={{
90
+ position: 'fixed',
91
+ left: `min(${anchor.left}px, max(12px, calc(100vw - 352px)))`,
92
+ top: `${anchor.bottom + 8}px`,
93
+ right: 'auto',
94
+ maxHeight: `min(70vh, calc(100dvh - ${anchor.bottom + 20}px))`,
95
+ }}
96
+ >
97
+ {recent.length > 0 && <div class="board-switcher-heading">Recent boards</div>}
98
+ <div class="board-switcher-recents">
99
+ {recent.map((entry) => (
100
+ <button
101
+ key={entry.id}
102
+ type="button"
103
+ role="menuitem"
104
+ class="toolbar-menu-item"
105
+ onClick={() => choose(entry.id)}
106
+ >
107
+ <span class="board-switcher-icon" aria-hidden="true">
108
+ <IconLogo size={18} />
109
+ </span>
110
+ <span class="board-switcher-item-copy">
111
+ <span class="board-switcher-item-name">{entry.name}</span>
112
+ <span class="board-switcher-item-folder">
113
+ {entry.category?.split('/').join(' / ') || 'Unfiled'}
114
+ </span>
115
+ </span>
116
+ <span class="board-switcher-item-meta">
117
+ {entry.nodeCount} {entry.nodeCount === 1 ? 'node' : 'nodes'}
118
+ </span>
119
+ </button>
120
+ ))}
121
+ </div>
122
+ <div class="board-switcher-actions">
123
+ <button
124
+ type="button"
125
+ role="menuitem"
126
+ class="toolbar-menu-item"
127
+ onClick={() => {
128
+ setOpen(false);
129
+ void promptNewBoard();
130
+ }}
131
+ >
132
+ <span class="board-switcher-icon" aria-hidden="true">
133
+ +
134
+ </span>
135
+ New board…
136
+ </button>
137
+ <button type="button" role="menuitem" class="toolbar-menu-item" onClick={() => choose(null)}>
138
+ <span class="board-switcher-icon" aria-hidden="true">
139
+ <IconArrange size={18} />
140
+ </span>
141
+ All boards (Home)
142
+ </button>
143
+ </div>
144
+ </div>
145
+ </BoardMenuPortal>
146
+ )}
147
+ </span>
148
+ );
149
+ }
@@ -187,6 +187,9 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
187
187
  viewport,
188
188
  onMove: handleMove,
189
189
  onDragEnd: handleDragEnd,
190
+ onClick: () => {
191
+ selectedNodeIds.value = new Set([node.id]);
192
+ },
190
193
  });
191
194
 
192
195
  // ── Resize ────────────────────────────────────────────
@@ -236,6 +239,11 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
236
239
  (e: PointerEvent) => {
237
240
  if (e.button !== 0) return;
238
241
  if (renaming) return;
242
+ // Title controls act on the node without selecting or dragging it.
243
+ if (e.target instanceof Element && e.target.closest('button, input')) {
244
+ e.stopPropagation();
245
+ return;
246
+ }
239
247
  if (canvasTool.value === 'connect') {
240
248
  startEdgeDrag(e);
241
249
  return;
@@ -46,6 +46,7 @@ import { HumanPresenceLayer } from './HumanPresenceLayer';
46
46
  import { reportHumanCursor } from '../state/human-store';
47
47
  import { ScopeFenceLayer } from './ScopeFenceLayer';
48
48
  import { CanvasNode } from './CanvasNode';
49
+ import { BoardNode } from '../nodes/BoardNode';
49
50
  import { EdgeLayer } from './EdgeLayer';
50
51
  import { AnnotationLayer } from './AnnotationLayer';
51
52
  import { activeGuides } from './snap-guides';
@@ -63,6 +64,8 @@ function renderNodeContent(node: CanvasNodeState) {
63
64
  return <McpAppNode node={node} />;
64
65
  case 'graph':
65
66
  return <McpAppNode node={node} />;
67
+ case 'board':
68
+ return <BoardNode node={node} />;
66
69
  case 'prompt':
67
70
  return <PromptNode node={node} />;
68
71
  case 'response':
@@ -17,6 +17,8 @@ import { TYPE_LABELS, type CanvasNodeState } from '../types';
17
17
  import { invalidateTokenCache } from '../theme/tokens';
18
18
  import { clearThemeOverride } from '../state/theme-override';
19
19
  import { getNodeIcon, IconArrange, IconFitAll, IconMinimap, IconMoon, IconNodeMarkdown, IconSteer } from '../icons';
20
+ import { activeBoardId, openBoard } from '../state/boards-store';
21
+ import { requestJson } from '../state/intent-bridge';
20
22
 
21
23
  import { modChord } from '../utils/platform';
22
24
  import { useFocusTrap } from './use-focus-trap';
@@ -24,7 +26,7 @@ import { useFocusTrap } from './use-focus-trap';
24
26
  // ── Types ───────────────────────────────────────────────────
25
27
  interface PaletteItem {
26
28
  id: string;
27
- kind: 'node' | 'action';
29
+ kind: 'node' | 'action' | 'library';
28
30
  label: string;
29
31
  description?: string;
30
32
  /** Keyboard shortcut shown as a kbd on action rows. */
@@ -35,6 +37,14 @@ interface PaletteItem {
35
37
  action: () => void;
36
38
  }
37
39
 
40
+ interface LibraryHit {
41
+ boardId: string;
42
+ boardTitle: string;
43
+ cardId: string;
44
+ title: string | null;
45
+ snippet: string;
46
+ }
47
+
38
48
  // ── Fuzzy match ─────────────────────────────────────────────
39
49
  function fuzzyMatch(query: string, text: string): { match: boolean; score: number; indices: number[] } {
40
50
  const q = query.toLowerCase();
@@ -106,6 +116,8 @@ function parseTypeFilter(query: string): { typeFilter: CanvasNodeState['type'] |
106
116
  export function CommandPalette({ onClose, onToggleMinimap }: { onClose: () => void; onToggleMinimap: () => void }) {
107
117
  const [query, setQuery] = useState('');
108
118
  const [selectedIndex, setSelectedIndex] = useState(0);
119
+ const [libraryHits, setLibraryHits] = useState<LibraryHit[]>([]);
120
+ const [libraryState, setLibraryState] = useState<'idle' | 'loading' | 'error'>('idle');
109
121
  const inputRef = useRef<HTMLInputElement>(null);
110
122
  const listRef = useRef<HTMLDivElement>(null);
111
123
  const dialogRef = useRef<HTMLDivElement>(null);
@@ -116,6 +128,39 @@ export function CommandPalette({ onClose, onToggleMinimap }: { onClose: () => vo
116
128
  setTimeout(() => inputRef.current?.focus(), 30);
117
129
  }, []);
118
130
 
131
+ useEffect(() => {
132
+ const q = query.trim();
133
+ if (q.length < 2) {
134
+ setLibraryHits([]);
135
+ setLibraryState('idle');
136
+ return;
137
+ }
138
+ let active = true;
139
+ setLibraryState('loading');
140
+ const timer = window.setTimeout(
141
+ () =>
142
+ void requestJson<{ results?: LibraryHit[] } | null>(
143
+ 'paletteLibrarySearch',
144
+ `/api/canvas/search?q=${encodeURIComponent(q)}&scope=library&limit=12`,
145
+ null,
146
+ ).then((result) => {
147
+ if (!active) return;
148
+ if (!result) {
149
+ setLibraryHits([]);
150
+ setLibraryState('error');
151
+ return;
152
+ }
153
+ setLibraryHits(result.results ?? []);
154
+ setLibraryState('idle');
155
+ }),
156
+ 150,
157
+ );
158
+ return () => {
159
+ active = false;
160
+ window.clearTimeout(timer);
161
+ };
162
+ }, [query]);
163
+
119
164
  // ── Build items ─────────────────────────────────────────
120
165
  const buildItems = useCallback((): PaletteItem[] => {
121
166
  const items: PaletteItem[] = [];
@@ -164,6 +209,15 @@ export function CommandPalette({ onClose, onToggleMinimap }: { onClose: () => vo
164
209
  onClose();
165
210
  },
166
211
  },
212
+ {
213
+ label: 'Link to board…',
214
+ icon: getNodeIcon('board'),
215
+ iconTone: 'accent',
216
+ action: () => {
217
+ window.dispatchEvent(new CustomEvent('pmx-open-board-link'));
218
+ onClose();
219
+ },
220
+ },
167
221
  ...(sessionActive.value
168
222
  ? []
169
223
  : [
@@ -275,9 +329,39 @@ export function CommandPalette({ onClose, onToggleMinimap }: { onClose: () => vo
275
329
  // Sort by score descending
276
330
  filtered.sort((a, b) => b.score - a.score);
277
331
  }
332
+ for (const hit of libraryHits) {
333
+ filtered.push({
334
+ id: `library:${hit.boardId}:${hit.cardId}`,
335
+ kind: 'library',
336
+ label: hit.title || hit.boardTitle,
337
+ description: `${hit.boardTitle} · ${hit.snippet}`,
338
+ icon: getNodeIcon('board'),
339
+ score: 0,
340
+ indices: [],
341
+ action: () => {
342
+ void (async () => {
343
+ await openBoard(hit.boardId);
344
+ if (activeBoardId.value !== hit.boardId) return;
345
+ for (let attempt = 0; attempt < 40 && activeBoardId.value === hit.boardId; attempt++) {
346
+ if (nodes.value.has(hit.cardId)) {
347
+ focusNode(hit.cardId);
348
+ onClose();
349
+ return;
350
+ }
351
+ await new Promise((resolve) => window.setTimeout(resolve, 25));
352
+ }
353
+ })();
354
+ },
355
+ });
356
+ }
278
357
  // Grouped render order (design item 7): Actions first, then Jump to.
279
- filtered = [...filtered.filter((item) => item.kind === 'action'), ...filtered.filter((item) => item.kind === 'node')];
358
+ filtered = [
359
+ ...filtered.filter((item) => item.kind === 'action'),
360
+ ...filtered.filter((item) => item.kind === 'node'),
361
+ ...filtered.filter((item) => item.kind === 'library'),
362
+ ];
280
363
  const firstNodeIndex = filtered.findIndex((item) => item.kind === 'node');
364
+ const firstLibraryIndex = filtered.findIndex((item) => item.kind === 'library');
281
365
 
282
366
  // ── Sync spatial search highlights to canvas ──────────────
283
367
  useEffect(() => {
@@ -376,10 +460,29 @@ export function CommandPalette({ onClose, onToggleMinimap }: { onClose: () => vo
376
460
  <kbd class="command-palette-esc">esc</kbd>
377
461
  </div>
378
462
  <div class="command-palette-results" ref={listRef}>
379
- {filtered.length === 0 && <div class="command-palette-empty">No matching nodes or actions</div>}
463
+ {libraryState === 'loading' && (
464
+ <div class="command-palette-empty" role="status">
465
+ Searching all boards…
466
+ </div>
467
+ )}
468
+ {libraryState === 'error' && (
469
+ <div class="command-palette-empty" role="alert">
470
+ Library search is unavailable.
471
+ </div>
472
+ )}
473
+ {filtered.length === 0 && libraryState !== 'loading' && (
474
+ <div class="command-palette-empty">No matching nodes, boards, or actions</div>
475
+ )}
380
476
  {filtered.map((item, i) => {
381
477
  const Icon = item.icon;
382
- const heading = i === 0 && item.kind === 'action' ? 'Actions' : i === firstNodeIndex ? 'Jump to' : null;
478
+ const heading =
479
+ i === 0 && item.kind === 'action'
480
+ ? 'Actions'
481
+ : i === firstNodeIndex
482
+ ? 'Jump to'
483
+ : i === firstLibraryIndex
484
+ ? 'Across boards'
485
+ : null;
383
486
  return (
384
487
  <div key={item.id} class="command-palette-row">
385
488
  {heading && <div class="command-palette-group">{heading}</div>}
@@ -437,6 +437,10 @@ function buildCanvasMenuItems(canvasX: number, canvasY: number): MenuItem[] {
437
437
  });
438
438
  },
439
439
  },
440
+ {
441
+ label: 'Link to board…',
442
+ action: () => window.dispatchEvent(new CustomEvent('pmx-open-board-link')),
443
+ },
440
444
  { separator: true },
441
445
  {
442
446
  label: 'New group',
@@ -16,6 +16,7 @@ import { PromptNode } from '../nodes/PromptNode';
16
16
  import { AxStepControls } from '../nodes/AxStepControls';
17
17
  import { ResponseNode } from '../nodes/ResponseNode';
18
18
  import { TraceNode } from '../nodes/TraceNode';
19
+ import { BoardNode } from '../nodes/BoardNode';
19
20
  import {
20
21
  collapseExpandedNode,
21
22
  contextPinnedNodeIds,
@@ -41,6 +42,8 @@ function renderContent(node: CanvasNodeState, expanded: boolean) {
41
42
  return <McpAppNode node={node} expanded={expanded} />;
42
43
  case 'graph':
43
44
  return <McpAppNode node={node} expanded={expanded} />;
45
+ case 'board':
46
+ return <BoardNode node={node} />;
44
47
  case 'prompt':
45
48
  return <PromptNode node={node} />;
46
49
  case 'response':