pmx-canvas 0.6.5 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +139 -14
  2. package/CHANGELOG.md +70 -0
  3. package/Readme.md +32 -6
  4. package/dist/canvas/global.css +510 -31
  5. package/dist/canvas/index.js +92 -77
  6. package/dist/types/cli/agent.d.ts +3 -0
  7. package/dist/types/cli/commands/backup.d.ts +1 -0
  8. package/dist/types/cli/commands/boards.d.ts +1 -0
  9. package/dist/types/cli/commands/export.d.ts +1 -0
  10. package/dist/types/cli/daemon.d.ts +5 -5
  11. package/dist/types/cli/shared.d.ts +3 -1
  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/nodes/AttachmentNode.d.ts +6 -0
  19. package/dist/types/client/nodes/BoardNode.d.ts +5 -0
  20. package/dist/types/client/state/boards-store.d.ts +79 -0
  21. package/dist/types/client/state/canvas-store.d.ts +2 -0
  22. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  23. package/dist/types/client/state/session-store.d.ts +7 -0
  24. package/dist/types/client/types.d.ts +2 -2
  25. package/dist/types/json-render/catalog.d.ts +1 -1
  26. package/dist/types/json-render/charts/definitions.d.ts +1 -1
  27. package/dist/types/json-render/server.d.ts +6 -0
  28. package/dist/types/mcp/canvas-access.d.ts +10 -3
  29. package/dist/types/mcp/context-uri-template.d.ts +6 -0
  30. package/dist/types/server/agent-presence.d.ts +1 -1
  31. package/dist/types/server/attribution.d.ts +11 -0
  32. package/dist/types/server/ax-context.d.ts +3 -1
  33. package/dist/types/server/ax-interaction.d.ts +2 -2
  34. package/dist/types/server/ax-state.d.ts +13 -3
  35. package/dist/types/server/backup.d.ts +36 -0
  36. package/dist/types/server/board-export.d.ts +45 -0
  37. package/dist/types/server/canvas-db.d.ts +63 -9
  38. package/dist/types/server/canvas-operations.d.ts +23 -0
  39. package/dist/types/server/canvas-provenance.d.ts +1 -1
  40. package/dist/types/server/canvas-state.d.ts +140 -3
  41. package/dist/types/server/context-brief.d.ts +111 -0
  42. package/dist/types/server/context-reads.d.ts +55 -0
  43. package/dist/types/server/document-import.d.ts +66 -0
  44. package/dist/types/server/index.d.ts +103 -4
  45. package/dist/types/server/mutation-history.d.ts +1 -0
  46. package/dist/types/server/operations/invoker.d.ts +2 -0
  47. package/dist/types/server/operations/mcp.d.ts +7 -1
  48. package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
  49. package/dist/types/server/operations/ops/backup.d.ts +2 -0
  50. package/dist/types/server/operations/ops/boards.d.ts +10 -0
  51. package/dist/types/server/operations/ops/export.d.ts +2 -0
  52. package/dist/types/server/operations/ops/imports.d.ts +2 -0
  53. package/dist/types/server/operations/ops/nodes.d.ts +1 -1
  54. package/dist/types/server/operations/ops/query.d.ts +2 -0
  55. package/dist/types/server/operations/registry.d.ts +9 -1
  56. package/dist/types/server/operations/types.d.ts +1 -1
  57. package/dist/types/server/server.d.ts +1 -0
  58. package/dist/types/server/trace-manager.d.ts +7 -0
  59. package/dist/types/server/webpage-node.d.ts +22 -1
  60. package/dist/types/server/workbench-auth.d.ts +3 -0
  61. package/dist/types/shared/boards.d.ts +6 -0
  62. package/docs/RELEASE.md +5 -0
  63. package/docs/cli.md +73 -0
  64. package/docs/http-api.md +198 -2
  65. package/docs/mcp.md +71 -6
  66. package/docs/node-types.md +26 -0
  67. package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
  68. package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
  69. package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
  70. package/docs/plans/plan-013-static-board-export.md +47 -0
  71. package/docs/plans/plan-014-connected-board-memory.md +140 -0
  72. package/docs/product-vision-2026-09.md +118 -18
  73. package/docs/sdk.md +50 -0
  74. package/package.json +1 -1
  75. package/skills/pmx-canvas/SKILL.md +68 -11
  76. package/skills/pmx-canvas/evals/evals.json +130 -0
  77. package/skills/pmx-canvas/references/full-reference.md +43 -7
  78. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
  79. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  80. package/skills/pmx-canvas/references/tours-and-recording.md +69 -0
  81. package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
  82. package/skills/pmx-canvas-testing/SKILL.md +11 -2
  83. package/src/cli/agent.ts +4 -0
  84. package/src/cli/commands/apps.ts +1 -1
  85. package/src/cli/commands/ax.ts +13 -0
  86. package/src/cli/commands/backup.ts +66 -0
  87. package/src/cli/commands/boards.ts +71 -0
  88. package/src/cli/commands/export.ts +37 -0
  89. package/src/cli/commands/nodes.ts +4 -1
  90. package/src/cli/commands/pins.ts +6 -2
  91. package/src/cli/commands/pump.ts +3 -0
  92. package/src/cli/commands/query.ts +33 -7
  93. package/src/cli/commands/view.ts +7 -5
  94. package/src/cli/daemon.ts +37 -7
  95. package/src/cli/index.ts +19 -9
  96. package/src/cli/shared.ts +11 -2
  97. package/src/client/App.tsx +21 -4
  98. package/src/client/canvas/AttentionToast.tsx +5 -3
  99. package/src/client/canvas/BoardCopyDialog.tsx +153 -0
  100. package/src/client/canvas/BoardFolderDialog.tsx +120 -0
  101. package/src/client/canvas/BoardLinkDialog.tsx +93 -0
  102. package/src/client/canvas/BoardSwitcher.tsx +149 -0
  103. package/src/client/canvas/CanvasNode.tsx +0 -1
  104. package/src/client/canvas/CanvasViewport.tsx +3 -0
  105. package/src/client/canvas/CommandPalette.tsx +107 -4
  106. package/src/client/canvas/ContextMenu.tsx +4 -1
  107. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
  108. package/src/client/canvas/ExportDialog.tsx +199 -0
  109. package/src/client/canvas/HomeView.tsx +277 -0
  110. package/src/client/canvas/Presentation.tsx +39 -9
  111. package/src/client/canvas/SelectionBar.tsx +0 -2
  112. package/src/client/canvas/SessionPanel.tsx +28 -1
  113. package/src/client/canvas/ToolRail.tsx +38 -3
  114. package/src/client/canvas/TopBar.tsx +34 -18
  115. package/src/client/canvas/import-files.ts +19 -1
  116. package/src/client/canvas/kind-colors.ts +1 -0
  117. package/src/client/icons.tsx +2 -0
  118. package/src/client/nodes/AttachmentNode.tsx +145 -0
  119. package/src/client/nodes/BoardNode.tsx +35 -0
  120. package/src/client/nodes/FileNode.tsx +6 -0
  121. package/src/client/nodes/MarkdownNode.tsx +47 -19
  122. package/src/client/nodes/ax-node-actions.ts +2 -5
  123. package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
  124. package/src/client/state/attention-bridge.ts +3 -1
  125. package/src/client/state/boards-store.ts +167 -0
  126. package/src/client/state/canvas-store.ts +22 -0
  127. package/src/client/state/intent-bridge.ts +30 -2
  128. package/src/client/state/node-factory.ts +1 -0
  129. package/src/client/state/session-store.ts +5 -0
  130. package/src/client/state/sse-bridge.ts +52 -6
  131. package/src/client/theme/global.css +510 -31
  132. package/src/client/types.ts +2 -0
  133. package/src/json-render/server.ts +16 -9
  134. package/src/mcp/canvas-access.ts +59 -22
  135. package/src/mcp/context-uri-template.ts +28 -0
  136. package/src/mcp/server.ts +122 -15
  137. package/src/server/agent-context.ts +116 -29
  138. package/src/server/attribution.ts +29 -0
  139. package/src/server/ax-context.ts +4 -2
  140. package/src/server/ax-interaction.ts +1 -0
  141. package/src/server/ax-state-manager.ts +72 -3
  142. package/src/server/ax-state.ts +41 -0
  143. package/src/server/backup.ts +178 -0
  144. package/src/server/board-export.ts +686 -0
  145. package/src/server/canvas-db.ts +599 -107
  146. package/src/server/canvas-operations.ts +68 -9
  147. package/src/server/canvas-provenance.ts +1 -0
  148. package/src/server/canvas-schema.ts +12 -0
  149. package/src/server/canvas-state.ts +774 -45
  150. package/src/server/context-brief.ts +372 -0
  151. package/src/server/context-reads.ts +238 -0
  152. package/src/server/demo-state.json +659 -2
  153. package/src/server/document-import.ts +223 -0
  154. package/src/server/index.ts +231 -5
  155. package/src/server/mutation-history.ts +10 -13
  156. package/src/server/operations/composites.ts +26 -4
  157. package/src/server/operations/http.ts +9 -3
  158. package/src/server/operations/index.ts +8 -0
  159. package/src/server/operations/invoker.ts +14 -3
  160. package/src/server/operations/mcp.ts +50 -11
  161. package/src/server/operations/ops/app.ts +5 -0
  162. package/src/server/operations/ops/ax-read.ts +10 -6
  163. package/src/server/operations/ops/ax-timeline.ts +75 -0
  164. package/src/server/operations/ops/backup.ts +134 -0
  165. package/src/server/operations/ops/batch.ts +8 -1
  166. package/src/server/operations/ops/boards.ts +351 -0
  167. package/src/server/operations/ops/export.ts +74 -0
  168. package/src/server/operations/ops/ext-app.ts +4 -0
  169. package/src/server/operations/ops/imports.ts +236 -0
  170. package/src/server/operations/ops/nodes.ts +57 -5
  171. package/src/server/operations/ops/query.ts +206 -9
  172. package/src/server/operations/registry.ts +123 -31
  173. package/src/server/operations/types.ts +1 -1
  174. package/src/server/server.ts +146 -2
  175. package/src/server/spatial-analysis.ts +9 -17
  176. package/src/server/trace-manager.ts +19 -0
  177. package/src/server/web-artifacts.ts +2 -1
  178. package/src/server/webpage-node.ts +173 -13
  179. package/src/server/workbench-auth.ts +11 -0
  180. package/src/shared/boards.ts +18 -0
  181. package/src/shared/node-sizes.ts +1 -0
