pmx-canvas 0.6.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +67 -3
  2. package/CHANGELOG.md +73 -0
  3. package/Readme.md +35 -5
  4. package/dist/canvas/global.css +498 -28
  5. package/dist/canvas/index.js +110 -95
  6. package/dist/canvas/mermaid-entry.js +258 -258
  7. package/dist/types/cli/agent.d.ts +4 -0
  8. package/dist/types/cli/commands/backup.d.ts +1 -0
  9. package/dist/types/cli/commands/boards.d.ts +1 -0
  10. package/dist/types/cli/commands/export.d.ts +1 -0
  11. package/dist/types/cli/commands/record.d.ts +12 -0
  12. package/dist/types/client/canvas/BoardCopyDialog.d.ts +5 -0
  13. package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
  14. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
  15. package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
  16. package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
  17. package/dist/types/client/canvas/HomeView.d.ts +9 -0
  18. package/dist/types/client/canvas/Presentation.d.ts +13 -0
  19. package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
  20. package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
  21. package/dist/types/client/nodes/BoardNode.d.ts +5 -0
  22. package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
  23. package/dist/types/client/state/boards-store.d.ts +79 -0
  24. package/dist/types/client/state/canvas-store.d.ts +2 -0
  25. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  26. package/dist/types/client/state/presentation.d.ts +3 -0
  27. package/dist/types/client/state/session-store.d.ts +7 -0
  28. package/dist/types/client/types.d.ts +2 -2
  29. package/dist/types/json-render/catalog.d.ts +3 -3
  30. package/dist/types/json-render/charts/definitions.d.ts +1 -1
  31. package/dist/types/json-render/server.d.ts +8 -0
  32. package/dist/types/mcp/canvas-access.d.ts +10 -3
  33. package/dist/types/mcp/context-uri-template.d.ts +6 -0
  34. package/dist/types/server/agent-presence.d.ts +1 -1
  35. package/dist/types/server/attribution.d.ts +11 -0
  36. package/dist/types/server/ax-context.d.ts +3 -1
  37. package/dist/types/server/ax-interaction.d.ts +2 -2
  38. package/dist/types/server/ax-state.d.ts +13 -3
  39. package/dist/types/server/backup.d.ts +36 -0
  40. package/dist/types/server/board-export.d.ts +45 -0
  41. package/dist/types/server/canvas-db.d.ts +65 -9
  42. package/dist/types/server/canvas-operations.d.ts +45 -0
  43. package/dist/types/server/canvas-provenance.d.ts +1 -1
  44. package/dist/types/server/canvas-schema.d.ts +1 -0
  45. package/dist/types/server/canvas-state.d.ts +146 -3
  46. package/dist/types/server/canvas-validation.d.ts +3 -0
  47. package/dist/types/server/context-brief.d.ts +111 -0
  48. package/dist/types/server/context-reads.d.ts +55 -0
  49. package/dist/types/server/document-import.d.ts +66 -0
  50. package/dist/types/server/html-surface.d.ts +1 -1
  51. package/dist/types/server/index.d.ts +107 -4
  52. package/dist/types/server/mutation-history.d.ts +1 -0
  53. package/dist/types/server/operations/invoker.d.ts +2 -0
  54. package/dist/types/server/operations/mcp.d.ts +7 -1
  55. package/dist/types/server/operations/ops/app.d.ts +2 -0
  56. package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
  57. package/dist/types/server/operations/ops/backup.d.ts +2 -0
  58. package/dist/types/server/operations/ops/boards.d.ts +10 -0
  59. package/dist/types/server/operations/ops/export.d.ts +2 -0
  60. package/dist/types/server/operations/ops/imports.d.ts +2 -0
  61. package/dist/types/server/operations/ops/json-render.d.ts +2 -0
  62. package/dist/types/server/operations/ops/nodes.d.ts +4 -2
  63. package/dist/types/server/operations/ops/query.d.ts +2 -0
  64. package/dist/types/server/operations/registry.d.ts +9 -1
  65. package/dist/types/server/operations/types.d.ts +1 -1
  66. package/dist/types/server/server.d.ts +1 -0
  67. package/dist/types/server/trace-manager.d.ts +7 -0
  68. package/dist/types/server/web-artifacts.d.ts +3 -0
  69. package/dist/types/server/webpage-node.d.ts +22 -1
  70. package/dist/types/server/workbench-auth.d.ts +3 -0
  71. package/dist/types/shared/boards.d.ts +6 -0
  72. package/dist/types/shared/tour.d.ts +72 -0
  73. package/docs/RELEASE.md +5 -0
  74. package/docs/cli.md +157 -0
  75. package/docs/http-api.md +277 -6
  76. package/docs/mcp.md +96 -7
  77. package/docs/node-types.md +106 -0
  78. package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
  79. package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
  80. package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
  81. package/docs/plans/plan-013-static-board-export.md +47 -0
  82. package/docs/plans/plan-014-connected-board-memory.md +140 -0
  83. package/docs/product-vision-2026-09.md +118 -18
  84. package/docs/sdk.md +50 -0
  85. package/package.json +1 -1
  86. package/skills/pmx-canvas/SKILL.md +62 -5
  87. package/skills/pmx-canvas/references/full-reference.md +44 -8
  88. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
  89. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  90. package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
  91. package/skills/pmx-canvas-testing/SKILL.md +4 -0
  92. package/src/cli/agent.ts +9 -0
  93. package/src/cli/commands/ax.ts +13 -0
  94. package/src/cli/commands/backup.ts +66 -0
  95. package/src/cli/commands/boards.ts +71 -0
  96. package/src/cli/commands/export.ts +30 -0
  97. package/src/cli/commands/nodes.ts +14 -1
  98. package/src/cli/commands/pins.ts +6 -2
  99. package/src/cli/commands/pump.ts +3 -0
  100. package/src/cli/commands/query.ts +33 -7
  101. package/src/cli/commands/record.ts +218 -0
  102. package/src/cli/commands/view.ts +7 -5
  103. package/src/cli/index.ts +21 -9
  104. package/src/client/App.tsx +28 -4
  105. package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
  106. package/src/client/canvas/BoardCopyDialog.tsx +153 -0
  107. package/src/client/canvas/BoardFolderDialog.tsx +120 -0
  108. package/src/client/canvas/BoardLinkDialog.tsx +93 -0
  109. package/src/client/canvas/BoardSwitcher.tsx +149 -0
  110. package/src/client/canvas/CanvasNode.tsx +8 -0
  111. package/src/client/canvas/CanvasViewport.tsx +3 -0
  112. package/src/client/canvas/CommandPalette.tsx +107 -4
  113. package/src/client/canvas/ContextMenu.tsx +4 -0
  114. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
  115. package/src/client/canvas/ExportDialog.tsx +197 -0
  116. package/src/client/canvas/HomeView.tsx +277 -0
  117. package/src/client/canvas/IntentLayer.tsx +9 -2
  118. package/src/client/canvas/Presentation.tsx +108 -0
  119. package/src/client/canvas/SessionPanel.tsx +28 -1
  120. package/src/client/canvas/ToolRail.tsx +38 -3
  121. package/src/client/canvas/TopBar.tsx +49 -7
  122. package/src/client/canvas/import-files.ts +19 -1
  123. package/src/client/canvas/kind-colors.ts +1 -0
  124. package/src/client/canvas/use-node-drag.ts +8 -3
  125. package/src/client/icons.tsx +2 -0
  126. package/src/client/mermaid-entry.ts +39 -0
  127. package/src/client/nodes/AttachmentNode.tsx +148 -0
  128. package/src/client/nodes/BoardNode.tsx +35 -0
  129. package/src/client/nodes/FileNode.tsx +6 -0
  130. package/src/client/nodes/HtmlNode.tsx +12 -13
  131. package/src/client/nodes/MarkdownNode.tsx +47 -19
  132. package/src/client/nodes/McpAppNode.tsx +105 -13
  133. package/src/client/nodes/MermaidNode.tsx +3 -2
  134. package/src/client/nodes/ax-node-actions.ts +2 -5
  135. package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
  136. package/src/client/state/boards-store.ts +167 -0
  137. package/src/client/state/canvas-store.ts +22 -0
  138. package/src/client/state/intent-bridge.ts +30 -2
  139. package/src/client/state/intent-store.ts +5 -1
  140. package/src/client/state/node-factory.ts +1 -0
  141. package/src/client/state/presentation.ts +7 -0
  142. package/src/client/state/session-store.ts +8 -0
  143. package/src/client/state/sse-bridge.ts +52 -5
  144. package/src/client/theme/global.css +498 -28
  145. package/src/client/types.ts +2 -0
  146. package/src/json-render/server.ts +45 -9
  147. package/src/mcp/canvas-access.ts +59 -22
  148. package/src/mcp/context-uri-template.ts +28 -0
  149. package/src/mcp/server.ts +122 -15
  150. package/src/server/agent-context.ts +116 -29
  151. package/src/server/agent-presence.ts +15 -9
  152. package/src/server/attribution.ts +29 -0
  153. package/src/server/ax-context.ts +4 -2
  154. package/src/server/ax-interaction.ts +1 -0
  155. package/src/server/ax-state-manager.ts +72 -3
  156. package/src/server/ax-state.ts +41 -0
  157. package/src/server/backup.ts +178 -0
  158. package/src/server/board-export.ts +686 -0
  159. package/src/server/canvas-db.ts +612 -105
  160. package/src/server/canvas-operations.ts +183 -31
  161. package/src/server/canvas-provenance.ts +1 -0
  162. package/src/server/canvas-schema.ts +22 -0
  163. package/src/server/canvas-state.ts +812 -48
  164. package/src/server/canvas-validation.ts +9 -1
  165. package/src/server/context-brief.ts +372 -0
  166. package/src/server/context-reads.ts +238 -0
  167. package/src/server/demo-state.json +670 -9
  168. package/src/server/document-import.ts +223 -0
  169. package/src/server/html-primitives.ts +199 -91
  170. package/src/server/html-surface.ts +8 -3
  171. package/src/server/index.ts +243 -5
  172. package/src/server/mutation-history.ts +10 -13
  173. package/src/server/operations/composites.ts +29 -5
  174. package/src/server/operations/http.ts +9 -3
  175. package/src/server/operations/index.ts +8 -0
  176. package/src/server/operations/invoker.ts +14 -3
  177. package/src/server/operations/mcp.ts +50 -11
  178. package/src/server/operations/ops/app.ts +15 -0
  179. package/src/server/operations/ops/ax-read.ts +10 -6
  180. package/src/server/operations/ops/ax-timeline.ts +75 -0
  181. package/src/server/operations/ops/backup.ts +134 -0
  182. package/src/server/operations/ops/batch.ts +20 -4
  183. package/src/server/operations/ops/boards.ts +351 -0
  184. package/src/server/operations/ops/export.ts +74 -0
  185. package/src/server/operations/ops/ext-app.ts +4 -0
  186. package/src/server/operations/ops/groups.ts +14 -7
  187. package/src/server/operations/ops/imports.ts +220 -0
  188. package/src/server/operations/ops/json-render.ts +37 -5
  189. package/src/server/operations/ops/nodes.ts +99 -21
  190. package/src/server/operations/ops/query.ts +206 -9
  191. package/src/server/operations/ops/viewport.ts +32 -0
  192. package/src/server/operations/registry.ts +129 -31
  193. package/src/server/operations/types.ts +1 -1
  194. package/src/server/scope-fence.ts +1 -1
  195. package/src/server/server.ts +155 -8
  196. package/src/server/spatial-analysis.ts +9 -17
  197. package/src/server/trace-manager.ts +19 -0
  198. package/src/server/web-artifacts.ts +11 -3
  199. package/src/server/webpage-node.ts +173 -13
  200. package/src/server/workbench-auth.ts +11 -0
  201. package/src/server/workboard.ts +3 -3
  202. package/src/shared/boards.ts +18 -0
  203. package/src/shared/node-sizes.ts +1 -0
  204. package/src/shared/tour.ts +87 -0
