pmx-canvas 0.7.1 → 0.7.3

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 (76) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +79 -61
  2. package/CHANGELOG.md +29 -1
  3. package/Readme.md +48 -9
  4. package/dist/canvas/global.css +144 -45
  5. package/dist/canvas/index.js +90 -90
  6. package/dist/json-render/index.js +55 -55
  7. package/dist/types/client/canvas/ChoiceMenu.d.ts +13 -0
  8. package/dist/types/client/canvas/ExternalWriters.d.ts +3 -3
  9. package/dist/types/client/icons.d.ts +15 -0
  10. package/dist/types/client/nodes/ExtAppFrame.d.ts +1 -1
  11. package/dist/types/client/nodes/FileNode.d.ts +0 -12
  12. package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
  13. package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -0
  14. package/dist/types/client/state/canvas-store.d.ts +2 -0
  15. package/dist/types/client/state/intent-bridge.d.ts +1 -1
  16. package/dist/types/json-render/renderer/index.d.ts +2 -1
  17. package/dist/types/json-render/server.d.ts +2 -0
  18. package/dist/types/shared/ax-surface-protocol.d.ts +2 -0
  19. package/dist/types/shared/delimited-text.d.ts +14 -0
  20. package/docs/ax-host-adapter-contract.md +13 -2
  21. package/docs/cli.md +9 -2
  22. package/docs/http-api.md +12 -8
  23. package/docs/mcp.md +8 -1
  24. package/docs/sdk.md +2 -2
  25. package/package.json +1 -1
  26. package/skills/pmx-canvas/SKILL.md +41 -16
  27. package/skills/pmx-canvas/evals/evals.json +112 -2
  28. package/skills/pmx-canvas/references/full-reference.md +9 -4
  29. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +17 -3
  30. package/skills/pmx-canvas/references/installing-pmx-canvas.md +76 -4
  31. package/skills/pmx-canvas-testing/SKILL.md +49 -0
  32. package/src/cli/commands/backup.ts +15 -4
  33. package/src/cli/commands/boards.ts +16 -3
  34. package/src/client/canvas/AttentionHistory.tsx +2 -1
  35. package/src/client/canvas/BoardSwitcher.tsx +3 -3
  36. package/src/client/canvas/CanvasNode.tsx +211 -197
  37. package/src/client/canvas/CanvasViewport.tsx +6 -0
  38. package/src/client/canvas/ChoiceMenu.tsx +122 -0
  39. package/src/client/canvas/CommandBar.tsx +27 -25
  40. package/src/client/canvas/ContextPinBar.tsx +3 -1
  41. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -3
  42. package/src/client/canvas/ExternalWriters.tsx +4 -3
  43. package/src/client/canvas/FeedbackDialog.tsx +12 -9
  44. package/src/client/canvas/IntentLayer.tsx +2 -2
  45. package/src/client/canvas/SelectionBar.tsx +2 -2
  46. package/src/client/canvas/SessionPanel.tsx +6 -4
  47. package/src/client/canvas/SessionReceipt.tsx +2 -1
  48. package/src/client/canvas/SnapshotPanel.tsx +3 -2
  49. package/src/client/canvas/ToolRail.tsx +48 -100
  50. package/src/client/canvas/TopBar.tsx +2 -2
  51. package/src/client/icons.tsx +118 -1
  52. package/src/client/nodes/BoardNode.tsx +2 -1
  53. package/src/client/nodes/ExtAppFrame.tsx +3 -1
  54. package/src/client/nodes/FileNode.tsx +4 -78
  55. package/src/client/nodes/ImageNode.tsx +2 -1
  56. package/src/client/nodes/MarkdownNode.tsx +2 -1
  57. package/src/client/nodes/McpAppNode.tsx +16 -0
  58. package/src/client/nodes/PromptNode.tsx +3 -1
  59. package/src/client/nodes/use-ax-surface-bridge.ts +4 -2
  60. package/src/client/nodes/viewer-runtime-state.ts +78 -0
  61. package/src/client/state/canvas-store.ts +3 -0
  62. package/src/client/state/intent-bridge.ts +14 -2
  63. package/src/client/theme/global.css +144 -45
  64. package/src/json-render/renderer/index.tsx +58 -38
  65. package/src/json-render/server.ts +8 -1
  66. package/src/mcp/server.ts +2 -2
  67. package/src/server/agent-presence.ts +17 -11
  68. package/src/server/board-export.ts +97 -32
  69. package/src/server/index.ts +9 -7
  70. package/src/server/operations/composites.ts +3 -2
  71. package/src/server/operations/ops/ax-presence.ts +7 -3
  72. package/src/server/operations/ops/boards.ts +12 -5
  73. package/src/server/operations/registry.ts +4 -13
  74. package/src/server/server.ts +13 -4
  75. package/src/shared/ax-surface-protocol.ts +3 -0
  76. package/src/shared/delimited-text.ts +75 -0
