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
@@ -17,7 +17,9 @@ cmd(
17
17
  if (flags.help || flags.h) return showCommandHelp('pin');
18
18
 
19
19
  if (flags.list) {
20
- const result = await invokeOperation('pinned-context.get', {});
20
+ const result = await invokeOperation('pinned-context.get', {
21
+ board: typeof flags.board === 'string' ? flags.board : undefined,
22
+ });
21
23
  output(result);
22
24
  return;
23
25
  }
@@ -36,7 +38,9 @@ cmd(
36
38
  }
37
39
 
38
40
  // Default: list
39
- const result = await invokeOperation('pinned-context.get', {});
41
+ const result = await invokeOperation('pinned-context.get', {
42
+ board: typeof flags.board === 'string' ? flags.board : undefined,
43
+ });
40
44
  output(result);
41
45
  },
42
46
  );
@@ -91,6 +91,9 @@ function runExec(template: string, steer: PendingSteer, consumer: string): Promi
91
91
  PMX_STEER_CONSUMER: consumer,
92
92
  },
93
93
  });
94
+ // A command that exits without reading stdin (`exit 1`, a notifier) closes
95
+ // the pipe first; the EPIPE is not the command's result — its exit code is.
96
+ child.stdin.on('error', () => {});
94
97
  child.stdin.write(steer.message);
95
98
  child.stdin.end();
96
99
  child.on('close', (code) => resolve(code ?? 1));
@@ -36,11 +36,30 @@ cmd(
36
36
  const query = positional[0] || (typeof flags.query === 'string' ? flags.query : '');
37
37
  if (!query) die('Missing search query', 'pmx-canvas search "query"');
38
38
 
39
- const result = await invokeOperation('search', { q: query });
39
+ const result = await invokeOperation('search', { q: query, ...(flags.library ? { scope: 'library' } : {}) });
40
40
  output(result);
41
41
  },
42
42
  );
43
43
 
44
+ cmd(
45
+ 'context',
46
+ 'Read the budgeted cross-board context brief',
47
+ ['pmx-canvas context --consumer codex --budget 16000'],
48
+ async (args) => {
49
+ const { flags } = parseFlags(args);
50
+ if (flags.help || flags.h) return showCommandHelp('context');
51
+ output(
52
+ await invokeOperation('context.get', {
53
+ ...(typeof flags.consumer === 'string' ? { consumer: flags.consumer } : {}),
54
+ ...(typeof flags.since === 'string' || typeof flags.since === 'number' ? { since: Number(flags.since) } : {}),
55
+ ...(typeof flags.budget === 'string' || typeof flags.budget === 'number'
56
+ ? { budget: Number(flags.budget) }
57
+ : {}),
58
+ }),
59
+ );
60
+ },
61
+ );
62
+
44
63
  // ── validate ─────────────────────────────────────────────────
