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.
- package/.github/extensions/pmx-canvas/extension.mjs +79 -61
- package/CHANGELOG.md +29 -1
- package/Readme.md +48 -9
- package/dist/canvas/global.css +144 -45
- package/dist/canvas/index.js +90 -90
- package/dist/json-render/index.js +55 -55
- package/dist/types/client/canvas/ChoiceMenu.d.ts +13 -0
- package/dist/types/client/canvas/ExternalWriters.d.ts +3 -3
- package/dist/types/client/icons.d.ts +15 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +1 -1
- package/dist/types/client/nodes/FileNode.d.ts +0 -12
- package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
- package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -0
- package/dist/types/client/state/canvas-store.d.ts +2 -0
- package/dist/types/client/state/intent-bridge.d.ts +1 -1
- package/dist/types/json-render/renderer/index.d.ts +2 -1
- package/dist/types/json-render/server.d.ts +2 -0
- package/dist/types/shared/ax-surface-protocol.d.ts +2 -0
- package/dist/types/shared/delimited-text.d.ts +14 -0
- package/docs/ax-host-adapter-contract.md +13 -2
- package/docs/cli.md +9 -2
- package/docs/http-api.md +12 -8
- package/docs/mcp.md +8 -1
- package/docs/sdk.md +2 -2
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +41 -16
- package/skills/pmx-canvas/evals/evals.json +112 -2
- package/skills/pmx-canvas/references/full-reference.md +9 -4
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +17 -3
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +76 -4
- package/skills/pmx-canvas-testing/SKILL.md +49 -0
- package/src/cli/commands/backup.ts +15 -4
- package/src/cli/commands/boards.ts +16 -3
- package/src/client/canvas/AttentionHistory.tsx +2 -1
- package/src/client/canvas/BoardSwitcher.tsx +3 -3
- package/src/client/canvas/CanvasNode.tsx +211 -197
- package/src/client/canvas/CanvasViewport.tsx +6 -0
- package/src/client/canvas/ChoiceMenu.tsx +122 -0
- package/src/client/canvas/CommandBar.tsx +27 -25
- package/src/client/canvas/ContextPinBar.tsx +3 -1
- package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -3
- package/src/client/canvas/ExternalWriters.tsx +4 -3
- package/src/client/canvas/FeedbackDialog.tsx +12 -9
- package/src/client/canvas/IntentLayer.tsx +2 -2
- package/src/client/canvas/SelectionBar.tsx +2 -2
- package/src/client/canvas/SessionPanel.tsx +6 -4
- package/src/client/canvas/SessionReceipt.tsx +2 -1
- package/src/client/canvas/SnapshotPanel.tsx +3 -2
- package/src/client/canvas/ToolRail.tsx +48 -100
- package/src/client/canvas/TopBar.tsx +2 -2
- package/src/client/icons.tsx +118 -1
- package/src/client/nodes/BoardNode.tsx +2 -1
- package/src/client/nodes/ExtAppFrame.tsx +3 -1
- package/src/client/nodes/FileNode.tsx +4 -78
- package/src/client/nodes/ImageNode.tsx +2 -1
- package/src/client/nodes/MarkdownNode.tsx +2 -1
- package/src/client/nodes/McpAppNode.tsx +16 -0
- package/src/client/nodes/PromptNode.tsx +3 -1
- package/src/client/nodes/use-ax-surface-bridge.ts +4 -2
- package/src/client/nodes/viewer-runtime-state.ts +78 -0
- package/src/client/state/canvas-store.ts +3 -0
- package/src/client/state/intent-bridge.ts +14 -2
- package/src/client/theme/global.css +144 -45
- package/src/json-render/renderer/index.tsx +58 -38
- package/src/json-render/server.ts +8 -1
- package/src/mcp/server.ts +2 -2
- package/src/server/agent-presence.ts +17 -11
- package/src/server/board-export.ts +97 -32
- package/src/server/index.ts +9 -7
- package/src/server/operations/composites.ts +3 -2
- package/src/server/operations/ops/ax-presence.ts +7 -3
- package/src/server/operations/ops/boards.ts +12 -5
- package/src/server/operations/registry.ts +4 -13
- package/src/server/server.ts +13 -4
- package/src/shared/ax-surface-protocol.ts +3 -0
- package/src/shared/delimited-text.ts +75 -0
package/src/client/icons.tsx
CHANGED
|
@@ -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
|
|
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 && <
|
|
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
|
-
|
|
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
|
-
{
|
|
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 {
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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. */
|