pmx-canvas 0.4.0 → 0.4.1
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 +23 -0
- package/Readme.md +9 -4
- package/dist/canvas/index.js +66 -66
- package/dist/types/client/nodes/iframe-document-url.d.ts +1 -0
- package/dist/types/client/nodes/use-surface-frame.d.ts +13 -0
- package/dist/types/client/state/canvas-store.d.ts +1 -0
- package/dist/types/client/state/iframe-mode.d.ts +25 -0
- package/docs/RELEASE.md +10 -4
- package/docs/http-api.md +22 -0
- package/docs/plans/plan-009-tech-debt-backlog.md +9 -1
- package/docs/screenshot.png +0 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +20 -4
- package/skills/pmx-canvas-testing/SKILL.md +3 -0
- package/src/client/index.tsx +5 -0
- package/src/client/nodes/ExtAppFrame.tsx +33 -0
- package/src/client/nodes/HtmlNode.tsx +5 -1
- package/src/client/nodes/McpAppNode.tsx +5 -1
- package/src/client/nodes/iframe-document-url.ts +62 -9
- package/src/client/nodes/use-surface-frame.ts +53 -0
- package/src/client/state/canvas-store.ts +5 -0
- package/src/client/state/iframe-mode.ts +103 -0
- package/src/client/state/sse-bridge.ts +3 -0
- package/src/server/server.ts +24 -1
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attributes for a surface iframe, honoring the boot-wide iframe mode
|
|
3
|
+
* (state/iframe-mode.ts): `src` by default, fetch() + `srcdoc` when src-URL
|
|
4
|
+
* iframes are blocked by the embedding context (nested-iframe hosts like Amp
|
|
5
|
+
* orb portals). Cross-origin URLs always stay `src` — they cannot be fetched
|
|
6
|
+
* from here. While the probe is pending, same-origin frames stay empty rather
|
|
7
|
+
* than loading a src that may show the broken placeholder; a failed srcdoc
|
|
8
|
+
* fetch falls back to `src` (no worse than before).
|
|
9
|
+
*/
|
|
10
|
+
export declare function useSurfaceFrame(url: string): {
|
|
11
|
+
src?: string;
|
|
12
|
+
srcdoc?: string;
|
|
13
|
+
};
|
|
@@ -5,6 +5,7 @@ export declare const edges: import("@preact/signals-core").Signal<Map<string, Ca
|
|
|
5
5
|
export declare const annotations: import("@preact/signals-core").Signal<Map<string, CanvasAnnotation>>;
|
|
6
6
|
export declare const activeNodeId: import("@preact/signals-core").Signal<string | null>;
|
|
7
7
|
export declare const connectionStatus: import("@preact/signals-core").Signal<ConnectionStatus>;
|
|
8
|
+
export declare const workbenchConnectionEpoch: import("@preact/signals-core").Signal<number>;
|
|
8
9
|
export declare const sessionId: import("@preact/signals-core").Signal<string>;
|
|
9
10
|
export declare const traceEnabled: import("@preact/signals-core").Signal<boolean>;
|
|
10
11
|
export declare const canvasTheme: import("@preact/signals-core").Signal<string>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type IframeMode = 'src' | 'srcdoc';
|
|
2
|
+
export declare const IFRAME_PROBE_PATH = "/api/canvas/iframe-probe";
|
|
3
|
+
/** Boot-wide iframe transport mode; null until the probe (or override) resolves. */
|
|
4
|
+
export declare const iframeMode: import("@preact/signals-core").Signal<IframeMode | null>;
|
|
5
|
+
export declare function forcedIframeMode(): IframeMode | null;
|
|
6
|
+
/**
|
|
7
|
+
* Probe whether `src`-URL iframes load in this embedding context. Resolves true
|
|
8
|
+
* when the hidden probe iframe fires `load`; false on error or timeout. The
|
|
9
|
+
* blocked-portal case shows its placeholder without reliably firing either
|
|
10
|
+
* event, so the timeout is the real signal there — and a false negative is
|
|
11
|
+
* safe, because srcdoc rendering works in normal hosts too.
|
|
12
|
+
*/
|
|
13
|
+
export declare function probeSrcIframes(timeoutMs?: number): Promise<boolean>;
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the boot-wide iframe mode once; all surface hooks share the result.
|
|
16
|
+
* The blocked-src condition only exists when the canvas page itself runs
|
|
17
|
+
* inside an iframe, so top-level documents resolve `src` synchronously — the
|
|
18
|
+
* normal path pays zero probe latency before surfaces mount. Only embedded
|
|
19
|
+
* documents run the probe (`embedded` is overridable for tests).
|
|
20
|
+
*/
|
|
21
|
+
export declare function resolveIframeMode(opts?: {
|
|
22
|
+
embedded?: boolean;
|
|
23
|
+
}): Promise<IframeMode>;
|
|
24
|
+
/** Test hook: clear the memoized probe so each test starts unresolved. */
|
|
25
|
+
export declare function resetIframeModeForTests(): void;
|
package/docs/RELEASE.md
CHANGED
|
@@ -128,12 +128,18 @@ it uses `NPM_TOKEN` directly.
|
|
|
128
128
|
|
|
129
129
|
Every release since 0.3.0 has been retested against **stale** skill mirrors:
|
|
130
130
|
consumer workspaces (e.g. `personalclaude`) carry copies of the pmx-canvas
|
|
131
|
-
|
|
131
|
+
skills under `.github/`, `.codex/`, `.claude/`, and `.agents/`, and testers
|
|
132
132
|
repeatedly had to sync them mid-cycle before results were trustworthy. After
|
|
133
133
|
publishing, update the installed package in the consumer workspace and re-sync
|
|
134
|
-
its four mirrors from
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
its four mirrors from `<global npm root>/pmx-canvas/skills/` so the first test
|
|
135
|
+
pass runs against the released skills, not the previous version's guidance.
|
|
136
|
+
|
|
137
|
+
Sync the **complete tree of every bundled skill** — `pmx-canvas` AND
|
|
138
|
+
`pmx-canvas-testing`, including `references/`, `evals/`, and fixtures — and
|
|
139
|
+
verify by diffing whole directories, not by comparing the `SKILL.md` hash
|
|
140
|
+
alone. The 0.3.4 and 0.4.0 cycles both hit drift where `SKILL.md` matched but
|
|
141
|
+
references/evals were stale, or a newly bundled skill was missing from the
|
|
142
|
+
mirrors entirely.
|
|
137
143
|
|
|
138
144
|
## GitHub release
|
|
139
145
|
|
package/docs/http-api.md
CHANGED
|
@@ -345,6 +345,28 @@ produces no event within 3 seconds of connecting, it switches to polling.
|
|
|
345
345
|
Force a transport with `/workbench?transport=poll` (or `transport=sse` to
|
|
346
346
|
disable the fallback).
|
|
347
347
|
|
|
348
|
+
## Iframe embed probe (nested-iframe hosts)
|
|
349
|
+
|
|
350
|
+
Some hosts embed the canvas page itself inside an iframe (the Amp orb portal
|
|
351
|
+
does), and Chrome then refuses to load child iframes from `src` URLs — even
|
|
352
|
+
same-origin ones — so iframe-backed nodes would show a broken placeholder.
|
|
353
|
+
When (and only when) the canvas page detects it is embedded, it probes the
|
|
354
|
+
real behavior at boot by loading a hidden iframe against:
|
|
355
|
+
|
|
356
|
+
```bash
|
|
357
|
+
curl http://localhost:4313/api/canvas/iframe-probe
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
If that iframe never loads, the client switches every same-origin surface
|
|
361
|
+
(HTML nodes, graph/json-render viewers, frame documents) to `fetch()` +
|
|
362
|
+
`srcdoc` inline rendering. External app URLs stay on `src` — they cannot be
|
|
363
|
+
fetched cross-origin. Force a mode with `/workbench?iframe-mode=srcdoc` (or
|
|
364
|
+
`iframe-mode=src`).
|
|
365
|
+
|
|
366
|
+
`/api/canvas/frame-documents/:id` also answers `HEAD`, which the browser uses
|
|
367
|
+
after every reconnect to detect frame documents lost to a server restart and
|
|
368
|
+
re-mint them automatically (Finding S).
|
|
369
|
+
|
|
348
370
|
## Time travel
|
|
349
371
|
|
|
350
372
|
```bash
|
|
@@ -66,5 +66,13 @@ The remaining items were re-analyzed with pros/cons and tiered; the maintainer a
|
|
|
66
66
|
|---|---|
|
|
67
67
|
| Windows web-artifacts | The artifact build pipeline is bash-script-backed (POSIX-only); `tests/unit/web-artifacts.test.ts` self-skips on win32 and docs now state the boundary. Decide: port the build scripts to a cross-platform runner, or keep the documented "build on POSIX, render anywhere" stance. |
|
|
68
68
|
| Composite parity no longer machine-checked | Since v0.3.0 removed the legacy standalones, the byte-identical composite-vs-legacy guarantee has no head-to-head test — regressions in `buildInput`/`formatResult` reuse surface only via output-shape assertions. Accepted at review; revisit if a composite drifts. |
|
|
69
|
-
| Portal-layer ext-app mount (contingency) | If the maintainer sets the WebKit UX bar at "never any visible gap"
|
|
69
|
+
| Portal-layer ext-app mount (contingency) | If the maintainer sets the WebKit UX bar at "never any visible gap", inline ext-app iframes must render outside the world transform — an architectural change that reopens the parked M3. The 0.4.0 Copilot report keeps Finding N open (residual black after settled recovery; enlarge/close still the reliable unstick) — 0.4.1 ships a cheaper mimic first (post-settle WebKit repaint nudge, `repaint-nudge` trail event). If the next Copilot cycle still fails with the nudge in place, THIS contingency is the next step. |
|
|
70
|
+
|
|
71
|
+
## Added post-0.4.0 (2026-08-04 report cycle)
|
|
72
|
+
|
|
73
|
+
| Item | What's open |
|
|
74
|
+
|---|---|
|
|
75
|
+
| Finding S — frame documents vs server restart | **Done (2026-08-04):** the browser revalidates minted frame-document URLs on every reconnect epoch (`workbenchConnectionEpoch`, HEAD probe) and re-mints against the new process when the store is gone; `/api/canvas/frame-documents/:id` answers HEAD. Confirmed-fix pending the next Copilot/Codex kill-respawn cycle. Server-side persistence of frame docs deliberately NOT done — they are client-derived and re-mintable. |
|
|
76
|
+
| Amp portal nested-iframe embedding | **Done (2026-08-04):** boot-time embed probe (`/api/canvas/iframe-probe`) + fetch()+srcdoc fallback for all same-origin surfaces (`useSurfaceFrame`, `useIframeDocument` srcdoc short-circuit), `?iframe-mode` override, e2e-forced-mode coverage. External (cross-origin) app URLs cannot be inlined — documented limitation. |
|
|
77
|
+
| Copilot adapter `canvas_snapshot list` descriptor lag | Host-side cached tool schema still requires `name`; fresh `--mcp` is correct. Adapter/packaging issue, not a daemon defect — nothing to change in core; re-verify next cycle. |
|
|
70
78
|
| Registry-migration dead-code sweep | **Done (2026-08-03):** removed the orphaned `describeOp`/`describeGateOp` (composites.ts, dead since the v0.3.0 registration filter) and the `compactNodePayload`/`compactLayoutPayload`/`wantsFullPayload` duplicates in `mcp/server.ts` (live copies are in `ops/nodes.ts`). |
|
package/docs/screenshot.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmx-canvas",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Spatial canvas workbench for coding agents \u2014 infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/server/index.ts",
|
|
@@ -223,12 +223,20 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
|
|
|
223
223
|
time), with a watchdog retry for iframes that never boot and a re-arm when a panel that loaded
|
|
224
224
|
hidden becomes visible. Treat the auto-recovery as best-effort, NOT guaranteed — the 0.3.2
|
|
225
225
|
Copilot-host report shows tiles can still come up black in real WKWebView panels (compositing
|
|
226
|
-
success is not observable from page JS, so retries are bounded).
|
|
226
|
+
success is not observable from page JS, so retries are bounded). Since 0.4.1 the canvas also
|
|
227
|
+
fires a post-boot **repaint nudge** under WebKit (a 1px layout bounce that forces the
|
|
228
|
+
compositor to re-rasterize the frame — the cheap half of the proven enlarge-close unstick;
|
|
229
|
+
shows as `repaint-nudge` in the recovery trail). Keep the cautionary behavior anyway:
|
|
227
230
|
recovery is deterministic via **expand-then-close** of the black tile (forces a fresh mount in
|
|
228
231
|
the fullscreen overlay, which always paints), or open the workbench in a normal browser
|
|
229
232
|
(Chrome). Do not diagnose a healthy app session as a broken node. When reporting a black tile,
|
|
230
233
|
fetch `GET /api/canvas/debug/ext-app-recovery` from the daemon and attach the trace — it shows
|
|
231
234
|
what the recovery queue actually did in that panel.
|
|
235
|
+
- Ext-app frame documents live in server memory. Through 0.4.0, killing/restarting the daemon
|
|
236
|
+
while a panel stays open leaves ext-app tiles on dead frame URLs (`Frame document not found`,
|
|
237
|
+
0.4.0 report Finding S) until a full workbench reload. Since 0.4.1 the browser revalidates its
|
|
238
|
+
frame documents on every reconnect and re-mints them against the new process automatically —
|
|
239
|
+
if a post-restart tile still looks blank on an older install, reload the workbench page.
|
|
232
240
|
- A hosted ext-app (Excalidraw) resized NARROW/TALL (e.g. 360x529) can show its diagram in the
|
|
233
241
|
upper region with the app's own dark fill below it — in every engine (0.3.4 report Finding Q).
|
|
234
242
|
This letterboxing lives inside the hosted app bundle's root container, not in PMX (a body-level
|
|
@@ -238,10 +246,18 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
|
|
|
238
246
|
- Behind proxies that buffer streaming responses (e.g. portal hosts), the workbench auto-falls
|
|
239
247
|
back from SSE to a polling transport within ~3s, so the board still boots and stays live.
|
|
240
248
|
Force a mode with `/workbench?transport=poll` (or `transport=sse`) when diagnosing.
|
|
249
|
+
- Nested-iframe embeds (e.g. the Amp orb portal renders the canvas page inside an ampcode.com
|
|
250
|
+
iframe) can block child iframes from loading ANY `src` URL, breaking every iframe-backed node
|
|
251
|
+
with a gray placeholder. The canvas probes this at boot and auto-falls back to fetching
|
|
252
|
+
same-origin surfaces and rendering them inline via `srcdoc` (HTML, graph, json-render, frame
|
|
253
|
+
documents). External app URLs cannot be inlined (cross-origin) and may stay blocked in such
|
|
254
|
+
hosts. Force a mode with `/workbench?iframe-mode=srcdoc` (or `iframe-mode=src`) when
|
|
255
|
+
diagnosing.
|
|
241
256
|
- Graph and json-render standalone surfaces use `display=site` and fill the browser viewport, and
|
|
242
|
-
reflow on a live window resize in a normal browser. Some single-tab host browsers
|
|
243
|
-
|
|
244
|
-
|
|
257
|
+
reflow on a live window resize in a normal browser. Some single-tab host browsers historically
|
|
258
|
+
didn't deliver live-resize events (current Codex builds do — the 0.4.0 retest saw a standalone
|
|
259
|
+
graph reflow on a native viewport resize), so if a resized standalone chart looks stale,
|
|
260
|
+
reload it or use a system browser for separate full-page viewing.
|
|
245
261
|
- Some hosts cannot automate inside sandboxed workbench iframes. Verify those interactions in a
|
|
246
262
|
system browser or through server-side AX state.
|
|
247
263
|
- `pmx-canvas screenshot` requires an active WebView. Start it with
|
|
@@ -71,6 +71,9 @@ Prefer extending the existing suites before inventing a one-off script.
|
|
|
71
71
|
- Avoid brittle selectors. Prefer stable text, roles, titles, or deliberate component hooks
|
|
72
72
|
- If a change spans server and client, add at least one server-side assertion and one browser or
|
|
73
73
|
API-level proof
|
|
74
|
+
- MCP harnesses using the official SDK must wind down BOTH halves: call `client.close()` AND
|
|
75
|
+
`transport.close()` on the `StdioClientTransport`. Closing only the client leaves the spawned
|
|
76
|
+
stdio server process alive after the test exits (0.4.0 cycle finding)
|
|
74
77
|
|
|
75
78
|
## Layout And Embedded Content Checks
|
|
76
79
|
|
package/src/client/index.tsx
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
import './uuid-polyfill';
|
|
3
3
|
import { render } from 'preact';
|
|
4
4
|
import { App } from './App';
|
|
5
|
+
import { resolveIframeMode } from './state/iframe-mode';
|
|
6
|
+
|
|
7
|
+
// Start the embed probe before first paint so iframe-backed nodes know whether
|
|
8
|
+
// src-URL iframes work in this context (Amp orb portals block them).
|
|
9
|
+
void resolveIframeMode();
|
|
5
10
|
|
|
6
11
|
const root = document.getElementById('app');
|
|
7
12
|
if (root) {
|
|
@@ -579,6 +579,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
579
579
|
if (!iframe) return;
|
|
580
580
|
let disposed = false;
|
|
581
581
|
let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
|
582
|
+
let repaintNudgeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
582
583
|
let hostContextResizeObserver: ResizeObserver | null = null;
|
|
583
584
|
let hostContextRaf: number | null = null;
|
|
584
585
|
let readyNudgeRaf: number | null = null;
|
|
@@ -654,6 +655,32 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
654
655
|
});
|
|
655
656
|
};
|
|
656
657
|
|
|
658
|
+
// Finding N best-effort: WKWebView can keep compositing a stale (black)
|
|
659
|
+
// texture for the doubly-nested app iframe even after a genuine settled
|
|
660
|
+
// boot — the user-proven unstick is enlarge+close, i.e. a layout-size
|
|
661
|
+
// change. Mimic the cheap half of that after the scene draw: bounce the
|
|
662
|
+
// iframe's height by 1px for two frames so WebKit must re-rasterize the
|
|
663
|
+
// layer. No reload, no app state loss; a healthy frame repaints
|
|
664
|
+
// identically. Scheduled past the remount queue's settle pause so it
|
|
665
|
+
// lands after the app has actually drawn.
|
|
666
|
+
const scheduleWebkitRepaintNudge = () => {
|
|
667
|
+
if (!isWebKitOnlyHost(navigator.userAgent)) return;
|
|
668
|
+
if (repaintNudgeTimer) clearTimeout(repaintNudgeTimer);
|
|
669
|
+
repaintNudgeTimer = setTimeout(() => {
|
|
670
|
+
repaintNudgeTimer = null;
|
|
671
|
+
const frame = iframeRef.current;
|
|
672
|
+
if (disposed || !frame) return;
|
|
673
|
+
const previous = frame.style.height;
|
|
674
|
+
frame.style.height = 'calc(100% - 1px)';
|
|
675
|
+
requestAnimationFrame(() => {
|
|
676
|
+
requestAnimationFrame(() => {
|
|
677
|
+
frame.style.height = previous;
|
|
678
|
+
extAppRecoveryLog(nodeId, 'repaint-nudge');
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
}, WEBKIT_REMOUNT_SETTLE_MS + 200);
|
|
682
|
+
};
|
|
683
|
+
|
|
657
684
|
const bridge = new AppBridge(
|
|
658
685
|
null,
|
|
659
686
|
{ name: 'PMX Canvas', version: '1.0.0' },
|
|
@@ -805,6 +832,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
805
832
|
extAppRecoveryLog(nodeId, 'settled');
|
|
806
833
|
for (const waiter of bootWaitersRef.current.splice(0)) waiter();
|
|
807
834
|
nudgeHostContextAfterLayout();
|
|
835
|
+
scheduleWebkitRepaintNudge();
|
|
808
836
|
})
|
|
809
837
|
.catch((err) => {
|
|
810
838
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -831,6 +859,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
831
859
|
setStatus(bootstrapToolResult ? 'done' : 'ready');
|
|
832
860
|
setError(null);
|
|
833
861
|
nudgeHostContextAfterLayout();
|
|
862
|
+
scheduleWebkitRepaintNudge();
|
|
834
863
|
})
|
|
835
864
|
.catch((err) => {
|
|
836
865
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -878,6 +907,10 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
878
907
|
return () => {
|
|
879
908
|
disposed = true;
|
|
880
909
|
clearFallbackTimer();
|
|
910
|
+
if (repaintNudgeTimer) {
|
|
911
|
+
clearTimeout(repaintNudgeTimer);
|
|
912
|
+
repaintNudgeTimer = null;
|
|
913
|
+
}
|
|
881
914
|
hostContextResizeObserver?.disconnect();
|
|
882
915
|
hostContextResizeObserver = null;
|
|
883
916
|
if (hostContextRaf !== null) {
|
|
@@ -5,6 +5,7 @@ import type { CanvasNodeState } from '../types';
|
|
|
5
5
|
import { nodeSurfaceUrl, surfaceContentHash } from './surface-url';
|
|
6
6
|
import { useAxSurfaceBridge } from './use-ax-surface-bridge';
|
|
7
7
|
import { useIframeContentHeight } from './use-iframe-content-height';
|
|
8
|
+
import { useSurfaceFrame } from './use-surface-frame';
|
|
8
9
|
|
|
9
10
|
export function shouldShowPresentationControls(node: CanvasNodeState): boolean {
|
|
10
11
|
return node.type === 'html' && node.data.presentation === true;
|
|
@@ -60,6 +61,9 @@ export function HtmlNode({
|
|
|
60
61
|
[html, presentation, presentationExitToken, themeToken, v, node.id, axToken, frameToken],
|
|
61
62
|
);
|
|
62
63
|
|
|
64
|
+
// src vs fetch()+srcdoc, decided by the boot-wide embed probe (Amp portals).
|
|
65
|
+
const surfaceFrame = useSurfaceFrame(surfaceSrc);
|
|
66
|
+
|
|
63
67
|
// Grow the node to fit the surface's reported content height (grow-only, gated).
|
|
64
68
|
// Never in the expanded overlay — there the surface fills the large overlay frame.
|
|
65
69
|
useIframeContentHeight(node, iframeRef, expanded ? '' : frameToken);
|
|
@@ -149,7 +153,7 @@ export function HtmlNode({
|
|
|
149
153
|
class={presentation ? 'html-node-frame html-node-frame-presentation' : 'html-node-frame'}
|
|
150
154
|
title={typeof node.data.title === 'string' ? node.data.title : 'HTML node'}
|
|
151
155
|
sandbox="allow-scripts"
|
|
152
|
-
|
|
156
|
+
{...surfaceFrame}
|
|
153
157
|
tabIndex={autoFocus ? 0 : undefined}
|
|
154
158
|
onLoad={handleFrameLoad}
|
|
155
159
|
style={{
|
|
@@ -6,6 +6,7 @@ import { shouldContentFitIframeNode } from '../canvas/auto-fit';
|
|
|
6
6
|
import { ExtAppFrame } from './ExtAppFrame';
|
|
7
7
|
import { useAxSurfaceBridge } from './use-ax-surface-bridge';
|
|
8
8
|
import { useIframeContentHeight } from './use-iframe-content-height';
|
|
9
|
+
import { useSurfaceFrame } from './use-surface-frame';
|
|
9
10
|
|
|
10
11
|
function withViewerParams(
|
|
11
12
|
url: string,
|
|
@@ -117,6 +118,9 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
|
|
|
117
118
|
frameToken || undefined,
|
|
118
119
|
contentFit,
|
|
119
120
|
);
|
|
121
|
+
// src vs fetch()+srcdoc, decided by the boot-wide embed probe (Amp portals).
|
|
122
|
+
// External viewer URLs always stay src — the hook passes them through.
|
|
123
|
+
const surfaceFrame = useSurfaceFrame(url);
|
|
120
124
|
const sourceServer = (node.data.sourceServer as string) || '';
|
|
121
125
|
const hostMode = (node.data.hostMode as string) || 'hosted';
|
|
122
126
|
const fallbackReason = node.data.fallbackReason as string | undefined;
|
|
@@ -174,7 +178,7 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
|
|
|
174
178
|
app/host RPC and broader capabilities. */}
|
|
175
179
|
<iframe
|
|
176
180
|
ref={iframeRef}
|
|
177
|
-
|
|
181
|
+
{...surfaceFrame}
|
|
178
182
|
class="mcp-app-frame"
|
|
179
183
|
sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
|
|
180
184
|
allow="clipboard-read; clipboard-write"
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { useEffect, useMemo, useState } from 'preact/hooks';
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
2
|
+
import { workbenchConnectionEpoch } from '../state/canvas-store';
|
|
3
|
+
import { iframeMode } from '../state/iframe-mode';
|
|
4
|
+
import { surfaceContentHash } from './surface-url';
|
|
2
5
|
|
|
3
6
|
interface FrameDocumentCreateResponse {
|
|
4
7
|
ok: boolean;
|
|
@@ -37,13 +40,20 @@ export async function createIframeDocumentUrl(html: string, sandbox: string): Pr
|
|
|
37
40
|
export function useIframeDocument(
|
|
38
41
|
html: string,
|
|
39
42
|
sandbox: string,
|
|
40
|
-
): { attributes: { src?: string }; ready: boolean; key: string } {
|
|
43
|
+
): { attributes: { src?: string; srcdoc?: string }; ready: boolean; key: string } {
|
|
44
|
+
// Boot-wide embed probe (state/iframe-mode.ts): srcdoc mode means src-URL
|
|
45
|
+
// iframes are blocked here (nested-iframe hosts like Amp orb portals), so the
|
|
46
|
+
// frame-documents round-trip would produce a URL the browser refuses to load.
|
|
47
|
+
const mode = iframeMode.value;
|
|
41
48
|
const [src, setSrc] = useState<string | null>(null);
|
|
49
|
+
const [remintNonce, setRemintNonce] = useState(0);
|
|
50
|
+
const mintEpochRef = useRef(0);
|
|
42
51
|
|
|
43
52
|
useEffect(() => {
|
|
44
53
|
setSrc(null);
|
|
45
|
-
if (!html) return;
|
|
54
|
+
if (!html || mode !== 'src') return;
|
|
46
55
|
let cancelled = false;
|
|
56
|
+
mintEpochRef.current = workbenchConnectionEpoch.value;
|
|
47
57
|
void createIframeDocumentUrl(html, sandbox)
|
|
48
58
|
.then((url) => {
|
|
49
59
|
if (!cancelled) setSrc(url);
|
|
@@ -54,14 +64,57 @@ export function useIframeDocument(
|
|
|
54
64
|
return () => {
|
|
55
65
|
cancelled = true;
|
|
56
66
|
};
|
|
57
|
-
}, [html, sandbox]);
|
|
67
|
+
}, [html, sandbox, mode, remintNonce]);
|
|
58
68
|
|
|
59
|
-
|
|
60
|
-
|
|
69
|
+
// Finding S: frame documents live in server memory, so a daemon restart turns
|
|
70
|
+
// this mount's URL into a 404 while the panel keeps showing the dead frame.
|
|
71
|
+
// Every reconnect frame (SSE reconnect or poll snapshot reset) revalidates the
|
|
72
|
+
// minted URL and re-mints a fresh document when the server no longer has it —
|
|
73
|
+
// no full workbench reload required.
|
|
74
|
+
const epoch = workbenchConnectionEpoch.value;
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (mode !== 'src' || epoch === mintEpochRef.current) return;
|
|
77
|
+
if (!src) {
|
|
78
|
+
// A previous mint failed outright (e.g. the daemon flapped down between
|
|
79
|
+
// the HEAD 404 and the re-mint POST). A fresh connection epoch means the
|
|
80
|
+
// server is reachable again — retry the mint instead of stranding the
|
|
81
|
+
// tile until a manual reload.
|
|
82
|
+
setRemintNonce((n) => n + 1);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
let cancelled = false;
|
|
86
|
+
void fetch(src, { method: 'HEAD' })
|
|
87
|
+
.then((res) => {
|
|
88
|
+
if (cancelled) return;
|
|
89
|
+
if (res.ok) {
|
|
90
|
+
mintEpochRef.current = epoch;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
setRemintNonce((n) => n + 1);
|
|
94
|
+
})
|
|
95
|
+
.catch(() => {
|
|
96
|
+
// Transient network failure — the next reconnect epoch retries.
|
|
97
|
+
});
|
|
98
|
+
return () => {
|
|
99
|
+
cancelled = true;
|
|
100
|
+
};
|
|
101
|
+
}, [epoch, src, mode]);
|
|
102
|
+
|
|
103
|
+
return useMemo(() => {
|
|
104
|
+
if (mode === 'srcdoc' && html) {
|
|
105
|
+
// The document already lives client-side, so serve it inline. Consumers
|
|
106
|
+
// always render the iframe `sandbox` attribute, which supplies the
|
|
107
|
+
// sandboxing the frame-document CSP header would have.
|
|
108
|
+
return {
|
|
109
|
+
attributes: { srcdoc: html },
|
|
110
|
+
ready: true,
|
|
111
|
+
key: `srcdoc-${surfaceContentHash(html)}`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
61
115
|
attributes: src ? { src } : {},
|
|
62
116
|
ready: Boolean(src),
|
|
63
117
|
key: src ?? '',
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
);
|
|
118
|
+
};
|
|
119
|
+
}, [src, mode, html]);
|
|
67
120
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
2
|
+
import { iframeMode } from '../state/iframe-mode';
|
|
3
|
+
|
|
4
|
+
function isSameOriginUrl(url: string): boolean {
|
|
5
|
+
try {
|
|
6
|
+
return new URL(url, window.location.origin).origin === window.location.origin;
|
|
7
|
+
} catch {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Attributes for a surface iframe, honoring the boot-wide iframe mode
|
|
14
|
+
* (state/iframe-mode.ts): `src` by default, fetch() + `srcdoc` when src-URL
|
|
15
|
+
* iframes are blocked by the embedding context (nested-iframe hosts like Amp
|
|
16
|
+
* orb portals). Cross-origin URLs always stay `src` — they cannot be fetched
|
|
17
|
+
* from here. While the probe is pending, same-origin frames stay empty rather
|
|
18
|
+
* than loading a src that may show the broken placeholder; a failed srcdoc
|
|
19
|
+
* fetch falls back to `src` (no worse than before).
|
|
20
|
+
*/
|
|
21
|
+
export function useSurfaceFrame(url: string): { src?: string; srcdoc?: string } {
|
|
22
|
+
const mode = iframeMode.value;
|
|
23
|
+
const [doc, setDoc] = useState<{ url: string; html: string } | null>(null);
|
|
24
|
+
const sameOrigin = Boolean(url) && isSameOriginUrl(url);
|
|
25
|
+
const wantsSrcdoc = mode === 'srcdoc' && sameOrigin;
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!wantsSrcdoc) return;
|
|
29
|
+
let cancelled = false;
|
|
30
|
+
void fetch(url, { credentials: 'same-origin' })
|
|
31
|
+
.then((res) => (res.ok ? res.text() : Promise.reject(new Error(`HTTP ${res.status}`))))
|
|
32
|
+
.then((html) => {
|
|
33
|
+
if (!cancelled) setDoc({ url, html });
|
|
34
|
+
})
|
|
35
|
+
.catch((error) => {
|
|
36
|
+
console.error('[surface-frame] srcdoc fetch failed, falling back to src:', url, error);
|
|
37
|
+
// Empty html marks the src fallback below.
|
|
38
|
+
if (!cancelled) setDoc({ url, html: '' });
|
|
39
|
+
});
|
|
40
|
+
return () => {
|
|
41
|
+
cancelled = true;
|
|
42
|
+
};
|
|
43
|
+
}, [wantsSrcdoc, url]);
|
|
44
|
+
|
|
45
|
+
if (!url) return {};
|
|
46
|
+
if (!wantsSrcdoc) {
|
|
47
|
+
return mode === null && sameOrigin ? {} : { src: url };
|
|
48
|
+
}
|
|
49
|
+
// Keep the previous document visible while a newer URL is being fetched —
|
|
50
|
+
// same UX as src mode, where the old document stays until the new one loads.
|
|
51
|
+
if (doc) return doc.html ? { srcdoc: doc.html } : { src: doc.url };
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
@@ -22,6 +22,11 @@ export const edges = signal<Map<string, CanvasEdge>>(new Map());
|
|
|
22
22
|
export const annotations = signal<Map<string, CanvasAnnotation>>(new Map());
|
|
23
23
|
export const activeNodeId = signal<string | null>(null);
|
|
24
24
|
export const connectionStatus = signal<ConnectionStatus>('connecting');
|
|
25
|
+
// Bumped on every processed `connected` frame (SSE reconnects and poll snapshot
|
|
26
|
+
// resets alike). Consumers holding server-minted URLs revalidate on change —
|
|
27
|
+
// a server restart invalidates in-memory resources like frame documents
|
|
28
|
+
// (Finding S) while the browser keeps the stale URLs alive.
|
|
29
|
+
export const workbenchConnectionEpoch = signal<number>(0);
|
|
25
30
|
export const sessionId = signal<string>('');
|
|
26
31
|
export const traceEnabled = signal<boolean>(false);
|
|
27
32
|
export const canvasTheme = signal<string>('dark');
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nested-iframe embedding detection (Amp orb portals).
|
|
3
|
+
*
|
|
4
|
+
* When the canvas page itself runs inside an iframe (ampcode.com embeds the
|
|
5
|
+
* portal URL on *.onamp.dev), Chrome refuses to load child iframes from `src`
|
|
6
|
+
* URLs — even same-origin relative ones — leaving every iframe-backed node a
|
|
7
|
+
* gray "refused to connect" placeholder. Inline documents (`srcdoc`) still
|
|
8
|
+
* render. This module probes the real behavior once per boot: a hidden 1px
|
|
9
|
+
* iframe navigates to the tiny /api/canvas/iframe-probe document; if `load`
|
|
10
|
+
* fires the host is normal (`src` mode), otherwise same-origin surfaces fall
|
|
11
|
+
* back to fetch() + `srcdoc` (see nodes/use-surface-frame.ts and
|
|
12
|
+
* nodes/iframe-document-url.ts).
|
|
13
|
+
*
|
|
14
|
+
* `src` stays the default everywhere it works: URL-loaded surfaces keep real
|
|
15
|
+
* document URLs, and the frame-document transport exists precisely because
|
|
16
|
+
* attribute-sized srcdoc documents were the worse default (v0.1.24). srcdoc is
|
|
17
|
+
* the blocked-embed fallback, not the new normal.
|
|
18
|
+
*
|
|
19
|
+
* Force a mode with `?iframe-mode=srcdoc` (or `=src`) for debugging.
|
|
20
|
+
*/
|
|
21
|
+
import { signal } from '@preact/signals';
|
|
22
|
+
|
|
23
|
+
export type IframeMode = 'src' | 'srcdoc';
|
|
24
|
+
|
|
25
|
+
export const IFRAME_PROBE_PATH = '/api/canvas/iframe-probe';
|
|
26
|
+
const PROBE_TIMEOUT_MS = 3000;
|
|
27
|
+
|
|
28
|
+
/** Boot-wide iframe transport mode; null until the probe (or override) resolves. */
|
|
29
|
+
export const iframeMode = signal<IframeMode | null>(null);
|
|
30
|
+
|
|
31
|
+
export function forcedIframeMode(): IframeMode | null {
|
|
32
|
+
const value = new URLSearchParams(window.location.search).get('iframe-mode');
|
|
33
|
+
return value === 'srcdoc' || value === 'src' ? value : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Probe whether `src`-URL iframes load in this embedding context. Resolves true
|
|
38
|
+
* when the hidden probe iframe fires `load`; false on error or timeout. The
|
|
39
|
+
* blocked-portal case shows its placeholder without reliably firing either
|
|
40
|
+
* event, so the timeout is the real signal there — and a false negative is
|
|
41
|
+
* safe, because srcdoc rendering works in normal hosts too.
|
|
42
|
+
*/
|
|
43
|
+
export function probeSrcIframes(timeoutMs = PROBE_TIMEOUT_MS): Promise<boolean> {
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
const test = document.createElement('iframe');
|
|
46
|
+
test.setAttribute('data-pmx-iframe-probe', '');
|
|
47
|
+
test.style.cssText = 'position:fixed;width:1px;height:1px;left:-9999px;border:none';
|
|
48
|
+
test.setAttribute('sandbox', 'allow-scripts');
|
|
49
|
+
const done = (ok: boolean) => {
|
|
50
|
+
window.clearTimeout(timer);
|
|
51
|
+
test.remove();
|
|
52
|
+
resolve(ok);
|
|
53
|
+
};
|
|
54
|
+
const timer = window.setTimeout(() => done(false), timeoutMs);
|
|
55
|
+
test.addEventListener('load', () => done(true));
|
|
56
|
+
test.addEventListener('error', () => done(false));
|
|
57
|
+
test.src = IFRAME_PROBE_PATH;
|
|
58
|
+
(document.body ?? document.documentElement).appendChild(test);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let pending: Promise<IframeMode> | null = null;
|
|
63
|
+
|
|
64
|
+
function isEmbeddedDocument(): boolean {
|
|
65
|
+
try {
|
|
66
|
+
return window.self !== window.top;
|
|
67
|
+
} catch {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve the boot-wide iframe mode once; all surface hooks share the result.
|
|
74
|
+
* The blocked-src condition only exists when the canvas page itself runs
|
|
75
|
+
* inside an iframe, so top-level documents resolve `src` synchronously — the
|
|
76
|
+
* normal path pays zero probe latency before surfaces mount. Only embedded
|
|
77
|
+
* documents run the probe (`embedded` is overridable for tests).
|
|
78
|
+
*/
|
|
79
|
+
export function resolveIframeMode(opts: { embedded?: boolean } = {}): Promise<IframeMode> {
|
|
80
|
+
if (iframeMode.value) return Promise.resolve(iframeMode.value);
|
|
81
|
+
if (pending) return pending;
|
|
82
|
+
const forced = forcedIframeMode();
|
|
83
|
+
if (forced) {
|
|
84
|
+
iframeMode.value = forced;
|
|
85
|
+
return Promise.resolve(forced);
|
|
86
|
+
}
|
|
87
|
+
if (!(opts.embedded ?? isEmbeddedDocument())) {
|
|
88
|
+
iframeMode.value = 'src';
|
|
89
|
+
return Promise.resolve('src');
|
|
90
|
+
}
|
|
91
|
+
pending = probeSrcIframes().then((srcWorks) => {
|
|
92
|
+
const mode: IframeMode = srcWorks ? 'src' : 'srcdoc';
|
|
93
|
+
iframeMode.value = mode;
|
|
94
|
+
return mode;
|
|
95
|
+
});
|
|
96
|
+
return pending;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Test hook: clear the memoized probe so each test starts unresolved. */
|
|
100
|
+
export function resetIframeModeForTests(): void {
|
|
101
|
+
iframeMode.value = null;
|
|
102
|
+
pending = null;
|
|
103
|
+
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
sessionId,
|
|
24
24
|
traceEnabled,
|
|
25
25
|
updateNodeData,
|
|
26
|
+
workbenchConnectionEpoch,
|
|
26
27
|
} from './canvas-store';
|
|
27
28
|
import { fetchAxSurfaceState } from './intent-bridge';
|
|
28
29
|
import { DEFAULT_POSITIONS, makeNodeState } from './node-factory';
|
|
@@ -393,6 +394,8 @@ function parseCanvasAnnotation(raw: Record<string, unknown>): CanvasAnnotation |
|
|
|
393
394
|
function handleConnected(data: Record<string, unknown>): void {
|
|
394
395
|
sessionId.value = (data.sessionId as string) || '';
|
|
395
396
|
connectionStatus.value = 'connected';
|
|
397
|
+
// Reconnect marker for holders of server-minted URLs (Finding S).
|
|
398
|
+
workbenchConnectionEpoch.value += 1;
|
|
396
399
|
if (typeof data.theme === 'string') {
|
|
397
400
|
applyCanvasTheme(data.theme);
|
|
398
401
|
}
|