45
64
  cmd(
46
65
  'validate',
@@ -114,13 +133,20 @@ cmd('code-graph', 'Show auto-detected file dependency graph', ['pmx-canvas code-
114
133
  });
115
134
 
116
135
  // ── spatial ──────────────────────────────────────────────────
117
- cmd('spatial', 'Spatial analysis: clusters, reading order, neighborhoods', ['pmx-canvas spatial'], async (args) => {
118
- const { flags } = parseFlags(args);
119
- if (flags.help || flags.h) return showCommandHelp('spatial');
136
+ cmd(
137
+ 'spatial',
138
+ 'Spatial analysis: clusters, reading order, neighborhoods',
139
+ ['pmx-canvas spatial', 'pmx-canvas spatial --board <id>'],
140
+ async (args) => {
141
+ const { flags } = parseFlags(args);
142
+ if (flags.help || flags.h) return showCommandHelp('spatial');
120
143
 
121
- const result = await invokeOperation('spatial.get', {});
122
- output(result);
123
- });
144
+ const result = await invokeOperation('spatial.get', {
145
+ board: typeof flags.board === 'string' ? flags.board : undefined,
146
+ });
147
+ output(result);
148
+ },
149
+ );
124
150
 
125
151
  // ── watch ────────────────────────────────────────────────────
126
152
  cmd(
@@ -42,16 +42,17 @@ cmd('open', 'Open the current workbench in the browser', ['pmx-canvas open'], as
42
42
  cmd(
43
43
  'layout',
44
44
  'Get the full canvas layout (nodes, edges, viewport)',
45
- ['pmx-canvas layout', 'pmx-canvas layout --summary'],
45
+ ['pmx-canvas layout', 'pmx-canvas layout --board <id>', 'pmx-canvas layout --summary'],
46
46
  async (args) => {
47
47
  const { flags } = parseFlags(args, { boolFlags: ['summary'] });
48
48
  if (flags.help || flags.h) return showCommandHelp('layout');
49
49
 
50
+ const board = typeof flags.board === 'string' ? flags.board : undefined;
50
51
  if (flags.summary || flags.compact) {
51
- output(await invokeOperation('summary.get', {}));
52
+ output(await invokeOperation('summary.get', { board }));
52
53
  return;
53
54
  }
54
- const result = await invokeOperation('layout.get', {});
55
+ const result = await invokeOperation('layout.get', { board });
55
56
  output(result);
56
57
  },
57
58
  );
@@ -61,12 +62,13 @@ cmd('status', 'Quick canvas summary', ['pmx-canvas status'], async (args) => {
61
62
  const { flags } = parseFlags(args);
62
63
  if (flags.help || flags.h) return showCommandHelp('status');
63
64
 
64
- const layout = (await invokeOperation('layout.get', {})) as {
65
+ const board = typeof flags.board === 'string' ? flags.board : undefined;
66
+ const layout = (await invokeOperation('layout.get', { board })) as {
65
67
  nodes: Array<Record<string, unknown>>;
66
68
  edges: unknown[];
67
69
  viewport: unknown;
68
70
  };
69
- const pinned = (await invokeOperation('pinned-context.get', {})) as { count: number; nodeIds: string[] };
71
+ const pinned = (await invokeOperation('pinned-context.get', { board })) as { count: number; nodeIds: string[] };
70
72
 
71
73
  const typeCounts: Record<string, number> = {};
72
74
  for (const n of layout.nodes) {
package/src/cli/daemon.ts CHANGED
@@ -49,14 +49,24 @@ export function isProcessRunning(pid: number): boolean {
49
49
  }
50
50
 
51
51
  /**
52
- * Whether pid's command line contains `needle`. Returns null when `ps` cannot
53
- * answer (no such process, unsupported platform) — callers fall back to the
54
- * plain liveness signal.
52
+ * Whether pid's command line contains `needle`. Returns null when the OS probe
53
+ * cannot answer (no such process or unavailable command). Stop must not treat
54
+ * an unknown identity as permission to signal a live process.
55
55
  */
56
56
  export function processCommandMatches(pid: number, needle: string): boolean | null {
57
57
  if (!Number.isInteger(pid) || pid <= 0) return null;
58
58
  try {
59
- const command = execFileSync('ps', ['-o', 'command=', '-p', String(pid)], {
59
+ const executable = process.platform === 'win32' ? 'powershell.exe' : 'ps';
60
+ const args =
61
+ process.platform === 'win32'
62
+ ? [
63
+ '-NoProfile',
64
+ '-NonInteractive',
65
+ '-Command',
66
+ `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').CommandLine`,
67
+ ]
68
+ : ['-o', 'command=', '-p', String(pid)];
69
+ const command = execFileSync(executable, args, {
60
70
  encoding: 'utf-8',
61
71
  stdio: ['ignore', 'pipe', 'ignore'],
62
72
  }).trim();
@@ -69,8 +79,8 @@ export function processCommandMatches(pid: number, needle: string): boolean | nu
69
79
 
70
80
  /**
71
81
  * Liveness with a PID-recycling guard: the pid must be alive AND its command
72
- * line must still look like our daemon (unless `ps` is unavailable, in which
73
- * case the plain liveness signal wins).
82
+ * line must still look like our daemon. Status falls back to plain liveness
83
+ * when the command probe is unavailable; stop requires a verified match.
74
84
  */
75
85
  export function isOwnDaemonProcess(pid: number, entryNeedle: string): boolean {
76
86
  if (!isProcessRunning(pid)) return false;
@@ -439,7 +449,27 @@ export async function stopServeDaemon(
439
449
  process.exit(1);
440
450
  }
441
451
 
442
- if (!isOwnDaemonProcess(pid, options.entry)) {
452
+ const commandMatches = processCommandMatches(pid, options.entry);
453
+ if (commandMatches !== true) {
454
+ if (isProcessRunning(pid)) {
455
+ outputJson({
456
+ ok: false,
457
+ daemon: true,
458
+ stopped: false,
459
+ running: responsive,
460
+ error:
461
+ commandMatches === false
462
+ ? `PID ${pid} is live but does not belong to this installation. The pid file was preserved.`
463
+ : `PID ${pid} is live but its installation could not be verified. The pid file was preserved.`,
464
+ hint: 'Use the matching installation and workspace to stop the daemon; verify the process before removing its pid file.',
465
+ pid,
466
+ url,
467
+ healthUrl,
468
+ logFile: options.logFile,
469
+ pidFile: options.pidFile,
470
+ });
471
+ process.exit(1);
472
+ }
443
473
  removePidFile(options.pidFile);
444
474
  outputJson({
445
475
  ok: true,
package/src/cli/index.ts CHANGED
@@ -38,6 +38,7 @@ const AGENT_COMMANDS = new Set([
38
38
  'edge',
39
39
  'json-render',
40
40
  'search',
41
+ 'context',
41
42
  'layout',
42
43
  'status',
43
44
  'arrange',
@@ -45,6 +46,10 @@ const AGENT_COMMANDS = new Set([
45
46
  'fit',
46
47
  'screenshot',
47
48
  'pin',
49
+ 'board',
50
+ 'backup',
51
+ 'restore',
52
+ 'export',
48
53
  'ax',
49
54
  'pump',
50
55
  'undo',
@@ -306,6 +311,10 @@ Agent CLI (works against running server):
306
311
  diagram add Add an Excalidraw diagram node
307
312
  html primitive add|schema Add or inspect HTML communication primitives
308
313
  pin <ids...> | --list | --clear Manage context pins
314
+ board list|create|rename|category Manage boards (the human opens them in the workbench)
315
+ backup [status|schedule] Back up every board, now or on a schedule
316
+ restore <backup-file> Replace every board with a backup
317
+ export [--board id] [--out file] Export a board as one HTML file anyone can open
309
318
  undo / redo / history Time travel
310
319
  snapshot save|list|restore|diff|delete
311
320
  Manage snapshots
@@ -425,14 +434,15 @@ Examples:
425
434
  console.log(' pmx-canvas --help (all commands)');
426
435
  console.log('\n Press Ctrl+C to stop\n');
427
436
 
428
- process.on('SIGINT', () => {
437
+ const shutdown = () => {
429
438
  console.log('\nShutting down...');
430
- canvas.stop();
431
- process.exit(0);
432
- });
433
-
434
- process.on('SIGTERM', () => {
435
- canvas.stop();
436
- process.exit(0);
437
- });
439
+ try {
440
+ canvas.stop();
441
+ process.exit(0);
442
+ } catch (error) {
443
+ console.error('Shutdown refused: canvas changes could not be saved. Fix persistence and retry shutdown.', error);
444
+ }
445
+ };
446
+ process.on('SIGINT', shutdown);
447
+ process.on('SIGTERM', shutdown);
438
448
  }
package/src/cli/shared.ts CHANGED
@@ -133,7 +133,8 @@ async function invokeOperation(name: string, input: Record<string, unknown>): Pr
133
133
  // ── Flag parsing ─────────────────────────────────────────────
134
134
 
135
135
  /**
136
- * `options.boolFlags` marks flags that are boolean FOR THIS COMMAND. Value
136
+ * `options.boolFlags` marks flags that are boolean FOR THIS COMMAND, while
137
+ * `options.valueFlags` overrides a globally boolean flag for one command. Value
137
138
  * flags consume the next token verbatim (so a unified diff beginning with
138
139
  * `--- a/file` survives), which makes a flag that is boolean here and
139
140
  * value-taking elsewhere ambiguous — `--summary` is boolean in `history` /
@@ -144,7 +145,7 @@ async function invokeOperation(name: string, input: Record<string, unknown>): Pr
144
145
  */
145
146
  function parseFlags(
146
147
  args: string[],
147
- options: { boolFlags?: readonly string[] } = {},
148
+ options: { boolFlags?: readonly string[]; valueFlags?: readonly string[] } = {},
148
149
  ): { positional: string[]; flags: Record<string, string | true> } {
149
150
  const positional: string[] = [];
150
151
  const flags: Record<string, string | true> = {};
@@ -187,6 +188,7 @@ function parseFlags(
187
188
  'check',
188
189
  'skip-mcp',
189
190
  ]);
191
+ for (const flag of options.valueFlags ?? []) BOOL_FLAGS.delete(flag);
190
192
  for (let i = 0; i < args.length; i++) {
191
193
  const arg = args[i];
192
194
  // Conventional end-of-flags separator: the rest is positional, never flags.
@@ -847,6 +849,13 @@ function showCommandHelp(name: string): void {
847
849
  console.log('\nAlias:');
848
850
  console.log(' Equivalent to: pmx-canvas external-app add --kind excalidraw ...');
849
851
  }
852
+ if (name === 'export') {
853
+ console.log('\nOptions:');
854
+ console.log(' --board <id> Export this board instead of the open board');
855
+ console.log(' --out <path> Copy the export to this path');
856
+ console.log(' --include-files Include local file contents (default: excluded)');
857
+ console.log(' --include-derived-text Include imported document text (default: excluded)');
858
+ }
850
859
  console.log('');
851
860
  }
852
861
 
@@ -8,9 +8,13 @@ import { CanvasViewport } from './canvas/CanvasViewport';
8
8
  import { CommandBar } from './canvas/CommandBar';
9
9
  import { ConnectionBanner } from './canvas/ConnectionBanner';
10
10
  import { CommandPalette } from './canvas/CommandPalette';
11
+ import { BoardLinkDialog } from './canvas/BoardLinkDialog';
11
12
  import { ContextMenu, useContextMenu } from './canvas/ContextMenu';
12
13
  import { ContextPinBar } from './canvas/ContextPinBar';
13
14
  import { EmptyState } from './canvas/EmptyState';
15
+ import { HomeView } from './canvas/HomeView';
16
+ import { ExportDialog } from './canvas/ExportDialog';
17
+ import { activeBoardId, boardsLoaded } from './state/boards-store';
14
18
  import { createNodeInView } from './canvas/create-in-view';
15
19
  import { undoFromKeyboard } from './state/session-store';
16
20
  import { ActivityFeed, WritersSheet } from './canvas/ExternalWriters';
@@ -67,11 +71,13 @@ export function App() {
67
71
  const [minimapVisible, setMinimapVisible] = useState(true);
68
72
  const [snapshotOpen, setSnapshotOpen] = useState(false);
69
73
  const [paletteOpen, setPaletteOpen] = useState(false);
74
+ const [boardLinkOpen, setBoardLinkOpen] = useState(false);
70
75
  const [shortcutsOpen, setShortcutsOpen] = useState(false);
71
76
  const [annotationTool, setAnnotationTool] = useState<AnnotationTool>(null);
72
77
  const snapshotBtnRef = useRef<HTMLButtonElement>(null);
73
78
  const { menu, openNodeMenu, openCanvasMenu, openEdgeMenu, closeMenu } = useContextMenu();
74
79
  const hasInitialLayout = hasInitialServerLayout.value;
80
+ const onHome = boardsLoaded.value && activeBoardId.value === null;
75
81
  // rail-chrome-v2: the one selector every agent surface mounts on. Exposed
76
82
  // as a data attribute so styling and tests can key on it; the quiet board
77
83
  // (no attached session) must stay byte-clean of agent chrome.
@@ -90,6 +96,12 @@ export function App() {
90
96
  return connectSSE();
91
97
  }, []);
92
98
 
99
+ useEffect(() => {
100
+ const open = () => setBoardLinkOpen(true);
101
+ window.addEventListener('pmx-open-board-link', open);
102
+ return () => window.removeEventListener('pmx-open-board-link', open);
103
+ }, []);
104
+
93
105
  // Keep the server's idea of this window's size current (0.4.6 orb feedback
94
106
  // #2) so an agent `fit` computes a scale that actually fits the human's
95
107
  // canvas region. Connect-time reporting lives in the SSE bridge; this covers
@@ -221,7 +233,6 @@ export function App() {
221
233
  if (ids.length === 0) return;
222
234
  e.preventDefault();
223
235
  for (const id of ids) {
224
- removeNode(id);
225
236
  void removeNodeFromClient(id).catch((error) => logAppError('delete', error));
226
237
  }
227
238
  clearSelection();
@@ -357,16 +368,21 @@ export function App() {
357
368
  <AttentionHistory />
358
369
  <ActivityFeed />
359
370
  <WritersSheet />
360
- {hasInitialLayout && allNodes.length === 0 && intents.value.size === 0 && (
361
- <EmptyState onOpenPalette={() => setPaletteOpen(true)} />
371
+ {onHome ? (
372
+ <HomeView />
373
+ ) : (
374
+ hasInitialLayout &&
375
+ allNodes.length === 0 &&
376
+ intents.value.size === 0 && <EmptyState onOpenPalette={() => setPaletteOpen(true)} />
362
377
  )}
363
378
  {selectedNodeIds.value.size > 0 && <SelectionBar />}
364
379
  {sessionIsActive ? <CommandBar /> : contextPinnedNodeIds.value.size > 0 && <ContextPinBar />}
365
380
  <SessionReceipt onOpenSnapshots={() => setSnapshotOpen(true)} />
366
381
  <TextPrompt />
382
+ <ExportDialog />
367
383
  {expandedNodeId.value && <ExpandedNodeOverlay />}
368
384
  <SnapshotPanel open={snapshotOpen} onClose={handleCloseSnapshot} anchorRef={snapshotBtnRef} />
369
- {minimapVisible && (
385
+ {minimapVisible && !onHome && (
370
386
  <Minimap
371
387
  viewport={viewport}
372
388
  nodes={nodes}
@@ -381,6 +397,7 @@ export function App() {
381
397
  {sessionIsActive && <SessionPanel />}
382
398
  {menu && <ContextMenu menu={menu} onClose={closeMenu} />}
383
399
  {paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} onToggleMinimap={handleToggleMinimap} />}
400
+ {boardLinkOpen && <BoardLinkDialog onClose={() => setBoardLinkOpen(false)} />}
384
401
  {shortcutsOpen && <ShortcutOverlay onClose={() => setShortcutsOpen(false)} />}
385
402
  </div>
386
403
  );
@@ -9,11 +9,13 @@ export function AttentionToast() {
9
9
  type="button"
10
10
  class={`attention-toast attention-tone-${toast.tone}`}
11
11
  onClick={openAttentionHistory}
12
- aria-label={`${toast.title} — open change history`}
13
- title={toast.detail || toast.title}
12
+ aria-label={`${toast.title}${toast.detail ? `: ${toast.detail}` : ''} — open change history`}
14
13
  >
15
14
  <span class="attention-toast-dot" aria-hidden="true" />
16
- <span class="attention-toast-title">{toast.title}</span>
15
+ <span class="attention-toast-copy">
16
+ <span class="attention-toast-title">{toast.title}</span>
17
+ {toast.detail && <span class="attention-toast-detail">{toast.detail}</span>}
18
+ </span>
17
19
  </button>
18
20
  );
19
21
  }
@@ -0,0 +1,153 @@
1
+ import { useEffect, useRef, useState } from 'preact/hooks';
2
+ import {
3
+ createBoardFrom,
4
+ openBoard,
5
+ previewBoardCopy,
6
+ type BoardCopyPreview,
7
+ type BoardSummary,
8
+ } from '../state/boards-store';
9
+ import { useFocusTrap } from './use-focus-trap';
10
+
11
+ export function BoardCopyDialog({ board, onClose }: { board: BoardSummary; onClose: () => void }) {
12
+ const dialogRef = useRef<HTMLDivElement>(null);
13
+ const nameRef = useRef<HTMLInputElement>(null);
14
+ const [preview, setPreview] = useState<BoardCopyPreview | null>(null);
15
+ const [selected, setSelected] = useState<Set<string>>(new Set());
16
+ const [name, setName] = useState(`${board.name} copy`);
17
+ const [includeReadme, setIncludeReadme] = useState(true);
18
+ const [includeStructure, setIncludeStructure] = useState(true);
19
+ const [openAfter, setOpenAfter] = useState(false);
20
+ const [error, setError] = useState('');
21
+ const [saving, setSaving] = useState(false);
22
+ useFocusTrap(dialogRef, true, { initial: nameRef });
23
+
24
+ useEffect(() => {
25
+ let active = true;
26
+ void previewBoardCopy(board.id).then((result) => {
27
+ if (!active) return;
28
+ if (!result || !Array.isArray(result.cards)) return setError('Could not load reusable cards. Try again.');
29
+ setPreview(result);
30
+ setSelected(new Set(result.cards.filter((card) => card.reusable).map((card) => card.id)));
31
+ });
32
+ return () => {
33
+ active = false;
34
+ };
35
+ }, [board.id]);
36
+
37
+ const submit = async (event: Event) => {
38
+ event.preventDefault();
39
+ if (!name.trim() || !preview) return;
40
+ setSaving(true);
41
+ setError('');
42
+ const created = await createBoardFrom({
43
+ sourceBoardId: board.id,
44
+ name: name.trim(),
45
+ ...(board.category ? { category: board.category } : {}),
46
+ nodeIds: [...selected],
47
+ includeReadme,
48
+ includeStructure,
49
+ });
50
+ setSaving(false);
51
+ if (!created) return setError('Could not create the board. Try a different name.');
52
+ if (openAfter) await openBoard(created.id);
53
+ onClose();
54
+ };
55
+
56
+ return (
57
+ <div class="text-prompt-backdrop" onMouseDown={onClose}>
58
+ <div
59
+ ref={dialogRef}
60
+ class="text-prompt board-copy-dialog"
61
+ role="dialog"
62
+ aria-modal="true"
63
+ aria-labelledby="board-copy-title"
64
+ onMouseDown={(e) => e.stopPropagation()}
65
+ >
66
+ <form onSubmit={submit}>
67
+ <h2 id="board-copy-title">Create from {board.name}</h2>
68
+ <label class="board-copy-field">
69
+ Board name
70
+ <input
71
+ ref={nameRef}
72
+ class="text-prompt-input"
73
+ value={name}
74
+ onInput={(e) => setName(e.currentTarget.value)}
75
+ />
76
+ </label>
77
+ {!preview && !error && (
78
+ <div class="board-copy-status" role="status">
79
+ Loading reusable cards…
80
+ </div>
81
+ )}
82
+ {error && (
83
+ <div class="board-copy-error" role="alert">
84
+ {error}
85
+ </div>
86
+ )}
87
+ {preview && (
88
+ <fieldset class="board-copy-cards">
89
+ <legend>Cards to copy</legend>
90
+ {preview.cards.filter((card) => card.reusable).length === 0 ? (
91
+ <div class="board-copy-status">No reusable cards on this board.</div>
92
+ ) : (
93
+ preview.cards
94
+ .filter((card) => card.reusable)
95
+ .map((card) => (
96
+ <label class="board-copy-card" key={card.id}>
97
+ <input
98
+ type="checkbox"
99
+ checked={selected.has(card.id)}
100
+ onChange={(e) => {
101
+ const next = new Set(selected);
102
+ e.currentTarget.checked ? next.add(card.id) : next.delete(card.id);
103
+ setSelected(next);
104
+ }}
105
+ />
106
+ <span>
107
+ <strong>{card.title || 'Untitled card'}</strong>
108
+ <small>{card.type}</small>
109
+ </span>
110
+ </label>
111
+ ))
112
+ )}
113
+ </fieldset>
114
+ )}
115
+ <div class="board-copy-options">
116
+ <label>
117
+ <input
118
+ type="checkbox"
119
+ checked={includeStructure}
120
+ onChange={(e) => setIncludeStructure(e.currentTarget.checked)}
121
+ />{' '}
122
+ Preserve groups and connections
123
+ </label>
124
+ <label>
125
+ <input
126
+ type="checkbox"
127
+ checked={includeReadme}
128
+ disabled={!preview?.readmeNodeId}
129
+ onChange={(e) => setIncludeReadme(e.currentTarget.checked)}
130
+ />{' '}
131
+ Include README
132
+ </label>
133
+ <label>
134
+ <input type="checkbox" checked={openAfter} onChange={(e) => setOpenAfter(e.currentTarget.checked)} /> Open
135
+ the new board after creating
136
+ </label>
137
+ </div>
138
+ <p class="board-copy-note">
139
+ Only selected cards and structure are copied. Asks, history, and context pins start clear.
140
+ </p>
141
+ <div class="text-prompt-actions">
142
+ <button type="button" onClick={onClose}>
143
+ Cancel
144
+ </button>
145
+ <button type="submit" disabled={!preview || !name.trim() || saving}>
146
+ {saving ? 'Creating…' : 'Create board'}
147
+ </button>
148
+ </div>
149
+ </form>
150
+ </div>
151
+ </div>
152
+ );
153
+ }
@@ -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
+ }