@@ -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
+ }
@@ -730,7 +730,6 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
730
730
  type="button"
731
731
  onClick={(e) => {
732
732
  e.stopPropagation();
733
- removeNode(node.id);
734
733
  void removeNodeFromClient(node.id);
735
734
  }}
736
735
  title="Close"
@@ -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',
@@ -665,7 +669,6 @@ function buildNodeMenuItems(node: CanvasNodeState): MenuItem[] {
665
669
  label: node.type === 'group' ? 'Delete frame (children stay)' : 'Delete',
666
670
  danger: true,
667
671
  action: () => {
668
- removeNode(node.id);
669
672
  void removeNodeFromClient(node.id);
670
673
  },
671
674
  });
@@ -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':
@@ -0,0 +1,199 @@
1
+ import { signal } from '@preact/signals';
2
+ import { useEffect, useState } from 'preact/hooks';
3
+ import { requestJson } from '../state/intent-bridge';
4
+ import { activeBoardId } from '../state/boards-store';
5
+
6
+ interface ExportManifest {
7
+ boardId: string;
8
+ boardName: string;
9
+ cards: number;
10
+ connections: number;
11
+ frames: number;
12
+ placeholders: Array<{ nodeId: string; title: string; reason: string }>;
13
+ files: Array<{ nodeId: string; path: string; included: boolean }>;
14
+ attachments: Array<{ nodeId: string; name: string }>;
15
+ sourceDerivedCards: Array<{ nodeId: string; included: boolean }>;
16
+ embeddedImages: number;
17
+ remoteImages: string[];
18
+ links: string[];
19
+ frameNetworkDestinations: string[];
20
+ embeddedCodeCanAccessNetwork: boolean;
21
+ }
22
+
23
+ interface ExportResult {
24
+ path: string;
25
+ url: string;
26
+ bytes: number;
27
+ }
28
+
29
+ export const exportDialogOpen = signal(false);
30
+
31
+ function sizeLabel(bytes: number): string {
32
+ return bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.ceil(bytes / 1024)} KB`;
33
+ }
34
+
35
+ /**
36
+ * Export the open board as one HTML file (plan 013). An export is a share, so
37
+ * the dialog lists what goes into the file before it is written; file contents
38
+ * stay out unless the owner ticks them in.
39
+ */
40
+ export function ExportDialog() {
41
+ const [includeFiles, setIncludeFiles] = useState(false);
42
+ const [includeDerivedText, setIncludeDerivedText] = useState(false);
43
+ const [manifest, setManifest] = useState<ExportManifest | null>(null);
44
+ const [result, setResult] = useState<ExportResult | null>(null);
45
+ const [busy, setBusy] = useState(false);
46
+ const open = exportDialogOpen.value;
47
+ const boardId = activeBoardId.value;
48
+
49
+ useEffect(() => {
50
+ if (!open || !boardId) return;
51
+ let current = true;
52
+ setResult(null);
53
+ setManifest(null);
54
+ void requestJson<{ manifest?: ExportManifest } | null>(
55
+ 'exportPreview',
56
+ `/api/canvas/export/preview?board=${encodeURIComponent(boardId)}&includeFiles=${includeFiles}&includeDerivedText=${includeDerivedText}`,
57
+ null,
58
+ ).then((body) => {
59
+ if (current) setManifest(body?.manifest ?? null);
60
+ });
61
+ return () => {
62
+ current = false;
63
+ };
64
+ }, [open, includeFiles, includeDerivedText, boardId]);
65
+
66
+ useEffect(() => {
67
+ setIncludeDerivedText(false);
68
+ }, [open, boardId]);
69
+
70
+ useEffect(() => {
71
+ if (!open) return;
72
+ const onKey = (e: KeyboardEvent) => {
73
+ if (e.key !== 'Escape') return;
74
+ e.stopPropagation();
75
+ exportDialogOpen.value = false;
76
+ };
77
+ window.addEventListener('keydown', onKey, { capture: true });
78
+ return () => window.removeEventListener('keydown', onKey, { capture: true });
79
+ }, [open]);
80
+
81
+ if (!open) return null;
82
+
83
+ const write = async () => {
84
+ if (!manifest || manifest.boardId !== activeBoardId.value) return;
85
+ setBusy(true);
86
+ const body = await requestJson<ExportResult | null>('exportBoard', '/api/canvas/export', null, {
87
+ method: 'POST',
88
+ headers: { 'Content-Type': 'application/json' },
89
+ body: JSON.stringify({ board: manifest.boardId, includeFiles, includeDerivedText }),
90
+ });
91
+ setBusy(false);
92
+ if (body?.path && activeBoardId.value === manifest.boardId) setResult(body);
93
+ };
94
+
95
+ return (
96
+ <div class="text-prompt-backdrop" onPointerDown={() => (exportDialogOpen.value = false)}>
97
+ <div
98
+ class="text-prompt export-dialog"
99
+ role="dialog"
100
+ aria-label="Export board"
101
+ data-testid="export-dialog"
102
+ onPointerDown={(e) => e.stopPropagation()}
103
+ >
104
+ <div class="text-prompt-title">Export {manifest ? `“${manifest.boardName}”` : 'board'}</div>
105
+ {!manifest ? (
106
+ <div class="export-dialog-note">Reading the board…</div>
107
+ ) : (
108
+ <>
109
+ <div class="export-dialog-note">
110
+ One HTML file anyone can open without installing anything. It will contain:
111
+ </div>
112
+ <ul class="export-dialog-list">
113
+ <li>
114
+ {manifest.cards} card{manifest.cards !== 1 ? 's' : ''} and {manifest.connections} connection
115
+ {manifest.connections !== 1 ? 's' : ''}
116
+ </li>
117
+ {manifest.frames > 0 && (
118
+ <li>{manifest.frames} embedded pages, charts or diagrams, with the code that draws them</li>
119
+ )}
120
+ {manifest.embeddedImages > 0 && <li>{manifest.embeddedImages} local images</li>}
121
+ {manifest.placeholders.length > 0 && (
122
+ <li>
123
+ Shown as placeholders:{' '}
124
+ {manifest.placeholders.map((entry) => `${entry.title} (${entry.reason})`).join(', ')}
125
+ </li>
126
+ )}
127
+ {manifest.remoteImages.length > 0 && (
128
+ <li>Web images loaded when opened: {manifest.remoteImages.join(', ')}</li>
129
+ )}
130
+ {manifest.links.length > 0 && <li>Links included in cards: {manifest.links.join(', ')}</li>}
131
+ {manifest.frameNetworkDestinations.length > 0 && (
132
+ <li>
133
+ Network destinations referenced by embedded pages: {manifest.frameNetworkDestinations.join(', ')}
134
+ </li>
135
+ )}
136
+ {manifest.embeddedCodeCanAccessNetwork && (
137
+ <li>
138
+ Warning: embedded code can access the network. Scripts may contact destinations that cannot be listed
139
+ in advance.
140
+ </li>
141
+ )}
142
+ </ul>
143
+ {(manifest.attachments?.length ?? 0) > 0 && (
144
+ <p>Original attachments are excluded. Their filenames remain visible.</p>
145
+ )}
146
+ {(manifest.sourceDerivedCards?.length ?? 0) > 0 && (
147
+ <label class="export-dialog-files">
148
+ <input
149
+ type="checkbox"
150
+ checked={includeDerivedText}
151
+ onChange={(event) => setIncludeDerivedText(event.currentTarget.checked)}
152
+ />
153
+ <span>Include imported document text ({manifest.sourceDerivedCards.length} cards)</span>
154
+ </label>
155
+ )}
156
+ {manifest.files.length > 0 && (
157
+ <label class="export-dialog-files">
158
+ <input
159
+ type="checkbox"
160
+ checked={includeFiles}
161
+ onChange={(e) => setIncludeFiles((e.target as HTMLInputElement).checked)}
162
+ />
163
+ <span>
164
+ Include the contents of {manifest.files.length} local file
165
+ {manifest.files.length !== 1 ? 's' : ''}: {manifest.files.map((file) => file.path).join(', ')}
166
+ </span>
167
+ </label>
168
+ )}
169
+ </>
170
+ )}
171
+ {result && (
172
+ <div class="export-dialog-result" data-testid="export-result">
173
+ <div>
174
+ Saved ({sizeLabel(result.bytes)}): <code>{result.path}</code>
175
+ </div>
176
+ <div class="export-dialog-links">
177
+ <a href={result.url} target="_blank" rel="noopener noreferrer">
178
+ Open
179
+ </a>
180
+ <a class="is-primary" href={`${result.url}?download=1`}>
181
+ Download
182
+ </a>
183
+ </div>
184
+ </div>
185
+ )}
186
+ <div class="text-prompt-actions">
187
+ <button type="button" class="text-prompt-cancel" onClick={() => (exportDialogOpen.value = false)}>
188
+ {result ? 'Done' : 'Cancel'}
189
+ </button>
190
+ {!result && (
191
+ <button type="button" class="text-prompt-confirm" disabled={!manifest || busy} onClick={() => void write()}>
192
+ {busy ? 'Exporting…' : 'Export'}
193
+ </button>
194
+ )}
195
+ </div>
196
+ </div>
197
+ </div>
198
+ );
199
+ }