pmx-canvas 0.3.3 → 0.4.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.
- package/CHANGELOG.md +99 -0
- package/Readme.md +13 -4
- package/dist/canvas/index.js +71 -71
- package/dist/json-render/index.js +47 -47
- package/dist/types/cli/agent.d.ts +13 -8
- package/dist/types/cli/commands/apps.d.ts +1 -0
- package/dist/types/cli/commands/ax.d.ts +1 -0
- package/dist/types/cli/commands/copilot.d.ts +1 -0
- package/dist/types/cli/commands/edges.d.ts +1 -0
- package/dist/types/cli/commands/groups.d.ts +1 -0
- package/dist/types/cli/commands/history.d.ts +1 -0
- package/dist/types/cli/commands/nodes.d.ts +1 -0
- package/dist/types/cli/commands/pins.d.ts +1 -0
- package/dist/types/cli/commands/query.d.ts +1 -0
- package/dist/types/cli/commands/view.d.ts +1 -0
- package/dist/types/cli/commands/webview.d.ts +1 -0
- package/dist/types/cli/shared.d.ts +73 -0
- package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -1
- package/dist/types/client/index.d.ts +1 -1
- package/dist/types/client/nodes/ExtAppFrame.d.ts +10 -0
- package/dist/types/client/nodes/HtmlNode.d.ts +1 -1
- package/dist/types/client/nodes/McpAppNode.d.ts +1 -1
- package/dist/types/client/nodes/use-ax-surface-bridge.d.ts +20 -0
- package/dist/types/client/state/node-factory.d.ts +29 -0
- package/dist/types/client/uuid-polyfill.d.ts +0 -0
- package/dist/types/server/canvas-db.d.ts +0 -1
- package/dist/types/server/canvas-operations.d.ts +2 -0
- package/dist/types/server/canvas-state.d.ts +14 -15
- package/dist/types/server/diagram-presets.d.ts +8 -0
- package/dist/types/server/operations/composites.d.ts +6 -30
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/shared/ax-surface-protocol.d.ts +18 -0
- package/docs/bun-webview-integration.md +5 -0
- package/docs/cli.md +5 -0
- package/docs/environment.md +48 -0
- package/docs/http-api.md +25 -1
- package/docs/mcp.md +21 -30
- package/docs/node-types.md +5 -0
- package/docs/plans/plan-009-tech-debt-backlog.md +34 -15
- package/docs/screenshot.png +0 -0
- package/docs/tech-debt-assessment-2026-06.md +5 -5
- package/package.json +13 -10
- package/skills/pmx-canvas/SKILL.md +16 -6
- package/skills/pmx-canvas/evals/evals.json +7 -7
- package/skills/pmx-canvas/references/full-reference.md +25 -16
- package/src/cli/agent.ts +22 -3512
- package/src/cli/commands/apps.ts +165 -0
- package/src/cli/commands/ax.ts +604 -0
- package/src/cli/commands/copilot.ts +43 -0
- package/src/cli/commands/edges.ts +73 -0
- package/src/cli/commands/groups.ts +81 -0
- package/src/cli/commands/history.ts +220 -0
- package/src/cli/commands/nodes.ts +984 -0
- package/src/cli/commands/pins.ts +42 -0
- package/src/cli/commands/query.ts +223 -0
- package/src/cli/commands/view.ts +186 -0
- package/src/cli/commands/webview.ts +227 -0
- package/src/cli/index.ts +8 -2
- package/src/cli/shared.ts +861 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -2
- package/src/client/index.tsx +2 -0
- package/src/client/nodes/ExtAppFrame.tsx +70 -51
- package/src/client/nodes/HtmlNode.tsx +8 -51
- package/src/client/nodes/McpAppNode.tsx +12 -47
- package/src/client/nodes/use-ax-surface-bridge.ts +71 -0
- package/src/client/state/node-factory.ts +72 -0
- package/src/client/state/sse-bridge.ts +209 -86
- package/src/client/uuid-polyfill.ts +14 -0
- package/src/json-render/renderer/index.tsx +3 -2
- package/src/mcp/server.ts +0 -47
- package/src/server/canvas-db.ts +0 -8
- package/src/server/canvas-operations.ts +8 -0
- package/src/server/canvas-schema.ts +2 -8
- package/src/server/canvas-state.ts +51 -151
- package/src/server/diagram-presets.ts +15 -1
- package/src/server/html-surface.ts +11 -6
- package/src/server/index.ts +3 -9
- package/src/server/operations/composites.ts +25 -34
- package/src/server/operations/invoker.ts +10 -3
- package/src/server/operations/mcp.ts +51 -1
- package/src/server/operations/ops/app.ts +5 -0
- package/src/server/operations/ops/snapshots.ts +6 -7
- package/src/server/operations/registry.ts +4 -1
- package/src/server/server.ts +172 -74
- package/src/shared/ax-surface-protocol.ts +22 -0
- package/dist/types/client/canvas/ContextPinHud.d.ts +0 -1
- package/src/client/canvas/ContextPinHud.tsx +0 -17
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
|
|
1
2
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
|
2
3
|
import { ContextNode } from '../nodes/ContextNode';
|
|
3
4
|
import { FileNode } from '../nodes/FileNode';
|
|
@@ -86,7 +87,7 @@ function isPresentationExitMessage(value: unknown, token: string): boolean {
|
|
|
86
87
|
return (
|
|
87
88
|
value !== null &&
|
|
88
89
|
typeof value === 'object' &&
|
|
89
|
-
(value as { source?: unknown }).source ===
|
|
90
|
+
(value as { source?: unknown }).source === HTML_SURFACE_PUSH_SOURCE &&
|
|
90
91
|
(value as { type?: unknown }).type === 'presentation-exit' &&
|
|
91
92
|
(value as { token?: unknown }).token === token
|
|
92
93
|
);
|
|
@@ -134,7 +135,7 @@ export function ExpandedNodeOverlay() {
|
|
|
134
135
|
);
|
|
135
136
|
frame?.contentWindow?.postMessage(
|
|
136
137
|
{
|
|
137
|
-
source:
|
|
138
|
+
source: HTML_SURFACE_PUSH_SOURCE,
|
|
138
139
|
token: presentationExitToken,
|
|
139
140
|
...message,
|
|
140
141
|
},
|
package/src/client/index.tsx
CHANGED
|
@@ -2,10 +2,13 @@ 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 {
|
|
6
|
+
AX_SURFACE_ACK_SOURCE,
|
|
7
|
+
AX_SURFACE_EMIT_SOURCE,
|
|
8
|
+
EXT_APP_BOOT_BEACON_SOURCE,
|
|
9
|
+
} from '../../shared/ax-surface-protocol.js';
|
|
5
10
|
import { extAppToolResultsMatch } from '../../shared/ext-app-tool-result.js';
|
|
6
11
|
import { DEFAULT_EXT_APP_SANDBOX } from '../../shared/surface.js';
|
|
7
|
-
import { submitAxInteractionFromClient } from '../state/intent-bridge';
|
|
8
|
-
import { showToast } from '../state/attention-bridge';
|
|
9
12
|
import {
|
|
10
13
|
canvasTheme,
|
|
11
14
|
collapseExpandedNode,
|
|
@@ -18,6 +21,7 @@ import {
|
|
|
18
21
|
import type { CanvasNodeState } from '../types';
|
|
19
22
|
import { AUTO_FIT_TITLEBAR_HEIGHT } from '../canvas/auto-fit';
|
|
20
23
|
import { useIframeDocument } from './iframe-document-url';
|
|
24
|
+
import { useAxSurfaceBridge } from './use-ax-surface-bridge';
|
|
21
25
|
|
|
22
26
|
type McpUiTheme = 'light' | 'dark';
|
|
23
27
|
|
|
@@ -205,7 +209,7 @@ export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): stri
|
|
|
205
209
|
}, 10000);
|
|
206
210
|
pending.set(correlationId, function (result) { clearTimeout(timer); resolve(result); });
|
|
207
211
|
window.parent.postMessage({
|
|
208
|
-
source: '
|
|
212
|
+
source: '${AX_SURFACE_EMIT_SOURCE}',
|
|
209
213
|
token: PMX_AX_TOKEN,
|
|
210
214
|
nodeId: PMX_AX_NODE_ID,
|
|
211
215
|
correlationId: correlationId,
|
|
@@ -218,7 +222,7 @@ export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): stri
|
|
|
218
222
|
};
|
|
219
223
|
window.addEventListener('message', function (event) {
|
|
220
224
|
const m = event.data;
|
|
221
|
-
if (!m || m.source !== '
|
|
225
|
+
if (!m || m.source !== '${AX_SURFACE_ACK_SOURCE}' || m.token !== PMX_AX_TOKEN) return;
|
|
222
226
|
const result = m.result || { ok: false };
|
|
223
227
|
const resolver = m.correlationId ? pending.get(m.correlationId) : undefined;
|
|
224
228
|
if (resolver) { pending.delete(m.correlationId); resolver(result); }
|
|
@@ -245,10 +249,23 @@ export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): stri
|
|
|
245
249
|
*/
|
|
246
250
|
export function buildExtAppBootBeaconScript(frameToken: string, nodeId: string): string {
|
|
247
251
|
return `<script data-pmx-canvas-boot-beacon>
|
|
248
|
-
window.parent.postMessage({ source: '
|
|
252
|
+
window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: ${JSON.stringify(frameToken)}, nodeId: ${JSON.stringify(nodeId)} }, '*');
|
|
249
253
|
</script>`;
|
|
250
254
|
}
|
|
251
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Base styles injected into every ext-app document (Finding Q, 0.3.4 report):
|
|
258
|
+
* apps aspect-fit their drawn surface to the frame width, and on narrow/tall
|
|
259
|
+
* tiles the leftover region shows the app document's own body background —
|
|
260
|
+
* pure black for the hosted Excalidraw bundle, in every engine. Making the
|
|
261
|
+
* app document transparent lets the host iframe's theme background
|
|
262
|
+
* (var(--c-panel)) show through the letterbox instead. Background only — app
|
|
263
|
+
* content, its drawn scene, and its own foreground styling are untouched.
|
|
264
|
+
*/
|
|
265
|
+
export function buildExtAppSurfaceBaseStyles(): string {
|
|
266
|
+
return '<style data-pmx-canvas-ext-app-base>html, body { background: transparent !important; }</style>';
|
|
267
|
+
}
|
|
268
|
+
|
|
252
269
|
export function injectExtAppAxBridgeScript(html: string, axBridgeScript: string): string {
|
|
253
270
|
if (!axBridgeScript) return html;
|
|
254
271
|
const headMatch = /<head\b[^>]*>/i.exec(html);
|
|
@@ -355,7 +372,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
355
372
|
const bootBeaconScript =
|
|
356
373
|
typeof html === 'string' && html.length > 0 ? buildExtAppBootBeaconScript(axToken, nodeId) : '';
|
|
357
374
|
const iframeDocument = useIframeDocument(
|
|
358
|
-
injectExtAppAxBridgeScript(html ?? '', bootBeaconScript + axBridgeScript),
|
|
375
|
+
injectExtAppAxBridgeScript(html ?? '', buildExtAppSurfaceBaseStyles() + bootBeaconScript + axBridgeScript),
|
|
359
376
|
iframeSandbox,
|
|
360
377
|
);
|
|
361
378
|
|
|
@@ -363,7 +380,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
363
380
|
function onBootBeacon(event: MessageEvent) {
|
|
364
381
|
if (event.source !== iframeRef.current?.contentWindow) return;
|
|
365
382
|
const data = event.data as { source?: string; token?: string; nodeId?: string } | null;
|
|
366
|
-
if (!data || data.source !==
|
|
383
|
+
if (!data || data.source !== EXT_APP_BOOT_BEACON_SOURCE || data.token !== axToken || data.nodeId !== nodeId)
|
|
367
384
|
return;
|
|
368
385
|
appScriptsRanRef.current = true;
|
|
369
386
|
}
|
|
@@ -371,46 +388,8 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
371
388
|
return () => window.removeEventListener('message', onBootBeacon);
|
|
372
389
|
}, [axToken, nodeId]);
|
|
373
390
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
function onAxMessage(event: MessageEvent) {
|
|
377
|
-
if (event.source !== iframeRef.current?.contentWindow) return;
|
|
378
|
-
const data = event.data as {
|
|
379
|
-
source?: string;
|
|
380
|
-
token?: string;
|
|
381
|
-
nodeId?: string;
|
|
382
|
-
correlationId?: string;
|
|
383
|
-
interaction?: { type?: unknown; payload?: unknown };
|
|
384
|
-
} | null;
|
|
385
|
-
if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== nodeId) return;
|
|
386
|
-
const interaction = data.interaction;
|
|
387
|
-
if (!interaction || typeof interaction.type !== 'string') return;
|
|
388
|
-
const interactionType = interaction.type;
|
|
389
|
-
void submitAxInteractionFromClient({
|
|
390
|
-
type: interactionType,
|
|
391
|
-
sourceNodeId: nodeId,
|
|
392
|
-
sourceSurface: 'mcp-app',
|
|
393
|
-
...(interaction.payload && typeof interaction.payload === 'object'
|
|
394
|
-
? { payload: interaction.payload as Record<string, unknown> }
|
|
395
|
-
: {}),
|
|
396
|
-
}).then((res) => {
|
|
397
|
-
if (res.ok) showToast('context', 'AX interaction', interactionType, [nodeId]);
|
|
398
|
-
else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [nodeId]);
|
|
399
|
-
iframeRef.current?.contentWindow?.postMessage(
|
|
400
|
-
{
|
|
401
|
-
source: 'pmx-canvas-ax-ack',
|
|
402
|
-
token: axToken,
|
|
403
|
-
...(data.correlationId ? { correlationId: data.correlationId } : {}),
|
|
404
|
-
interaction: { type: interactionType },
|
|
405
|
-
result: res,
|
|
406
|
-
},
|
|
407
|
-
'*',
|
|
408
|
-
);
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
window.addEventListener('message', onAxMessage);
|
|
412
|
-
return () => window.removeEventListener('message', onAxMessage);
|
|
413
|
-
}, [axEnabled, axToken, nodeId]);
|
|
391
|
+
// Ext-app AX emits — the shared sandboxed-surface trust boundary (M2).
|
|
392
|
+
useAxSurfaceBridge({ enabled: axEnabled, token: axToken, nodeId, sourceSurface: 'mcp-app', iframeRef });
|
|
414
393
|
|
|
415
394
|
// Enqueue one serialized remount attempt for this node (Finding F recovery).
|
|
416
395
|
// Attempts are capped so a persistently-failing app degrades to the manual
|
|
@@ -439,6 +418,11 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
439
418
|
// every scheduling path (post-boot repaint, boot watchdog, re-arm).
|
|
440
419
|
bootedWhileHiddenRef.current = typeof document !== 'undefined' && document.visibilityState === 'hidden';
|
|
441
420
|
extAppRecoveryLog(nodeId, `remount-run #${webkitRemountAttemptsRef.current}`);
|
|
421
|
+
// Back to 'loading' so the connecting overlay covers this recovery
|
|
422
|
+
// remount's boot window too (the fallback boot flipped status to
|
|
423
|
+
// ready/done even when the previous frame was dead — without this the
|
|
424
|
+
// overlay only ever covered the INITIAL boot, not recovery).
|
|
425
|
+
setStatus('loading');
|
|
442
426
|
setRetryKey((k) => k + 1);
|
|
443
427
|
return true;
|
|
444
428
|
},
|
|
@@ -717,7 +701,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
717
701
|
|
|
718
702
|
bridge.onsandboxready = async () => {
|
|
719
703
|
await bridge.sendSandboxResourceReady({
|
|
720
|
-
|
|
704
|
+
// Same Finding Q base styles for the sandbox-proxy path: the inner
|
|
705
|
+
// resource document is the one that letterboxes on narrow tiles.
|
|
706
|
+
html: injectExtAppAxBridgeScript(html, buildExtAppSurfaceBaseStyles()),
|
|
721
707
|
sandbox: DEFAULT_EXT_APP_SANDBOX,
|
|
722
708
|
...(resourceMeta?.csp ? { csp: resourceMeta.csp } : {}),
|
|
723
709
|
...(resourceMeta?.permissions ? { permissions: resourceMeta.permissions } : {}),
|
|
@@ -1077,9 +1063,6 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
1077
1063
|
</button>
|
|
1078
1064
|
</div>
|
|
1079
1065
|
)}
|
|
1080
|
-
{status === 'loading' && (
|
|
1081
|
-
<div style={{ padding: '8px', fontSize: '11px', color: 'var(--c-muted)' }}>Connecting to ext-app viewer...</div>
|
|
1082
|
-
)}
|
|
1083
1066
|
{/* Iframe stack: the widget renders a preview; when not expanded, a
|
|
1084
1067
|
transparent click-catcher sits on top so the first click always
|
|
1085
1068
|
expands the node. Without this, widgets like Excalidraw show their
|
|
@@ -1109,6 +1092,42 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
1109
1092
|
}}
|
|
1110
1093
|
title={`Ext App: ${toolName}`}
|
|
1111
1094
|
/>
|
|
1095
|
+
{/* Connecting overlay: an opaque sibling ABOVE the iframe while this
|
|
1096
|
+
frame boots. During WebKit recovery remounts the iframe's own layer
|
|
1097
|
+
can composite black (Finding N), but sibling DOM always paints — so
|
|
1098
|
+
the multi-second recovery window reads as an intentional loading
|
|
1099
|
+
state instead of a broken black tile. pointer-events pass through
|
|
1100
|
+
so the expand click-catcher keeps working. */}
|
|
1101
|
+
{status === 'loading' && !error && (
|
|
1102
|
+
<div
|
|
1103
|
+
style={{
|
|
1104
|
+
position: 'absolute',
|
|
1105
|
+
inset: 0,
|
|
1106
|
+
display: 'flex',
|
|
1107
|
+
flexDirection: 'column',
|
|
1108
|
+
alignItems: 'center',
|
|
1109
|
+
justifyContent: 'center',
|
|
1110
|
+
gap: '10px',
|
|
1111
|
+
background: 'var(--c-panel)',
|
|
1112
|
+
color: 'var(--c-muted)',
|
|
1113
|
+
fontSize: '12px',
|
|
1114
|
+
pointerEvents: 'none',
|
|
1115
|
+
}}
|
|
1116
|
+
>
|
|
1117
|
+
<div
|
|
1118
|
+
style={{
|
|
1119
|
+
width: '22px',
|
|
1120
|
+
height: '22px',
|
|
1121
|
+
border: '2px solid var(--c-line)',
|
|
1122
|
+
borderTopColor: 'var(--c-muted)',
|
|
1123
|
+
borderRadius: '50%',
|
|
1124
|
+
animation: 'spin 1s linear infinite',
|
|
1125
|
+
}}
|
|
1126
|
+
/>
|
|
1127
|
+
<div>Connecting to {toolName}...</div>
|
|
1128
|
+
<style>{'@keyframes spin { to { transform: rotate(360deg); } }'}</style>
|
|
1129
|
+
</div>
|
|
1130
|
+
)}
|
|
1112
1131
|
{!isExpanded && (
|
|
1113
1132
|
<button
|
|
1114
1133
|
type="button"
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef } from 'preact/hooks';
|
|
2
|
+
import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
|
|
2
3
|
import { axSurfaceState, canvasTheme } from '../state/canvas-store';
|
|
3
|
-
import { submitAxInteractionFromClient } from '../state/intent-bridge';
|
|
4
|
-
import { showToast } from '../state/attention-bridge';
|
|
5
4
|
import type { CanvasNodeState } from '../types';
|
|
6
5
|
import { nodeSurfaceUrl, surfaceContentHash } from './surface-url';
|
|
6
|
+
import { useAxSurfaceBridge } from './use-ax-surface-bridge';
|
|
7
7
|
import { useIframeContentHeight } from './use-iframe-content-height';
|
|
8
8
|
|
|
9
9
|
export function shouldShowPresentationControls(node: CanvasNodeState): boolean {
|
|
@@ -64,56 +64,13 @@ export function HtmlNode({
|
|
|
64
64
|
// Never in the expanded overlay — there the surface fills the large overlay frame.
|
|
65
65
|
useIframeContentHeight(node, iframeRef, expanded ? '' : frameToken);
|
|
66
66
|
|
|
67
|
-
// Phase 3 HTML bridge
|
|
68
|
-
|
|
69
|
-
// through the capability-gated endpoint (the server re-validates capabilities).
|
|
70
|
-
useEffect(() => {
|
|
71
|
-
function onAxMessage(event: MessageEvent) {
|
|
72
|
-
// Bind to THIS node's own iframe (matches the ext-app bridge); the nonce +
|
|
73
|
-
// nodeId are a second gate, not the only one.
|
|
74
|
-
if (event.source !== iframeRef.current?.contentWindow) return;
|
|
75
|
-
const data = event.data as {
|
|
76
|
-
source?: string;
|
|
77
|
-
token?: string;
|
|
78
|
-
nodeId?: string;
|
|
79
|
-
correlationId?: string;
|
|
80
|
-
interaction?: { type?: unknown; payload?: unknown };
|
|
81
|
-
} | null;
|
|
82
|
-
if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== node.id) return;
|
|
83
|
-
const interaction = data.interaction;
|
|
84
|
-
if (!interaction || typeof interaction.type !== 'string') return;
|
|
85
|
-
const interactionType = interaction.type;
|
|
86
|
-
void submitAxInteractionFromClient({
|
|
87
|
-
type: interactionType,
|
|
88
|
-
sourceNodeId: node.id,
|
|
89
|
-
sourceSurface: 'html-node',
|
|
90
|
-
...(interaction.payload && typeof interaction.payload === 'object'
|
|
91
|
-
? { payload: interaction.payload as Record<string, unknown> }
|
|
92
|
-
: {}),
|
|
93
|
-
}).then((res) => {
|
|
94
|
-
if (res.ok) showToast('context', 'AX interaction', interactionType, [node.id]);
|
|
95
|
-
else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [node.id]);
|
|
96
|
-
// Report #55: ack back to the surface so it can self-confirm (e.g. "queued ✓").
|
|
97
|
-
iframeRef.current?.contentWindow?.postMessage(
|
|
98
|
-
{
|
|
99
|
-
source: 'pmx-canvas-ax-ack',
|
|
100
|
-
token: axToken,
|
|
101
|
-
...(data.correlationId ? { correlationId: data.correlationId } : {}),
|
|
102
|
-
interaction: { type: interactionType },
|
|
103
|
-
result: res,
|
|
104
|
-
},
|
|
105
|
-
'*',
|
|
106
|
-
);
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
window.addEventListener('message', onAxMessage);
|
|
110
|
-
return () => window.removeEventListener('message', onAxMessage);
|
|
111
|
-
}, [axToken, node.id]);
|
|
67
|
+
// Phase 3 HTML bridge — the shared sandboxed-surface trust boundary (M2).
|
|
68
|
+
useAxSurfaceBridge({ enabled: true, token: axToken, nodeId: node.id, sourceSurface: 'html-node', iframeRef });
|
|
112
69
|
|
|
113
70
|
useEffect(() => {
|
|
114
71
|
iframeRef.current?.contentWindow?.postMessage(
|
|
115
72
|
{
|
|
116
|
-
source:
|
|
73
|
+
source: HTML_SURFACE_PUSH_SOURCE,
|
|
117
74
|
type: 'theme-update',
|
|
118
75
|
token: themeToken,
|
|
119
76
|
theme,
|
|
@@ -134,7 +91,7 @@ export function HtmlNode({
|
|
|
134
91
|
if (!axEnabled || axStateValue == null) return;
|
|
135
92
|
iframeRef.current?.contentWindow?.postMessage(
|
|
136
93
|
{
|
|
137
|
-
source:
|
|
94
|
+
source: HTML_SURFACE_PUSH_SOURCE,
|
|
138
95
|
type: 'ax-update',
|
|
139
96
|
token: axToken,
|
|
140
97
|
state: axStateValue,
|
|
@@ -152,7 +109,7 @@ export function HtmlNode({
|
|
|
152
109
|
const handleFrameLoad = () => {
|
|
153
110
|
iframeRef.current?.contentWindow?.postMessage(
|
|
154
111
|
{
|
|
155
|
-
source:
|
|
112
|
+
source: HTML_SURFACE_PUSH_SOURCE,
|
|
156
113
|
type: 'theme-update',
|
|
157
114
|
token: themeToken,
|
|
158
115
|
theme,
|
|
@@ -162,7 +119,7 @@ export function HtmlNode({
|
|
|
162
119
|
if (axEnabled && axSurfaceState.value != null) {
|
|
163
120
|
iframeRef.current?.contentWindow?.postMessage(
|
|
164
121
|
{
|
|
165
|
-
source:
|
|
122
|
+
source: HTML_SURFACE_PUSH_SOURCE,
|
|
166
123
|
type: 'ax-update',
|
|
167
124
|
token: axToken,
|
|
168
125
|
state: axSurfaceState.value,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef } from 'preact/hooks';
|
|
2
|
+
import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
|
|
2
3
|
import type { CanvasNodeState } from '../types';
|
|
3
4
|
import { axSurfaceState, canvasTheme } from '../state/canvas-store';
|
|
4
5
|
import { shouldContentFitIframeNode } from '../canvas/auto-fit';
|
|
5
|
-
import { submitAxInteractionFromClient } from '../state/intent-bridge';
|
|
6
|
-
import { showToast } from '../state/attention-bridge';
|
|
7
6
|
import { ExtAppFrame } from './ExtAppFrame';
|
|
7
|
+
import { useAxSurfaceBridge } from './use-ax-surface-bridge';
|
|
8
8
|
import { useIframeContentHeight } from './use-iframe-content-height';
|
|
9
9
|
|
|
10
10
|
function withViewerParams(
|
|
@@ -80,50 +80,15 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
|
|
|
80
80
|
const frameToken = useMemo(() => (contentFit ? `frame-${crypto.randomUUID()}` : ''), [contentFit]);
|
|
81
81
|
useIframeContentHeight(node, iframeRef, frameToken);
|
|
82
82
|
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
token?: string;
|
|
93
|
-
nodeId?: string;
|
|
94
|
-
correlationId?: string;
|
|
95
|
-
interaction?: { type?: unknown; payload?: unknown };
|
|
96
|
-
} | null;
|
|
97
|
-
if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== node.id) return;
|
|
98
|
-
const interaction = data.interaction;
|
|
99
|
-
if (!interaction || typeof interaction.type !== 'string') return;
|
|
100
|
-
const interactionType = interaction.type;
|
|
101
|
-
void submitAxInteractionFromClient({
|
|
102
|
-
type: interactionType,
|
|
103
|
-
sourceNodeId: node.id,
|
|
104
|
-
sourceSurface: axSurface,
|
|
105
|
-
...(interaction.payload && typeof interaction.payload === 'object'
|
|
106
|
-
? { payload: interaction.payload as Record<string, unknown> }
|
|
107
|
-
: {}),
|
|
108
|
-
}).then((res) => {
|
|
109
|
-
if (res.ok) showToast('context', 'AX interaction', interactionType, [node.id]);
|
|
110
|
-
else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [node.id]);
|
|
111
|
-
// Report #55: ack back to the viewer so the surface can self-confirm.
|
|
112
|
-
iframeRef.current?.contentWindow?.postMessage(
|
|
113
|
-
{
|
|
114
|
-
source: 'pmx-canvas-ax-ack',
|
|
115
|
-
token: axToken,
|
|
116
|
-
...(data.correlationId ? { correlationId: data.correlationId } : {}),
|
|
117
|
-
interaction: { type: interactionType },
|
|
118
|
-
result: res,
|
|
119
|
-
},
|
|
120
|
-
'*',
|
|
121
|
-
);
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
window.addEventListener('message', onAxMessage);
|
|
125
|
-
return () => window.removeEventListener('message', onAxMessage);
|
|
126
|
-
}, [isAxViewer, axToken, node.id]);
|
|
83
|
+
// AX emits forwarded by the json-render viewer — the shared sandboxed-surface
|
|
84
|
+
// trust boundary (M2).
|
|
85
|
+
useAxSurfaceBridge({
|
|
86
|
+
enabled: isAxViewer && Boolean(axToken),
|
|
87
|
+
token: axToken,
|
|
88
|
+
nodeId: node.id,
|
|
89
|
+
sourceSurface: axSurface,
|
|
90
|
+
iframeRef,
|
|
91
|
+
});
|
|
127
92
|
|
|
128
93
|
// Read-side: push live AX state into the json-render viewer so a spec bound to
|
|
129
94
|
// /ax reflects the work queue. Validated by the viewer against axToken.
|
|
@@ -132,7 +97,7 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
|
|
|
132
97
|
if (!isAxViewer || !axToken || axStateValue == null) return;
|
|
133
98
|
iframeRef.current?.contentWindow?.postMessage(
|
|
134
99
|
{
|
|
135
|
-
source:
|
|
100
|
+
source: HTML_SURFACE_PUSH_SOURCE,
|
|
136
101
|
type: 'ax-update',
|
|
137
102
|
token: axToken,
|
|
138
103
|
state: axStateValue,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { RefObject } from 'preact';
|
|
2
|
+
import { useEffect } from 'preact/hooks';
|
|
3
|
+
import { AX_SURFACE_ACK_SOURCE, AX_SURFACE_EMIT_SOURCE } from '../../shared/ax-surface-protocol.js';
|
|
4
|
+
import { showToast } from '../state/attention-bridge';
|
|
5
|
+
import { submitAxInteractionFromClient } from '../state/intent-bridge';
|
|
6
|
+
|
|
7
|
+
/** Sandboxed surfaces that emit through this bridge (clamped to their own node server-side). */
|
|
8
|
+
export type AxSurfaceBridgeSurface = 'json-render' | 'html-node' | 'mcp-app';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The sandboxed-surface AX trust boundary, single-sited (plan-009 M2; the
|
|
12
|
+
* listener was copy-pasted across HtmlNode, McpAppNode, and ExtAppFrame — a
|
|
13
|
+
* prior near-miss lived exactly in that duplication). Receives a
|
|
14
|
+
* `window.PMX_AX.emit(...)` message from THIS node's iframe, validates the
|
|
15
|
+
* event source against the live contentWindow plus the per-mount nonce token
|
|
16
|
+
* and node id, submits the interaction through the capability-gated endpoint
|
|
17
|
+
* (the server re-validates capabilities and clamps sandboxed surfaces to their
|
|
18
|
+
* own node), and acks the result back so the surface can self-confirm (#55).
|
|
19
|
+
*/
|
|
20
|
+
export function useAxSurfaceBridge(options: {
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
token: string;
|
|
23
|
+
nodeId: string;
|
|
24
|
+
sourceSurface: AxSurfaceBridgeSurface;
|
|
25
|
+
iframeRef: RefObject<HTMLIFrameElement>;
|
|
26
|
+
}): void {
|
|
27
|
+
const { enabled, token, nodeId, sourceSurface, iframeRef } = options;
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!enabled || !token) return;
|
|
30
|
+
function onAxMessage(event: MessageEvent) {
|
|
31
|
+
// Bind to THIS node's own iframe; the nonce + nodeId are a second gate,
|
|
32
|
+
// not the only one.
|
|
33
|
+
if (event.source !== iframeRef.current?.contentWindow) return;
|
|
34
|
+
const data = event.data as {
|
|
35
|
+
source?: string;
|
|
36
|
+
token?: string;
|
|
37
|
+
nodeId?: string;
|
|
38
|
+
correlationId?: string;
|
|
39
|
+
interaction?: { type?: unknown; payload?: unknown };
|
|
40
|
+
} | null;
|
|
41
|
+
if (!data || data.source !== AX_SURFACE_EMIT_SOURCE || data.token !== token || data.nodeId !== nodeId) return;
|
|
42
|
+
const interaction = data.interaction;
|
|
43
|
+
if (!interaction || typeof interaction.type !== 'string') return;
|
|
44
|
+
const interactionType = interaction.type;
|
|
45
|
+
void submitAxInteractionFromClient({
|
|
46
|
+
type: interactionType,
|
|
47
|
+
sourceNodeId: nodeId,
|
|
48
|
+
sourceSurface,
|
|
49
|
+
...(interaction.payload && typeof interaction.payload === 'object'
|
|
50
|
+
? { payload: interaction.payload as Record<string, unknown> }
|
|
51
|
+
: {}),
|
|
52
|
+
}).then((res) => {
|
|
53
|
+
if (res.ok) showToast('context', 'AX interaction', interactionType, [nodeId]);
|
|
54
|
+
else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [nodeId]);
|
|
55
|
+
iframeRef.current?.contentWindow?.postMessage(
|
|
56
|
+
{
|
|
57
|
+
source: AX_SURFACE_ACK_SOURCE,
|
|
58
|
+
token,
|
|
59
|
+
...(data.correlationId ? { correlationId: data.correlationId } : {}),
|
|
60
|
+
interaction: { type: interactionType },
|
|
61
|
+
result: res,
|
|
62
|
+
},
|
|
63
|
+
'*',
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
window.addEventListener('message', onAxMessage);
|
|
68
|
+
return () => window.removeEventListener('message', onAxMessage);
|
|
69
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
70
|
+
}, [enabled, token, nodeId, sourceSurface]);
|
|
71
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { CanvasNodeState } from '../types';
|
|
2
|
+
|
|
3
|
+
// ── Client node-creation defaults (plan-009 H5, client half) ──
|
|
4
|
+
// Single source of truth for the frame (position/size/zIndex/flags) a NEW
|
|
5
|
+
// client-created node gets when its creating event carries no explicit
|
|
6
|
+
// geometry. Extracted from sse-bridge, which kept one `makeNode` helper plus
|
|
7
|
+
// three inline node literals (ext-app open, prompt, response) duplicating
|
|
8
|
+
// these defaults.
|
|
9
|
+
//
|
|
10
|
+
// Divergences found during extraction — documented, NOT silently unified:
|
|
11
|
+
// - `canvas-store.ts` holds no node-creation defaults today. Human-created
|
|
12
|
+
// nodes go through `createNodeFromClient` (intent-bridge → POST
|
|
13
|
+
// /api/canvas/node) and get the SERVER's `defaultNodeSize`
|
|
14
|
+
// (src/server/operations/ops/nodes.ts), which differs from this table for
|
|
15
|
+
// most types (e.g. its fallback is 360x200). The server half of H5
|
|
16
|
+
// (`syncEventToCanvasState`, `ensureDefaultDockedNodes`) is deliberately
|
|
17
|
+
// deferred — do not "fix" that mismatch from this file.
|
|
18
|
+
// - The server seeds `status-main`/`context-main` docked AND collapsed
|
|
19
|
+
// (`ensureDefaultDockedNodes` in src/server/server.ts); the SSE bridge
|
|
20
|
+
// creates the same ids expanded, with `context-main` undocked. Client
|
|
21
|
+
// behavior is preserved as-is.
|
|
22
|
+
// - sse-bridge's prompt literal hardcoded `height: 400` beside a table entry
|
|
23
|
+
// of the same value; both now read the table (no rendering change).
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_POSITIONS: Record<CanvasNodeState['type'], { x: number; y: number; w: number; h: number }> &
|
|
26
|
+
Record<'prompt' | 'response', { x: number; y: number; w: number; h: number }> = {
|
|
27
|
+
status: { x: 40, y: 80, w: 300, h: 120 },
|
|
28
|
+
markdown: { x: 380, y: 80, w: 720, h: 600 },
|
|
29
|
+
context: { x: 1130, y: 80, w: 320, h: 400 },
|
|
30
|
+
'mcp-app': { x: 380, y: 720, w: 960, h: 600 },
|
|
31
|
+
webpage: { x: 380, y: 80, w: 520, h: 420 },
|
|
32
|
+
'json-render': { x: 380, y: 720, w: 840, h: 620 },
|
|
33
|
+
graph: { x: 380, y: 720, w: 760, h: 520 },
|
|
34
|
+
ledger: { x: 1130, y: 520, w: 320, h: 280 },
|
|
35
|
+
trace: { x: 40, y: 900, w: 200, h: 56 },
|
|
36
|
+
file: { x: 380, y: 80, w: 720, h: 600 },
|
|
37
|
+
image: { x: 380, y: 80, w: 720, h: 520 },
|
|
38
|
+
html: { x: 380, y: 80, w: 720, h: 640 },
|
|
39
|
+
group: { x: 220, y: 60, w: 840, h: 560 },
|
|
40
|
+
prompt: { x: 380, y: 1260, w: 520, h: 400 },
|
|
41
|
+
response: { x: 380, y: 1480, w: 720, h: 400 },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build a canvas node with the shared client defaults. `position`/`size`
|
|
46
|
+
* default to the type's DEFAULT_POSITIONS entry; callers that compute
|
|
47
|
+
* placement (auto-placement, event-supplied geometry) pass explicit values.
|
|
48
|
+
* Status nodes sit at zIndex 0 (background chrome), everything else at 1.
|
|
49
|
+
*/
|
|
50
|
+
export function makeNodeState(
|
|
51
|
+
id: string,
|
|
52
|
+
type: CanvasNodeState['type'],
|
|
53
|
+
data: Record<string, unknown>,
|
|
54
|
+
options: {
|
|
55
|
+
position?: { x: number; y: number };
|
|
56
|
+
size?: { width: number; height: number };
|
|
57
|
+
dockPosition?: 'left' | 'right' | null;
|
|
58
|
+
} = {},
|
|
59
|
+
): CanvasNodeState {
|
|
60
|
+
const defaults = DEFAULT_POSITIONS[type];
|
|
61
|
+
return {
|
|
62
|
+
id,
|
|
63
|
+
type,
|
|
64
|
+
position: options.position ?? { x: defaults.x, y: defaults.y },
|
|
65
|
+
size: options.size ?? { width: defaults.w, height: defaults.h },
|
|
66
|
+
zIndex: type === 'status' ? 0 : 1,
|
|
67
|
+
collapsed: false,
|
|
68
|
+
pinned: false,
|
|
69
|
+
dockPosition: options.dockPosition ?? null,
|
|
70
|
+
data,
|
|
71
|
+
};
|
|
72
|
+
}
|