pmx-canvas 0.6.5 → 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 (169) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +67 -3
  2. package/CHANGELOG.md +54 -0
  3. package/Readme.md +29 -5
  4. package/dist/canvas/global.css +440 -24
  5. package/dist/canvas/index.js +90 -75
  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/client/canvas/BoardCopyDialog.d.ts +5 -0
  11. package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
  12. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
  13. package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
  14. package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
  15. package/dist/types/client/canvas/HomeView.d.ts +9 -0
  16. package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
  17. package/dist/types/client/nodes/BoardNode.d.ts +5 -0
  18. package/dist/types/client/state/boards-store.d.ts +79 -0
  19. package/dist/types/client/state/canvas-store.d.ts +2 -0
  20. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  21. package/dist/types/client/state/session-store.d.ts +7 -0
  22. package/dist/types/client/types.d.ts +2 -2
  23. package/dist/types/json-render/catalog.d.ts +1 -1
  24. package/dist/types/json-render/charts/definitions.d.ts +1 -1
  25. package/dist/types/json-render/server.d.ts +6 -0
  26. package/dist/types/mcp/canvas-access.d.ts +10 -3
  27. package/dist/types/mcp/context-uri-template.d.ts +6 -0
  28. package/dist/types/server/agent-presence.d.ts +1 -1
  29. package/dist/types/server/attribution.d.ts +11 -0
  30. package/dist/types/server/ax-context.d.ts +3 -1
  31. package/dist/types/server/ax-interaction.d.ts +2 -2
  32. package/dist/types/server/ax-state.d.ts +13 -3
  33. package/dist/types/server/backup.d.ts +36 -0
  34. package/dist/types/server/board-export.d.ts +45 -0
  35. package/dist/types/server/canvas-db.d.ts +63 -9
  36. package/dist/types/server/canvas-operations.d.ts +23 -0
  37. package/dist/types/server/canvas-provenance.d.ts +1 -1
  38. package/dist/types/server/canvas-state.d.ts +140 -3
  39. package/dist/types/server/context-brief.d.ts +111 -0
  40. package/dist/types/server/context-reads.d.ts +55 -0
  41. package/dist/types/server/document-import.d.ts +66 -0
  42. package/dist/types/server/index.d.ts +103 -4
  43. package/dist/types/server/mutation-history.d.ts +1 -0
  44. package/dist/types/server/operations/invoker.d.ts +2 -0
  45. package/dist/types/server/operations/mcp.d.ts +7 -1
  46. package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
  47. package/dist/types/server/operations/ops/backup.d.ts +2 -0
  48. package/dist/types/server/operations/ops/boards.d.ts +10 -0
  49. package/dist/types/server/operations/ops/export.d.ts +2 -0
  50. package/dist/types/server/operations/ops/imports.d.ts +2 -0
  51. package/dist/types/server/operations/ops/nodes.d.ts +1 -1
  52. package/dist/types/server/operations/ops/query.d.ts +2 -0
  53. package/dist/types/server/operations/registry.d.ts +9 -1
  54. package/dist/types/server/operations/types.d.ts +1 -1
  55. package/dist/types/server/server.d.ts +1 -0
  56. package/dist/types/server/trace-manager.d.ts +7 -0
  57. package/dist/types/server/webpage-node.d.ts +22 -1
  58. package/dist/types/server/workbench-auth.d.ts +3 -0
  59. package/dist/types/shared/boards.d.ts +6 -0
  60. package/docs/RELEASE.md +5 -0
  61. package/docs/cli.md +68 -0
  62. package/docs/http-api.md +198 -2
  63. package/docs/mcp.md +71 -6
  64. package/docs/node-types.md +26 -0
  65. package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
  66. package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
  67. package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
  68. package/docs/plans/plan-013-static-board-export.md +47 -0
  69. package/docs/plans/plan-014-connected-board-memory.md +140 -0
  70. package/docs/product-vision-2026-09.md +118 -18
  71. package/docs/sdk.md +50 -0
  72. package/package.json +1 -1
  73. package/skills/pmx-canvas/SKILL.md +53 -4
  74. package/skills/pmx-canvas/references/full-reference.md +43 -7
  75. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
  76. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  77. package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
  78. package/skills/pmx-canvas-testing/SKILL.md +4 -0
  79. package/src/cli/agent.ts +4 -0
  80. package/src/cli/commands/ax.ts +13 -0
  81. package/src/cli/commands/backup.ts +66 -0
  82. package/src/cli/commands/boards.ts +71 -0
  83. package/src/cli/commands/export.ts +30 -0
  84. package/src/cli/commands/nodes.ts +4 -1
  85. package/src/cli/commands/pins.ts +6 -2
  86. package/src/cli/commands/pump.ts +3 -0
  87. package/src/cli/commands/query.ts +33 -7
  88. package/src/cli/commands/view.ts +7 -5
  89. package/src/cli/index.ts +19 -9
  90. package/src/client/App.tsx +21 -3
  91. package/src/client/canvas/BoardCopyDialog.tsx +153 -0
  92. package/src/client/canvas/BoardFolderDialog.tsx +120 -0
  93. package/src/client/canvas/BoardLinkDialog.tsx +93 -0
  94. package/src/client/canvas/BoardSwitcher.tsx +149 -0
  95. package/src/client/canvas/CanvasViewport.tsx +3 -0
  96. package/src/client/canvas/CommandPalette.tsx +107 -4
  97. package/src/client/canvas/ContextMenu.tsx +4 -0
  98. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
  99. package/src/client/canvas/ExportDialog.tsx +197 -0
  100. package/src/client/canvas/HomeView.tsx +277 -0
  101. package/src/client/canvas/SessionPanel.tsx +28 -1
  102. package/src/client/canvas/ToolRail.tsx +38 -3
  103. package/src/client/canvas/TopBar.tsx +34 -18
  104. package/src/client/canvas/import-files.ts +19 -1
  105. package/src/client/canvas/kind-colors.ts +1 -0
  106. package/src/client/icons.tsx +2 -0
  107. package/src/client/nodes/AttachmentNode.tsx +148 -0
  108. package/src/client/nodes/BoardNode.tsx +35 -0
  109. package/src/client/nodes/FileNode.tsx +6 -0
  110. package/src/client/nodes/MarkdownNode.tsx +47 -19
  111. package/src/client/nodes/ax-node-actions.ts +2 -5
  112. package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
  113. package/src/client/state/boards-store.ts +167 -0
  114. package/src/client/state/canvas-store.ts +22 -0
  115. package/src/client/state/intent-bridge.ts +30 -2
  116. package/src/client/state/node-factory.ts +1 -0
  117. package/src/client/state/session-store.ts +5 -0
  118. package/src/client/state/sse-bridge.ts +49 -5
  119. package/src/client/theme/global.css +440 -24
  120. package/src/client/types.ts +2 -0
  121. package/src/json-render/server.ts +16 -9
  122. package/src/mcp/canvas-access.ts +59 -22
  123. package/src/mcp/context-uri-template.ts +28 -0
  124. package/src/mcp/server.ts +122 -15
  125. package/src/server/agent-context.ts +116 -29
  126. package/src/server/attribution.ts +29 -0
  127. package/src/server/ax-context.ts +4 -2
  128. package/src/server/ax-interaction.ts +1 -0
  129. package/src/server/ax-state-manager.ts +72 -3
  130. package/src/server/ax-state.ts +41 -0
  131. package/src/server/backup.ts +178 -0
  132. package/src/server/board-export.ts +686 -0
  133. package/src/server/canvas-db.ts +599 -107
  134. package/src/server/canvas-operations.ts +68 -9
  135. package/src/server/canvas-provenance.ts +1 -0
  136. package/src/server/canvas-schema.ts +12 -0
  137. package/src/server/canvas-state.ts +774 -45
  138. package/src/server/context-brief.ts +372 -0
  139. package/src/server/context-reads.ts +238 -0
  140. package/src/server/demo-state.json +659 -2
  141. package/src/server/document-import.ts +223 -0
  142. package/src/server/index.ts +231 -5
  143. package/src/server/mutation-history.ts +10 -13
  144. package/src/server/operations/composites.ts +26 -4
  145. package/src/server/operations/http.ts +9 -3
  146. package/src/server/operations/index.ts +8 -0
  147. package/src/server/operations/invoker.ts +14 -3
  148. package/src/server/operations/mcp.ts +50 -11
  149. package/src/server/operations/ops/app.ts +5 -0
  150. package/src/server/operations/ops/ax-read.ts +10 -6
  151. package/src/server/operations/ops/ax-timeline.ts +75 -0
  152. package/src/server/operations/ops/backup.ts +134 -0
  153. package/src/server/operations/ops/batch.ts +8 -1
  154. package/src/server/operations/ops/boards.ts +351 -0
  155. package/src/server/operations/ops/export.ts +74 -0
  156. package/src/server/operations/ops/ext-app.ts +4 -0
  157. package/src/server/operations/ops/imports.ts +220 -0
  158. package/src/server/operations/ops/nodes.ts +57 -5
  159. package/src/server/operations/ops/query.ts +206 -9
  160. package/src/server/operations/registry.ts +123 -31
  161. package/src/server/operations/types.ts +1 -1
  162. package/src/server/server.ts +146 -2
  163. package/src/server/spatial-analysis.ts +9 -17
  164. package/src/server/trace-manager.ts +19 -0
  165. package/src/server/web-artifacts.ts +2 -1
  166. package/src/server/webpage-node.ts +173 -13
  167. package/src/server/workbench-auth.ts +11 -0
  168. package/src/shared/boards.ts +18 -0
  169. package/src/shared/node-sizes.ts +1 -0
