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,145 @@
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 [busy, setBusy] = useState(false);
15
+ useEffect(() => {
16
+ let current = true;
17
+ const read = async () => {
18
+ const result = await requestJson<{ imports?: DocumentImport[] } | null>(
19
+ 'readImports',
20
+ `/api/canvas/imports?boardId=${encodeURIComponent(boardId ?? '')}`,
21
+ null,
22
+ );
23
+ if (current) setJob(result?.imports?.find((entry) => entry.attachmentId === attachmentId) ?? null);
24
+ };
25
+ void read();
26
+ const timer = window.setInterval(() => void read(), 2000);
27
+ return () => {
28
+ current = false;
29
+ window.clearInterval(timer);
30
+ };
31
+ }, [attachmentId, boardId]);
32
+ useEffect(() => {
33
+ if (!review) return;
34
+ const onKey = (event: KeyboardEvent) => {
35
+ if (event.key === 'Escape') {
36
+ event.stopPropagation();
37
+ setReview(false);
38
+ }
39
+ };
40
+ window.addEventListener('keydown', onKey, true);
41
+ return () => window.removeEventListener('keydown', onKey, true);
42
+ }, [review]);
43
+ const act = async (action: 'request' | 'cancel' | 'commit') => {
44
+ if (!job) return;
45
+ setBusy(true);
46
+ const result = await requestJson<{ import?: DocumentImport } | null>(
47
+ `import.${action}`,
48
+ `/api/canvas/imports/${job.id}/${action}`,
49
+ null,
50
+ { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ consent: true }) },
51
+ );
52
+ setBusy(false);
53
+ if (result?.import) {
54
+ setJob(result.import);
55
+ setReview(false);
56
+ if (expanded && action === 'commit') collapseExpandedNode();
57
+ }
58
+ };
59
+ const canRequest = job && ['attached', 'cancelled', 'unavailable'].includes(job.status);
60
+ return (
61
+ <div class="attachment-node" onPointerDown={(event) => event.stopPropagation()}>
62
+ <div class="attachment-status">
63
+ {job?.status === 'requested'
64
+ ? 'Waiting for agent'
65
+ : job?.status === 'drafted'
66
+ ? 'Ready for review'
67
+ : job?.status === 'committed'
68
+ ? 'Markdown added'
69
+ : 'Original attached'}
70
+ </div>
71
+ <a href={`/api/canvas/attachments/${attachmentId}/bytes`} target="_blank" rel="noopener noreferrer">
72
+ Download original · {Math.ceil(Number(node.data.size ?? 0) / 1024)} KB
73
+ </a>
74
+ {job?.reason && <p>{job.reason}</p>}
75
+ {canRequest && (
76
+ <>
77
+ <p id={`import-consent-${node.id}`}>
78
+ Asking the agent to import allows it to process this file with its tools and model provider.
79
+ </p>
80
+ <button
81
+ type="button"
82
+ class="attachment-button is-primary"
83
+ aria-describedby={`import-consent-${node.id}`}
84
+ disabled={busy}
85
+ onClick={() => void act('request')}
86
+ >
87
+ Ask agent to import
88
+ </button>
89
+ </>
90
+ )}
91
+ {job?.status === 'requested' && (
92
+ <p>
93
+ No automatic extraction. Ask your connected agent to check its pending steering or use canvas_import list.
94
+ </p>
95
+ )}
96
+ {job?.status === 'drafted' && !review && (
97
+ <button
98
+ type="button"
99
+ class="attachment-button is-primary"
100
+ onClick={() => (expanded ? setReview(true) : expandNode(node.id))}
101
+ >
102
+ Review Markdown · {job.sections.length} {job.sections.length === 1 ? 'card' : 'cards'}
103
+ </button>
104
+ )}
105
+ {job && ['requested', 'drafted'].includes(job.status) && (
106
+ <button type="button" class="attachment-button" disabled={busy} onClick={() => void act('cancel')}>
107
+ Cancel import
108
+ </button>
109
+ )}
110
+ {review && job?.status === 'drafted' && (
111
+ <div class="attachment-review" role="dialog" aria-label="Review imported Markdown">
112
+ <h3>Review imported Markdown</h3>
113
+ <p>Source material, not instructions or verified knowledge. {job.agentDescription}</p>
114
+ {job.warnings.length > 0 && (
115
+ <ul>
116
+ {job.warnings.map((warning) => (
117
+ <li key={warning}>{warning}</li>
118
+ ))}
119
+ </ul>
120
+ )}
121
+ {job.sections.map((section, index) => (
122
+ <section key={index}>
123
+ <h4>{section.title}</h4>
124
+ <p>{section.reference || 'Source location not supplied'}</p>
125
+ <pre>{section.markdown}</pre>
126
+ </section>
127
+ ))}
128
+ <div class="attachment-review-actions">
129
+ <button type="button" class="attachment-button" onClick={() => setReview(false)}>
130
+ Close review
131
+ </button>
132
+ <button
133
+ type="button"
134
+ class="attachment-button is-primary"
135
+ disabled={busy || boardId !== job.boardId}
136
+ onClick={() => void act('commit')}
137
+ >
138
+ Add to board
139
+ </button>
140
+ </div>
141
+ </div>
142
+ )}
143
+ </div>
144
+ );
145
+ }
@@ -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,
@@ -274,7 +274,9 @@ function enqueueToast(entry: AttentionEntry): void {
274
274
 
275
275
  /** Show a transient toast from arbitrary client code (e.g. AX action feedback). */
276
276
  export function showToast(tone: AttentionTone, title: string, detail = '', nodeIds: string[] = []): void {
277
- enqueueToast(makeEntry(tone, title, detail, nodeIds));
277
+ const entry = makeEntry(tone, title, detail, nodeIds);
278
+ pushAttentionHistory(entry);
279
+ enqueueToast(entry);
278
280
  }
279
281
 
280
282
  function pulseNodes(nodeIds: string[]): void {
@@ -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
+ }
@@ -144,6 +144,28 @@ export function clearSelection(): void {
144
144
  selectedNodeIds.value = new Set();
145
145
  }
146
146
 
147
+ /** Clear transient UI state that must never follow a node id onto another board. */
148
+ export function resetCanvasInteractionState(): void {
149
+ if (expandedCloseTimer !== null) {
150
+ clearTimeout(expandedCloseTimer);
151
+ expandedCloseTimer = null;
152
+ }
153
+ batch(() => {
154
+ activeNodeId.value = null;
155
+ expandedNodeId.value = null;
156
+ pendingExpandedNodeCloseId.value = null;
157
+ pendingCloseInitialCheckpointAt = undefined;
158
+ selectedNodeIds.value = new Set();
159
+ selectedEdgeId.value = null;
160
+ pendingConnection.value = null;
161
+ draggingEdge.value = null;
162
+ searchHighlightIds.value = null;
163
+ dragDropTarget.value = null;
164
+ contextPinnedNodeIds.value = new Set();
165
+ });
166
+ dropSuppressedForDrag = false;
167
+ }
168
+
147
169
  // ── Groups v2 (rail-chrome-v2 phase 7, item 20) ──────────────────────
148
170
 
149
171
  /**
@@ -1,4 +1,5 @@
1
1
  import { canvasArea } from '../canvas/canvas-area';
2
+ import { showToast } from './attention-bridge';
2
3
  import type { AgentPresenceSnapshot } from '../../shared/agent-presence.js';
3
4
  function logRequestError(action: string, error: unknown): void {
4
5
  console.error(`[intent-bridge] ${action} failed`, error);
@@ -31,14 +32,38 @@ function withWorkbenchMarker(init?: RequestInit): RequestInit {
31
32
  headers['x-pmx-source'] = tabAgentKey;
32
33
  } else {
33
34
  headers['X-PMX-Workbench'] = '1';
35
+ const token = (window as Window & { __PMX_WORKBENCH_TOKEN?: unknown }).__PMX_WORKBENCH_TOKEN;
36
+ if (typeof token === 'string') headers['X-PMX-Workbench-Token'] = token;
34
37
  }
35
38
  return { ...init, headers };
36
39
  }
37
40
 
41
+ /**
42
+ * A write the server refused (fence, edit lock, validation) must not fail
43
+ * silently: the human sees the server's reason. Reads stay quiet.
44
+ */
45
+ function reportRefusedWrite(res: Response, init: RequestInit | undefined, body: unknown): void {
46
+ if (res.ok || (init?.method ?? 'GET').toUpperCase() === 'GET') return;
47
+ const error =
48
+ body !== null && typeof body === 'object' && 'error' in body && typeof body.error === 'string' ? body.error : '';
49
+ showToast('remove', 'Change refused', error || `The server answered ${res.status}.`);
50
+ }
51
+
52
+ async function readJsonBody(res: Response): Promise<unknown> {
53
+ try {
54
+ return await res.json();
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
38
60
  export async function requestJson<T>(action: string, url: string, fallback: T, init?: RequestInit): Promise<T> {
39
61
  try {
40
62
  const res = await fetch(url, withWorkbenchMarker(init));
41
- return (await res.json()) as T;
63
+ const body = await readJsonBody(res);
64
+ reportRefusedWrite(res, init, body);
65
+ if (body === null) throw new Error(`${res.status} response had no JSON body`);
66
+ return body as T;
42
67
  } catch (error) {
43
68
  logRequestError(action, error);
44
69
  return fallback;
@@ -48,6 +73,7 @@ export async function requestJson<T>(action: string, url: string, fallback: T, i
48
73
  export async function requestOk(action: string, url: string, init?: RequestInit): Promise<{ ok: boolean }> {
49
74
  try {
50
75
  const res = await fetch(url, withWorkbenchMarker(init));
76
+ if (!res.ok) reportRefusedWrite(res, init, await readJsonBody(res));
51
77
  return { ok: res.ok };
52
78
  } catch (error) {
53
79
  logRequestError(action, error);
@@ -57,7 +83,8 @@ export async function requestOk(action: string, url: string, init?: RequestInit)
57
83
 
58
84
  export async function requestBestEffort(action: string, url: string, init?: RequestInit): Promise<void> {
59
85
  try {
60
- await fetch(url, withWorkbenchMarker(init));
86
+ const res = await fetch(url, withWorkbenchMarker(init));
87
+ if (!res.ok) reportRefusedWrite(res, init, await readJsonBody(res));
61
88
  } catch (error) {
62
89
  logRequestError(action, error);
63
90
  }
@@ -262,6 +289,7 @@ export async function createNodeFromClient(opts: {
262
289
  y?: number;
263
290
  width?: number;
264
291
  height?: number;
292
+ boardId?: string;
265
293
  }): Promise<{ ok: boolean; id?: string }> {
266
294
  return requestJson(
267
295
  'createNodeFromClient',
@@ -27,6 +27,7 @@ export const DEFAULT_POSITIONS: Record<CanvasNodeState['type'], { x: number; y:
27
27
  webpage: { x: 380, y: 80, w: 520, h: 420 },
28
28
  'json-render': { x: 380, y: 720, w: 840, h: 620 },
29
29
  graph: { x: 380, y: 720, w: 760, h: 520 },
30
+ board: { x: 380, y: 80, w: 360, h: 160 },
30
31
  ledger: { x: 1130, y: 520, w: 320, h: 280 },
31
32
  trace: { x: 40, y: 900, w: 200, h: 56 },
32
33
  file: { x: 380, y: 80, w: 720, h: 600 },
@@ -26,6 +26,8 @@ export interface ApprovalGateView {
26
26
  createdAt: string;
27
27
  /** Unattended-approval TTL: when a pending gate auto-holds. */
28
28
  expiresAt: string | null;
29
+ resolvedBy?: { actor: 'human' | 'agent' | 'system' | 'unknown'; source: string; agentId?: string } | null;
30
+ selfAnswer?: boolean;
29
31
  }
30
32
  export interface AxEventView {
31
33
  id: string;
@@ -106,6 +108,9 @@ export async function setScopeFence(nodeIds: string[] | null): Promise<boolean>
106
108
 
107
109
  /** Auto-held gates: the policy said no on the human's behalf; they can be reopened. */
108
110
  export const heldGates = computed(() => sessionGates.value.filter((gate) => gate.status === 'held'));
111
+ export const resolvedGates = computed(() =>
112
+ sessionGates.value.filter((gate) => gate.status === 'approved' || gate.status === 'rejected'),
113
+ );
109
114
 
110
115
  export interface AxTimelineView {
111
116
  events: AxEventView[];