@@ -11,16 +11,133 @@ const defaults = {
11
11
  'stroke-width': '1.5',
12
12
  'stroke-linecap': 'round',
13
13
  'stroke-linejoin': 'round',
14
+ 'aria-hidden': 'true',
14
15
  } as const;
15
16
 
16
17
  function Icon({ size = 16, children, ...rest }: IconProps & { children: JSX.Element | JSX.Element[] }): JSX.Element {
17
18
  return (
18
- <svg width={size} height={size} viewBox="0 0 16 16" {...defaults} {...rest}>
19
+ <svg
20
+ width={size}
21
+ height={size}
22
+ viewBox="0 0 16 16"
23
+ style={{ verticalAlign: 'middle', flexShrink: 0 }}
24
+ {...defaults}
25
+ {...rest}
26
+ >
19
27
  {children}
20
28
  </svg>
21
29
  );
22
30
  }
23
31
 
32
+ export function IconClose(p: IconProps): JSX.Element {
33
+ return (
34
+ <Icon {...p}>
35
+ <path d="m4 4 8 8M12 4l-8 8" />
36
+ </Icon>
37
+ );
38
+ }
39
+
40
+ export function IconPlus(p: IconProps): JSX.Element {
41
+ return (
42
+ <Icon {...p}>
43
+ <path d="M8 2v12M2 8h12" />
44
+ </Icon>
45
+ );
46
+ }
47
+
48
+ export function IconPlusCircle(p: IconProps): JSX.Element {
49
+ return (
50
+ <Icon {...p}>
51
+ <circle cx="8" cy="8" r="6" />
52
+ <path d="M8 5v6M5 8h6" />
53
+ </Icon>
54
+ );
55
+ }
56
+
57
+ export function IconChevronRight(p: IconProps): JSX.Element {
58
+ return (
59
+ <Icon {...p}>
60
+ <polyline points="6 3 11 8 6 13" />
61
+ </Icon>
62
+ );
63
+ }
64
+
65
+ export function IconMore(p: IconProps): JSX.Element {
66
+ return (
67
+ <Icon {...p}>
68
+ <circle cx="3" cy="8" r="0.75" />
69
+ <circle cx="8" cy="8" r="0.75" />
70
+ <circle cx="13" cy="8" r="0.75" />
71
+ </Icon>
72
+ );
73
+ }
74
+
75
+ export function IconExternalLink(p: IconProps): JSX.Element {
76
+ return (
77
+ <Icon {...p}>
78
+ <path d="M9 2h5v5M14 2 7 9M6 3H2v11h11v-4" />
79
+ </Icon>
80
+ );
81
+ }
82
+
83
+ export function IconExpand(p: IconProps): JSX.Element {
84
+ return (
85
+ <Icon {...p}>
86
+ <path d="M9 2h5v5M14 2l-5 5M7 14H2V9M2 14l5-5" />
87
+ </Icon>
88
+ );
89
+ }
90
+
91
+ export function IconArrowLeft(p: IconProps): JSX.Element {
92
+ return (
93
+ <Icon {...p}>
94
+ <path d="M14 8H2m5-5-5 5 5 5" />
95
+ </Icon>
96
+ );
97
+ }
98
+
99
+ export function IconRefresh(p: IconProps): JSX.Element {
100
+ return (
101
+ <Icon {...p}>
102
+ <path d="M13.5 6A5.5 5.5 0 1 0 13 11M14 2v4h-4" />
103
+ </Icon>
104
+ );
105
+ }
106
+
107
+ export function IconUndo(p: IconProps): JSX.Element {
108
+ return (
109
+ <Icon {...p}>
110
+ <path d="M6 2 2 6l4 4M2 6h7a4 4 0 0 1 0 8" />
111
+ </Icon>
112
+ );
113
+ }
114
+
115
+ export function IconSparkle(p: IconProps): JSX.Element {
116
+ return (
117
+ <Icon {...p}>
118
+ <path d="m8 1 2 5 5 2-5 2-2 5-2-5-5-2 5-2Z" />
119
+ </Icon>
120
+ );
121
+ }
122
+
123
+ /** Upload into a tray — attach a document. */
124
+ export function IconUpload(p: IconProps): JSX.Element {
125
+ return (
126
+ <Icon {...p}>
127
+ <path d="M8 10V2M4.5 5.5 8 2l3.5 3.5M2 10v3.5h12V10" />
128
+ </Icon>
129
+ );
130
+ }
131
+
132
+ /** Dropdown disclosure, sized like the other toolbar icons. */
133
+ export function IconChevronDown(p: IconProps): JSX.Element {
134
+ return (
135
+ <Icon {...p}>
136
+ <polyline points="3 6 8 11 13 6" />
137
+ </Icon>
138
+ );
139
+ }
140
+
24
141
  /** Expand-arrows — fit all nodes */