@@ -1,3 +1,6 @@
1
+ import { exportDialogOpen } from './ExportDialog';
2
+ import { activeBoardId } from '../state/boards-store';
3
+ import { BoardSwitcher } from './BoardSwitcher';
1
4
  import type { ComponentChildren } from 'preact';
2
5
  import { presenting } from '../state/presentation';
3
6
  import { useEffect, useState } from 'preact/hooks';
@@ -264,8 +267,8 @@ export function TopBar() {
264
267
  const isTraceOn = traceEnabled.value;
265
268
  const traceNodeCount = Array.from(nodes.value.values()).filter((n) => n.type === 'trace').length;
266
269
 
267
- // Board identity = workspace basename, fetched once from /health. The
268
- // canvas has no board-name concept — the workspace IS the board.
270
+ // Until the board list arrives, the switcher shows the workspace basename
271
+ // (fetched once from /health).
269
272
  const [workspaceName, setWorkspaceName] = useState<string>('');
270
273
  useEffect(() => {
271
274
  let cancelled = false;
@@ -314,9 +317,7 @@ export function TopBar() {
314
317
  >
315
318
  <span class={`connection-dot ${degraded ?? status}`} aria-label={`Canvas status: ${statusTitle}`} />
316
319
  </BarHint>
317
- <span class="top-bar-title" title={workspaceName || 'PMX Canvas'}>
318
- {workspaceName || 'PMX Canvas'}
319
- </span>
320
+ <BoardSwitcher fallbackName={workspaceName || 'PMX Canvas'} />
320
321
  <span class="top-bar-meta hud-collapsible-text">{sessionId.value ? sessionId.value.slice(0, 12) : '…'}</span>
321
322
  <span class="top-bar-meta hud-collapsible-text">{countsLabel}</span>
322
323
 
@@ -330,20 +331,35 @@ export function TopBar() {
330
331
 
331
332
  <div class="top-bar-sep" />
332
333
 
333
- <BarHint
334
- label="Present board"
335
- body="Arrow keys or Space step through stops; Esc exits. Without a saved tour, groups are read top-to-bottom, left-to-right."
336
- >
337
- <button
338
- type="button"
339
- class="present-button"
340
- onClick={() => {
341
- presenting.value = true;
342
- }}
334
+ {activeBoardId.value && (
335
+ <BarHint label="Export board" body="One HTML file of this board that anyone can open, no install needed.">
336
+ <button
337
+ type="button"
338
+ class="present-button"
339
+ onClick={() => {
340
+ exportDialogOpen.value = true;
341
+ }}
342
+ >
343
+ Export
344
+ </button>
345
+ </BarHint>
346
+ )}
347
+ {activeBoardId.value && (
348
+ <BarHint
349
+ label="Present board"
350
+ body="Arrow keys or Space step through stops; Esc exits. Without a saved tour, groups are read top-to-bottom, left-to-right."
343
351
  >
344
- Present
345
- </button>
346
- </BarHint>
352
+ <button
353
+ type="button"
354
+ class="present-button"
355
+ onClick={() => {
356
+ presenting.value = true;
357
+ }}
358
+ >
359
+ Present
360
+ </button>
361
+ </BarHint>
362
+ )}
347
363
  <BarHint label="Zoom out" shortcut={modChord('\u2212')}>
348
364
  <button type="button" class="top-bar-btn" onClick={() => zoomByFactor(1 / 1.25)} aria-label="Zoom out">
349
365
  <IconZoomOut />
@@ -1,4 +1,6 @@
1
- import { createNodeFromClient } from '../state/intent-bridge';
1
+ import { createNodeFromClient, requestJson } from '../state/intent-bridge';
2
+ import { activeBoardId } from '../state/boards-store';
3
+ import { showToast } from '../state/attention-bridge';
2
4
 
3
5
  const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico', 'avif']);
4
6
  const MD_EXTS = new Set(['md', 'mdx', 'markdown']);
@@ -17,6 +19,7 @@ export function nodeTypeFromFilename(name: string): 'image' | 'markdown' | 'file
17
19
  * markdown, everything else a file node with the text inlined.
18
20
  */
19
21
  export async function importFiles(files: File[], baseWx: number, baseWy: number): Promise<void> {
22
+ const boardId = activeBoardId.value;
20
23
  const nodeW = 400;
21
24
  const nodeH = 300;
22
25
  const spacing = 20;
@@ -32,12 +35,24 @@ export async function importFiles(files: File[], baseWx: number, baseWy: number)
32
35
  const type = nodeTypeFromFilename(file.name);
33
36
  const fileName = file.name;
34
37
 
38
+ if (/\.(pdf|pptx?|xlsx?|docx?|od[pts])$/i.test(fileName)) {
39
+ if (!boardId || file.size === 0 || file.size > 20 * 1024 * 1024) {
40
+ showToast('remove', 'File not attached', 'Open a board and choose a nonempty file under 20 MiB.');
41
+ continue;
42
+ }
43
+ const query = new URLSearchParams({ boardId, name: fileName, mime: file.type, x: String(wx), y: String(wy) });
44
+ showToast('context', 'Attaching document', fileName);
45
+ await requestJson('attachDocument', `/api/canvas/attachments?${query}`, null, { method: 'POST', body: file });
46
+ continue;
47
+ }
48
+ if (activeBoardId.value !== boardId) return;
35
49
  if (type === 'image') {
36
50
  const reader = new FileReader();
37
51
  const dataUri: string = await new Promise((resolve) => {
38
52
  reader.onload = () => resolve(reader.result as string);
39
53
  reader.readAsDataURL(file);
40
54
  });
55
+ if (activeBoardId.value !== boardId) return;
41
56
  await createNodeFromClient({
42
57
  type: 'image',
43
58
  title: fileName,
@@ -46,9 +61,11 @@ export async function importFiles(files: File[], baseWx: number, baseWy: number)
46
61
  y: wy,
47
62
  width: nodeW,
48
63
  height: nodeH,
64
+ ...(boardId ? { boardId } : {}),
49
65
  });
50
66
  } else {
51
67
  const text = await file.text();
68
+ if (activeBoardId.value !== boardId) return;
52
69
  const isWide = type === 'markdown' || type === 'file';
53
70
  await createNodeFromClient({
54
71
  type,
@@ -58,6 +75,7 @@ export async function importFiles(files: File[], baseWx: number, baseWy: number)
58
75
  y: wy,
59
76
  width: isWide ? 720 : nodeW,
60
77
  height: isWide ? 500 : nodeH,
78
+ ...(boardId ? { boardId } : {}),
61
79
  });
62
80
  }
63
81
  }
@@ -7,6 +7,7 @@ export const KIND_COLOR: Record<CanvasNodeState['type'], string> = {
7
7
  webpage: 'var(--c-warn)',
8
8
  'json-render': 'var(--c-ok)',
9
9
  graph: 'var(--c-purple)',
10
+ board: 'var(--c-accent)',
10
11
  prompt: 'var(--c-accent)',
11
12
  response: 'var(--c-ok)',
12
13
  status: 'var(--c-warn)',
@@ -538,6 +538,8 @@ export function getNodeIcon(type: string): (p: IconProps) => JSX.Element {
538
538
  return IconNodeJsonRender;
539
539
  case 'graph':
540
540
  return IconNodeGraph;
541
+ case 'board':
542
+ return IconNodeGraph;
541
543
  case 'html':
542
544
  return IconNodeWebpage;
543
545
  default:
@@ -0,0 +1,148 @@
1
+ import { useEffect, useState } from 'preact/hooks';
2
+ import type { DocumentImport } from '../../server/document-import';
3
+ import type { CanvasNodeState } from '../types';
4
+ import { activeBoardId } from '../state/boards-store';
5
+ import { requestJson } from '../state/intent-bridge';
6
+ import { expandNode, collapseExpandedNode } from '../state/canvas-store';
7
+
8
+ /** Originals stay useful even when the connected host cannot extract them. */
9
+ export function AttachmentNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
10
+ const attachmentId = String(node.data.attachmentId);
11
+ const boardId = activeBoardId.value;
12
+ const [job, setJob] = useState<DocumentImport | null>(null);
13
+ const [review, setReview] = useState(expanded);
14
+ const [consent, setConsent] = useState(false);
15
+ const [busy, setBusy] = useState(false);
16
+ useEffect(() => {
17
+ let current = true;
18
+ const read = async () => {
19
+ const result = await requestJson<{ imports?: DocumentImport[] } | null>(
20
+ 'readImports',
21
+ `/api/canvas/imports?boardId=${encodeURIComponent(boardId ?? '')}`,
22
+ null,
23
+ );
24
+ if (current) setJob(result?.imports?.find((entry) => entry.attachmentId === attachmentId) ?? null);
25
+ };
26
+ void read();
27
+ const timer = window.setInterval(() => void read(), 2000);
28
+ return () => {
29
+ current = false;
30
+ window.clearInterval(timer);
31
+ };
32
+ }, [attachmentId, boardId]);
33
+ useEffect(() => {
34
+ if (!review) return;
35
+ const onKey = (event: KeyboardEvent) => {
36
+ if (event.key === 'Escape') {
37
+ event.stopPropagation();
38
+ setReview(false);
39
+ }
40
+ };
41
+ window.addEventListener('keydown', onKey, true);
42
+ return () => window.removeEventListener('keydown', onKey, true);
43
+ }, [review]);
44
+ const act = async (action: 'request' | 'cancel' | 'commit') => {
45
+ if (!job) return;
46
+ setBusy(true);
47
+ const result = await requestJson<{ import?: DocumentImport } | null>(
48
+ `import.${action}`,
49
+ `/api/canvas/imports/${job.id}/${action}`,
50
+ null,
51
+ { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ consent: true }) },
52
+ );
53
+ setBusy(false);
54
+ if (result?.import) {
55
+ setJob(result.import);
56
+ setConsent(false);
57
+ setReview(false);
58
+ if (expanded && action === 'commit') collapseExpandedNode();
59
+ }
60
+ };
61
+ const canRequest = job && ['attached', 'cancelled', 'unavailable'].includes(job.status);
62
+ return (
63
+ <div class="attachment-node" onPointerDown={(event) => event.stopPropagation()}>
64
+ <div class="attachment-status">
65
+ {job?.status === 'requested'
66
+ ? 'Waiting for agent'
67
+ : job?.status === 'drafted'
68
+ ? 'Ready for review'
69
+ : job?.status === 'committed'
70
+ ? 'Markdown added'
71
+ : 'Original attached'}
72
+ </div>
73
+ <a href={`/api/canvas/attachments/${attachmentId}/bytes`} target="_blank" rel="noopener noreferrer">
74
+ Download original · {Math.ceil(Number(node.data.size ?? 0) / 1024)} KB
75
+ </a>
76
+ {job?.reason && <p>{job.reason}</p>}
77
+ {canRequest && !consent && (
78
+ <button type="button" class="home-board-action" onClick={() => setConsent(true)}>
79
+ Ask agent to import
80
+ </button>
81
+ )}
82
+ {consent && (
83
+ <>
84
+ <p>
85
+ The connected agent may send this file to its model provider. It needs file-reading tools and access to the
86
+ original. Canvas does not convert files itself.
87
+ </p>
88
+ <button type="button" class="home-board-action" disabled={busy} onClick={() => void act('request')}>
89
+ Allow processing & request
90
+ </button>
91
+ <button type="button" class="home-board-action" onClick={() => setConsent(false)}>
92
+ Not now
93
+ </button>
94
+ </>
95
+ )}
96
+ {job?.status === 'requested' && (
97
+ <p>
98
+ No automatic extraction. Ask your connected agent to check its pending steering or use canvas_import list.
99
+ </p>
100
+ )}
101
+ {job?.status === 'drafted' && (
102
+ <button
103
+ type="button"
104
+ class="home-board-action"
105
+ onClick={() => (expanded ? setReview(true) : expandNode(node.id))}
106
+ >
107
+ Review Markdown · {job.sections.length} {job.sections.length === 1 ? 'card' : 'cards'}
108
+ </button>
109
+ )}
110
+ {job && ['requested', 'drafted'].includes(job.status) && (
111
+ <button type="button" class="home-board-action" disabled={busy} onClick={() => void act('cancel')}>
112
+ Cancel import
113
+ </button>
114
+ )}
115
+ {review && job?.status === 'drafted' && (
116
+ <div class="attachment-review" role="dialog" aria-label="Review imported Markdown">
117
+ <h3>Review imported Markdown</h3>
118
+ <p>Source material, not instructions or verified knowledge. {job.agentDescription}</p>
119
+ {job.warnings.length > 0 && (
120
+ <ul>
121
+ {job.warnings.map((warning) => (
122
+ <li key={warning}>{warning}</li>
123
+ ))}
124
+ </ul>
125
+ )}
126
+ {job.sections.map((section, index) => (
127
+ <section key={index}>
128
+ <h4>{section.title}</h4>
129
+ <p>{section.reference || 'Source location not supplied'}</p>
130
+ <pre>{section.markdown}</pre>
131
+ </section>
132
+ ))}
133
+ <button type="button" class="home-board-action" onClick={() => setReview(false)}>
134
+ Close review
135
+ </button>
136
+ <button
137
+ type="button"
138
+ class="home-view-new"
139
+ disabled={busy || boardId !== job.boardId}
140
+ onClick={() => void act('commit')}
141
+ >
142
+ Add to board
143
+ </button>
144
+ </div>
145
+ )}
146
+ </div>
147
+ );
148
+ }
@@ -0,0 +1,35 @@
1
+ import type { CanvasNodeState } from '../types';
2
+ import { boardList, openBoard } from '../state/boards-store';
3
+
4
+ /** Native text-only cross-board link. Target identity is the durable board id. */
5
+ export function BoardNode({ node }: { node: CanvasNodeState }) {
6
+ const boardId = typeof node.data.boardId === 'string' ? node.data.boardId : '';
7
+ const target = boardList.value.find((board) => board.id === boardId);
8
+ return (
9
+ <div class="board-node">
10
+ <div class="board-node-content">
11
+ <div class="board-node-label">Linked board</div>
12
+ <strong class="board-node-title">{target?.name ?? 'Missing board'}</strong>
13
+ <div class="board-node-meta">{target ? `${target.nodeCount} nodes` : boardId || 'No target id'}</div>
14
+ {target?.summary && <p class="board-node-summary">{target.summary}</p>}
15
+ {!!target?.pinnedTitles?.length && (
16
+ <p class="board-node-meta">Pinned: {target.pinnedTitles.map((pin) => pin.title).join(', ')}</p>
17
+ )}
18
+ {!!target?.backlinks?.length && (
19
+ <p class="board-node-meta">
20
+ Linked from: {[...new Set(target.backlinks.map((link) => link.title))].join(', ')}
21
+ </p>
22
+ )}
23
+ </div>
24
+ <button
25
+ class="board-node-open"
26
+ type="button"
27
+ disabled={!target}
28
+ onClick={() => target && void openBoard(target.id)}
29
+ >
30
+ {target ? 'Open board' : 'Target unavailable'}
31
+ {target && <span aria-hidden="true">↗</span>}
32
+ </button>
33
+ </div>
34
+ );
35
+ }
@@ -4,6 +4,7 @@ import { iframeMode } from '../state/iframe-mode';
4
4
  import { fetchFile, updateNodeFromClient } from '../state/intent-bridge';
5
5
  import type { CanvasNodeState } from '../types';
6
6
  import { runNodeAxInteraction } from './ax-node-actions';
7
+ import { AttachmentNode } from './AttachmentNode';
7
8
 
8
9
  /** Guess a language label from a file extension for display. */
9
10
  function langFromPath(path: string): string {
@@ -134,6 +135,11 @@ export function formatBytes(bytes: number): string {
134
135
  }
135
136
 
136
137
  export function FileNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
138
+ if (typeof node.data.attachmentId === 'string') return <AttachmentNode node={node} expanded={expanded} />;
139
+ return <TextFileNode node={node} expanded={expanded} />;
140
+ }
141
+
142
+ function TextFileNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
137
143
  const filePath = (node.data.path as string) || (node.data.content as string) || '';
138
144
  const title = (node.data.title as string) || filePath.split('/').pop() || 'File';
139
145
  const cachedContent = node.data.fileContent as string | undefined;
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
3
3
  import { expandNode, updateNodeData } from '../state/canvas-store';
4
4
  import { fetchFile, renderMarkdown, saveFile, updateNodeFromClient } from '../state/intent-bridge';
5
5
  import type { CanvasNodeState } from '../types';
6
+ import { activeBoard, setBoardReadme } from '../state/boards-store';
6
7
  import { MdFormatBar } from './MdFormatBar';
7
8
  import { handleFormatShortcut, handleTab } from './md-format';
8
9
  import { InlineMarkdownEditor } from './InlineMarkdownEditor';
@@ -82,6 +83,7 @@ export function toggleTaskMarker(markdown: string, index: number): string {
82
83
  }
83
84
 
84
85
  export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
86
+ const board = activeBoard();
85
87
  const path = node.data.path as string;
86
88
  const [content, setContent] = useState('');
87
89
  const [rendered, setRendered] = useState('');
@@ -362,26 +364,52 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
362
364
  // ── Card preview ──────────────────────────────────────────────
363
365
 
364
366
  return (
365
- <div style={{ height: '100%', position: 'relative' }}>
366
- {reviewBanner}
367
- <RenderedMarkdown
368
- html={rendered}
369
- style={{ padding: rendered ? '0' : '12px', color: rendered ? undefined : 'var(--c-dim)' }}
370
- // Task checkboxes are live on the card itself — no need to expand to tick one.
371
- onTaskToggle={(index) => {
372
- const next = toggleTaskMarker(content, index);
373
- if (next !== content) handleInlineSave(next);
374
- }}
375
- />
376
- {!loaded && <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>Loading…</div>}
377
- {loaded && !rendered && (
378
- <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>Empty node</div>
367
+ <div class="md-card-preview">
368
+ <div class="md-card-content">
369
+ {reviewBanner}
370
+ <RenderedMarkdown
371
+ html={rendered}
372
+ style={{ padding: rendered ? '0' : '12px', color: rendered ? undefined : 'var(--c-dim)' }}
373
+ // Task checkboxes are live on the card itself — no need to expand to tick one.
374
+ onTaskToggle={(index) => {
375
+ const next = toggleTaskMarker(content, index);
376
+ if (next !== content) handleInlineSave(next);
377
+ }}
378
+ />
379
+ {!loaded && <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>Loading…</div>}
380
+ {loaded && !rendered && (
381
+ <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>Empty node</div>
382
+ )}
383
+ </div>
384
+ {node.data.source && typeof node.data.source === 'object' && 'attachmentId' in node.data.source && (
385
+ <div class="md-import-source">
386
+ Source material ·{' '}
387
+ <a
388
+ href={`/api/canvas/attachments/${encodeURIComponent(String(node.data.source.attachmentId))}/bytes`}
389
+ target="_blank"
390
+ rel="noopener noreferrer"
391
+ >
392
+ {String((node.data.source as Record<string, unknown>).filename ?? 'Original')}
393
+ </a>
394
+ {' · '}
395
+ {String((node.data.source as Record<string, unknown>).reference ?? 'Location not supplied')}
396
+ </div>
379
397
  )}
380
- {/* Hover-revealed, solid-backed chip — always-on it sat on the first
381
- line of content (double-click edits inline; this is discovery). */}
382
- <button type="button" class="md-edit-btn" onClick={() => expandNode(node.id)}>
383
- Edit
384
- </button>
398
+ <div class="md-card-actions">
399
+ {board && (
400
+ <button
401
+ type="button"
402
+ class="md-readme-btn"
403
+ aria-pressed={board.readmeNodeId === node.id}
404
+ onClick={() => void setBoardReadme(board.id, board.readmeNodeId === node.id ? null : node.id)}
405
+ >
406
+ {board.readmeNodeId === node.id ? 'README' : 'Set as README'}
407
+ </button>
408
+ )}
409
+ <button type="button" class="md-card-edit" onClick={() => expandNode(node.id)}>
410
+ Edit
411
+ </button>
412
+ </div>
385
413
  </div>
386
414
  );
387
415
  }
@@ -19,11 +19,8 @@ export async function runNodeAxInteraction(
19
19
  sourceSurface: 'native-node',
20
20
  ...(payload ? { payload } : {}),
21
21
  });
22
- if (res.ok) {
23
- showToast('context', successTitle, '', [node.id]);
24
- } else {
25
- showToast('remove', 'AX action failed', res.error ?? res.code ?? 'Unknown error', [node.id]);
26
- }
22
+ // A refusal is toasted by the bridge with the server's reason.
23
+ if (res.ok) showToast('context', successTitle, '', [node.id]);
27
24
  }
28
25
 
29
26
  /** Shared style for the small inline AX action button on native nodes. */
@@ -50,8 +50,8 @@ export function useAxSurfaceBridge(options: {
50
50
  ? { payload: interaction.payload as Record<string, unknown> }
51
51
  : {}),
52
52
  }).then((res) => {
53
+ // A refusal is toasted by the bridge with the server's reason.
53
54
  if (res.ok) showToast('context', 'AX interaction', interactionType, [nodeId]);
54
- else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [nodeId]);
55
55
  iframeRef.current?.contentWindow?.postMessage(
56
56
  {
57
57
  source: AX_SURFACE_ACK_SOURCE,
@@ -0,0 +1,167 @@
1
+ import { signal } from '@preact/signals';
2
+ import { requestJson } from './intent-bridge';
3
+
4
+ /**
5
+ * Many boards (plan 012): the workspace's boards and which one is open. The
6
+ * server is the authority; this mirrors GET /api/canvas/boards and the
7
+ * `boards-changed` SSE frame. `activeBoardId === null` is Home.
8
+ */
9
+ export interface BoardSummary {
10
+ id: string;
11
+ name: string;
12
+ category: string | null;
13
+ createdAt: string;
14
+ lastOpenedAt: string | null;
15
+ nodeCount: number;
16
+ readmeNodeId?: string | null;
17
+ summary?: string | null;
18
+ pinnedTitles?: Array<{ nodeId: string; title: string }>;
19
+ links?: Array<{ nodeId: string; boardId: string; title: string | null; missing: boolean }>;
20
+ backlinks?: Array<{ boardId: string; title: string; nodeId: string }>;
21
+ }
22
+
23
+ interface BoardsPayload {
24
+ activeBoardId: string | null;
25
+ boards: BoardSummary[];
26
+ }
27
+
28
+ export const boardList = signal<BoardSummary[]>([]);
29
+ export const activeBoardId = signal<string | null>(null);
30
+ /** False until the first list arrives — Home must not flash before the server answers. */
31
+ export const boardsLoaded = signal(false);
32
+
33
+ let onBoardSwitched: (() => void) | null = null;
34
+ let boardsRevision = 0;
35
+ let openRequestSequence = 0;
36
+ let openRequestQueue: Promise<void> = Promise.resolve();
37
+
38
+ /** The SSE bridge resyncs the canvas when the open board changes under it. */
39
+ export function setBoardSwitchHandler(handler: (() => void) | null): void {
40
+ onBoardSwitched = handler;
41
+ }
42
+
43
+ export function applyBoards(data: { activeBoardId?: unknown; boards?: unknown; reloaded?: unknown } | null): void {
44
+ if (!data || !Array.isArray(data.boards)) return;
45
+ boardsRevision += 1;
46
+ const nextActive = typeof data.activeBoardId === 'string' ? data.activeBoardId : null;
47
+ // `reloaded`: the library was restored, so even the same board id holds new content.
48
+ const switched = boardsLoaded.value && (nextActive !== activeBoardId.value || data.reloaded === true);
49
+ boardList.value = data.boards as BoardSummary[];
50
+ activeBoardId.value = nextActive;
51
+ boardsLoaded.value = true;
52
+ if (switched) onBoardSwitched?.();
53
+ }
54
+
55
+ export async function loadBoards(): Promise<void> {
56
+ const revision = boardsRevision;
57
+ const payload = await requestJson<BoardsPayload | null>('loadBoards', '/api/canvas/boards', null);
58
+ if (revision === boardsRevision) applyBoards(payload);
59
+ }
60
+
61
+ async function post(
62
+ action: string,
63
+ url: string,
64
+ method: string,
65
+ body?: unknown,
66
+ revision = boardsRevision,
67
+ ): Promise<BoardsPayload | null> {
68
+ const payload = await requestJson<BoardsPayload | null>(action, url, null, {
69
+ method,
70
+ headers: { 'Content-Type': 'application/json' },
71
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
72
+ });
73
+ return revision === boardsRevision ? payload : null;
74
+ }
75
+
76
+ /** Open a board, or Home with null. */
77
+ export async function openBoard(id: string | null): Promise<void> {
78
+ const sequence = ++openRequestSequence;
79
+ const revision = boardsRevision;
80
+ let payload: BoardsPayload | null = null;
81
+ const request = openRequestQueue.then(async () => {
82
+ payload = await post('openBoard', '/api/canvas/boards/open', 'POST', { id }, revision);
83
+ });
84
+ openRequestQueue = request.catch(() => undefined);
85
+ await request;
86
+ // post drops replies superseded by SSE; also ignore an older open request.
87
+ if (sequence === openRequestSequence) applyBoards(payload);
88
+ }
89
+
90
+ export async function createAndOpenBoard(name: string): Promise<void> {
91
+ const sequence = ++openRequestSequence;
92
+ const created = await requestJson<{ board?: BoardSummary } | null>('createBoard', '/api/canvas/boards', null, {
93
+ method: 'POST',
94
+ headers: { 'Content-Type': 'application/json' },
95
+ body: JSON.stringify({ name }),
96
+ });
97
+ if (created?.board && sequence === openRequestSequence) await openBoard(created.board.id);
98
+ }
99
+
100
+ export interface BoardCopyCard {
101
+ id: string;
102
+ type: string;
103
+ title: string | null;
104
+ reusable: boolean;
105
+ }
106
+
107
+ export interface BoardCopyPreview {
108
+ sourceBoardId: string;
109
+ readmeNodeId: string | null;
110
+ cards: BoardCopyCard[];
111
+ edges: Array<{ id: string; from: string; to: string; type: string }>;
112
+ }
113
+
114
+ export async function previewBoardCopy(sourceBoardId: string): Promise<BoardCopyPreview | null> {
115
+ return requestJson<BoardCopyPreview | null>('previewBoardCopy', '/api/canvas/boards/from', null, {
116
+ method: 'POST',
117
+ headers: { 'Content-Type': 'application/json' },
118
+ body: JSON.stringify({ sourceBoardId, name: 'Preview', preview: true }),
119
+ });
120
+ }
121
+
122
+ export async function createBoardFrom(opts: {
123
+ sourceBoardId: string;
124
+ name: string;
125
+ category?: string;
126
+ nodeIds: string[];
127
+ includeReadme: boolean;
128
+ includeStructure: boolean;
129
+ }): Promise<BoardSummary | null> {
130
+ const result = await requestJson<{ board?: BoardSummary } | null>(
131
+ 'createBoardFrom',
132
+ '/api/canvas/boards/from',
133
+ null,
134
+ {
135
+ method: 'POST',
136
+ headers: { 'Content-Type': 'application/json' },
137
+ body: JSON.stringify(opts),
138
+ },
139
+ );
140
+ await loadBoards();
141
+ return result?.board ?? null;
142
+ }
143
+
144
+ /** Rename and/or re-file a board; `category: null` removes it from its category. */
145
+ export async function updateBoard(id: string, patch: { name?: string; category?: string | null }): Promise<void> {
146
+ const payload = await post('updateBoard', `/api/canvas/boards/${encodeURIComponent(id)}`, 'PATCH', {
147
+ ...patch,
148
+ ...(patch.category === null ? { category: '' } : {}),
149
+ });
150
+ applyBoards(payload);
151
+ }
152
+
153
+ export async function setBoardReadme(id: string, readmeNodeId: string | null): Promise<void> {
154
+ const payload = await post('setBoardReadme', `/api/canvas/boards/${encodeURIComponent(id)}`, 'PATCH', {
155
+ readmeNodeId,
156
+ });
157
+ applyBoards(payload);
158
+ }
159
+
160
+ export async function deleteBoard(id: string): Promise<void> {
161
+ const payload = await post('deleteBoard', `/api/canvas/boards/${encodeURIComponent(id)}`, 'DELETE');
162
+ applyBoards(payload);
163
+ }
164
+
165
+ export function activeBoard(): BoardSummary | null {
166
+ return boardList.value.find((board) => board.id === activeBoardId.value) ?? null;
167
+ }