pmx-canvas 0.3.2 → 0.3.4
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 +69 -0
- package/Readme.md +2 -1
- package/dist/canvas/index.js +85 -83
- 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/daemon.d.ts +17 -0
- package/dist/types/cli/shared.d.ts +73 -0
- package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -1
- package/dist/types/client/nodes/ExtAppFrame.d.ts +13 -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/server/canvas-operations.d.ts +2 -0
- package/dist/types/server/diagram-presets.d.ts +8 -0
- package/dist/types/server/operations/composites.d.ts +0 -9
- package/dist/types/shared/ax-surface-protocol.d.ts +18 -0
- package/docs/RELEASE.md +11 -0
- package/docs/bun-webview-integration.md +5 -0
- package/docs/environment.md +48 -0
- package/docs/node-types.md +5 -0
- package/docs/plans/plan-009-tech-debt-backlog.md +27 -9
- package/docs/screenshot.png +0 -0
- package/package.json +8 -5
- package/skills/pmx-canvas/SKILL.md +11 -8
- package/skills/pmx-canvas/evals/evals.json +5 -5
- package/skills/pmx-canvas/references/full-reference.md +8 -1
- 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 +954 -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/daemon.ts +39 -7
- package/src/cli/shared.ts +860 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -2
- package/src/client/nodes/ExtAppFrame.tsx +166 -52
- 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/json-render/renderer/index.tsx +3 -2
- package/src/mcp/server.ts +0 -47
- package/src/server/bundled-skills.ts +5 -1
- package/src/server/canvas-operations.ts +8 -0
- package/src/server/diagram-presets.ts +15 -1
- package/src/server/html-surface.ts +11 -6
- package/src/server/operations/composites.ts +1 -13
- 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/server.ts +61 -3
- 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
|
},
|
|
@@ -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
|
|
|
@@ -92,6 +96,33 @@ export function extAppRecoveryLog(nodeId: string, event: string): void {
|
|
|
92
96
|
const host = window as unknown as { __PMX_EXTAPP_LOG?: Array<{ t: number; nodeId: string; event: string }> };
|
|
93
97
|
host.__PMX_EXTAPP_LOG ??= [];
|
|
94
98
|
if (host.__PMX_EXTAPP_LOG.length < 500) host.__PMX_EXTAPP_LOG.push({ t: Date.now(), nodeId, event });
|
|
99
|
+
queueExtAppRecoveryReport({ t: Date.now(), nodeId, event });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Mirror the recovery trail to the daemon (batched, fire-and-forget) so a host
|
|
103
|
+
// panel without devtools access — the Copilot WKWebView, exactly where the
|
|
104
|
+
// black tiles happen — still yields an actionable trace: agents read it back
|
|
105
|
+
// via GET /api/canvas/debug/ext-app-recovery (0.3.2 report Finding N).
|
|
106
|
+
let extAppReportQueue: Array<{ t: number; nodeId: string; event: string; visibility?: string }> = [];
|
|
107
|
+
let extAppReportTimer: ReturnType<typeof setTimeout> | null = null;
|
|
108
|
+
function queueExtAppRecoveryReport(entry: { t: number; nodeId: string; event: string }): void {
|
|
109
|
+
extAppReportQueue.push({
|
|
110
|
+
...entry,
|
|
111
|
+
...(typeof document !== 'undefined' ? { visibility: document.visibilityState } : {}),
|
|
112
|
+
});
|
|
113
|
+
if (extAppReportTimer) return;
|
|
114
|
+
extAppReportTimer = setTimeout(() => {
|
|
115
|
+
extAppReportTimer = null;
|
|
116
|
+
const entries = extAppReportQueue.splice(0);
|
|
117
|
+
if (entries.length === 0) return;
|
|
118
|
+
void fetch('/api/canvas/debug/ext-app-recovery', {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: { 'Content-Type': 'application/json' },
|
|
121
|
+
body: JSON.stringify({ entries }),
|
|
122
|
+
}).catch(() => {
|
|
123
|
+
// Diagnostics only — never let reporting failures affect recovery.
|
|
124
|
+
});
|
|
125
|
+
}, 1500);
|
|
95
126
|
}
|
|
96
127
|
|
|
97
128
|
let webkitRemountChain: Promise<void> = Promise.resolve();
|
|
@@ -178,7 +209,7 @@ export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): stri
|
|
|
178
209
|
}, 10000);
|
|
179
210
|
pending.set(correlationId, function (result) { clearTimeout(timer); resolve(result); });
|
|
180
211
|
window.parent.postMessage({
|
|
181
|
-
source: '
|
|
212
|
+
source: '${AX_SURFACE_EMIT_SOURCE}',
|
|
182
213
|
token: PMX_AX_TOKEN,
|
|
183
214
|
nodeId: PMX_AX_NODE_ID,
|
|
184
215
|
correlationId: correlationId,
|
|
@@ -191,7 +222,7 @@ export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): stri
|
|
|
191
222
|
};
|
|
192
223
|
window.addEventListener('message', function (event) {
|
|
193
224
|
const m = event.data;
|
|
194
|
-
if (!m || m.source !== '
|
|
225
|
+
if (!m || m.source !== '${AX_SURFACE_ACK_SOURCE}' || m.token !== PMX_AX_TOKEN) return;
|
|
195
226
|
const result = m.result || { ok: false };
|
|
196
227
|
const resolver = m.correlationId ? pending.get(m.correlationId) : undefined;
|
|
197
228
|
if (resolver) { pending.delete(m.correlationId); resolver(result); }
|
|
@@ -204,6 +235,24 @@ export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): stri
|
|
|
204
235
|
</script>`;
|
|
205
236
|
}
|
|
206
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Boot beacon injected into EVERY ext-app document (not gated on AX): posts one
|
|
240
|
+
* parent message the moment the iframe's scripts execute. This is the liveness
|
|
241
|
+
* signal the WebKit never-booted watchdog keys on — it distinguishes an app
|
|
242
|
+
* that is alive but boots via the 1200ms bootstrap fallback (e.g. a non-SDK
|
|
243
|
+
* widget that never sends ui/notifications/initialized) from a dead window
|
|
244
|
+
* whose scripts never ran. Without it, the watchdog treated every
|
|
245
|
+
* fallback-booted app as never-booted and remounted it up to the attempt cap —
|
|
246
|
+
* a reboot/flicker loop for exactly the apps the fallback exists to support
|
|
247
|
+
* (0.3.2 pre-release review). The token scopes the message to this component;
|
|
248
|
+
* the host also checks event.source against the live iframe.
|
|
249
|
+
*/
|
|
250
|
+
export function buildExtAppBootBeaconScript(frameToken: string, nodeId: string): string {
|
|
251
|
+
return `<script data-pmx-canvas-boot-beacon>
|
|
252
|
+
window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: ${JSON.stringify(frameToken)}, nodeId: ${JSON.stringify(nodeId)} }, '*');
|
|
253
|
+
</script>`;
|
|
254
|
+
}
|
|
255
|
+
|
|
207
256
|
export function injectExtAppAxBridgeScript(html: string, axBridgeScript: string): string {
|
|
208
257
|
if (!axBridgeScript) return html;
|
|
209
258
|
const headMatch = /<head\b[^>]*>/i.exec(html);
|
|
@@ -262,8 +311,15 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
262
311
|
// handshake (bridge.oninitialized) — NOT by the 1200ms bootstrap fallback,
|
|
263
312
|
// which flips status via notifications that resolve even into a dead iframe.
|
|
264
313
|
const appInitializedRef = useRef(false);
|
|
314
|
+
// Liveness signal from the injected boot beacon: the CURRENT frame's scripts
|
|
315
|
+
// executed. Proves "alive" for apps that boot via the fallback path without
|
|
316
|
+
// ever sending initialized; a dead window never beacons.
|
|
317
|
+
const appScriptsRanRef = useRef(false);
|
|
265
318
|
const bootWaitersRef = useRef<Array<() => void>>([]);
|
|
266
319
|
const remountQueuedRef = useRef(false);
|
|
320
|
+
// Recovery ran while the document was hidden (compositing suppressed) — re-arm
|
|
321
|
+
// one fresh remount round when the panel becomes visible (Finding N).
|
|
322
|
+
const bootedWhileHiddenRef = useRef(false);
|
|
267
323
|
const unmountedRef = useRef(false);
|
|
268
324
|
const [status, setStatus] = useState<ExtAppFrameStatus>('loading');
|
|
269
325
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -298,48 +354,29 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
298
354
|
const axEnabled = axCaps?.enabled === true && typeof html === 'string' && html.length > 0;
|
|
299
355
|
const axToken = useMemo(() => `ax-${crypto.randomUUID()}`, []);
|
|
300
356
|
const axBridgeScript = axEnabled ? buildExtAppAxBridgeScript(axToken, nodeId) : '';
|
|
301
|
-
|
|
357
|
+
// The boot beacon precedes the AX bridge so it runs first at parse time; it
|
|
358
|
+
// shares the per-component token (used purely to authenticate the message).
|
|
359
|
+
const bootBeaconScript =
|
|
360
|
+
typeof html === 'string' && html.length > 0 ? buildExtAppBootBeaconScript(axToken, nodeId) : '';
|
|
361
|
+
const iframeDocument = useIframeDocument(
|
|
362
|
+
injectExtAppAxBridgeScript(html ?? '', bootBeaconScript + axBridgeScript),
|
|
363
|
+
iframeSandbox,
|
|
364
|
+
);
|
|
302
365
|
|
|
303
366
|
useEffect(() => {
|
|
304
|
-
|
|
305
|
-
function onAxMessage(event: MessageEvent) {
|
|
367
|
+
function onBootBeacon(event: MessageEvent) {
|
|
306
368
|
if (event.source !== iframeRef.current?.contentWindow) return;
|
|
307
|
-
const data = event.data as {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
correlationId?: string;
|
|
312
|
-
interaction?: { type?: unknown; payload?: unknown };
|
|
313
|
-
} | null;
|
|
314
|
-
if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== nodeId) return;
|
|
315
|
-
const interaction = data.interaction;
|
|
316
|
-
if (!interaction || typeof interaction.type !== 'string') return;
|
|
317
|
-
const interactionType = interaction.type;
|
|
318
|
-
void submitAxInteractionFromClient({
|
|
319
|
-
type: interactionType,
|
|
320
|
-
sourceNodeId: nodeId,
|
|
321
|
-
sourceSurface: 'mcp-app',
|
|
322
|
-
...(interaction.payload && typeof interaction.payload === 'object'
|
|
323
|
-
? { payload: interaction.payload as Record<string, unknown> }
|
|
324
|
-
: {}),
|
|
325
|
-
}).then((res) => {
|
|
326
|
-
if (res.ok) showToast('context', 'AX interaction', interactionType, [nodeId]);
|
|
327
|
-
else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [nodeId]);
|
|
328
|
-
iframeRef.current?.contentWindow?.postMessage(
|
|
329
|
-
{
|
|
330
|
-
source: 'pmx-canvas-ax-ack',
|
|
331
|
-
token: axToken,
|
|
332
|
-
...(data.correlationId ? { correlationId: data.correlationId } : {}),
|
|
333
|
-
interaction: { type: interactionType },
|
|
334
|
-
result: res,
|
|
335
|
-
},
|
|
336
|
-
'*',
|
|
337
|
-
);
|
|
338
|
-
});
|
|
369
|
+
const data = event.data as { source?: string; token?: string; nodeId?: string } | null;
|
|
370
|
+
if (!data || data.source !== EXT_APP_BOOT_BEACON_SOURCE || data.token !== axToken || data.nodeId !== nodeId)
|
|
371
|
+
return;
|
|
372
|
+
appScriptsRanRef.current = true;
|
|
339
373
|
}
|
|
340
|
-
window.addEventListener('message',
|
|
341
|
-
return () => window.removeEventListener('message',
|
|
342
|
-
}, [
|
|
374
|
+
window.addEventListener('message', onBootBeacon);
|
|
375
|
+
return () => window.removeEventListener('message', onBootBeacon);
|
|
376
|
+
}, [axToken, nodeId]);
|
|
377
|
+
|
|
378
|
+
// Ext-app AX emits — the shared sandboxed-surface trust boundary (M2).
|
|
379
|
+
useAxSurfaceBridge({ enabled: axEnabled, token: axToken, nodeId, sourceSurface: 'mcp-app', iframeRef });
|
|
343
380
|
|
|
344
381
|
// Enqueue one serialized remount attempt for this node (Finding F recovery).
|
|
345
382
|
// Attempts are capped so a persistently-failing app degrades to the manual
|
|
@@ -361,7 +398,18 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
361
398
|
extAppRecoveryLog(nodeId, 'remount-skipped');
|
|
362
399
|
return false;
|
|
363
400
|
}
|
|
401
|
+
// Stamp visibility at RUN time, not schedule time: the serialized queue
|
|
402
|
+
// can hold this slot for many seconds, and what decides whether the
|
|
403
|
+
// repaint can composite is the document's visibility when the remount
|
|
404
|
+
// actually executes (the Finding N re-arm keys on this flag). Covers
|
|
405
|
+
// every scheduling path (post-boot repaint, boot watchdog, re-arm).
|
|
406
|
+
bootedWhileHiddenRef.current = typeof document !== 'undefined' && document.visibilityState === 'hidden';
|
|
364
407
|
extAppRecoveryLog(nodeId, `remount-run #${webkitRemountAttemptsRef.current}`);
|
|
408
|
+
// Back to 'loading' so the connecting overlay covers this recovery
|
|
409
|
+
// remount's boot window too (the fallback boot flipped status to
|
|
410
|
+
// ready/done even when the previous frame was dead — without this the
|
|
411
|
+
// overlay only ever covered the INITIAL boot, not recovery).
|
|
412
|
+
setStatus('loading');
|
|
365
413
|
setRetryKey((k) => k + 1);
|
|
366
414
|
return true;
|
|
367
415
|
},
|
|
@@ -401,11 +449,42 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
401
449
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
402
450
|
}, [status, hasReplayToolResult]);
|
|
403
451
|
|
|
404
|
-
//
|
|
452
|
+
// Finding N (0.3.2 report): the Copilot panel can load the canvas while the
|
|
453
|
+
// WKWebView document is HIDDEN (panel backgrounded/occluded). WebKit suppresses
|
|
454
|
+
// compositing for hidden documents, so the whole serialized recovery above can
|
|
455
|
+
// run to completion without ever producing a visible paint — and nothing
|
|
456
|
+
// retried once the panel was shown, which is why tiles stayed black until a
|
|
457
|
+
// manual expand+close (necessarily performed while visible). When the document
|
|
458
|
+
// becomes visible again, re-arm ONE fresh serialized remount round for frames
|
|
459
|
+
// whose recovery ran while hidden.
|
|
460
|
+
useEffect(() => {
|
|
461
|
+
if (expanded) return;
|
|
462
|
+
if (typeof document === 'undefined' || typeof navigator === 'undefined' || typeof window === 'undefined') return;
|
|
463
|
+
if (!isWebKitOnlyHost(navigator.userAgent)) return;
|
|
464
|
+
function onVisibilityChange() {
|
|
465
|
+
if (document.visibilityState !== 'visible') return;
|
|
466
|
+
if (!bootedWhileHiddenRef.current || unmountedRef.current) return;
|
|
467
|
+
bootedWhileHiddenRef.current = false;
|
|
468
|
+
// Fresh visibility = fresh recovery budget: the hidden-run attempts were
|
|
469
|
+
// spent without a chance of compositing.
|
|
470
|
+
webkitRemountAttemptsRef.current = 0;
|
|
471
|
+
extAppRecoveryLog(nodeId, 'visible-again-rearm');
|
|
472
|
+
scheduleWebkitRemount('visible-after-hidden-boot');
|
|
473
|
+
}
|
|
474
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
475
|
+
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
476
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
477
|
+
}, [expanded, nodeId]);
|
|
478
|
+
|
|
479
|
+
// Never-booted watchdog (0.3.2): an iframe whose scripts never ran in the burst
|
|
405
480
|
// shows no error — the bootstrap fallback flips status via notifications that
|
|
406
|
-
// resolve into a dead window, so the tile just stays black.
|
|
407
|
-
//
|
|
408
|
-
//
|
|
481
|
+
// resolve into a dead window, so the tile just stays black. "Alive" is either
|
|
482
|
+
// boot signal from the CURRENT frame: the genuine initialize handshake, or the
|
|
483
|
+
// injected boot beacon (which covers apps that boot via the fallback without
|
|
484
|
+
// ever sending initialized — remounting those was a flicker loop, and checking
|
|
485
|
+
// bridgeReadyRef instead would be wrong: the fallback sets it blindly, dead
|
|
486
|
+
// window or not). Only a frame with NEITHER signal is retried through the
|
|
487
|
+
// serialized queue (bounded by the shared attempt cap).
|
|
409
488
|
const WEBKIT_BOOT_WATCHDOG_MS = 6000;
|
|
410
489
|
useEffect(() => {
|
|
411
490
|
if (expanded) return;
|
|
@@ -413,7 +492,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
413
492
|
if (!isWebKitOnlyHost(navigator.userAgent)) return;
|
|
414
493
|
if (!iframeDocument.ready) return;
|
|
415
494
|
const timer = window.setTimeout(() => {
|
|
416
|
-
if (appInitializedRef.current || unmountedRef.current) return;
|
|
495
|
+
if (appInitializedRef.current || appScriptsRanRef.current || unmountedRef.current) return;
|
|
417
496
|
scheduleWebkitRemount('boot-watchdog');
|
|
418
497
|
}, WEBKIT_BOOT_WATCHDOG_MS);
|
|
419
498
|
return () => window.clearTimeout(timer);
|
|
@@ -494,9 +573,11 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
494
573
|
lastSentToolResultRef.current = undefined;
|
|
495
574
|
toolResultSendingRef.current = null;
|
|
496
575
|
bridgeReadyRef.current = false;
|
|
497
|
-
// New frame = new boot attempt:
|
|
498
|
-
//
|
|
576
|
+
// New frame = new boot attempt: both boot signals belong to the CURRENT
|
|
577
|
+
// iframe (the beacon listener also pins event.source to the live element).
|
|
578
|
+
// The queue's awaitBoot waits on this frame's handshake.
|
|
499
579
|
appInitializedRef.current = false;
|
|
580
|
+
appScriptsRanRef.current = false;
|
|
500
581
|
|
|
501
582
|
const clearFallbackTimer = (): void => {
|
|
502
583
|
if (!fallbackTimer) return;
|
|
@@ -967,9 +1048,6 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
967
1048
|
</button>
|
|
968
1049
|
</div>
|
|
969
1050
|
)}
|
|
970
|
-
{status === 'loading' && (
|
|
971
|
-
<div style={{ padding: '8px', fontSize: '11px', color: 'var(--c-muted)' }}>Connecting to ext-app viewer...</div>
|
|
972
|
-
)}
|
|
973
1051
|
{/* Iframe stack: the widget renders a preview; when not expanded, a
|
|
974
1052
|
transparent click-catcher sits on top so the first click always
|
|
975
1053
|
expands the node. Without this, widgets like Excalidraw show their
|
|
@@ -999,6 +1077,42 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
999
1077
|
}}
|
|
1000
1078
|
title={`Ext App: ${toolName}`}
|
|
1001
1079
|
/>
|
|
1080
|
+
{/* Connecting overlay: an opaque sibling ABOVE the iframe while this
|
|
1081
|
+
frame boots. During WebKit recovery remounts the iframe's own layer
|
|
1082
|
+
can composite black (Finding N), but sibling DOM always paints — so
|
|
1083
|
+
the multi-second recovery window reads as an intentional loading
|
|
1084
|
+
state instead of a broken black tile. pointer-events pass through
|
|
1085
|
+
so the expand click-catcher keeps working. */}
|
|
1086
|
+
{status === 'loading' && !error && (
|
|
1087
|
+
<div
|
|
1088
|
+
style={{
|
|
1089
|
+
position: 'absolute',
|
|
1090
|
+
inset: 0,
|
|
1091
|
+
display: 'flex',
|
|
1092
|
+
flexDirection: 'column',
|
|
1093
|
+
alignItems: 'center',
|
|
1094
|
+
justifyContent: 'center',
|
|
1095
|
+
gap: '10px',
|
|
1096
|
+
background: 'var(--c-panel)',
|
|
1097
|
+
color: 'var(--c-muted)',
|
|
1098
|
+
fontSize: '12px',
|
|
1099
|
+
pointerEvents: 'none',
|
|
1100
|
+
}}
|
|
1101
|
+
>
|
|
1102
|
+
<div
|
|
1103
|
+
style={{
|
|
1104
|
+
width: '22px',
|
|
1105
|
+
height: '22px',
|
|
1106
|
+
border: '2px solid var(--c-line)',
|
|
1107
|
+
borderTopColor: 'var(--c-muted)',
|
|
1108
|
+
borderRadius: '50%',
|
|
1109
|
+
animation: 'spin 1s linear infinite',
|
|
1110
|
+
}}
|
|
1111
|
+
/>
|
|
1112
|
+
<div>Connecting to {toolName}...</div>
|
|
1113
|
+
<style>{'@keyframes spin { to { transform: rotate(360deg); } }'}</style>
|
|
1114
|
+
</div>
|
|
1115
|
+
)}
|
|
1002
1116
|
{!isExpanded && (
|
|
1003
1117
|
<button
|
|
1004
1118
|
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
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { Spec } from '@json-render/core';
|
|
11
|
+
import { AX_SURFACE_EMIT_SOURCE, HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
|
|
11
12
|
import { useEffect } from 'react';
|
|
12
13
|
import { createRoot } from 'react-dom/client';
|
|
13
14
|
import { defineRegistry, JSONUIProvider, Renderer, useStateBinding } from '@json-render/react';
|
|
@@ -101,7 +102,7 @@ function AxStateSync() {
|
|
|
101
102
|
if (!token) return undefined;
|
|
102
103
|
function onMessage(event: MessageEvent) {
|
|
103
104
|
const m = event.data as { source?: string; type?: string; token?: string; state?: unknown } | null;
|
|
104
|
-
if (!m || m.source !==
|
|
105
|
+
if (!m || m.source !== HTML_SURFACE_PUSH_SOURCE || m.type !== 'ax-update' || m.token !== token) return;
|
|
105
106
|
window.__PMX_CANVAS_AX_STATE__ = m.state;
|
|
106
107
|
setAx(m.state);
|
|
107
108
|
}
|
|
@@ -141,7 +142,7 @@ function buildAxHandlers(): Record<string, (params: Record<string, unknown>) =>
|
|
|
141
142
|
handlers[type] = (params: Record<string, unknown>) => {
|
|
142
143
|
window.parent.postMessage(
|
|
143
144
|
{
|
|
144
|
-
source:
|
|
145
|
+
source: AX_SURFACE_EMIT_SOURCE,
|
|
145
146
|
token,
|
|
146
147
|
nodeId,
|
|
147
148
|
interaction: { type, payload: params && typeof params === 'object' ? params : {} },
|