@@ -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
  }
@@ -1,4 +1,5 @@
1
- import { useEffect, useMemo, useRef } from 'preact/hooks';
1
+ import type { RefObject } from 'preact';
2
+ import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
2
3
  import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
3
4
  import { canvasThemeScheme } from '../../shared/themes.js';
4
5
  import type { CanvasNodeState } from '../types';
@@ -54,6 +55,106 @@ export function isSameOriginFrameDocumentUrl(url: string, origin = window.locati
54
55
  }
55
56
  }
56
57
 
58
+ type ViewerFrameSource = { src?: string; srcdoc?: string };
59
+
60
+ /**
61
+ * Keep the last painted viewer in front while its replacement navigates. Viewer
62
+ * specs are immutable documents, so updates normally change `?v=` and reload the
63
+ * iframe; putting the new document in a hidden sibling avoids exposing Chromium's
64
+ * white navigation paint. Two animation frames after load gives the new document
65
+ * a composited paint before the old one is removed.
66
+ */
67
+ export function RefreshingViewerFrame({
68
+ source,
69
+ iframeRef,
70
+ onLoad,
71
+ title,
72
+ className = 'mcp-app-frame',
73
+ sandbox = 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox',
74
+ allow = 'clipboard-read; clipboard-write',
75
+ tabIndex,
76
+ }: {
77
+ source: ViewerFrameSource;
78
+ iframeRef: RefObject<HTMLIFrameElement>;
79
+ onLoad: () => void;
80
+ title: string;
81
+ className?: string;
82
+ sandbox?: string;
83
+ allow?: string;
84
+ tabIndex?: number;
85
+ }) {
86
+ const sourceKey = source.src ?? source.srcdoc ?? '';
87
+ const [painted, setPainted] = useState({ key: sourceKey, source });
88
+ const [pending, setPending] = useState<{ key: string; source: ViewerFrameSource } | null>(null);
89
+ const promotionRef = useRef(0);
90
+
91
+ useEffect(() => {
92
+ promotionRef.current += 1;
93
+ if (!sourceKey || sourceKey === painted.key) {
94
+ setPending(null);
95
+ return;
96
+ }
97
+ if (!painted.key) {
98
+ setPainted({ key: sourceKey, source });
99
+ return;
100
+ }
101
+ setPending({ key: sourceKey, source });
102
+ }, [sourceKey, painted.key]);
103
+
104
+ useEffect(
105
+ () => () => {
106
+ promotionRef.current += 1;
107
+ },
108
+ [],
109
+ );
110
+
111
+ const frame = (entry: { key: string; source: ViewerFrameSource }, isPending: boolean) => (
112
+ <iframe
113
+ key={entry.key}
114
+ ref={isPending ? undefined : iframeRef}
115
+ {...entry.source}
116
+ class={className}
117
+ sandbox={sandbox}
118
+ allow={allow}
119
+ tabIndex={tabIndex}
120
+ loading={iframeMode.value === 'srcdoc' ? undefined : 'lazy'}
121
+ onLoad={(event) => {
122
+ if (!isPending) {
123
+ onLoad();
124
+ return;
125
+ }
126
+ const token = ++promotionRef.current;
127
+ const loadedFrame = event.currentTarget;
128
+ requestAnimationFrame(() =>
129
+ requestAnimationFrame(() => {
130
+ if (promotionRef.current !== token) return;
131
+ iframeRef.current = loadedFrame;
132
+ setPainted(entry);
133
+ setPending(null);
134
+ onLoad();
135
+ }),
136
+ );
137
+ }}
138
+ style={{
139
+ position: 'absolute',
140
+ inset: 0,
141
+ width: '100%',
142
+ height: '100%',
143
+ visibility: isPending ? 'hidden' : 'visible',
144
+ background: 'var(--c-panel-soft)',
145
+ }}
146
+ title={title}
147
+ />
148
+ );
149
+
150
+ return (
151
+ <div class="mcp-app-frame-stack">
152
+ {frame(painted, false)}
153
+ {pending && frame(pending, true)}
154
+ </div>
155
+ );
156
+ }
157
+
57
158
  export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