25
142
  export function IconFitAll(p: IconProps): JSX.Element {
26
143
  return (
@@ -1,5 +1,6 @@
1
1
  import type { CanvasNodeState } from '../types';
2
2
  import { boardList, openBoard } from '../state/boards-store';
3
+ import { IconExternalLink } from '../icons';
3
4
 
4
5
  /** Native text-only cross-board link. Target identity is the durable board id. */
5
6
  export function BoardNode({ node }: { node: CanvasNodeState }) {
@@ -28,7 +29,7 @@ export function BoardNode({ node }: { node: CanvasNodeState }) {
28
29
  onClick={() => target && void openBoard(target.id)}
29
30
  >
30
31
  {target ? 'Open board' : 'Target unavailable'}
31
- {target && <span aria-hidden="true">↗</span>}
32
+ {target && <IconExternalLink size={14} />}
32
33
  </button>
33
34
  </div>
34
35
  );
@@ -2,6 +2,7 @@ import type { CallToolResult, ListToolsResult, RequestId, Tool } from '@modelcon
2
2
  import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
3
3
  import { AppBridge, PostMessageTransport, buildAllowAttribute } from '@modelcontextprotocol/ext-apps/app-bridge';
4
4
  import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
5
+ import { IconClose } from '../icons';
5
6
  import {
6
7
  AX_SURFACE_ACK_SOURCE,
7
8
  AX_SURFACE_EMIT_SOURCE,
@@ -1429,6 +1430,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1429
1430
  <button
1430
1431
  type="button"
1431
1432
  onClick={() => setError(null)}
1433
+ aria-label="Dismiss error"
1432
1434
  style={{
1433
1435
  background: 'none',
1434
1436
  border: 'none',
@@ -1438,7 +1440,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1438
1440
  padding: '0 2px',
1439
1441
  }}
1440
1442
  >
1441
- ×
1443
+ <IconClose size={14} />
1442
1444
  </button>
1443
1445
  </div>
1444
1446
  )}
@@ -1,4 +1,6 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
2
+ import { IconPlusCircle, IconRefresh } from '../icons';
3
+ import { delimiterForPath, parseDelimitedText } from '../../shared/delimited-text';
2
4
  import { updateNodeData } from '../state/canvas-store';
3
5
  import { iframeMode } from '../state/iframe-mode';
4
6
  import { fetchFile, updateNodeFromClient } from '../state/intent-bridge';
@@ -44,82 +46,6 @@ function langFromPath(path: string): string {
44
46
  /** Rendered-row ceiling for delimited files — a 200k-row CSV must not lock the browser. */
45
47
  const MAX_TABLE_ROWS = 500;
46
48
 
47
- export interface DelimitedTable {
48
- header: string[];
49
- /** Rectangular: every row padded (short) or truncated (long) to the header width. */
50
- rows: string[][];
51
- }
52
-
53
- /** Column delimiter for a delimited-data path, or null when the file is not one. */
54
- function delimiterForPath(path: string): string | null {
55
- const lower = path.toLowerCase();
56
- if (lower.endsWith('.csv')) return ',';
57
- if (lower.endsWith('.tsv')) return '\t';
58
- return null;
59
- }
60
-
61
- /**
62
- * RFC4180-ish delimited-text parser. Quoted fields may contain the delimiter,
63
- * newlines, and `""` escapes; CRLF, LF and lone CR all end a record; a trailing
64
- * newline produces no phantom empty row. Ragged records are squared off against
65
- * the header width instead of throwing.
66
- */
67
- export function parseDelimitedText(text: string, delimiter: string): DelimitedTable {
68
- const records: string[][] = [];
69
- let row: string[] = [];
70
- let field = '';
71
- let quoted = false;
72
- // Tracks whether the current record consumed anything — distinguishes a real
73
- // empty-quoted record from the dangling state after a terminating newline.
74
- let started = false;
75
-
76
- for (let i = 0; i < text.length; i++) {
77
- const char = text[i];
78
- started = true;
79
- if (quoted) {
80
- if (char === '"') {
81
- if (text[i + 1] === '"') {
82
- field += '"';
83
- i++;
84
- } else {
85
- quoted = false;
86
- }
87
- } else if (char === '\r' && text[i + 1] === '\n') {
88
- field += '\n';
89
- i++;
90
- } else {
91
- field += char;
92
- }
93
- continue;
94
- }
95
- if (char === '"' && field === '') {
96
- quoted = true;
97
- } else if (char === delimiter) {
98
- row.push(field);
99
- field = '';
100
- } else if (char === '\n' || char === '\r') {
101
- if (char === '\r' && text[i + 1] === '\n') i++;
102
- row.push(field);
103
- field = '';
104
- records.push(row);
105
- row = [];
106
- started = false;
107
- } else {
108
- field += char;
109
- }
110
- }
111
- if (started) {
112
- row.push(field);
113
- records.push(row);
114
- }
115
-
116
- const header = records.shift() ?? [];
117
- return {
118
- header,
119
- rows: records.map((record) => Array.from({ length: header.length }, (_, i) => record[i] ?? '')),
120
- };
121
- }
122
-
123
49
  /** Human-readable byte size, e.g. `4.2 MB`. */
124
50
  export function formatBytes(bytes: number): string {
125
51
  if (!Number.isFinite(bytes) || bytes < 0) return '';
@@ -349,7 +275,7 @@ function TextFileNode({ node, expanded = false }: { node: CanvasNodeState; expan
349
275
  flexShrink: 0,
350
276
  }}
351
277
  >
352
- ⊕
278
+ <IconPlusCircle size={14} />
353
279
  </button>
354
280
  <button
355
281
  type="button"
@@ -365,7 +291,7 @@ function TextFileNode({ node, expanded = false }: { node: CanvasNodeState; expan
365
291
  flexShrink: 0,
366
292
  }}
367
293
  >
368
- ↻
294
+ <IconRefresh size={14} />
369
295
  </button>
370
296
  </div>
371
297
 
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
+ import { IconRefresh } from '../icons';
2
3
  import type { CanvasNodeState } from '../types';
3
4
  import { getImageNodeWarnings } from './image-warnings';
4
5
 
@@ -140,7 +141,7 @@ export function ImageNode({ node, expanded = false }: { node: CanvasNodeState; e
140
141
  {sizeLabel && <span>{sizeLabel}</span>}
141
142
  {zoom !== 1 && (
142
143
  <button type="button" class="image-node-zoom-reset" onClick={resetView} title="Reset zoom">
143
- {zoomPct}% ↺
144
+ {zoomPct}% <IconRefresh size={14} />
144
145
  </button>
145
146
  )}
146
147
  </span>
@@ -1,5 +1,6 @@
1
1
  import type { JSX } from 'preact';
2
2
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
3
+ import { IconArrowLeft } from '../icons';
3
4
  import { expandNode, updateNodeData } from '../state/canvas-store';
4
5
  import { fetchFile, renderMarkdown, saveFile, updateNodeFromClient } from '../state/intent-bridge';
5
6
  import type { CanvasNodeState } from '../types';
@@ -254,7 +255,7 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
254
255
  <div class="md-editor-expanded" onKeyDown={handleKeyDown}>
255
256
  <div class="md-editor-toolbar">
256
257
  <button type="button" class="md-toolbar-btn" onClick={() => setSourceMode(false)}>
257
- ← Back to document
258
+ <IconArrowLeft size={14} /> Back to document
258
259
  </button>
259
260
  <div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
260
261
  {path && <span class="md-toolbar-path">{path.split('/').pop()}</span>}
@@ -10,6 +10,7 @@ import { ExtAppFrame } from './ExtAppFrame';
10
10
  import { useAxSurfaceBridge } from './use-ax-surface-bridge';
11
11
  import { useIframeContentHeight } from './use-iframe-content-height';
12
12
  import { useSurfaceFrame } from './use-surface-frame';
13
+ import { restoreViewerRuntimeState } from './viewer-runtime-state';
13
14
 
14
15
  function withViewerParams(
15
16
  url: string,
@@ -19,6 +20,7 @@ function withViewerParams(
19
20
  axNodeId?: string,
20
21
  frameToken?: string,
21
22
  fitContent?: boolean,
23
+ stateToken?: string,
22
24
  ): string {
23
25
  if (!url) return url;
24
26
  try {
@@ -38,6 +40,7 @@ function withViewerParams(
38
40
  // Content-fit: report natural height (charts render intrinsic) so the node grows.
39
41
  if (frameToken) resolved.searchParams.set('frameToken', frameToken);
40
42
  if (fitContent) resolved.searchParams.set('fit', 'content');
43
+ if (stateToken) resolved.searchParams.set('stateToken', stateToken);
41
44
  return resolved.toString();
42
45
  } catch {
43
46
  return url;
@@ -68,6 +71,7 @@ export function RefreshingViewerFrame({
68
71
  source,
69
72
  iframeRef,
70
73
  onLoad,
74
+ onDocumentLoad,
71
75
  title,
72
76
  className = 'mcp-app-frame',
73
77
  sandbox = 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox',
@@ -77,6 +81,7 @@ export function RefreshingViewerFrame({
77
81
  source: ViewerFrameSource;
78
82
  iframeRef: RefObject<HTMLIFrameElement>;
79
83
  onLoad: () => void;
84
+ onDocumentLoad?: (frame: HTMLIFrameElement) => void;
80
85
  title: string;
81
86
  className?: string;
82
87
  sandbox?: string;
@@ -119,6 +124,9 @@ export function RefreshingViewerFrame({
119
124
  tabIndex={tabIndex}
120
125
  loading={iframeMode.value === 'srcdoc' ? undefined : 'lazy'}
121
126
  onLoad={(event) => {
127
+ // Seed runtime state while a replacement is still behind the painted
128
+ // frame, rather than mounting an empty viewer after promotion.
129
+ onDocumentLoad?.(event.currentTarget);
122
130
  if (!isPending) {
123
131
  onLoad();
124
132
  return;
@@ -214,6 +222,10 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
214
222
  useEffect(pushAxState, [isAxViewer, axToken, axStateValue]);
215
223
 
216
224
  const specVersion = typeof node.data.specVersion === 'number' ? node.data.specVersion : undefined;
225
+ const stateToken = useMemo(
226
+ () => (isJsonViewer ? `ui-${crypto.randomUUID()}` : ''),
227
+ [isJsonViewer, specVersion, node.data.url],
228
+ );
217
229
  const url = withViewerParams(
218
230
  (node.data.url as string) || '',
219
231
  expanded,
@@ -222,6 +234,7 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
222
234
  isAxViewer ? node.id : undefined,
223
235
  frameToken || undefined,
224
236
  contentFit,
237
+ stateToken || undefined,
225
238
  );
226
239
  // src vs fetch()+srcdoc, decided by the boot-wide embed probe (Amp portals).
227
240
  // External viewer URLs always stay src — the hook passes them through.
@@ -284,6 +297,9 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
284
297
  <RefreshingViewerFrame
285
298
  source={surfaceFrame}
286
299
  iframeRef={iframeRef}
300
+ onDocumentLoad={(frame) => {
301
+ if (stateToken && frame.contentWindow) restoreViewerRuntimeState(node.id, stateToken, frame.contentWindow);
302
+ }}
287
303
  onLoad={pushAxState}
288
304
  title={`MCP App: ${sourceServer}`}
289
305
  />
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
+ import { IconClose } from '../icons';
2
3
  import { updateNodeData } from '../state/canvas-store';
3
4
  import { fetchSlashCommands, renderMarkdown, submitCanvasPrompt, submitThreadReply } from '../state/intent-bridge';
4
5
  import type { CanvasNodeState } from '../types';
@@ -98,6 +99,7 @@ function ErrorBanner({ message, onDismiss }: { message: string | null; onDismiss
98
99
  <button
99
100
  type="button"
100
101
  onClick={onDismiss}
102
+ aria-label="Dismiss error"
101
103
  style={{
102
104
  background: 'none',
103
105
  border: 'none',
@@ -107,7 +109,7 @@ function ErrorBanner({ message, onDismiss }: { message: string | null; onDismiss
107
109
  padding: '0 2px',
108
110
  }}
109
111
  >
110
- {'\u00d7'}
112
+ <IconClose size={14} />
111
113
  </button>
112
114
  </div>
113
115
  );
@@ -1,5 +1,5 @@
1
1
  import type { RefObject } from 'preact';
2
- import { useEffect } from 'preact/hooks';
2
+ import { useLayoutEffect } from 'preact/hooks';
3
3
  import { AX_SURFACE_ACK_SOURCE, AX_SURFACE_EMIT_SOURCE } from '../../shared/ax-surface-protocol.js';
4
4
  import { showToast } from '../state/attention-bridge';
5
5
  import { submitAxInteractionFromClient } from '../state/intent-bridge';
@@ -25,7 +25,9 @@ export function useAxSurfaceBridge(options: {
25
25
  iframeRef: RefObject<HTMLIFrameElement>;
26
26
  }): void {
27
27
  const { enabled, token, nodeId, sourceSurface, iframeRef } = options;
28
- useEffect(() => {
28
+ // Register before paint: a fast iframe can accept input before passive effects
29
+ // run, and its first emit must not be lost in that gap.
30
+ useLayoutEffect(() => {
29
31
  if (!enabled || !token) return;
30
32
  function onAxMessage(event: MessageEvent) {
31
33
  // Bind to THIS node's own iframe; the nonce + nodeId are a second gate,
@@ -0,0 +1,78 @@
1
+ import { effect } from '@preact/signals';
2
+ import { JSON_VIEWER_STATE_SOURCE } from '../../shared/ax-surface-protocol.js';
3
+ import { canvasInteractionEpoch, nodes } from '../state/canvas-store';
4
+
5
+ type Snapshot = {
6
+ epoch: number;
7
+ version: unknown;
8
+ documentUrl: unknown;
9
+ state: Record<string, unknown> | null;
10
+ owners: Array<{ token: string; source: Window }>;
11
+ };
12
+
13
+ // Tab-local UI state only: never persist form drafts in canvas data or AX. Keep
14
+ // the receiver alive across the inline/expanded unmount so queued input messages
15
+ // cannot fall into a gap between component effects. Two owners cover a handover;
16
+ // removal, board navigation and authored spec changes discard the entire entry.
17
+ const snapshots = new Map<string, Snapshot>();
18
+ let listening = false;
19
+
20
+ export function restoreViewerRuntimeState(nodeId: string, token: string, source: Window): void {
21
+ if (!listening) {
22
+ listening = true;
23
+ effect(() => {
24
+ const current = nodes.value;
25
+ const epoch = canvasInteractionEpoch.value;
26
+ for (const [id, snapshot] of snapshots) {
27
+ const node = current.get(id);
28
+ if (
29
+ !node ||
30
+ snapshot.epoch !== epoch ||
31
+ snapshot.version !== node.data.specVersion ||
32
+ snapshot.documentUrl !== node.data.url
33
+ )
34
+ snapshots.delete(id);
35
+ }
36
+ });
37
+ window.addEventListener('message', (event: MessageEvent) => {
38
+ const message = event.data as {
39
+ source?: string;
40
+ type?: string;
41
+ token?: string;
42
+ nodeId?: string;
43
+ state?: unknown;
44
+ } | null;
45
+ if (
46
+ !message ||
47
+ message.source !== JSON_VIEWER_STATE_SOURCE ||
48
+ message.type !== 'snapshot' ||
49
+ typeof message.nodeId !== 'string'
50
+ )
51
+ return;
52
+ const snapshot = snapshots.get(message.nodeId);
53
+ const ownerIndex =
54
+ snapshot?.owners.findIndex((owner) => owner.token === message.token && owner.source === event.source) ?? -1;
55
+ if (!snapshot || ownerIndex < 0) return;
56
+ if (!message.state || typeof message.state !== 'object' || Array.isArray(message.state)) return;
57
+ const { ax: _ax, ...state } = message.state as Record<string, unknown>;
58
+ snapshot.state = state;
59
+ // Once the replacement emits, the retired document cannot overwrite it.
60
+ snapshot.owners = snapshot.owners.slice(ownerIndex);
61
+ });
62
+ }
63
+ const node = nodes.value.get(nodeId);
64
+ if (!node) return;
65
+ let snapshot = snapshots.get(nodeId);
66
+ if (!snapshot) {
67
+ snapshot = {
68
+ epoch: canvasInteractionEpoch.value,
69
+ version: node.data.specVersion,
70
+ documentUrl: node.data.url,
71
+ state: null,
72
+ owners: [],
73
+ };
74
+ snapshots.set(nodeId, snapshot);
75
+ }
76
+ snapshot.owners = [...snapshot.owners.filter((owner) => owner.source !== source).slice(-1), { token, source }];
77
+ source.postMessage({ source: JSON_VIEWER_STATE_SOURCE, type: 'restore', token, state: snapshot.state }, '*');
78
+ }
@@ -58,6 +58,8 @@ const EXCALIDRAW_CLOSE_MAX_WAIT_MS = 2500;
58
58
 
59
59
  // ── Pending edge connection (for context menu "Connect from") ─
60
60
  export const pendingConnection = signal<{ from: string } | null>(null);
61
+ /** Invalidates in-flight interaction replies when board navigation resets the canvas. */
62
+ export const canvasInteractionEpoch = signal(0);
61
63
 
62
64
  // ── Drag-to-connect (live edge preview) ─────────────────────
63
65
  export const draggingEdge = signal<{
@@ -151,6 +153,7 @@ export function resetCanvasInteractionState(): void {
151
153
  expandedCloseTimer = null;
152
154
  }
153
155
  batch(() => {
156
+ canvasInteractionEpoch.value += 1;
154
157
  activeNodeId.value = null;
155
158
  expandedNodeId.value = null;
156
159
  pendingExpandedNodeCloseId.value = null;
@@ -1,6 +1,8 @@
1
1
  import { canvasArea } from '../canvas/canvas-area';
2
2
  import { showToast } from './attention-bridge';
3
3
  import type { AgentPresenceSnapshot } from '../../shared/agent-presence.js';
4
+ import type { CanvasEdge } from '../types';
5
+ import { addEdge, canvasInteractionEpoch, nodes } from './canvas-store';
4
6
  function logRequestError(action: string, error: unknown): void {
5
7
  console.error(`[intent-bridge] ${action} failed`, error);
6
8
  }
@@ -261,14 +263,15 @@ export async function pushCanvasUpdate(
261
263
  });
262
264
  }
263
265
 
264
- /** Create a canvas edge via the server. */
266
+ /** Render the server-confirmed edge immediately, without waiting for SSE/polling. */
265
267
  export async function createEdgeFromClient(
266
268
  from: string,
267
269
  to: string,
268
270
  type: string,
269
271
  label?: string,
270
272
  ): Promise<{ ok: boolean; id?: string }> {
271
- return requestJson(
273
+ const epoch = canvasInteractionEpoch.value;
274
+ const result = await requestJson<({ ok: true } & CanvasEdge) | { ok: false; error?: string }>(
272
275
  'createEdgeFromClient',
273
276
  '/api/canvas/edge',
274
277
  { ok: false },
@@ -278,6 +281,15 @@ export async function createEdgeFromClient(
278
281
  body: JSON.stringify({ from, to, type, label }),
279
282
  },
280
283
  );
284
+ if (epoch === canvasInteractionEpoch.value && nodes.value.has(from) && nodes.value.has(to)) {
285
+ if (result.ok) {
286
+ const { ok, ...edge } = result;
287
+ addEdge(edge);
288
+ } else if (!result.error) {
289
+ showToast('remove', 'Connection not added', 'Could not reach the server. Try again.');
290
+ }
291
+ }
292
+ return result;
281
293
  }
282
294
 
283
295
  /** Create a canvas node via the server. Returns the new node ID. */