58
159
  if (node.data.mode === 'ext-app') {
59
160
  return <ExtAppFrame node={node} expanded={expanded} />;
@@ -180,19 +281,10 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
180
281
  {/* Plain iframe-backed viewers stay on an opaque origin. Hosted ext-apps use
181
282
  the explicit postMessage bridge instead, which is the only path that needs
182
283
  app/host RPC and broader capabilities. */}
183
- <iframe
184
- ref={iframeRef}
185
- {...surfaceFrame}
186
- class="mcp-app-frame"
187
- sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
188
- allow="clipboard-read; clipboard-write"
189
- // Lazy loading only helps src mode (defers the HTTP request for
190
- // off-screen nodes). In srcdoc mode the content is already inline —
191
- // lazy would just delay PAINTING until the node scrolls near the
192
- // viewport, which reads as "blank until opened" (Amp portal report).
193
- loading={iframeMode.value === 'srcdoc' ? undefined : 'lazy'}
284
+ <RefreshingViewerFrame
285
+ source={surfaceFrame}
286
+ iframeRef={iframeRef}
194
287
  onLoad={pushAxState}
195
- style={{ flex: 1, minHeight: 0, width: '100%' }}
196
288
  title={`MCP App: ${sourceServer}`}
197
289
  />
198
290
  </div>
@@ -21,7 +21,8 @@ export function MermaidNode({ node, expanded = false }: { node: CanvasNodeState;
21
21
  // Per-mount nonce for the content-height reporter (node grows to fit content).
22
22
  const frameToken = useMemo(() => `frame-${crypto.randomUUID()}`, []);
23
23
  const source = typeof node.data.content === 'string' ? node.data.content : '';
24
- const v = useMemo(() => surfaceContentHash(source), [source]);
24
+ const fit = node.data.fit === 'none' ? 'none' : 'contain';
25
+ const v = useMemo(() => surfaceContentHash(`${fit}:${source}`), [source, fit]);
25
26
 
26
27
  // `theme` is intentionally excluded from the deps: live theme changes are
27
28
  // pushed via postMessage below (no reload), while `v` reloads the frame when
@@ -70,7 +71,7 @@ export function MermaidNode({ node, expanded = false }: { node: CanvasNodeState;
70
71
  style={{
71
72
  width: '100%',
72
73
  height: '100%',
73
- minHeight: expanded ? '70vh' : '300px',
74
+ minHeight: expanded ? '70vh' : '0',
74
75
  border: 'none',
75
76
  background: 'var(--c-bg)',
76
77
  borderRadius: '6px',
@@ -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
+ }
@@ -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',
@@ -83,9 +83,13 @@ function setPhase(id: string, phase: IntentPhase, ms: number, settledNodeId?: st
83
83
  next.set(id, { ...current, phase, ...(settledNodeId ? { settledNodeId } : {}) });
84
84
  writeIntents(next);
85
85
  clearExitTimer(id);
86
+ // The layer removes ghosts on animationend, after the browser actually paints
87
+ // the morph. A wall-clock deadline equal to the CSS duration can delete a
88
+ // ghost before its delayed first paint or transition completes on a busy tab.
89
+ // Retain a backstop for hidden tabs or disabled animations.
86
90
  exitTimers.set(
87
91
  id,
88
- setTimeout(() => removeIntent(id), ms),
92
+ setTimeout(() => removeIntent(id), ms + 1500),
89
93
  );
90
94
  }
91
95
 
@@ -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 },
@@ -0,0 +1,7 @@
1
+ import { signal } from '@preact/signals';
2
+
3
+ export const presenting = signal(false);
4
+ export const recordingCamera = signal(false);
5
+ export function ownsCamera(): boolean {
6
+ return presenting.value || recordingCamera.value;
7
+ }
@@ -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[];
@@ -386,6 +391,9 @@ export function applySessionReceipt(data: Record<string, unknown>): void {
386
391
  const counts = data.counts as Partial<SessionReceipt['counts']> | undefined;
387
392
  const snapshot = data.snapshot as SessionReceipt['snapshot'] | undefined;
388
393
  if (typeof data.label !== 'string' || typeof data.endedAt !== 'string') return;
394
+ // Keep history/timeline intact, but only interrupt for a changed top-level
395
+ // session. Quiet endings must not replace an existing useful receipt.
396
+ if (data.unchanged === true || typeof data.parentAgentId === 'string') return;
389
397
  sessionReceipt.value = {
390
398
  label: data.label,
391
399
  endedAt: data.endedAt,