pmx-canvas 0.9.3 → 0.11.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/.github/extensions/pmx-canvas/runtime.json +1 -1
- package/.github/extensions/pmx-canvas/tools.json +9418 -2016
- package/CHANGELOG.md +54 -1
- package/dist/canvas/chunks/abnfDiagram-N423BO3Z-p8dtp6va.js +1 -0
- package/dist/canvas/chunks/architecture-TIHT7OUA-9tffvpsv.js +1 -0
- package/dist/canvas/chunks/architectureDiagram-T3A2C74G-5jhmxrzf.js +36 -0
- package/dist/canvas/chunks/blockDiagram-VBNYF7ZC-pw1fa9yb.js +132 -0
- package/dist/canvas/chunks/c4Diagram-5PPSVZJV-x07nq0tt.js +10 -0
- package/dist/canvas/chunks/classDiagram-JCYQIIEL-nc6g5x1r.js +1 -0
- package/dist/canvas/chunks/classDiagram-v2-OCEON4UE-0qnbpwvh.js +1 -0
- package/dist/canvas/chunks/cose-bilkent-JH36ORCC-nm39npq0.js +1 -0
- package/dist/canvas/chunks/cynefin-VYW2F7L2-ys8p46ts.js +1 -0
- package/dist/canvas/chunks/cynefinDiagram-MW4NZA55-vdxrwahz.js +62 -0
- package/dist/canvas/chunks/dagre-VZM6K2ZE-bqdm7wpd.js +4 -0
- package/dist/canvas/chunks/diagram-7IWD3JNH-gs5gpr93.js +30 -0
- package/dist/canvas/chunks/diagram-B4RE2ZJO-cgwjg47s.js +3 -0
- package/dist/canvas/chunks/diagram-LBJQPF4R-w3vahpav.js +24 -0
- package/dist/canvas/chunks/diagram-Q27KOJAE-42837z6h.js +24 -0
- package/dist/canvas/chunks/diagram-UB23O5K3-4fz83q59.js +41 -0
- package/dist/canvas/chunks/ebnfDiagram-BXEA7PRR-zphtgegj.js +1 -0
- package/dist/canvas/chunks/erDiagram-JOGREHBK-zvbbdxp4.js +85 -0
- package/dist/canvas/chunks/eventmodeling-45OFAUF4-v9q6dn4c.js +1 -0
- package/dist/canvas/chunks/flowDiagram-UKHOOZJN-yrr5z8tc.js +1 -0
- package/dist/canvas/chunks/ganttDiagram-PKOTCBZU-282jzycm.js +292 -0
- package/dist/canvas/chunks/gitGraph-TEB2WS4Q-kdwb8m0a.js +1 -0
- package/dist/canvas/chunks/gitGraphDiagram-DS77QQ5N-vqqy6p1b.js +106 -0
- package/dist/canvas/chunks/index-0rk4gp1g.js +2 -0
- package/dist/canvas/chunks/index-194m3hc3.js +2 -0
- package/dist/canvas/chunks/index-1r72hdzb.js +301 -0
- package/dist/canvas/chunks/index-353m7b0p.js +2 -0
- package/dist/canvas/chunks/index-3cy1bzwh.js +2 -0
- package/dist/canvas/chunks/index-5tv8v1hk.js +1 -0
- package/dist/canvas/chunks/index-6ceya556.js +332 -0
- package/dist/canvas/chunks/index-6qmsnxwy.js +4 -0
- package/dist/canvas/chunks/index-d3b3gqe9.js +2 -0
- package/dist/canvas/chunks/index-fjtdggjn.js +2 -0
- package/dist/canvas/chunks/index-g3pwnqeb.js +2 -0
- package/dist/canvas/chunks/index-gf4e0et4.js +2 -0
- package/dist/canvas/chunks/index-hj99nz1f.js +1 -0
- package/dist/canvas/chunks/index-nprm96xp.js +16 -0
- package/dist/canvas/chunks/index-qj8fxwsf.js +2 -0
- package/dist/canvas/chunks/index-r2fyc9w1.js +89 -0
- package/dist/canvas/chunks/index-rft2hcz5.js +2 -0
- package/dist/canvas/chunks/index-rh7nv68n.js +2 -0
- package/dist/canvas/chunks/index-s4j678gj.js +2 -0
- package/dist/canvas/chunks/index-tb1wf525.js +179 -0
- package/dist/canvas/chunks/index-vv56h019.js +2 -0
- package/dist/canvas/chunks/index-w2mra17t.js +207 -0
- package/dist/canvas/chunks/index-wgv7en21.js +232 -0
- package/dist/canvas/chunks/index-yw8h9vha.js +157 -0
- package/dist/canvas/chunks/info-DKCQHKI2-wpzs6sz3.js +1 -0
- package/dist/canvas/chunks/infoDiagram-6WML65LV-v1yvggqj.js +2 -0
- package/dist/canvas/chunks/ishikawaDiagram-WSZJBQD7-5hnq1a9p.js +70 -0
- package/dist/canvas/chunks/journeyDiagram-NVQOT4AX-zjcffveh.js +139 -0
- package/dist/canvas/chunks/kanban-definition-27J2QSJJ-dakqtfp1.js +89 -0
- package/dist/canvas/chunks/katex-dmg8k4zm.js +257 -0
- package/dist/canvas/chunks/mermaid.core-j2geswrd.js +12 -0
- package/dist/canvas/chunks/mindmap-definition-FAOFIHXS-142xhr3k.js +96 -0
- package/dist/canvas/chunks/packet-7NZHBO7P-xn439f0s.js +1 -0
- package/dist/canvas/chunks/pegDiagram-VL7TDLO6-5wvk7vsd.js +1 -0
- package/dist/canvas/chunks/pie-RZYD4A2V-0kf16rwh.js +1 -0
- package/dist/canvas/chunks/pieDiagram-7S7Q4E2Y-ztdhwycc.js +39 -0
- package/dist/canvas/chunks/quadrantDiagram-CIZ2JOQS-c5p9gmg6.js +7 -0
- package/dist/canvas/chunks/radar-I7S5WNFK-0g2pz36t.js +1 -0
- package/dist/canvas/chunks/railroad-3IZDKUUU-fcaran6j.js +1 -0
- package/dist/canvas/chunks/railroad-abnf-AHOZXSZD-wgdya41q.js +1 -0
- package/dist/canvas/chunks/railroad-ebnf-EBAXGLYW-gy07a4ka.js +1 -0
- package/dist/canvas/chunks/railroad-peg-LSFZ7HO6-ewk6wtkh.js +1 -0
- package/dist/canvas/chunks/railroadDiagram-AXF67PYL-xkmqkjwf.js +1 -0
- package/dist/canvas/chunks/requirementDiagram-LRYGKXZP-9wm2dyaw.js +84 -0
- package/dist/canvas/chunks/sankeyDiagram-W5VNT64P-2w19etgb.js +40 -0
- package/dist/canvas/chunks/sequenceDiagram-SI44F4Z6-0jn0vwmz.js +162 -0
- package/dist/canvas/chunks/sizeCapture-X5ZJPWSS-x75epwdq.js +1 -0
- package/dist/canvas/chunks/stateDiagram-OKZ733FA-schjpy6d.js +1 -0
- package/dist/canvas/chunks/stateDiagram-v2-UEYNNEHI-k8sfp2e7.js +1 -0
- package/dist/canvas/chunks/swimlanes-SLNWSIFB-gkw4xce3.js +1 -0
- package/dist/canvas/chunks/swimlanesDiagram-ULZ7WXOC-nqysw67j.js +8 -0
- package/dist/canvas/chunks/timeline-definition-Z64GVDOM-x7nkcy01.js +120 -0
- package/dist/canvas/chunks/treeView-QDETBFTQ-cesw6gp0.js +1 -0
- package/dist/canvas/chunks/treemap-6X3UGDF4-zdv4knp9.js +1 -0
- package/dist/canvas/chunks/vennDiagram-T6HMQDX7-x8dqn9yf.js +34 -0
- package/dist/canvas/chunks/wardley-OPB4EBWU-44pdddez.js +1 -0
- package/dist/canvas/chunks/wardleyDiagram-T6FBY63Y-t1j52t9r.js +78 -0
- package/dist/canvas/chunks/xychartDiagram-ELKLHX3M-38t16pme.js +7 -0
- package/dist/canvas/global.css +389 -229
- package/dist/canvas/index.js +141 -211
- package/dist/canvas/mcp-app.html +4205 -554
- package/dist/canvas/mermaid-entry.js +338 -338
- package/dist/canvas/surface-theme.css +6 -6
- package/dist/json-render/index.css +1 -1
- package/dist/json-render/index.js +49 -49
- package/dist/types/client/canvas/BarHint.d.ts +5 -3
- package/dist/types/client/canvas/CanvasNode.d.ts +0 -11
- package/dist/types/client/canvas/EdgeLayer.d.ts +1 -2
- package/dist/types/client/canvas/auto-fit.d.ts +4 -5
- package/dist/types/client/mermaid-entry.d.ts +1 -2
- package/dist/types/client/mermaid-render.d.ts +1 -0
- package/dist/types/client/nodes/BoardNode.d.ts +1 -1
- package/dist/types/client/nodes/ExtAppFrame.d.ts +7 -90
- package/dist/types/client/nodes/McpAppNode.d.ts +1 -25
- package/dist/types/client/nodes/MermaidNode.d.ts +1 -8
- package/dist/types/client/nodes/ViewerFrame.d.ts +29 -0
- package/dist/types/client/nodes/ViewerHost.d.ts +6 -0
- package/dist/types/client/nodes/iframe-document-url.d.ts +3 -9
- package/dist/types/client/nodes/primitive-copy.d.ts +5 -0
- package/dist/types/client/nodes/use-iframe-content-height.d.ts +1 -1
- package/dist/types/client/nodes/use-surface-frame.d.ts +4 -6
- package/dist/types/client/nodes/viewer-runtime-state.d.ts +2 -1
- package/dist/types/client/state/canvas-store.d.ts +3 -1
- package/dist/types/client/state/intent-bridge.d.ts +1 -0
- package/dist/types/client/state/presentation.d.ts +17 -6
- package/dist/types/json-render/server.d.ts +2 -1
- package/dist/types/mcp/canvas-access.d.ts +1 -12
- package/dist/types/mcp/resource-subscriptions.d.ts +7 -0
- package/dist/types/mcp/server.d.ts +2 -0
- package/dist/types/server/ax-wait.d.ts +1 -0
- package/dist/types/server/canvas-operations.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +2 -0
- package/dist/types/server/index.d.ts +11 -6
- package/dist/types/server/operations/http.d.ts +10 -0
- package/dist/types/server/operations/index.d.ts +2 -2
- package/dist/types/server/operations/invoker.d.ts +4 -3
- package/dist/types/server/operations/mcp-contracts.d.ts +20 -0
- package/dist/types/server/operations/mcp.d.ts +2 -0
- package/dist/types/server/operations/ops/batch.d.ts +1 -0
- package/dist/types/server/operations/ops/boards.d.ts +0 -1
- package/dist/types/server/operations/registry.d.ts +2 -2
- package/dist/types/server/operations/types.d.ts +12 -2
- package/dist/types/server/server.d.ts +2 -0
- package/dist/types/server/tour-control.d.ts +13 -3
- package/dist/types/server/webpage-node.d.ts +1 -0
- package/dist/types/shared/ax-surface-protocol.d.ts +0 -9
- package/dist/types/shared/tour.d.ts +8 -0
- package/docs/api-reference.md +2 -2
- package/docs/cli.md +1 -1
- package/docs/design/Chrome.dc.html +32 -6
- package/docs/design/Types1.dc.html +3 -2
- package/docs/design/Types2.dc.html +1 -1
- package/docs/design/Types3.dc.html +1 -1
- package/docs/design/Zoom.dc.html +37 -60
- package/docs/design/canvas.json +1 -1
- package/docs/design.md +169 -4
- package/docs/http-api.md +82 -1
- package/docs/mcp.md +101 -21
- package/docs/node-types.md +38 -3
- package/docs/plans/plan-009-tech-debt-backlog.md +2 -2
- package/docs/plans/plan-019-post-0.9-roadmap.md +402 -13
- package/docs/product-vision-2026-09.md +9 -0
- package/docs/sdk.md +5 -2
- package/package.json +2 -2
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
- package/plugins/pmx-canvas/plugin.json +1 -1
- package/plugins/pmx-canvas/runtime.json +1 -1
- package/plugins/pmx-canvas/tools.json +9418 -2016
- package/skills/pmx-canvas/SKILL.md +19 -21
- package/skills/pmx-canvas/references/full-reference.md +44 -6
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +7 -7
- package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +3 -5
- package/src/client/canvas/BarHint.tsx +6 -3
- package/src/client/canvas/CanvasNode.tsx +34 -47
- package/src/client/canvas/CanvasViewport.tsx +6 -7
- package/src/client/canvas/ContextMenu.tsx +63 -3
- package/src/client/canvas/EdgeLayer.tsx +365 -169
- package/src/client/canvas/ExpandedNodeOverlay.tsx +1 -2
- package/src/client/canvas/Presentation.tsx +53 -3
- package/src/client/canvas/ToolRail.tsx +2 -0
- package/src/client/canvas/TopBar.tsx +24 -9
- package/src/client/canvas/auto-fit.ts +9 -11
- package/src/client/canvas/canvas-area.ts +1 -1
- package/src/client/canvas/use-focus-trap.ts +6 -1
- package/src/client/mermaid-entry.ts +6 -20
- package/src/client/mermaid-render.ts +39 -0
- package/src/client/nodes/AxStepControls.tsx +45 -69
- package/src/client/nodes/BoardNode.tsx +65 -9
- package/src/client/nodes/ContextNode.tsx +88 -396
- package/src/client/nodes/ExtAppFrame.tsx +283 -1462
- package/src/client/nodes/FileNode.tsx +92 -81
- package/src/client/nodes/HtmlNode.tsx +8 -36
- package/src/client/nodes/LedgerNode.tsx +31 -17
- package/src/client/nodes/MarkdownNode.tsx +8 -17
- package/src/client/nodes/McpAppNode.tsx +48 -154
- package/src/client/nodes/MermaidNode.tsx +80 -69
- package/src/client/nodes/PromptNode.tsx +12 -14
- package/src/client/nodes/ResponseNode.tsx +8 -6
- package/src/client/nodes/StatusNode.tsx +22 -7
- package/src/client/nodes/StatusSummary.tsx +5 -3
- package/src/client/nodes/TraceNode.tsx +8 -8
- package/src/client/nodes/ViewerFrame.tsx +262 -0
- package/src/client/nodes/ViewerHost.tsx +41 -0
- package/src/client/nodes/WebpageNode.tsx +22 -24
- package/src/client/nodes/iframe-document-url.ts +20 -27
- package/src/client/nodes/primitive-copy.ts +97 -0
- package/src/client/nodes/use-iframe-content-height.ts +34 -10
- package/src/client/nodes/use-surface-frame.ts +22 -11
- package/src/client/nodes/viewer-runtime-state.ts +41 -35
- package/src/client/state/canvas-store.ts +18 -12
- package/src/client/state/intent-bridge.ts +1 -0
- package/src/client/state/presentation.ts +135 -15
- package/src/client/state/sse-bridge.ts +18 -19
- package/src/client/theme/global.css +389 -229
- package/src/client/theme/surface-theme.css +6 -6
- package/src/json-render/charts/tufte-components.tsx +44 -2
- package/src/json-render/renderer/index.css +62 -2
- package/src/json-render/renderer/index.tsx +31 -7
- package/src/json-render/server.ts +3 -2
- package/src/mcp/canvas-access.ts +6 -47
- package/src/mcp/resource-subscriptions.ts +181 -0
- package/src/mcp/server.ts +185 -219
- package/src/server/ax-wait.ts +21 -6
- package/src/server/board-export.ts +35 -24
- package/src/server/canvas-geometry.ts +1 -0
- package/src/server/canvas-operations.ts +6 -2
- package/src/server/canvas-state.ts +5 -0
- package/src/server/demo-state.json +106 -100
- package/src/server/html-primitives.ts +3 -9
- package/src/server/html-surface.ts +8 -0
- package/src/server/index.ts +30 -12
- package/src/server/operations/http.ts +69 -21
- package/src/server/operations/index.ts +8 -1
- package/src/server/operations/invoker.ts +53 -5
- package/src/server/operations/mcp-contracts.ts +354 -0
- package/src/server/operations/mcp.ts +153 -18
- package/src/server/operations/ops/ax-await.ts +18 -10
- package/src/server/operations/ops/ax-read.ts +2 -0
- package/src/server/operations/ops/ax-timeline.ts +2 -2
- package/src/server/operations/ops/batch.ts +27 -8
- package/src/server/operations/ops/boards.ts +1 -3
- package/src/server/operations/ops/nodes.ts +18 -3
- package/src/server/operations/ops/viewport.ts +11 -9
- package/src/server/operations/registry.ts +24 -4
- package/src/server/operations/types.ts +16 -2
- package/src/server/server.ts +17 -59
- package/src/server/tour-control.ts +66 -6
- package/src/server/webpage-node.ts +9 -4
- package/src/shared/ax-surface-protocol.ts +0 -11
- package/src/shared/content-height-reporter.ts +4 -3
- package/src/shared/tour.ts +8 -0
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import type { CallToolResult, ListToolsResult, RequestId, Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
import {
|
|
4
|
+
AppBridge,
|
|
5
|
+
PostMessageTransport,
|
|
6
|
+
buildAllowAttribute,
|
|
7
|
+
McpUiHostStylesSchema,
|
|
8
|
+
} from '@modelcontextprotocol/ext-apps/app-bridge';
|
|
9
|
+
import { useEffect, useMemo, useRef } from 'preact/hooks';
|
|
7
10
|
import {
|
|
8
11
|
AX_SURFACE_ACK_SOURCE,
|
|
9
12
|
AX_SURFACE_EMIT_SOURCE,
|
|
10
|
-
|
|
11
|
-
EXT_APP_PAINT_PROBE_SOURCE,
|
|
13
|
+
HTML_SURFACE_PUSH_SOURCE,
|
|
12
14
|
} from '../../shared/ax-surface-protocol.js';
|
|
13
15
|
import { extAppToolResultsMatch } from '../../shared/ext-app-tool-result.js';
|
|
14
16
|
import { DEFAULT_EXT_APP_SANDBOX } from '../../shared/surface.js';
|
|
15
17
|
import { canvasThemeScheme } from '../../shared/themes.js';
|
|
18
|
+
import { AUTO_FIT_TITLEBAR_HEIGHT } from '../canvas/auto-fit';
|
|
16
19
|
import {
|
|
17
20
|
canvasTheme,
|
|
18
21
|
collapseExpandedNode,
|
|
@@ -22,24 +25,13 @@ import {
|
|
|
22
25
|
persistLayout,
|
|
23
26
|
resizeNode,
|
|
24
27
|
} from '../state/canvas-store';
|
|
28
|
+
import { workbenchFetch } from '../state/workbench-transport';
|
|
25
29
|
import type { CanvasNodeState } from '../types';
|
|
26
|
-
import { AUTO_FIT_TITLEBAR_HEIGHT } from '../canvas/auto-fit';
|
|
27
30
|
import { useIframeDocument } from './iframe-document-url';
|
|
28
31
|
import { useAxSurfaceBridge } from './use-ax-surface-bridge';
|
|
32
|
+
import { RefreshingViewerFrame, waitForViewerPaint } from './ViewerFrame';
|
|
29
33
|
|
|
30
|
-
type McpUiTheme = 'light' | 'dark';
|
|
31
|
-
|
|
32
|
-
type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
|
|
33
34
|
type DisplayMode = 'inline' | 'fullscreen' | 'pip';
|
|
34
|
-
type ExtAppFrameStatus = 'loading' | 'ready' | 'done';
|
|
35
|
-
/** Paint probe outcome: only a content-carrying tick counts as painted. */
|
|
36
|
-
export type PaintProbeVerdict = 'content-verified' | 'tick-no-content' | 'timeout';
|
|
37
|
-
|
|
38
|
-
interface ExtAppHostDimensionsTarget {
|
|
39
|
-
clientWidth?: number;
|
|
40
|
-
clientHeight?: number;
|
|
41
|
-
getBoundingClientRect(): Pick<DOMRectReadOnly, 'width' | 'height'>;
|
|
42
|
-
}
|
|
43
35
|
|
|
44
36
|
async function postJson<T>(url: string, body: Record<string, unknown>): Promise<T> {
|
|
45
37
|
const response = await workbenchFetch(url, {
|
|
@@ -47,186 +39,37 @@ async function postJson<T>(url: string, body: Record<string, unknown>): Promise<
|
|
|
47
39
|
headers: { 'Content-Type': 'application/json' },
|
|
48
40
|
body: JSON.stringify(body),
|
|
49
41
|
});
|
|
50
|
-
const json = (await response.json()) as {
|
|
51
|
-
ok: boolean;
|
|
52
|
-
result?: T;
|
|
53
|
-
error?: string;
|
|
54
|
-
};
|
|
42
|
+
const json = (await response.json()) as { ok: boolean; result?: T; error?: string };
|
|
55
43
|
if (!json.ok) throw new Error(json.error ?? `Request failed: ${url}`);
|
|
56
44
|
return json.result as T;
|
|
57
45
|
}
|
|
58
46
|
|
|
59
|
-
|
|
60
|
-
* Finding F (0.2.4): detect a WebKit-only host — Safari or a WKWebView (e.g. the
|
|
61
|
-
* GitHub Copilot app's embedded panel). Blink engines (Chrome / Chromium / Edge /
|
|
62
|
-
* the Codex browser, all of which carry a Chrome/Chromium/CriOS/Edg token) and
|
|
63
|
-
* Android WebView are excluded, as is Gecko (no `AppleWebKit`). Used to gate the
|
|
64
|
-
* one-time ext-app iframe repaint remount to the only engine that exhibits the
|
|
65
|
-
* present-at-load black-tile paint race, so the remount is a strict no-op
|
|
66
|
-
* everywhere we can test (Chrome / Codex / Playwright).
|
|
67
|
-
*/
|
|
68
|
-
export function isWebKitOnlyHost(userAgent: string): boolean {
|
|
69
|
-
return /AppleWebKit/.test(userAgent) && !/Chrome|Chromium|CriOS|Edg|Android/.test(userAgent);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Finding N (0.4.7 report): the GitHub Copilot panel's WKWebView reports
|
|
74
|
-
* `document.visibilityState === 'hidden'` CONTINUOUSLY — the workbench document
|
|
75
|
-
* never transitions to 'visible' even with the panel open and on-screen. Two
|
|
76
|
-
* things break as a result: the `visibilitychange` re-arm below can never fire
|
|
77
|
-
* (there is no transition to listen for), and `paint-ok` becomes a false green —
|
|
78
|
-
* the app document happily answers the double-rAF paint probe while the
|
|
79
|
-
* compositor still shows black. The instrumented trail from that session carries
|
|
80
|
-
* `visibility: "hidden"` on essentially every recovery event.
|
|
81
|
-
*
|
|
82
|
-
* So: when the document CLAIMS hidden but the frame is demonstrably on-screen,
|
|
83
|
-
* do not trust the claim — spend one more recovery round as if visible. A host
|
|
84
|
-
* that ever reports 'visible' has a working signal and is left to the
|
|
85
|
-
* visibilitychange path, which keeps a genuinely backgrounded panel quiet.
|
|
86
|
-
*/
|
|
87
|
-
export function shouldAssumeVisibleRearm(options: {
|
|
88
|
-
userAgent: string;
|
|
89
|
-
visibilityState: string;
|
|
90
|
-
rect: { top: number; left: number; bottom: number; right: number; width: number; height: number };
|
|
91
|
-
viewport: { width: number; height: number };
|
|
92
|
-
alreadyRearmed: boolean;
|
|
93
|
-
}): boolean {
|
|
94
|
-
const { userAgent, visibilityState, rect, viewport, alreadyRearmed } = options;
|
|
95
|
-
if (alreadyRearmed) return false;
|
|
96
|
-
if (!isWebKitOnlyHost(userAgent)) return false;
|
|
97
|
-
if (visibilityState !== 'hidden') return false;
|
|
98
|
-
if (rect.width <= 0 || rect.height <= 0) return false;
|
|
99
|
-
return rect.bottom > 0 && rect.right > 0 && rect.top < viewport.height && rect.left < viewport.width;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Finding F (0.2.5, reworked 0.3.1): BOOT-AWARE serialized WebKit remount queue.
|
|
103
|
-
// The black tile is a cold-hydration BURST problem — a single ext-app repaints fine
|
|
104
|
-
// into an idle panel (like a live-created node, or expand+close), but several
|
|
105
|
-
// compositing at once overwhelm WebKit and all stay black. The 0.2.5 fixed-stagger
|
|
106
|
-
// slots (450ms apart) were NOT boot-aware: each recovery remount reboots the app
|
|
107
|
-
// (~1-2s for Excalidraw), so N staggered remounts overlapped into a fresh burst and
|
|
108
|
-
// the per-node one-shot flag was spent — exactly the multi-app reload blackout the
|
|
109
|
-
// 0.3.1 report shows. This queue runs remounts strictly one at a time: each entry
|
|
110
|
-
// performs its remount, then waits for that app's GENUINE initialized handshake
|
|
111
|
-
// (or a bounded timeout for an app that never boots) plus a settle delay before the
|
|
112
|
-
// next entry fires — so every remount lands in an idle panel, which is the
|
|
113
|
-
// empirically always-successful recovery (what expand+close does manually).
|
|
114
|
-
// Settle covers the scene DRAW: the app's initialized handshake fires before the
|
|
115
|
-
// replayed tool result arrives and the scene is painted, so the queue waits for the
|
|
116
|
-
// settled signal (bootstrap chain incl. tool-result send complete) plus this pause.
|
|
117
|
-
export const WEBKIT_REMOUNT_SETTLE_MS = 1000;
|
|
118
|
-
const WEBKIT_BOOT_TIMEOUT_MS = 7000;
|
|
119
|
-
|
|
120
|
-
export interface WebkitRemountTask {
|
|
121
|
-
/** Perform the remount. Return false if the node no longer needs it (skips the boot wait). */
|
|
122
|
-
remount: () => boolean;
|
|
123
|
-
/** Resolves when the remounted app genuinely boots AND finishes its bootstrap replay, or after a bounded timeout. */
|
|
124
|
-
awaitBoot: () => Promise<void>;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Bounded recovery trail readable via `webview evaluate` / devtools
|
|
128
|
-
// (window.__PMX_EXTAPP_LOG). The WebKit compositor dropout is otherwise
|
|
129
|
-
// unobservable from page JS — this is the only diagnostic surface.
|
|
130
|
-
export function extAppRecoveryLog(nodeId: string, event: string): void {
|
|
131
|
-
if (typeof window === 'undefined') return;
|
|
132
|
-
const host = window as unknown as { __PMX_EXTAPP_LOG?: Array<{ t: number; nodeId: string; event: string }> };
|
|
133
|
-
host.__PMX_EXTAPP_LOG ??= [];
|
|
134
|
-
if (host.__PMX_EXTAPP_LOG.length < 500) host.__PMX_EXTAPP_LOG.push({ t: Date.now(), nodeId, event });
|
|
135
|
-
queueExtAppRecoveryReport({ t: Date.now(), nodeId, event });
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Mirror the recovery trail to the daemon (batched, fire-and-forget) so a host
|
|
139
|
-
// panel without devtools access — the Copilot WKWebView, exactly where the
|
|
140
|
-
// black tiles happen — still yields an actionable trace: agents read it back
|
|
141
|
-
// via GET /api/canvas/debug/ext-app-recovery (0.3.2 report Finding N).
|
|
142
|
-
const extAppReportQueue: Array<{ t: number; nodeId: string; event: string; visibility?: string }> = [];
|
|
143
|
-
let extAppReportTimer: ReturnType<typeof setTimeout> | null = null;
|
|
144
|
-
function queueExtAppRecoveryReport(entry: { t: number; nodeId: string; event: string }): void {
|
|
145
|
-
extAppReportQueue.push({
|
|
146
|
-
...entry,
|
|
147
|
-
...(typeof document !== 'undefined' ? { visibility: document.visibilityState } : {}),
|
|
148
|
-
});
|
|
149
|
-
if (extAppReportTimer) return;
|
|
150
|
-
extAppReportTimer = setTimeout(() => {
|
|
151
|
-
extAppReportTimer = null;
|
|
152
|
-
const entries = extAppReportQueue.splice(0);
|
|
153
|
-
if (entries.length === 0) return;
|
|
154
|
-
void workbenchFetch('/api/canvas/debug/ext-app-recovery', {
|
|
155
|
-
method: 'POST',
|
|
156
|
-
headers: { 'Content-Type': 'application/json' },
|
|
157
|
-
body: JSON.stringify({ entries }),
|
|
158
|
-
}).catch(() => {
|
|
159
|
-
// Diagnostics only — never let reporting failures affect recovery.
|
|
160
|
-
});
|
|
161
|
-
}, 1500);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
let webkitRemountChain: Promise<void> = Promise.resolve();
|
|
165
|
-
export function enqueueWebkitRemount(task: WebkitRemountTask): void {
|
|
166
|
-
webkitRemountChain = webkitRemountChain
|
|
167
|
-
.then(async () => {
|
|
168
|
-
if (!task.remount()) return;
|
|
169
|
-
await task.awaitBoot();
|
|
170
|
-
await new Promise((resolve) => setTimeout(resolve, WEBKIT_REMOUNT_SETTLE_MS));
|
|
171
|
-
})
|
|
172
|
-
.catch(() => {});
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number): string {
|
|
176
|
-
const html = typeof node.data.html === 'string' ? node.data.html : '';
|
|
177
|
-
const serverName = typeof node.data.serverName === 'string' ? node.data.serverName : '';
|
|
178
|
-
const appSessionId = typeof node.data.appSessionId === 'string' ? node.data.appSessionId : '';
|
|
179
|
-
const sessionStatus = typeof node.data.sessionStatus === 'string' ? node.data.sessionStatus : '';
|
|
180
|
-
return `${node.id}:${retryKey}:${serverName}:${appSessionId}:${sessionStatus}:${html}`;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
export function resolveExtAppDisplayModeRequest(
|
|
184
|
-
requestedMode: DisplayMode,
|
|
185
|
-
isExpanded: boolean,
|
|
186
|
-
): { nextMode: DisplayMode; shouldExpand: boolean; shouldCollapse: boolean } {
|
|
187
|
-
if (requestedMode === 'fullscreen') {
|
|
188
|
-
return {
|
|
189
|
-
nextMode: 'fullscreen',
|
|
190
|
-
shouldExpand: !isExpanded,
|
|
191
|
-
shouldCollapse: false,
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
if (requestedMode === 'inline') {
|
|
196
|
-
return {
|
|
197
|
-
nextMode: 'inline',
|
|
198
|
-
shouldExpand: false,
|
|
199
|
-
shouldCollapse: isExpanded,
|
|
200
|
-
};
|
|
201
|
-
}
|
|
202
|
-
|
|
47
|
+
export function resolveExtAppDisplayModeRequest(requestedMode: DisplayMode, isExpanded: boolean) {
|
|
203
48
|
return {
|
|
204
49
|
nextMode: requestedMode,
|
|
205
|
-
shouldExpand:
|
|
206
|
-
shouldCollapse:
|
|
50
|
+
shouldExpand: requestedMode === 'fullscreen' && !isExpanded,
|
|
51
|
+
shouldCollapse: requestedMode === 'inline' && isExpanded,
|
|
207
52
|
};
|
|
208
53
|
}
|
|
209
54
|
|
|
210
|
-
export async function sendExtAppBootstrapState(
|
|
211
|
-
bridge: ExtAppBridgeNotifications,
|
|
212
|
-
toolInput: Record<string, unknown>,
|
|
213
|
-
toolResult: CallToolResult | undefined,
|
|
214
|
-
): Promise<void> {
|
|
215
|
-
await bridge.sendToolInput({ arguments: toolInput });
|
|
216
|
-
if (toolResult) {
|
|
217
|
-
await bridge.sendToolResult(toolResult);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
export function resolveExtAppSandbox(value: unknown): string {
|
|
222
|
-
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : DEFAULT_EXT_APP_SANDBOX;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
55
|
export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): string {
|
|
226
56
|
return `<script data-pmx-canvas-ax-bridge>
|
|
227
57
|
(function () {
|
|
228
58
|
const PMX_AX_TOKEN = ${JSON.stringify(axToken)};
|
|
229
59
|
const PMX_AX_NODE_ID = ${JSON.stringify(nodeId)};
|
|
60
|
+
window.addEventListener('message', function (event) {
|
|
61
|
+
const m = event.data;
|
|
62
|
+
if (event.source !== parent || !m || m.source !== 'pmx-viewer-theme' || m.token !== PMX_AX_TOKEN) return;
|
|
63
|
+
for (const [name, value] of Object.entries(m.variables)) document.documentElement.style.setProperty(name, value);
|
|
64
|
+
});
|
|
65
|
+
window.addEventListener('message', function (event) {
|
|
66
|
+
const m = event.data;
|
|
67
|
+
if (event.source !== parent || !m || m.source !== '${HTML_SURFACE_PUSH_SOURCE}' || m.token !== PMX_AX_TOKEN || m.type !== 'check-ready') return;
|
|
68
|
+
parent.postMessage({ source: '${HTML_SURFACE_PUSH_SOURCE}', type: 'mounted', token: PMX_AX_TOKEN }, '*');
|
|
69
|
+
requestAnimationFrame(function () { requestAnimationFrame(function () {
|
|
70
|
+
parent.postMessage({ source: '${HTML_SURFACE_PUSH_SOURCE}', type: 'ready', token: PMX_AX_TOKEN }, '*');
|
|
71
|
+
}); });
|
|
72
|
+
});
|
|
230
73
|
window.PMX_AX = window.PMX_AX || {};
|
|
231
74
|
const pending = new Map();
|
|
232
75
|
const ackListeners = [];
|
|
@@ -241,9 +84,7 @@ export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): stri
|
|
|
241
84
|
}, 10000);
|
|
242
85
|
pending.set(correlationId, function (result) { clearTimeout(timer); resolve(result); });
|
|
243
86
|
window.parent.postMessage({
|
|
244
|
-
source: '${AX_SURFACE_EMIT_SOURCE}',
|
|
245
|
-
token: PMX_AX_TOKEN,
|
|
246
|
-
nodeId: PMX_AX_NODE_ID,
|
|
87
|
+
source: '${AX_SURFACE_EMIT_SOURCE}', token: PMX_AX_TOKEN, nodeId: PMX_AX_NODE_ID,
|
|
247
88
|
correlationId: correlationId,
|
|
248
89
|
interaction: { type: String(type), payload: payload && typeof payload === 'object' ? payload : {} },
|
|
249
90
|
}, '*');
|
|
@@ -254,7 +95,7 @@ export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): stri
|
|
|
254
95
|
};
|
|
255
96
|
window.addEventListener('message', function (event) {
|
|
256
97
|
const m = event.data;
|
|
257
|
-
if (!m || m.source !== '${AX_SURFACE_ACK_SOURCE}' || m.token !== PMX_AX_TOKEN) return;
|
|
98
|
+
if (event.source !== window.parent || !m || m.source !== '${AX_SURFACE_ACK_SOURCE}' || m.token !== PMX_AX_TOKEN) return;
|
|
258
99
|
const result = m.result || { ok: false };
|
|
259
100
|
const resolver = m.correlationId ? pending.get(m.correlationId) : undefined;
|
|
260
101
|
if (resolver) { pending.delete(m.correlationId); resolver(result); }
|
|
@@ -267,132 +108,16 @@ export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): stri
|
|
|
267
108
|
</script>`;
|
|
268
109
|
}
|
|
269
110
|
|
|
270
|
-
/**
|
|
271
|
-
* Boot beacon injected into EVERY ext-app document (not gated on AX): posts one
|
|
272
|
-
* parent message the moment the iframe's scripts execute. This is the liveness
|
|
273
|
-
* signal the WebKit never-booted watchdog keys on — it distinguishes an app
|
|
274
|
-
* that is alive but boots via the 1200ms bootstrap fallback (e.g. a non-SDK
|
|
275
|
-
* widget that never sends ui/notifications/initialized) from a dead window
|
|
276
|
-
* whose scripts never ran. Without it, the watchdog treated every
|
|
277
|
-
* fallback-booted app as never-booted and remounted it up to the attempt cap —
|
|
278
|
-
* a reboot/flicker loop for exactly the apps the fallback exists to support
|
|
279
|
-
* (0.3.2 pre-release review). The token scopes the message to this component;
|
|
280
|
-
* the host also checks event.source against the live iframe.
|
|
281
|
-
*/
|
|
282
|
-
export function buildExtAppBootBeaconScript(frameToken: string, nodeId: string): string {
|
|
283
|
-
return `<script data-pmx-canvas-boot-beacon>
|
|
284
|
-
(function () {
|
|
285
|
-
var TOKEN = ${JSON.stringify(frameToken)};
|
|
286
|
-
var NODE_ID = ${JSON.stringify(nodeId)};
|
|
287
|
-
window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: TOKEN, nodeId: NODE_ID }, '*');
|
|
288
|
-
// Paint-tick answers (Finding N): a double rAF only fires when this frame's
|
|
289
|
-
// rendering pipeline is actually running — a frame whose layer WebKit never
|
|
290
|
-
// composites stays silent, and that silence drives the parent's recovery
|
|
291
|
-
// ladder. One unsolicited tick at boot, then on-demand answers to probes.
|
|
292
|
-
// Content oracle v2 (Finding N, 0.5.1 reopen): a LAID-OUT canvas passed the
|
|
293
|
-
// 0.5.1 oracle even when nothing was ever drawn into it — Excalidraw mounts
|
|
294
|
-
// its canvas element the moment React renders, so blank cream tiles read
|
|
295
|
-
// paint-ok (content-verified), the ladder concluded success, and Retry
|
|
296
|
-
// never engaged (Copilot 0.5.1 report: A/C/E cream with a green trail).
|
|
297
|
-
// The oracle now samples the canvas's PIXELS: a readable main drawing
|
|
298
|
-
// surface with uniform pixels is not content, whatever shell chrome or
|
|
299
|
-
// boilerplate text the document laid out around it.
|
|
300
|
-
function canvasInk(el) {
|
|
301
|
-
try {
|
|
302
|
-
if (!el.width || !el.height) return 'blank';
|
|
303
|
-
var sw = Math.min(96, el.width);
|
|
304
|
-
var sh = Math.min(64, el.height);
|
|
305
|
-
var probe = document.createElement('canvas');
|
|
306
|
-
probe.width = sw;
|
|
307
|
-
probe.height = sh;
|
|
308
|
-
var ctx = probe.getContext('2d', { willReadFrequently: true });
|
|
309
|
-
if (!ctx) return 'unknown';
|
|
310
|
-
ctx.drawImage(el, 0, 0, sw, sh);
|
|
311
|
-
var d = ctx.getImageData(0, 0, sw, sh).data;
|
|
312
|
-
var r = d[0], g = d[1], b = d[2], a = d[3];
|
|
313
|
-
for (var i = 4; i < d.length; i += 4) {
|
|
314
|
-
if (Math.abs(d[i] - r) > 3 || Math.abs(d[i + 1] - g) > 3 || Math.abs(d[i + 2] - b) > 3 || Math.abs(d[i + 3] - a) > 3) return 'ink';
|
|
315
|
-
}
|
|
316
|
-
return 'blank';
|
|
317
|
-
} catch (e) {
|
|
318
|
-
return 'unknown'; // tainted or WebGL-unreadable: pixels cannot be judged
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
function contentVerdict() {
|
|
322
|
-
try {
|
|
323
|
-
var canvases = document.querySelectorAll('canvas');
|
|
324
|
-
var blankMain = false;
|
|
325
|
-
var unreadable = false;
|
|
326
|
-
for (var i = 0; i < canvases.length; i++) {
|
|
327
|
-
var r = canvases[i].getBoundingClientRect();
|
|
328
|
-
if (r.width <= 8 || r.height <= 8) continue;
|
|
329
|
-
var ink = canvasInk(canvases[i]);
|
|
330
|
-
if (ink === 'ink') return { content: true, detail: 'ink' };
|
|
331
|
-
if (ink === 'unknown') unreadable = true;
|
|
332
|
-
else if (r.width >= 160 && r.height >= 120) blankMain = true;
|
|
333
|
-
}
|
|
334
|
-
// A readable, never-painted main surface vetoes everything below:
|
|
335
|
-
// toolbar icons and shell text around a blank drawing surface is
|
|
336
|
-
// exactly the false green the human reports as an empty cream tile.
|
|
337
|
-
if (blankMain) return { content: false, detail: 'blank-canvas' };
|
|
338
|
-
if (unreadable) return { content: true, detail: 'canvas-unreadable' };
|
|
339
|
-
var svgs = document.querySelectorAll('svg');
|
|
340
|
-
for (var j = 0; j < svgs.length; j++) {
|
|
341
|
-
var sr = svgs[j].getBoundingClientRect();
|
|
342
|
-
if (sr.width > 8 && sr.height > 8 && svgs[j].querySelector('path, rect, circle, ellipse, line, polyline, polygon, text, image, use')) {
|
|
343
|
-
return { content: true, detail: 'svg' };
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
var body = document.body;
|
|
347
|
-
if (body && body.innerText && body.innerText.trim().length > 0) return { content: true, detail: 'text' };
|
|
348
|
-
return { content: false, detail: 'empty' };
|
|
349
|
-
} catch (e) {
|
|
350
|
-
return { content: false, detail: 'probe-error' };
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
function sendPaintTick() {
|
|
354
|
-
requestAnimationFrame(function () {
|
|
355
|
-
requestAnimationFrame(function () {
|
|
356
|
-
var v = contentVerdict();
|
|
357
|
-
window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: TOKEN, nodeId: NODE_ID, kind: 'paint-tick', content: v.content, detail: v.detail }, '*');
|
|
358
|
-
});
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
window.addEventListener('message', function (event) {
|
|
362
|
-
var m = event.data;
|
|
363
|
-
if (m && m.source === '${EXT_APP_PAINT_PROBE_SOURCE}' && m.token === TOKEN) sendPaintTick();
|
|
364
|
-
});
|
|
365
|
-
sendPaintTick();
|
|
366
|
-
})();
|
|
367
|
-
</script>`;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* Base styles injected into every ext-app document (Finding Q, 0.3.4 report):
|
|
372
|
-
* apps aspect-fit their drawn surface to the frame width, and on narrow/tall
|
|
373
|
-
* tiles the leftover region shows the app document's own body background —
|
|
374
|
-
* pure black for the hosted Excalidraw bundle, in every engine. Making the
|
|
375
|
-
* app document transparent lets the host iframe's theme background
|
|
376
|
-
* (var(--c-panel)) show through the letterbox instead. Background only — app
|
|
377
|
-
* content, its drawn scene, and its own foreground styling are untouched.
|
|
378
|
-
*/
|
|
379
111
|
export function buildExtAppSurfaceBaseStyles(): string {
|
|
380
|
-
return '<style data-pmx-canvas-ext-app-base>html, body { background: transparent !important; }</style>';
|
|
112
|
+
return '<style data-pmx-canvas-ext-app-base>html, body { background: var(--c-panel, transparent) !important; }</style>';
|
|
381
113
|
}
|
|
382
114
|
|
|
383
|
-
export function injectExtAppAxBridgeScript(html: string,
|
|
384
|
-
if (!
|
|
385
|
-
const
|
|
386
|
-
if (
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
}
|
|
390
|
-
const bodyMatch = /<body\b[^>]*>/i.exec(html);
|
|
391
|
-
if (bodyMatch?.index !== undefined) {
|
|
392
|
-
const insertAt = bodyMatch.index + bodyMatch[0].length;
|
|
393
|
-
return `${html.slice(0, insertAt)}${axBridgeScript}${html.slice(insertAt)}`;
|
|
394
|
-
}
|
|
395
|
-
return `${axBridgeScript}${html}`;
|
|
115
|
+
export function injectExtAppAxBridgeScript(html: string, script: string): string {
|
|
116
|
+
if (!script) return html;
|
|
117
|
+
const head = /<head\b[^>]*>/i.exec(html) ?? /<body\b[^>]*>/i.exec(html);
|
|
118
|
+
if (!head) return `${script}${html}`;
|
|
119
|
+
const end = head.index + head[0].length;
|
|
120
|
+
return `${html.slice(0, end)}${script}${html.slice(end)}`;
|
|
396
121
|
}
|
|
397
122
|
|
|
398
123
|
function positiveDimension(value: number, fallback: number): number {
|
|
@@ -402,9 +127,16 @@ function positiveDimension(value: number, fallback: number): number {
|
|
|
402
127
|
}
|
|
403
128
|
|
|
404
129
|
export function resolveExtAppContainerDimensions(
|
|
405
|
-
target:
|
|
130
|
+
target:
|
|
131
|
+
| {
|
|
132
|
+
clientWidth?: number;
|
|
133
|
+
clientHeight?: number;
|
|
134
|
+
getBoundingClientRect(): Pick<DOMRectReadOnly, 'width' | 'height'>;
|
|
135
|
+
}
|
|
136
|
+
| null
|
|
137
|
+
| undefined,
|
|
406
138
|
fallback: { width: number; height: number },
|
|
407
|
-
)
|
|
139
|
+
) {
|
|
408
140
|
const rect = target?.getBoundingClientRect();
|
|
409
141
|
return {
|
|
410
142
|
width: positiveDimension(target?.clientWidth ?? 0, positiveDimension(rect?.width ?? 0, fallback.width)),
|
|
@@ -416,1186 +148,275 @@ export function shouldApplyExtAppSizeChange(height: unknown, isExpanded: boolean
|
|
|
416
148
|
return typeof height === 'number' && Number.isFinite(height) && height > 0 && !isExpanded;
|
|
417
149
|
}
|
|
418
150
|
|
|
419
|
-
|
|
420
|
-
|
|
151
|
+
// The MCP scheme remains dark/light, but apps also receive the complete canvas
|
|
152
|
+
// palette and the SDK's standard style aliases without a document reload.
|
|
153
|
+
function canvasVariables() {
|
|
154
|
+
const computed = getComputedStyle(document.documentElement);
|
|
155
|
+
const variables: Record<string, string> = {};
|
|
156
|
+
for (const name of Array.from(computed)) {
|
|
157
|
+
if (/^--(c-|font-|fs-|space-|r-|e-)/.test(name)) variables[name] = computed.getPropertyValue(name).trim();
|
|
158
|
+
}
|
|
159
|
+
return variables;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function hostStyles() {
|
|
163
|
+
const computed = getComputedStyle(document.documentElement);
|
|
164
|
+
const variables: Record<string, string> = {};
|
|
165
|
+
const aliases: Record<string, string> = {
|
|
166
|
+
'--color-background-primary': '--c-panel',
|
|
167
|
+
'--color-background-secondary': '--c-panel-soft',
|
|
168
|
+
'--color-text-primary': '--c-text',
|
|
169
|
+
'--color-text-secondary': '--c-text-soft',
|
|
170
|
+
'--color-border-primary': '--c-line',
|
|
171
|
+
'--font-sans': '--font-ui',
|
|
172
|
+
'--font-mono': '--font-code',
|
|
173
|
+
};
|
|
174
|
+
for (const [name, token] of Object.entries(aliases)) variables[name] = computed.getPropertyValue(token).trim();
|
|
175
|
+
return McpUiHostStylesSchema.parse({ variables });
|
|
421
176
|
}
|
|
422
177
|
|
|
423
178
|
export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
|
|
424
179
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
const sizePersistTimerRef = useRef<number | null>(null);
|
|
428
|
-
const latestToolInputRef = useRef<Record<string, unknown>>({});
|
|
429
|
-
const latestToolResultRef = useRef<CallToolResult | undefined>(undefined);
|
|
430
|
-
const toolResultSentRef = useRef(false);
|
|
431
|
-
const lastSentToolResultRef = useRef<CallToolResult | undefined>(undefined);
|
|
432
|
-
const toolResultSendingRef = useRef<Promise<void> | null>(null);
|
|
433
|
-
const bridgeReadyRef = useRef(false);
|
|
434
|
-
const themeUnsubRef = useRef<(() => void) | null>(null);
|
|
435
|
-
const webkitRemountAttemptsRef = useRef(0);
|
|
436
|
-
// Paint oracle (Finding N): resolvers waiting for the frame's next paint-tick,
|
|
437
|
-
// each handed the tick's content verdict + the oracle's reason for it.
|
|
438
|
-
const paintTickWaitersRef = useRef<Array<(tick: { content: boolean; detail: string }) => void>>([]);
|
|
439
|
-
// Last tick's oracle detail (ink / blank-canvas / svg / text / …) for the
|
|
440
|
-
// recovery trail — the label is the diagnostic the field reports read.
|
|
441
|
-
const lastPaintDetailRef = useRef<string>('');
|
|
442
|
-
// Genuine boot signal: set ONLY when the app completes the ui/initialize
|
|
443
|
-
// handshake (bridge.oninitialized) — NOT by the 1200ms bootstrap fallback,
|
|
444
|
-
// which flips status via notifications that resolve even into a dead iframe.
|
|
445
|
-
const appInitializedRef = useRef(false);
|
|
446
|
-
// Liveness signal from the injected boot beacon: the CURRENT frame's scripts
|
|
447
|
-
// executed. Proves "alive" for apps that boot via the fallback path without
|
|
448
|
-
// ever sending initialized; a dead window never beacons.
|
|
449
|
-
const appScriptsRanRef = useRef(false);
|
|
450
|
-
const bootWaitersRef = useRef<Array<() => void>>([]);
|
|
451
|
-
const remountQueuedRef = useRef(false);
|
|
452
|
-
// Recovery ran while the document was hidden (compositing suppressed) — re-arm
|
|
453
|
-
// one fresh remount round when the panel becomes visible (Finding N).
|
|
454
|
-
const bootedWhileHiddenRef = useRef(false);
|
|
455
|
-
// Finding N (0.4.7): at most one "assume visible" round per frame lifetime, so
|
|
456
|
-
// a host that is hidden forever cannot turn the re-arm into a remount loop.
|
|
457
|
-
const assumeVisibleRearmDoneRef = useRef(false);
|
|
458
|
-
const unmountedRef = useRef(false);
|
|
459
|
-
const [status, setStatus] = useState<ExtAppFrameStatus>('loading');
|
|
460
|
-
const [error, setError] = useState<string | null>(null);
|
|
461
|
-
const [retryKey, setRetryKey] = useState(0);
|
|
462
|
-
// WebKit paint ladder state: overlay holds until paint is confirmed; when the
|
|
463
|
-
// ladder exhausts its budget the tile shows an explicit Retry instead of
|
|
464
|
-
// silent black (Finding N).
|
|
465
|
-
const [paintPending, setPaintPending] = useState(false);
|
|
466
|
-
const [recoveryExhausted, setRecoveryExhausted] = useState(false);
|
|
467
|
-
// Serialized initial mount (Finding N): on WebKit, present-at-load ext-apps
|
|
468
|
-
// boot strictly one at a time through the shared queue — the cold-hydration
|
|
469
|
-
// BURST is what overwhelms the compositor. Everywhere else, mount instantly.
|
|
470
|
-
const [mountGranted, setMountGranted] = useState(
|
|
471
|
-
() =>
|
|
472
|
-
expanded ||
|
|
473
|
-
typeof navigator === 'undefined' ||
|
|
474
|
-
typeof window === 'undefined' ||
|
|
475
|
-
!isWebKitOnlyHost(navigator.userAgent),
|
|
180
|
+
const connections = useRef(
|
|
181
|
+
new Map<HTMLIFrameElement, { flush: () => void; theme: () => void; loaded: () => Promise<boolean> }>(),
|
|
476
182
|
);
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const
|
|
183
|
+
const latest = useRef(node);
|
|
184
|
+
latest.current = node;
|
|
185
|
+
const html = typeof node.data.html === 'string' ? node.data.html : '';
|
|
480
186
|
const appSessionId = node.data.appSessionId as string | undefined;
|
|
481
|
-
const toolInput = (node.data.toolInput as Record<string, unknown> | undefined) ?? {};
|
|
482
|
-
const toolResult = node.data.toolResult as CallToolResult | undefined;
|
|
483
|
-
const toolName = (node.data.toolName as string) ?? 'ext-app';
|
|
484
|
-
const toolDefinition = node.data.toolDefinition as Tool | undefined;
|
|
485
|
-
const rawToolCallId = node.data.toolCallId;
|
|
486
|
-
const toolCallId: RequestId | undefined =
|
|
487
|
-
typeof rawToolCallId === 'string' || typeof rawToolCallId === 'number' ? rawToolCallId : undefined;
|
|
488
187
|
const resourceMeta = node.data.resourceMeta as
|
|
489
|
-
| {
|
|
490
|
-
csp?: Record<string, unknown>;
|
|
491
|
-
permissions?: Record<string, unknown>;
|
|
492
|
-
}
|
|
188
|
+
| { csp?: Record<string, unknown>; permissions?: Record<string, unknown> }
|
|
493
189
|
| undefined;
|
|
494
|
-
const sessionStatus = node.data.sessionStatus as string | undefined;
|
|
495
|
-
const sessionError = node.data.sessionError as string | undefined;
|
|
496
|
-
const maxHeight = node.size.height;
|
|
497
|
-
const nodeId = node.id;
|
|
498
|
-
const frameKey = getExtAppBridgeInitKey(node, retryKey);
|
|
499
|
-
const iframeSandbox = resolveExtAppSandbox(null);
|
|
500
|
-
// Phase 6 — opt-in ext-app AX bridge. When the node sets data.axCapabilities.enabled,
|
|
501
|
-
// inject window.PMX_AX into the app HTML and accept emits below (server re-validates).
|
|
502
|
-
const axCaps = node.data.axCapabilities as { enabled?: boolean } | undefined;
|
|
503
|
-
const axEnabled = axCaps?.enabled === true && typeof html === 'string' && html.length > 0;
|
|
504
190
|
const axToken = useMemo(() => `ax-${crypto.randomUUID()}`, []);
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
typeof html === 'string' && html.length > 0 ? buildExtAppBootBeaconScript(axToken, nodeId) : '';
|
|
510
|
-
const iframeDocument = useIframeDocument(
|
|
511
|
-
injectExtAppAxBridgeScript(html ?? '', buildExtAppSurfaceBaseStyles() + bootBeaconScript + axBridgeScript),
|
|
512
|
-
iframeSandbox,
|
|
191
|
+
const axEnabled = (node.data.axCapabilities as { enabled?: boolean } | undefined)?.enabled === true;
|
|
192
|
+
const documentHtml = injectExtAppAxBridgeScript(
|
|
193
|
+
html,
|
|
194
|
+
buildExtAppSurfaceBaseStyles() + buildExtAppAxBridgeScript(axToken, node.id),
|
|
513
195
|
);
|
|
196
|
+
const frameDocument = useIframeDocument(documentHtml, DEFAULT_EXT_APP_SANDBOX);
|
|
197
|
+
useAxSurfaceBridge({ enabled: axEnabled, token: axToken, nodeId: node.id, sourceSurface: 'mcp-app', iframeRef });
|
|
514
198
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
if (!data || data.source !== EXT_APP_BOOT_BEACON_SOURCE || data.token !== axToken || data.nodeId !== nodeId)
|
|
520
|
-
return;
|
|
521
|
-
appScriptsRanRef.current = true;
|
|
522
|
-
if (data.kind === 'paint-tick') {
|
|
523
|
-
const tickData = data as { content?: unknown; detail?: unknown };
|
|
524
|
-
const tick = {
|
|
525
|
-
content: tickData.content === true,
|
|
526
|
-
detail: typeof tickData.detail === 'string' ? tickData.detail : '',
|
|
527
|
-
};
|
|
528
|
-
for (const waiter of paintTickWaitersRef.current.splice(0)) waiter(tick);
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
window.addEventListener('message', onBootBeacon);
|
|
532
|
-
return () => window.removeEventListener('message', onBootBeacon);
|
|
533
|
-
}, [axToken, nodeId]);
|
|
199
|
+
const sessionMessage =
|
|
200
|
+
node.data.sessionStatus === 'error'
|
|
201
|
+
? `Live session unavailable. Showing saved content. Reopen the app to restore interactivity.${node.data.sessionError ? ` (${node.data.sessionError})` : ''}`
|
|
202
|
+
: 'Reconnecting saved app session…';
|
|
534
203
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
setMountGranted(true);
|
|
547
|
-
return true;
|
|
548
|
-
},
|
|
549
|
-
awaitBoot: () =>
|
|
550
|
-
new Promise<void>((resolve) => {
|
|
551
|
-
const timer = window.setTimeout(finish, WEBKIT_BOOT_TIMEOUT_MS);
|
|
552
|
-
function finish() {
|
|
553
|
-
window.clearTimeout(timer);
|
|
554
|
-
resolve();
|
|
555
|
-
}
|
|
556
|
-
bootWaitersRef.current.push(finish);
|
|
557
|
-
}),
|
|
558
|
-
});
|
|
559
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
560
|
-
}, []);
|
|
561
|
-
|
|
562
|
-
// Ext-app AX emits — the shared sandboxed-surface trust boundary (M2).
|
|
563
|
-
useAxSurfaceBridge({ enabled: axEnabled, token: axToken, nodeId, sourceSurface: 'mcp-app', iframeRef });
|
|
564
|
-
|
|
565
|
-
// Enqueue one serialized remount attempt for this node (Finding F recovery).
|
|
566
|
-
// Attempts are capped so a persistently-failing app degrades to the manual
|
|
567
|
-
// fallback (expand+close / Retry) instead of remount-looping forever.
|
|
568
|
-
const WEBKIT_MAX_REMOUNT_ATTEMPTS = 3;
|
|
569
|
-
const scheduleWebkitRemount = (reason: string): boolean => {
|
|
570
|
-
if (webkitRemountAttemptsRef.current >= WEBKIT_MAX_REMOUNT_ATTEMPTS) {
|
|
571
|
-
extAppRecoveryLog(nodeId, `remount-cap-hit (${reason})`);
|
|
572
|
-
return false;
|
|
573
|
-
}
|
|
574
|
-
if (remountQueuedRef.current) return true; // an attempt is already queued and has not run yet
|
|
575
|
-
webkitRemountAttemptsRef.current += 1;
|
|
576
|
-
remountQueuedRef.current = true;
|
|
577
|
-
extAppRecoveryLog(nodeId, `remount-queued #${webkitRemountAttemptsRef.current} (${reason})`);
|
|
578
|
-
enqueueWebkitRemount({
|
|
579
|
-
remount: () => {
|
|
580
|
-
remountQueuedRef.current = false;
|
|
581
|
-
if (unmountedRef.current || (!expanded && expandedNodeId.value === nodeId)) {
|
|
582
|
-
extAppRecoveryLog(nodeId, 'remount-skipped');
|
|
583
|
-
// The ladder left paintPending set on the promise that this remount
|
|
584
|
-
// would re-enter it via the new frame's settle path. A skip means
|
|
585
|
-
// that never happens — release the overlay here, or the tile spins
|
|
586
|
-
// "Finishing paint…" forever with no Retry (the expanded overlay
|
|
587
|
-
// mounts its own frame, and closing it re-runs the inline ladder).
|
|
588
|
-
setPaintPending(false);
|
|
589
|
-
return false;
|
|
590
|
-
}
|
|
591
|
-
// Stamp visibility at RUN time, not schedule time: the serialized queue
|
|
592
|
-
// can hold this slot for many seconds, and what decides whether the
|
|
593
|
-
// repaint can composite is the document's visibility when the remount
|
|
594
|
-
// actually executes (the Finding N re-arm keys on this flag). Covers
|
|
595
|
-
// every scheduling path (post-boot repaint, boot watchdog, re-arm).
|
|
596
|
-
bootedWhileHiddenRef.current = typeof document !== 'undefined' && document.visibilityState === 'hidden';
|
|
597
|
-
extAppRecoveryLog(nodeId, `remount-run #${webkitRemountAttemptsRef.current}`);
|
|
598
|
-
// Back to 'loading' so the connecting overlay covers this recovery
|
|
599
|
-
// remount's boot window too (the fallback boot flipped status to
|
|
600
|
-
// ready/done even when the previous frame was dead — without this the
|
|
601
|
-
// overlay only ever covered the INITIAL boot, not recovery).
|
|
602
|
-
setStatus('loading');
|
|
603
|
-
setRetryKey((k) => k + 1);
|
|
604
|
-
return true;
|
|
605
|
-
},
|
|
606
|
-
awaitBoot: () =>
|
|
607
|
-
new Promise<void>((resolve) => {
|
|
608
|
-
const timer = window.setTimeout(finish, WEBKIT_BOOT_TIMEOUT_MS);
|
|
609
|
-
function finish() {
|
|
610
|
-
window.clearTimeout(timer);
|
|
611
|
-
resolve();
|
|
612
|
-
}
|
|
613
|
-
bootWaitersRef.current.push(finish);
|
|
614
|
-
}),
|
|
615
|
-
});
|
|
616
|
-
return true;
|
|
617
|
-
};
|
|
618
|
-
|
|
619
|
-
// ── Paint oracle + recovery ladder (Finding N, 0.4.5 report) ────────────
|
|
620
|
-
// The old stack keyed recovery off "settled" (bridge ready + bootstrap
|
|
621
|
-
// replayed) and blind-remounted every booted app once. But Finding N is a
|
|
622
|
-
// COMPOSITOR failure after all those signals look healthy — the DOM
|
|
623
|
-
// recovers, the painted layer stays black, and the blind remount both
|
|
624
|
-
// missed real failures and rebooted healthy apps. The oracle: ask the app
|
|
625
|
-
// document for a double-rAF paint-tick. A frame whose rendering pipeline is
|
|
626
|
-
// live answers within a frame or two; silence is the paint-fail signal.
|
|
627
|
-
// Ladder on silence: soft-expand cycle (the automatic analogue of the
|
|
628
|
-
// user-proven enlarge+close — a real container size change forces WebKit to
|
|
629
|
-
// allocate a fresh layer texture, discarding the stale black one) → probe
|
|
630
|
-
// again → serialized remount (bounded by the shared attempt cap) → explicit
|
|
631
|
-
// Retry affordance. Never silent eternal black. WebKit inline frames only.
|
|
632
|
-
const PAINT_TICK_TIMEOUT_MS = 1500;
|
|
633
|
-
const probePaint = (): Promise<PaintProbeVerdict> =>
|
|
634
|
-
new Promise((resolve) => {
|
|
635
|
-
const contentWindow = iframeRef.current?.contentWindow;
|
|
636
|
-
if (!contentWindow || unmountedRef.current) {
|
|
637
|
-
resolve('timeout');
|
|
638
|
-
return;
|
|
639
|
-
}
|
|
640
|
-
let settled = false;
|
|
641
|
-
const timer = window.setTimeout(() => {
|
|
642
|
-
if (settled) return;
|
|
643
|
-
settled = true;
|
|
644
|
-
resolve('timeout');
|
|
645
|
-
}, PAINT_TICK_TIMEOUT_MS);
|
|
646
|
-
paintTickWaitersRef.current.push((tick) => {
|
|
647
|
-
if (settled) return;
|
|
648
|
-
settled = true;
|
|
649
|
-
window.clearTimeout(timer);
|
|
650
|
-
lastPaintDetailRef.current = tick.detail;
|
|
651
|
-
// A tick without content is NOT paint-ok: the rAF pipeline runs while
|
|
652
|
-
// the document is empty cream / never composited (Finding N reopen).
|
|
653
|
-
resolve(tick.content ? 'content-verified' : 'tick-no-content');
|
|
654
|
-
});
|
|
655
|
-
contentWindow.postMessage({ source: EXT_APP_PAINT_PROBE_SOURCE, token: axToken }, '*');
|
|
204
|
+
const connect = (iframe: HTMLIFrameElement, reportFailure: () => void) => {
|
|
205
|
+
let disposed = false;
|
|
206
|
+
let resizeRaf = 0;
|
|
207
|
+
let persistTimer = 0;
|
|
208
|
+
let initialized = false;
|
|
209
|
+
let inputSent = false;
|
|
210
|
+
let lastSent: CallToolResult | undefined;
|
|
211
|
+
let sending: Promise<void> | undefined;
|
|
212
|
+
let finishBoot!: (ready: boolean) => void;
|
|
213
|
+
const ready = new Promise<boolean>((resolve) => {
|
|
214
|
+
finishBoot = resolve;
|
|
656
215
|
});
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
// Force a real layer reallocation: grow the iframe's box meaningfully for two
|
|
671
|
-
// frames, then restore. Invisible under the paint-pending overlay.
|
|
672
|
-
const runSoftExpandCycle = (): Promise<void> =>
|
|
673
|
-
new Promise((resolve) => {
|
|
674
|
-
const frame = iframeRef.current;
|
|
675
|
-
if (!frame) {
|
|
676
|
-
resolve();
|
|
677
|
-
return;
|
|
678
|
-
}
|
|
679
|
-
const previousWidth = frame.style.width;
|
|
680
|
-
const previousHeight = frame.style.height;
|
|
681
|
-
frame.style.width = '140%';
|
|
682
|
-
frame.style.height = '140%';
|
|
683
|
-
requestAnimationFrame(() => {
|
|
684
|
-
requestAnimationFrame(() => {
|
|
685
|
-
frame.style.width = previousWidth;
|
|
686
|
-
frame.style.height = previousHeight;
|
|
687
|
-
extAppRecoveryLog(nodeId, 'soft-expand-cycle');
|
|
688
|
-
resolve();
|
|
689
|
-
});
|
|
690
|
-
});
|
|
216
|
+
let bootTimer: ReturnType<typeof setTimeout>;
|
|
217
|
+
const context = () => ({
|
|
218
|
+
theme: canvasThemeScheme(canvasTheme.value),
|
|
219
|
+
styles: hostStyles(),
|
|
220
|
+
platform: 'web' as const,
|
|
221
|
+
containerDimensions: resolveExtAppContainerDimensions(iframe, latest.current.size),
|
|
222
|
+
displayMode: expanded ? ('fullscreen' as const) : ('inline' as const),
|
|
223
|
+
locale: navigator.language,
|
|
224
|
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
225
|
+
...(node.data.toolDefinition
|
|
226
|
+
? { toolInfo: { id: node.data.toolCallId as RequestId | undefined, tool: node.data.toolDefinition as Tool } }
|
|
227
|
+
: {}),
|
|
691
228
|
});
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
if (
|
|
704
|
-
!shouldAssumeVisibleRearm({
|
|
705
|
-
userAgent: navigator.userAgent,
|
|
706
|
-
visibilityState: document.visibilityState,
|
|
707
|
-
rect: frame.getBoundingClientRect(),
|
|
708
|
-
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
709
|
-
alreadyRearmed: assumeVisibleRearmDoneRef.current,
|
|
710
|
-
})
|
|
711
|
-
)
|
|
712
|
-
return;
|
|
713
|
-
assumeVisibleRearmDoneRef.current = true;
|
|
714
|
-
// Re-check after a beat: a host whose visibility signal merely lagged the
|
|
715
|
-
// panel opening resolves itself here, and the visibilitychange path owns it.
|
|
716
|
-
window.setTimeout(() => {
|
|
717
|
-
if (unmountedRef.current || document.visibilityState === 'visible') return;
|
|
718
|
-
webkitRemountAttemptsRef.current = 0;
|
|
719
|
-
setRecoveryExhausted(false);
|
|
720
|
-
bootedWhileHiddenRef.current = false;
|
|
721
|
-
extAppRecoveryLog(nodeId, 'assume-visible-rearm');
|
|
722
|
-
scheduleWebkitRemount('assume-visible');
|
|
723
|
-
}, ASSUME_VISIBLE_REARM_DELAY_MS);
|
|
724
|
-
};
|
|
725
|
-
|
|
726
|
-
// A paint-ok collected while the document claims hidden proves only that the
|
|
727
|
-
// app's rAF pipeline is alive — NOT that anything composited (Finding N,
|
|
728
|
-
// 0.4.7). Say so in the trail instead of recording an unqualified success.
|
|
729
|
-
// The [detail] bracket names WHAT the oracle verified (ink / svg / text) so
|
|
730
|
-
// field reports can distinguish pixel-verified greens from weaker ones.
|
|
731
|
-
const paintOkLabel = (suffix = ''): string => {
|
|
732
|
-
const detail = lastPaintDetailRef.current ? ` [${lastPaintDetailRef.current}]` : '';
|
|
733
|
-
return typeof document !== 'undefined' && document.visibilityState === 'hidden'
|
|
734
|
-
? `paint-ok (content-verified)${detail}${suffix} (host hidden)`
|
|
735
|
-
: `paint-ok (content-verified)${detail}${suffix}`;
|
|
736
|
-
};
|
|
737
|
-
|
|
738
|
-
const runPaintLadder = async (): Promise<void> => {
|
|
739
|
-
// Runs for the EXPANDED instance too (Finding N reopen: expanded C was a
|
|
740
|
-
// black frame with no recovery — the ladder used to early-return here).
|
|
741
|
-
if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
|
|
742
|
-
if (!isWebKitOnlyHost(navigator.userAgent)) return;
|
|
743
|
-
setPaintPending(true);
|
|
744
|
-
let verdict = await probePaint();
|
|
745
|
-
if (verdict === 'content-verified') {
|
|
746
|
-
extAppRecoveryLog(nodeId, paintOkLabel());
|
|
747
|
-
setPaintPending(false);
|
|
748
|
-
maybeAssumeVisibleRearm();
|
|
749
|
-
return;
|
|
750
|
-
}
|
|
751
|
-
extAppRecoveryLog(
|
|
752
|
-
nodeId,
|
|
753
|
-
verdict === 'tick-no-content'
|
|
754
|
-
? `paint-fail (tick without content: ${lastPaintDetailRef.current || 'unknown'})`
|
|
755
|
-
: 'paint-fail (no tick)',
|
|
229
|
+
const bridge = new AppBridge(
|
|
230
|
+
null,
|
|
231
|
+
{ name: 'PMX Canvas', version: '1.0.0' },
|
|
232
|
+
{
|
|
233
|
+
openLinks: {},
|
|
234
|
+
serverTools: { listChanged: false },
|
|
235
|
+
serverResources: { listChanged: false },
|
|
236
|
+
logging: {},
|
|
237
|
+
updateModelContext: { text: {}, structuredContent: {} },
|
|
238
|
+
},
|
|
239
|
+
{ hostContext: context() },
|
|
756
240
|
);
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
}
|
|
765
|
-
if (unmountedRef.current) return;
|
|
766
|
-
// A contentless-but-alive app may still be DRAWING its scene: give the
|
|
767
|
-
// oracle a bounded settle before burning a remount attempt on it.
|
|
768
|
-
if (verdict === 'tick-no-content' && (await awaitContentVerified(3_000))) {
|
|
769
|
-
extAppRecoveryLog(nodeId, paintOkLabel(' (after content wait)'));
|
|
770
|
-
setPaintPending(false);
|
|
771
|
-
maybeAssumeVisibleRearm();
|
|
772
|
-
return;
|
|
773
|
-
}
|
|
774
|
-
if (unmountedRef.current) return;
|
|
775
|
-
// The remounted frame re-enters this ladder from its own settle path.
|
|
776
|
-
if (!scheduleWebkitRemount('paint-fail')) {
|
|
777
|
-
extAppRecoveryLog(nodeId, 'recovery-exhausted');
|
|
778
|
-
setPaintPending(false);
|
|
779
|
-
setRecoveryExhausted(true);
|
|
780
|
-
maybeAssumeVisibleRearm();
|
|
781
|
-
}
|
|
782
|
-
};
|
|
783
|
-
|
|
784
|
-
// Finding N (0.3.2 report): the Copilot panel can load the canvas while the
|
|
785
|
-
// WKWebView document is HIDDEN (panel backgrounded/occluded). WebKit suppresses
|
|
786
|
-
// compositing for hidden documents, so the whole serialized recovery above can
|
|
787
|
-
// run to completion without ever producing a visible paint — and nothing
|
|
788
|
-
// retried once the panel was shown, which is why tiles stayed black until a
|
|
789
|
-
// manual expand+close (necessarily performed while visible). When the document
|
|
790
|
-
// becomes visible again, re-arm ONE fresh serialized remount round for frames
|
|
791
|
-
// whose recovery ran while hidden.
|
|
792
|
-
useEffect(() => {
|
|
793
|
-
if (expanded) return;
|
|
794
|
-
if (typeof document === 'undefined' || typeof navigator === 'undefined' || typeof window === 'undefined') return;
|
|
795
|
-
if (!isWebKitOnlyHost(navigator.userAgent)) return;
|
|
796
|
-
function onVisibilityChange() {
|
|
797
|
-
if (document.visibilityState !== 'visible') return;
|
|
798
|
-
if (!bootedWhileHiddenRef.current || unmountedRef.current) return;
|
|
799
|
-
bootedWhileHiddenRef.current = false;
|
|
800
|
-
// Fresh visibility = fresh recovery budget: the hidden-run attempts were
|
|
801
|
-
// spent without a chance of compositing.
|
|
802
|
-
webkitRemountAttemptsRef.current = 0;
|
|
803
|
-
setRecoveryExhausted(false);
|
|
804
|
-
extAppRecoveryLog(nodeId, 'visible-again-rearm');
|
|
805
|
-
scheduleWebkitRemount('visible-after-hidden-boot');
|
|
806
|
-
}
|
|
807
|
-
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
808
|
-
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
809
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
810
|
-
}, [expanded, nodeId]);
|
|
811
|
-
|
|
812
|
-
// Never-booted watchdog (0.3.2): an iframe whose scripts never ran in the burst
|
|
813
|
-
// shows no error — the bootstrap fallback flips status via notifications that
|
|
814
|
-
// resolve into a dead window, so the tile just stays black. "Alive" is either
|
|
815
|
-
// boot signal from the CURRENT frame: the genuine initialize handshake, or the
|
|
816
|
-
// injected boot beacon (which covers apps that boot via the fallback without
|
|
817
|
-
// ever sending initialized — remounting those was a flicker loop, and checking
|
|
818
|
-
// bridgeReadyRef instead would be wrong: the fallback sets it blindly, dead
|
|
819
|
-
// window or not). Only a frame with NEITHER signal is retried through the
|
|
820
|
-
// serialized queue (bounded by the shared attempt cap).
|
|
821
|
-
const WEBKIT_BOOT_WATCHDOG_MS = 6000;
|
|
822
|
-
useEffect(() => {
|
|
823
|
-
// Covers the expanded instance too — its fresh iframe was black with no
|
|
824
|
-
// recovery at all (Finding N reopen, expanded C).
|
|
825
|
-
if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
|
|
826
|
-
if (!isWebKitOnlyHost(navigator.userAgent)) return;
|
|
827
|
-
if (!iframeDocument.ready || !mountGranted) return;
|
|
828
|
-
const timer = window.setTimeout(() => {
|
|
829
|
-
if (appInitializedRef.current || appScriptsRanRef.current || unmountedRef.current) return;
|
|
830
|
-
scheduleWebkitRemount('boot-watchdog');
|
|
831
|
-
}, WEBKIT_BOOT_WATCHDOG_MS);
|
|
832
|
-
return () => window.clearTimeout(timer);
|
|
833
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
834
|
-
}, [frameKey, iframeDocument.ready, expanded, mountGranted]);
|
|
835
|
-
|
|
836
|
-
useEffect(
|
|
837
|
-
() => () => {
|
|
838
|
-
unmountedRef.current = true;
|
|
839
|
-
for (const waiter of bootWaitersRef.current.splice(0)) waiter();
|
|
840
|
-
for (const waiter of paintTickWaitersRef.current.splice(0)) waiter({ content: false, detail: 'unmounted' });
|
|
841
|
-
},
|
|
842
|
-
[],
|
|
843
|
-
);
|
|
844
|
-
|
|
845
|
-
// MCP apps only understand dark/light — collapse named themes to their scheme.
|
|
846
|
-
const toMcpTheme = (theme: string): McpUiTheme => canvasThemeScheme(theme);
|
|
847
|
-
const isExpanded = expanded || expandedNodeId.value === nodeId;
|
|
848
|
-
|
|
849
|
-
latestToolInputRef.current = toolInput;
|
|
850
|
-
latestToolResultRef.current = toolResult;
|
|
851
|
-
|
|
852
|
-
// Finding U (0.4.2 report): a stressed reconnect can leave sessionStatus
|
|
853
|
-
// 'error' (e.g. "MCP error -32001: Request timed out") while the cached app
|
|
854
|
-
// document still renders fine below. When content IS available, present the
|
|
855
|
-
// error as a degraded-interactivity notice, not a full-alarm banner over a
|
|
856
|
-
// working diagram.
|
|
857
|
-
const sessionErrorWithContent = sessionStatus === 'error' && typeof html === 'string' && html.length > 0;
|
|
858
|
-
const sessionUnavailableMessage =
|
|
859
|
-
sessionStatus === 'error'
|
|
860
|
-
? sessionErrorWithContent
|
|
861
|
-
? `Live session unavailable — showing saved content. Reopen the app to restore interactivity.${sessionError ? ` (${sessionError})` : ''}`
|
|
862
|
-
: (sessionError ?? 'Saved app session is unavailable. Reopen the app to restore interactivity.')
|
|
863
|
-
: 'Reconnecting saved app session...';
|
|
864
|
-
|
|
865
|
-
const flushToolResult = (bridge: AppBridge | null): Promise<void> | null => {
|
|
866
|
-
const pendingToolResult = latestToolResultRef.current;
|
|
867
|
-
if (!bridge || !bridgeReadyRef.current || !pendingToolResult) {
|
|
868
|
-
return null;
|
|
869
|
-
}
|
|
870
|
-
// Skip when the content is unchanged. Updates from callServerTool
|
|
871
|
-
// (e.g. Excalidraw saving edits) produce a new reference via SSE and
|
|
872
|
-
// must be forwarded to keep other clients in sync — but SSE layout
|
|
873
|
-
// updates *also* mint new references when nothing in the tool result
|
|
874
|
-
// has actually changed (e.g. after the widget's own updateModelContext
|
|
875
|
-
// call), which would echo the result back and cause the widget to
|
|
876
|
-
// re-render mid-interaction (see: Counter fixture click instability).
|
|
877
|
-
// Deep-equality via structural compare handles both cases: new content
|
|
878
|
-
// is forwarded, unchanged content is suppressed.
|
|
879
|
-
if (lastSentToolResultRef.current === pendingToolResult) {
|
|
880
|
-
return null;
|
|
881
|
-
}
|
|
882
|
-
if (lastSentToolResultRef.current && extAppToolResultsMatch(lastSentToolResultRef.current, pendingToolResult)) {
|
|
883
|
-
lastSentToolResultRef.current = pendingToolResult;
|
|
884
|
-
return null;
|
|
885
|
-
}
|
|
886
|
-
if (toolResultSendingRef.current) return toolResultSendingRef.current;
|
|
887
|
-
const sendPromise = bridge
|
|
888
|
-
.sendToolResult(pendingToolResult)
|
|
889
|
-
.then(() => {
|
|
890
|
-
lastSentToolResultRef.current = pendingToolResult;
|
|
891
|
-
toolResultSentRef.current = true;
|
|
892
|
-
setStatus('done');
|
|
893
|
-
})
|
|
894
|
-
.catch((err) => {
|
|
895
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
896
|
-
setError(`Tool result delivery failed: ${msg}`);
|
|
897
|
-
throw err;
|
|
898
|
-
})
|
|
899
|
-
.finally(() => {
|
|
900
|
-
toolResultSendingRef.current = null;
|
|
901
|
-
});
|
|
902
|
-
toolResultSendingRef.current = sendPromise;
|
|
903
|
-
return sendPromise;
|
|
904
|
-
};
|
|
905
|
-
|
|
906
|
-
// Initialize as soon as HTML is mounted; some apps send initialize before iframe load fires.
|
|
907
|
-
useEffect(() => {
|
|
908
|
-
if (!html || !iframeDocument.ready || !mountGranted) return;
|
|
909
|
-
const iframe = iframeRef.current;
|
|
910
|
-
if (!iframe) return;
|
|
911
|
-
let disposed = false;
|
|
912
|
-
let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
|
913
|
-
let repaintNudgeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
914
|
-
let hostContextResizeObserver: ResizeObserver | null = null;
|
|
915
|
-
let hostContextRaf: number | null = null;
|
|
916
|
-
let readyNudgeRaf: number | null = null;
|
|
917
|
-
toolResultSentRef.current = false;
|
|
918
|
-
lastSentToolResultRef.current = undefined;
|
|
919
|
-
toolResultSendingRef.current = null;
|
|
920
|
-
bridgeReadyRef.current = false;
|
|
921
|
-
// New frame = new boot attempt: both boot signals belong to the CURRENT
|
|
922
|
-
// iframe (the beacon listener also pins event.source to the live element).
|
|
923
|
-
// The queue's awaitBoot waits on this frame's handshake.
|
|
924
|
-
appInitializedRef.current = false;
|
|
925
|
-
appScriptsRanRef.current = false;
|
|
926
|
-
|
|
927
|
-
const clearFallbackTimer = (): void => {
|
|
928
|
-
if (!fallbackTimer) return;
|
|
929
|
-
clearTimeout(fallbackTimer);
|
|
930
|
-
fallbackTimer = null;
|
|
241
|
+
const transport = new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!);
|
|
242
|
+
const fail = (failure: unknown) => {
|
|
243
|
+
if (disposed) return;
|
|
244
|
+
clearTimeout(bootTimer);
|
|
245
|
+
finishBoot(false);
|
|
246
|
+
console.error('Ext-app connection failed', failure);
|
|
247
|
+
reportFailure();
|
|
931
248
|
};
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
if (
|
|
935
|
-
const
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
displayMode: DisplayMode = expandedNodeId.value === nodeId ? 'fullscreen' : 'inline',
|
|
942
|
-
) => ({
|
|
943
|
-
theme: toMcpTheme(canvasTheme.value),
|
|
944
|
-
platform: 'web' as const,
|
|
945
|
-
containerDimensions: resolveExtAppContainerDimensions(iframe, {
|
|
946
|
-
width: node.size.width,
|
|
947
|
-
height: maxHeight,
|
|
948
|
-
}),
|
|
949
|
-
displayMode,
|
|
950
|
-
locale: navigator.language,
|
|
951
|
-
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
952
|
-
...(toolDefinition
|
|
953
|
-
? {
|
|
954
|
-
toolInfo: {
|
|
955
|
-
id: toolCallId,
|
|
956
|
-
tool: toolDefinition,
|
|
957
|
-
},
|
|
958
|
-
}
|
|
959
|
-
: {}),
|
|
960
|
-
});
|
|
961
|
-
|
|
962
|
-
const scheduleHostContextUpdate = () => {
|
|
963
|
-
if (hostContextRaf !== null) return;
|
|
964
|
-
hostContextRaf = requestAnimationFrame(() => {
|
|
965
|
-
hostContextRaf = null;
|
|
966
|
-
if (disposed || !bridgeReadyRef.current) return;
|
|
967
|
-
bridge.setHostContext?.(buildHostContext());
|
|
968
|
-
});
|
|
969
|
-
};
|
|
970
|
-
|
|
971
|
-
// Re-deliver host context once the iframe has been laid out and painted.
|
|
972
|
-
// Canvas-backed widgets (e.g. Excalidraw) size their drawing surface from
|
|
973
|
-
// containerDimensions at first render; the single handshake-time delivery
|
|
974
|
-
// can land before the embedded frame has settled, leaving a black canvas
|
|
975
|
-
// until an expand/collapse forces a reflow. A double rAF lands after
|
|
976
|
-
// layout+paint, and sendHostContextChange always delivers (setHostContext
|
|
977
|
-
// would diff-suppress the identical context just sent at handshake).
|
|
978
|
-
const nudgeHostContextAfterLayout = () => {
|
|
979
|
-
if (readyNudgeRaf !== null) return;
|
|
980
|
-
readyNudgeRaf = requestAnimationFrame(() => {
|
|
981
|
-
readyNudgeRaf = requestAnimationFrame(() => {
|
|
982
|
-
readyNudgeRaf = null;
|
|
983
|
-
if (disposed || !bridgeReadyRef.current) return;
|
|
984
|
-
void bridge.sendHostContextChange?.(buildHostContext());
|
|
985
|
-
});
|
|
986
|
-
});
|
|
987
|
-
};
|
|
988
|
-
|
|
989
|
-
// Finding N: run the paint oracle + recovery ladder after the app has had
|
|
990
|
-
// its post-settle draw window. Scheduled past the remount queue's settle
|
|
991
|
-
// pause so the probe measures the frame AFTER the scene draw.
|
|
992
|
-
const schedulePaintLadder = () => {
|
|
993
|
-
if (!isWebKitOnlyHost(navigator.userAgent)) return;
|
|
994
|
-
if (repaintNudgeTimer) clearTimeout(repaintNudgeTimer);
|
|
995
|
-
repaintNudgeTimer = setTimeout(() => {
|
|
996
|
-
repaintNudgeTimer = null;
|
|
997
|
-
if (disposed) return;
|
|
998
|
-
void runPaintLadder();
|
|
999
|
-
}, WEBKIT_REMOUNT_SETTLE_MS + 200);
|
|
1000
|
-
};
|
|
1001
|
-
|
|
1002
|
-
const bridge = new AppBridge(
|
|
1003
|
-
null,
|
|
1004
|
-
{ name: 'PMX Canvas', version: '1.0.0' },
|
|
1005
|
-
{
|
|
1006
|
-
openLinks: {},
|
|
1007
|
-
serverTools: { listChanged: false },
|
|
1008
|
-
serverResources: { listChanged: false },
|
|
1009
|
-
logging: {},
|
|
1010
|
-
updateModelContext: { text: {}, structuredContent: {} },
|
|
1011
|
-
},
|
|
1012
|
-
{
|
|
1013
|
-
hostContext: buildHostContext(isExpanded ? 'fullscreen' : 'inline'),
|
|
1014
|
-
},
|
|
1015
|
-
);
|
|
1016
|
-
|
|
1017
|
-
// Register handlers BEFORE connect
|
|
1018
|
-
bridge.onsizechange = async ({ height }) => {
|
|
1019
|
-
const currentNode = nodes.value.get(nodeId);
|
|
1020
|
-
// Late app measurements must not undo an explicit manual card size.
|
|
1021
|
-
if (!currentNode || currentNode.data.userResized === true) return {};
|
|
1022
|
-
if (shouldApplyExtAppSizeChange(height, expandedNodeId.value === nodeId)) {
|
|
1023
|
-
const hostDimensions = resolveExtAppContainerDimensions(iframe.parentElement ?? iframe, {
|
|
1024
|
-
width: node.size.width,
|
|
1025
|
-
height: maxHeight,
|
|
1026
|
-
});
|
|
1027
|
-
const inlineFrameHeight = resolveExtAppInlineFrameHeight(height, hostDimensions.height);
|
|
1028
|
-
iframe.style.height = `${inlineFrameHeight}px`;
|
|
1029
|
-
const currentSize = currentNode.size;
|
|
1030
|
-
const nodeHeight = Math.max(currentSize.height, inlineFrameHeight + AUTO_FIT_TITLEBAR_HEIGHT);
|
|
1031
|
-
if (Math.abs(nodeHeight - currentSize.height) > 8) {
|
|
1032
|
-
resizeNode(nodeId, { width: currentSize.width, height: nodeHeight });
|
|
1033
|
-
if (sizePersistTimerRef.current !== null) {
|
|
1034
|
-
window.clearTimeout(sizePersistTimerRef.current);
|
|
1035
|
-
}
|
|
1036
|
-
sizePersistTimerRef.current = window.setTimeout(() => {
|
|
1037
|
-
sizePersistTimerRef.current = null;
|
|
1038
|
-
const latest = nodes.value.get(nodeId);
|
|
1039
|
-
if (latest && latest.data.userResized !== true) persistLayout({ recordHistory: false });
|
|
1040
|
-
}, 0);
|
|
1041
|
-
}
|
|
1042
|
-
}
|
|
1043
|
-
return {};
|
|
1044
|
-
};
|
|
1045
|
-
|
|
1046
|
-
bridge.onopenlink = async ({ url }) => {
|
|
1047
|
-
window.open(url, '_blank', 'noopener');
|
|
1048
|
-
return {};
|
|
1049
|
-
};
|
|
1050
|
-
|
|
1051
|
-
bridge.onsandboxready = async () => {
|
|
1052
|
-
await bridge.sendSandboxResourceReady({
|
|
1053
|
-
// Same Finding Q base styles for the sandbox-proxy path: the inner
|
|
1054
|
-
// resource document is the one that letterboxes on narrow tiles.
|
|
1055
|
-
html: injectExtAppAxBridgeScript(html, buildExtAppSurfaceBaseStyles()),
|
|
1056
|
-
sandbox: DEFAULT_EXT_APP_SANDBOX,
|
|
1057
|
-
...(resourceMeta?.csp ? { csp: resourceMeta.csp } : {}),
|
|
1058
|
-
...(resourceMeta?.permissions ? { permissions: resourceMeta.permissions } : {}),
|
|
1059
|
-
});
|
|
1060
|
-
};
|
|
1061
|
-
|
|
1062
|
-
// Handle native fullscreen requests from the widget (e.g. Excalidraw expand button)
|
|
1063
|
-
bridge.onrequestdisplaymode = async ({ mode }) => {
|
|
1064
|
-
const { nextMode, shouldExpand, shouldCollapse } = resolveExtAppDisplayModeRequest(mode, isExpanded);
|
|
1065
|
-
if (shouldExpand) {
|
|
1066
|
-
expandNode(nodeId);
|
|
1067
|
-
} else if (shouldCollapse) {
|
|
1068
|
-
collapseExpandedNode();
|
|
1069
|
-
}
|
|
1070
|
-
return { mode: nextMode };
|
|
1071
|
-
};
|
|
1072
|
-
|
|
1073
|
-
// Proxy callServerTool back to PMX server
|
|
1074
|
-
bridge.oncalltool = async (params) => {
|
|
1075
|
-
if (!appSessionId) {
|
|
1076
|
-
throw new Error(sessionUnavailableMessage);
|
|
1077
|
-
}
|
|
1078
|
-
try {
|
|
1079
|
-
const result = await postJson<CallToolResult>('/api/ext-app/call-tool', {
|
|
1080
|
-
sessionId: appSessionId,
|
|
1081
|
-
nodeId,
|
|
1082
|
-
serverName,
|
|
1083
|
-
toolName: params.name,
|
|
1084
|
-
arguments: params.arguments ?? {},
|
|
1085
|
-
});
|
|
1086
|
-
setError(null);
|
|
1087
|
-
return result;
|
|
1088
|
-
} catch (err) {
|
|
1089
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1090
|
-
setError(`Tool call failed: ${msg}`);
|
|
1091
|
-
throw err;
|
|
249
|
+
const flushToolResult = (): Promise<void> | undefined => {
|
|
250
|
+
if (disposed || !inputSent) return;
|
|
251
|
+
if (sending) return sending;
|
|
252
|
+
const sendLatest = async () => {
|
|
253
|
+
while (!disposed) {
|
|
254
|
+
const result = latest.current.data.toolResult as CallToolResult | undefined;
|
|
255
|
+
if (!result || (lastSent && extAppToolResultsMatch(lastSent, result))) return;
|
|
256
|
+
await bridge.sendToolResult(result);
|
|
257
|
+
lastSent = result;
|
|
1092
258
|
}
|
|
1093
259
|
};
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
if (!appSessionId) {
|
|
1097
|
-
return { tools: [] } satisfies ListToolsResult;
|
|
1098
|
-
}
|
|
1099
|
-
return postJson<ListToolsResult>('/api/ext-app/list-tools', { sessionId: appSessionId });
|
|
260
|
+
sending = sendLatest().finally(() => {
|
|
261
|
+
sending = undefined;
|
|
1100
262
|
});
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
bridge.
|
|
1111
|
-
|
|
1112
|
-
|
|
263
|
+
return sending;
|
|
264
|
+
};
|
|
265
|
+
const sendContext = () => {
|
|
266
|
+
if (disposed) return;
|
|
267
|
+
iframe.contentWindow?.postMessage(
|
|
268
|
+
{ source: 'pmx-viewer-theme', token: axToken, variables: canvasVariables() },
|
|
269
|
+
'*',
|
|
270
|
+
);
|
|
271
|
+
if (!initialized) return;
|
|
272
|
+
bridge.setHostContext(context());
|
|
273
|
+
void Promise.resolve(bridge.sendHostContextChange(context())).catch(fail);
|
|
274
|
+
};
|
|
275
|
+
const boot = async () => {
|
|
276
|
+
if (disposed || initialized) return;
|
|
277
|
+
initialized = true;
|
|
278
|
+
clearTimeout(bootTimer);
|
|
279
|
+
sendContext();
|
|
280
|
+
await bridge.sendToolInput({ arguments: (latest.current.data.toolInput as Record<string, unknown>) ?? {} });
|
|
281
|
+
inputSent = true;
|
|
282
|
+
await flushToolResult();
|
|
283
|
+
if (disposed) return;
|
|
284
|
+
finishBoot(true);
|
|
285
|
+
resizeRaf = requestAnimationFrame(() => {
|
|
286
|
+
resizeRaf = requestAnimationFrame(sendContext);
|
|
287
|
+
});
|
|
288
|
+
};
|
|
289
|
+
bridge.oninitialized = () => {
|
|
290
|
+
void boot().catch(fail);
|
|
291
|
+
};
|
|
292
|
+
bridge.onsizechange = async ({ height }) => {
|
|
293
|
+
const current = nodes.value.get(node.id);
|
|
294
|
+
if (!current || current.data.userResized === true) return {};
|
|
295
|
+
if (shouldApplyExtAppSizeChange(height, expanded || expandedNodeId.value === node.id)) {
|
|
296
|
+
const next = Math.max(current.size.height, height + AUTO_FIT_TITLEBAR_HEIGHT);
|
|
297
|
+
if (next - current.size.height > 8) {
|
|
298
|
+
resizeNode(node.id, { width: current.size.width, height: next });
|
|
299
|
+
clearTimeout(persistTimer);
|
|
300
|
+
persistTimer = window.setTimeout(() => persistLayout({ recordHistory: false }), 0);
|
|
1113
301
|
}
|
|
1114
|
-
return postJson('/api/ext-app/read-resource', {
|
|
1115
|
-
sessionId: appSessionId,
|
|
1116
|
-
uri: params.uri,
|
|
1117
|
-
});
|
|
1118
|
-
};
|
|
1119
|
-
|
|
1120
|
-
bridge.onlistprompts = async () =>
|
|
1121
|
-
appSessionId ? postJson('/api/ext-app/list-prompts', { sessionId: appSessionId }) : { prompts: [] };
|
|
1122
|
-
|
|
1123
|
-
bridge.onupdatemodelcontext = async (params) => {
|
|
1124
|
-
if (!appSessionId) return {};
|
|
1125
|
-
await postJson('/api/ext-app/model-context', {
|
|
1126
|
-
nodeId,
|
|
1127
|
-
...(Array.isArray(params.content) ? { content: params.content } : {}),
|
|
1128
|
-
...(params.structuredContent && typeof params.structuredContent === 'object'
|
|
1129
|
-
? { structuredContent: params.structuredContent }
|
|
1130
|
-
: {}),
|
|
1131
|
-
});
|
|
1132
|
-
return {};
|
|
1133
|
-
};
|
|
1134
|
-
|
|
1135
|
-
const transport = new PostMessageTransport(contentWindow, contentWindow);
|
|
1136
|
-
|
|
1137
|
-
bridge.oninitialized = () => {
|
|
1138
|
-
if (disposed) return;
|
|
1139
|
-
clearFallbackTimer();
|
|
1140
|
-
bridgeReadyRef.current = true;
|
|
1141
|
-
appInitializedRef.current = true;
|
|
1142
|
-
extAppRecoveryLog(nodeId, 'initialized');
|
|
1143
|
-
setStatus('ready');
|
|
1144
|
-
setError(null);
|
|
1145
|
-
void Promise.resolve(bridge.sendHostContextChange(buildHostContext(isExpanded ? 'fullscreen' : 'inline')))
|
|
1146
|
-
.then(() => sendExtAppBootstrapState(bridge, latestToolInputRef.current, undefined))
|
|
1147
|
-
.then(() => flushToolResult(bridge))
|
|
1148
|
-
.then(() => {
|
|
1149
|
-
// Settled: handshake + bootstrap replay delivered — the app draws its
|
|
1150
|
-
// scene right after this. Release the remount queue (which then adds
|
|
1151
|
-
// its own settle pause covering the draw) only from this genuine path;
|
|
1152
|
-
// the bootstrap fallback never releases it (a dead iframe must run the
|
|
1153
|
-
// queue's bounded timeout instead of green-lighting the next remount).
|
|
1154
|
-
// Release the mount queue only once this document's CONTENT
|
|
1155
|
-
// oracle is green (bounded): a settled-but-empty app must not
|
|
1156
|
-
// green-light the next mount slot (D-then-E repro, Finding N
|
|
1157
|
-
// reopen). WebKit only — elsewhere the oracle race doesn't exist.
|
|
1158
|
-
const releaseQueue = () => {
|
|
1159
|
-
for (const waiter of bootWaitersRef.current.splice(0)) waiter();
|
|
1160
|
-
};
|
|
1161
|
-
if (typeof navigator !== 'undefined' && isWebKitOnlyHost(navigator.userAgent)) {
|
|
1162
|
-
void awaitContentVerified(4_000).then((verified) => {
|
|
1163
|
-
extAppRecoveryLog(
|
|
1164
|
-
nodeId,
|
|
1165
|
-
verified
|
|
1166
|
-
? 'settled (content-verified)'
|
|
1167
|
-
: `settled (content-timeout${lastPaintDetailRef.current ? `: ${lastPaintDetailRef.current}` : ''})`,
|
|
1168
|
-
);
|
|
1169
|
-
releaseQueue();
|
|
1170
|
-
});
|
|
1171
|
-
} else {
|
|
1172
|
-
extAppRecoveryLog(nodeId, 'settled');
|
|
1173
|
-
releaseQueue();
|
|
1174
|
-
}
|
|
1175
|
-
nudgeHostContextAfterLayout();
|
|
1176
|
-
schedulePaintLadder();
|
|
1177
|
-
})
|
|
1178
|
-
.catch((err) => {
|
|
1179
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1180
|
-
setError(`Bridge bootstrap failed: ${msg}`);
|
|
1181
|
-
});
|
|
1182
|
-
};
|
|
1183
|
-
|
|
1184
|
-
// Fallback bootstrap for widgets whose initialized notification arrives late
|
|
1185
|
-
// or never fires. This keeps standards-based apps usable even when the host
|
|
1186
|
-
// handshake timing differs across SDK versions.
|
|
1187
|
-
fallbackTimer = setTimeout(() => {
|
|
1188
|
-
if (disposed || bridgeReadyRef.current) return;
|
|
1189
|
-
const bootstrapToolResult = latestToolResultRef.current;
|
|
1190
|
-
const hostContext = buildHostContext(isExpanded ? 'fullscreen' : 'inline');
|
|
1191
|
-
bridgeReadyRef.current = true;
|
|
1192
|
-
bridge.setHostContext?.(hostContext);
|
|
1193
|
-
void Promise.resolve(bridge.sendHostContextChange(hostContext))
|
|
1194
|
-
.then(() => sendExtAppBootstrapState(bridge, latestToolInputRef.current, bootstrapToolResult))
|
|
1195
|
-
.then(() => {
|
|
1196
|
-
toolResultSentRef.current = Boolean(bootstrapToolResult);
|
|
1197
|
-
if (bootstrapToolResult) {
|
|
1198
|
-
lastSentToolResultRef.current = bootstrapToolResult;
|
|
1199
|
-
}
|
|
1200
|
-
setStatus(bootstrapToolResult ? 'done' : 'ready');
|
|
1201
|
-
setError(null);
|
|
1202
|
-
nudgeHostContextAfterLayout();
|
|
1203
|
-
schedulePaintLadder();
|
|
1204
|
-
// Release the serialized queue's boot wait — but ONLY when the boot
|
|
1205
|
-
// beacon proved this frame's scripts ran. A live non-SDK app (never
|
|
1206
|
-
// sends initialized) would otherwise hold every later mount slot
|
|
1207
|
-
// for the full boot timeout; a dead window still runs it.
|
|
1208
|
-
if (appScriptsRanRef.current) {
|
|
1209
|
-
extAppRecoveryLog(nodeId, 'settled (fallback)');
|
|
1210
|
-
for (const waiter of bootWaitersRef.current.splice(0)) waiter();
|
|
1211
|
-
}
|
|
1212
|
-
})
|
|
1213
|
-
.catch((err) => {
|
|
1214
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1215
|
-
setError(`Bridge bootstrap fallback failed: ${msg}`);
|
|
1216
|
-
});
|
|
1217
|
-
}, 1200);
|
|
1218
|
-
|
|
1219
|
-
await bridge.connect(transport);
|
|
1220
|
-
if (disposed) {
|
|
1221
|
-
clearFallbackTimer();
|
|
1222
|
-
await transport.close();
|
|
1223
|
-
return;
|
|
1224
302
|
}
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
return;
|
|
1238
|
-
}
|
|
1239
|
-
if (disposed) return;
|
|
1240
|
-
bridge.setHostContext?.({
|
|
1241
|
-
...buildHostContext(),
|
|
1242
|
-
theme: toMcpTheme(newTheme),
|
|
1243
|
-
});
|
|
1244
|
-
void bridge.sendHostContextChange?.(buildHostContext());
|
|
303
|
+
return {};
|
|
304
|
+
};
|
|
305
|
+
bridge.onopenlink = async ({ url }) => {
|
|
306
|
+
window.open(url, '_blank', 'noopener');
|
|
307
|
+
return {};
|
|
308
|
+
};
|
|
309
|
+
bridge.onsandboxready = async () => {
|
|
310
|
+
await bridge.sendSandboxResourceReady({
|
|
311
|
+
html: documentHtml,
|
|
312
|
+
sandbox: DEFAULT_EXT_APP_SANDBOX,
|
|
313
|
+
...(resourceMeta?.csp ? { csp: resourceMeta.csp } : {}),
|
|
314
|
+
...(resourceMeta?.permissions ? { permissions: resourceMeta.permissions } : {}),
|
|
1245
315
|
});
|
|
1246
|
-
|
|
1247
|
-
void flushToolResult(bridge);
|
|
1248
316
|
};
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
});
|
|
1255
|
-
|
|
1256
|
-
return () => {
|
|
1257
|
-
disposed = true;
|
|
1258
|
-
clearFallbackTimer();
|
|
1259
|
-
if (repaintNudgeTimer) {
|
|
1260
|
-
clearTimeout(repaintNudgeTimer);
|
|
1261
|
-
repaintNudgeTimer = null;
|
|
1262
|
-
}
|
|
1263
|
-
hostContextResizeObserver?.disconnect();
|
|
1264
|
-
hostContextResizeObserver = null;
|
|
1265
|
-
if (hostContextRaf !== null) {
|
|
1266
|
-
cancelAnimationFrame(hostContextRaf);
|
|
1267
|
-
hostContextRaf = null;
|
|
1268
|
-
}
|
|
1269
|
-
if (readyNudgeRaf !== null) {
|
|
1270
|
-
cancelAnimationFrame(readyNudgeRaf);
|
|
1271
|
-
readyNudgeRaf = null;
|
|
1272
|
-
}
|
|
1273
|
-
bridgeReadyRef.current = false;
|
|
1274
|
-
toolResultSendingRef.current = null;
|
|
1275
|
-
themeUnsubRef.current?.();
|
|
1276
|
-
themeUnsubRef.current = null;
|
|
1277
|
-
if (sizePersistTimerRef.current !== null) {
|
|
1278
|
-
window.clearTimeout(sizePersistTimerRef.current);
|
|
1279
|
-
sizePersistTimerRef.current = null;
|
|
1280
|
-
}
|
|
1281
|
-
bridgeRef.current = null;
|
|
1282
|
-
if (transportRef.current) {
|
|
1283
|
-
transportRef.current.close().catch((closeError) => {
|
|
1284
|
-
console.error('[ext-app] transport close failed:', closeError);
|
|
1285
|
-
});
|
|
1286
|
-
transportRef.current = null;
|
|
1287
|
-
}
|
|
317
|
+
bridge.onrequestdisplaymode = async ({ mode }) => {
|
|
318
|
+
const request = resolveExtAppDisplayModeRequest(mode, expanded);
|
|
319
|
+
if (request.shouldExpand) expandNode(node.id);
|
|
320
|
+
if (request.shouldCollapse) collapseExpandedNode();
|
|
321
|
+
return { mode: request.nextMode };
|
|
1288
322
|
};
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
theme: toMcpTheme(canvasTheme.value),
|
|
1323
|
-
platform: 'web' as const,
|
|
1324
|
-
containerDimensions: resolveExtAppContainerDimensions(iframeRef.current, {
|
|
1325
|
-
width: node.size.width,
|
|
1326
|
-
height: maxHeight,
|
|
1327
|
-
}),
|
|
1328
|
-
displayMode: isExpanded ? ('fullscreen' as const) : ('inline' as const),
|
|
1329
|
-
locale: navigator.language,
|
|
1330
|
-
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
1331
|
-
};
|
|
1332
|
-
bridge.setHostContext?.(hostContext);
|
|
1333
|
-
void bridge.sendHostContextChange?.(hostContext);
|
|
323
|
+
bridge.oncalltool = async (params) => {
|
|
324
|
+
if (!appSessionId) throw new Error(sessionMessage);
|
|
325
|
+
return postJson<CallToolResult>('/api/ext-app/call-tool', {
|
|
326
|
+
sessionId: appSessionId,
|
|
327
|
+
nodeId: node.id,
|
|
328
|
+
serverName: node.data.serverName,
|
|
329
|
+
toolName: params.name,
|
|
330
|
+
arguments: params.arguments ?? {},
|
|
331
|
+
});
|
|
332
|
+
};
|
|
333
|
+
bridge.setRequestHandler(ListToolsRequestSchema, async () =>
|
|
334
|
+
appSessionId ? postJson<ListToolsResult>('/api/ext-app/list-tools', { sessionId: appSessionId }) : { tools: [] },
|
|
335
|
+
);
|
|
336
|
+
bridge.onlistresources = async () =>
|
|
337
|
+
appSessionId ? postJson('/api/ext-app/list-resources', { sessionId: appSessionId }) : { resources: [] };
|
|
338
|
+
bridge.onlistresourcetemplates = async () =>
|
|
339
|
+
appSessionId
|
|
340
|
+
? postJson('/api/ext-app/list-resource-templates', { sessionId: appSessionId })
|
|
341
|
+
: { resourceTemplates: [] };
|
|
342
|
+
bridge.onreadresource = async ({ uri }) => {
|
|
343
|
+
if (!appSessionId) throw new Error(sessionMessage);
|
|
344
|
+
return postJson('/api/ext-app/read-resource', { sessionId: appSessionId, uri });
|
|
345
|
+
};
|
|
346
|
+
bridge.onlistprompts = async () =>
|
|
347
|
+
appSessionId ? postJson('/api/ext-app/list-prompts', { sessionId: appSessionId }) : { prompts: [] };
|
|
348
|
+
bridge.onupdatemodelcontext = async (params) => {
|
|
349
|
+
if (!appSessionId) return {};
|
|
350
|
+
await postJson('/api/ext-app/model-context', {
|
|
351
|
+
nodeId: node.id,
|
|
352
|
+
...(Array.isArray(params.content) ? { content: params.content } : {}),
|
|
353
|
+
...(params.structuredContent && typeof params.structuredContent === 'object'
|
|
354
|
+
? { structuredContent: params.structuredContent }
|
|
355
|
+
: {}),
|
|
1334
356
|
});
|
|
357
|
+
return {};
|
|
358
|
+
};
|
|
359
|
+
connections.current.set(iframe, {
|
|
360
|
+
loaded: () => {
|
|
361
|
+
// Some apps consume notifications without sending initialized. Start
|
|
362
|
+
// their one bootstrap only after scripts loaded, never before listeners.
|
|
363
|
+
if (!initialized)
|
|
364
|
+
bootTimer = setTimeout(() => {
|
|
365
|
+
void boot().catch(fail);
|
|
366
|
+
}, 1200);
|
|
367
|
+
return ready;
|
|
368
|
+
},
|
|
369
|
+
flush: () => {
|
|
370
|
+
void flushToolResult()?.catch(fail);
|
|
371
|
+
},
|
|
372
|
+
theme: sendContext,
|
|
1335
373
|
});
|
|
374
|
+
void bridge.connect(transport).catch(fail);
|
|
375
|
+
const observer = new ResizeObserver(() => {
|
|
376
|
+
cancelAnimationFrame(resizeRaf);
|
|
377
|
+
resizeRaf = requestAnimationFrame(sendContext);
|
|
378
|
+
});
|
|
379
|
+
observer.observe(iframe);
|
|
380
|
+
const unsubscribe = canvasTheme.subscribe(sendContext);
|
|
1336
381
|
return () => {
|
|
1337
|
-
|
|
1338
|
-
|
|
382
|
+
disposed = true;
|
|
383
|
+
clearTimeout(bootTimer);
|
|
384
|
+
finishBoot(false);
|
|
385
|
+
clearTimeout(persistTimer);
|
|
386
|
+
cancelAnimationFrame(resizeRaf);
|
|
387
|
+
observer.disconnect();
|
|
388
|
+
unsubscribe();
|
|
389
|
+
connections.current.delete(iframe);
|
|
390
|
+
void bridge.close().catch(() => {});
|
|
1339
391
|
};
|
|
1340
|
-
}
|
|
392
|
+
};
|
|
1341
393
|
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
<div
|
|
1346
|
-
style={{
|
|
1347
|
-
height: '100%',
|
|
1348
|
-
display: 'flex',
|
|
1349
|
-
alignItems: 'center',
|
|
1350
|
-
justifyContent: 'center',
|
|
1351
|
-
color: 'var(--c-muted)',
|
|
1352
|
-
fontSize: '13px',
|
|
1353
|
-
flexDirection: 'column',
|
|
1354
|
-
gap: '8px',
|
|
1355
|
-
}}
|
|
1356
|
-
>
|
|
1357
|
-
<div style={{ opacity: 0.6 }}>Loading {toolName} viewer...</div>
|
|
1358
|
-
<div
|
|
1359
|
-
style={{
|
|
1360
|
-
width: '24px',
|
|
1361
|
-
height: '24px',
|
|
1362
|
-
border: '2px solid var(--c-line)',
|
|
1363
|
-
borderTopColor: 'var(--c-muted)',
|
|
1364
|
-
borderRadius: '50%',
|
|
1365
|
-
animation: 'spin 1s linear infinite',
|
|
1366
|
-
}}
|
|
1367
|
-
/>
|
|
1368
|
-
<style>{'@keyframes spin { to { transform: rotate(360deg); } }'}</style>
|
|
1369
|
-
</div>
|
|
1370
|
-
);
|
|
1371
|
-
}
|
|
394
|
+
useEffect(() => {
|
|
395
|
+
for (const connection of connections.current.values()) connection.flush();
|
|
396
|
+
}, [node.data.toolResult]);
|
|
1372
397
|
|
|
1373
398
|
return (
|
|
1374
|
-
<div
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
width: '100%',
|
|
1378
|
-
height: '100%',
|
|
1379
|
-
minWidth: 0,
|
|
1380
|
-
minHeight: 0,
|
|
1381
|
-
display: 'flex',
|
|
1382
|
-
flexDirection: 'column',
|
|
1383
|
-
}}
|
|
1384
|
-
>
|
|
1385
|
-
{sessionStatus && sessionStatus !== 'ready' && (
|
|
1386
|
-
<div
|
|
1387
|
-
style={{
|
|
1388
|
-
padding: '6px 10px',
|
|
1389
|
-
fontSize: '11px',
|
|
1390
|
-
background:
|
|
1391
|
-
sessionStatus === 'error' && !sessionErrorWithContent
|
|
1392
|
-
? 'color-mix(in srgb, var(--c-danger) 12%, transparent)'
|
|
1393
|
-
: 'color-mix(in srgb, var(--c-warn) 10%, transparent)',
|
|
1394
|
-
color: sessionStatus === 'error' && !sessionErrorWithContent ? 'var(--c-danger)' : 'var(--c-warn)',
|
|
1395
|
-
borderBottom: `1px solid ${
|
|
1396
|
-
sessionStatus === 'error' && !sessionErrorWithContent
|
|
1397
|
-
? 'color-mix(in srgb, var(--c-danger) 12%, transparent)'
|
|
1398
|
-
: 'color-mix(in srgb, var(--c-warn) 15%, transparent)'
|
|
1399
|
-
}`,
|
|
1400
|
-
}}
|
|
1401
|
-
>
|
|
1402
|
-
{sessionUnavailableMessage}
|
|
1403
|
-
</div>
|
|
1404
|
-
)}
|
|
1405
|
-
{error && (
|
|
1406
|
-
<div
|
|
1407
|
-
style={{
|
|
1408
|
-
padding: '6px 10px',
|
|
1409
|
-
fontSize: '11px',
|
|
1410
|
-
background: 'color-mix(in srgb, var(--c-danger) 12%, transparent)',
|
|
1411
|
-
color: 'var(--c-danger)',
|
|
1412
|
-
borderBottom: '1px solid color-mix(in srgb, var(--c-danger) 12%, transparent)',
|
|
1413
|
-
display: 'flex',
|
|
1414
|
-
alignItems: 'center',
|
|
1415
|
-
gap: '6px',
|
|
1416
|
-
}}
|
|
1417
|
-
>
|
|
1418
|
-
<span>⚠</span>
|
|
1419
|
-
<span style={{ flex: 1 }}>{error}</span>
|
|
1420
|
-
<button
|
|
1421
|
-
type="button"
|
|
1422
|
-
onClick={() => {
|
|
1423
|
-
setError(null);
|
|
1424
|
-
setStatus('loading');
|
|
1425
|
-
setRetryKey((k) => k + 1);
|
|
1426
|
-
}}
|
|
1427
|
-
style={{
|
|
1428
|
-
background: 'var(--c-surface-hover)',
|
|
1429
|
-
border: '1px solid color-mix(in srgb, var(--c-danger) 12%, transparent)',
|
|
1430
|
-
borderRadius: '3px',
|
|
1431
|
-
color: 'var(--c-danger)',
|
|
1432
|
-
cursor: 'pointer',
|
|
1433
|
-
fontSize: '10px',
|
|
1434
|
-
padding: '1px 6px',
|
|
1435
|
-
}}
|
|
1436
|
-
>
|
|
1437
|
-
Retry
|
|
1438
|
-
</button>
|
|
1439
|
-
<button
|
|
1440
|
-
type="button"
|
|
1441
|
-
onClick={() => setError(null)}
|
|
1442
|
-
aria-label="Dismiss error"
|
|
1443
|
-
style={{
|
|
1444
|
-
background: 'none',
|
|
1445
|
-
border: 'none',
|
|
1446
|
-
color: 'var(--c-danger)',
|
|
1447
|
-
cursor: 'pointer',
|
|
1448
|
-
fontSize: '13px',
|
|
1449
|
-
padding: '0 2px',
|
|
1450
|
-
}}
|
|
1451
|
-
>
|
|
1452
|
-
<IconClose size={14} />
|
|
1453
|
-
</button>
|
|
1454
|
-
</div>
|
|
399
|
+
<div class="ext-app-viewer">
|
|
400
|
+
{node.data.sessionStatus && node.data.sessionStatus !== 'ready' && (
|
|
401
|
+
<div class="ext-app-session-notice">⚠ {sessionMessage}</div>
|
|
1455
402
|
)}
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
// expanded ext-app overlay (#55 e2e).
|
|
1474
|
-
style={{
|
|
1475
|
-
flex: 1,
|
|
1476
|
-
width: '100%',
|
|
1477
|
-
height: '100%',
|
|
1478
|
-
minHeight: 0,
|
|
1479
|
-
border: 'none',
|
|
1480
|
-
background: 'var(--c-panel)',
|
|
1481
|
-
// Keep the expanded frame in Chromium's pointer hit-test tree from
|
|
1482
|
-
// its first paint. Toggling none → auto when boot completes can
|
|
1483
|
-
// leave the compositor routing the first click to the parent even
|
|
1484
|
-
// though DOM hit testing and Playwright both resolve the button.
|
|
1485
|
-
// The loading sibling is non-interactive; inline frames remain
|
|
1486
|
-
// guarded by the preview catcher below.
|
|
1487
|
-
pointerEvents: isExpanded ? 'auto' : 'none',
|
|
1488
|
-
}}
|
|
1489
|
-
title={`Ext App: ${toolName}`}
|
|
1490
|
-
/>
|
|
1491
|
-
)}
|
|
1492
|
-
{/* Recovery exhausted (Finding N): the paint ladder ran out of budget.
|
|
1493
|
-
Never leave a silent black tile — offer an explicit Retry with a
|
|
1494
|
-
fresh recovery budget (a manual retry mirrors the user-proven
|
|
1495
|
-
expand+close in an idle panel). */}
|
|
1496
|
-
{recoveryExhausted && !error && (
|
|
1497
|
-
<div
|
|
1498
|
-
style={{
|
|
1499
|
-
position: 'absolute',
|
|
1500
|
-
inset: 0,
|
|
1501
|
-
display: 'flex',
|
|
1502
|
-
flexDirection: 'column',
|
|
1503
|
-
alignItems: 'center',
|
|
1504
|
-
justifyContent: 'center',
|
|
1505
|
-
gap: '10px',
|
|
1506
|
-
background: 'var(--c-panel)',
|
|
1507
|
-
color: 'var(--c-warn)',
|
|
1508
|
-
fontSize: '12px',
|
|
1509
|
-
}}
|
|
1510
|
-
>
|
|
1511
|
-
<div>App surface failed to paint.</div>
|
|
1512
|
-
<button
|
|
1513
|
-
type="button"
|
|
1514
|
-
onClick={(e) => {
|
|
1515
|
-
e.stopPropagation();
|
|
1516
|
-
extAppRecoveryLog(nodeId, 'manual-retry');
|
|
1517
|
-
webkitRemountAttemptsRef.current = 0;
|
|
1518
|
-
setRecoveryExhausted(false);
|
|
1519
|
-
setStatus('loading');
|
|
1520
|
-
setRetryKey((k) => k + 1);
|
|
1521
|
-
}}
|
|
1522
|
-
style={{
|
|
1523
|
-
background: 'var(--c-surface-hover)',
|
|
1524
|
-
border: '1px solid color-mix(in srgb, var(--c-warn) 15%, transparent)',
|
|
1525
|
-
borderRadius: '4px',
|
|
1526
|
-
color: 'var(--c-warn)',
|
|
1527
|
-
cursor: 'pointer',
|
|
1528
|
-
fontSize: '11px',
|
|
1529
|
-
padding: '3px 10px',
|
|
1530
|
-
}}
|
|
1531
|
-
>
|
|
1532
|
-
Retry
|
|
1533
|
-
</button>
|
|
1534
|
-
</div>
|
|
1535
|
-
)}
|
|
1536
|
-
{/* Connecting overlay: an opaque sibling ABOVE the iframe while this
|
|
1537
|
-
frame boots — and, on WebKit, until the paint oracle confirms the
|
|
1538
|
-
frame actually composited (Finding N). Sibling DOM always paints,
|
|
1539
|
-
so the boot + recovery window reads as an intentional loading
|
|
1540
|
-
state instead of a broken black tile. pointer-events pass through
|
|
1541
|
-
so the expand click-catcher keeps working. */}
|
|
1542
|
-
{(status === 'loading' || paintPending) && !error && !recoveryExhausted && (
|
|
1543
|
-
<div
|
|
1544
|
-
style={{
|
|
1545
|
-
position: 'absolute',
|
|
1546
|
-
inset: 0,
|
|
1547
|
-
display: 'flex',
|
|
1548
|
-
flexDirection: 'column',
|
|
1549
|
-
alignItems: 'center',
|
|
1550
|
-
justifyContent: 'center',
|
|
1551
|
-
gap: '10px',
|
|
1552
|
-
background: 'var(--c-panel)',
|
|
1553
|
-
color: 'var(--c-muted)',
|
|
1554
|
-
fontSize: '12px',
|
|
1555
|
-
pointerEvents: 'none',
|
|
1556
|
-
}}
|
|
1557
|
-
>
|
|
1558
|
-
<div
|
|
1559
|
-
style={{
|
|
1560
|
-
width: '22px',
|
|
1561
|
-
height: '22px',
|
|
1562
|
-
border: '2px solid var(--c-line)',
|
|
1563
|
-
borderTopColor: 'var(--c-muted)',
|
|
1564
|
-
borderRadius: '50%',
|
|
1565
|
-
animation: 'spin 1s linear infinite',
|
|
1566
|
-
}}
|
|
1567
|
-
/>
|
|
1568
|
-
<div>{status === 'loading' ? `Connecting to ${toolName}...` : 'Finishing paint...'}</div>
|
|
1569
|
-
<style>{'@keyframes spin { to { transform: rotate(360deg); } }'}</style>
|
|
1570
|
-
</div>
|
|
1571
|
-
)}
|
|
1572
|
-
{/* Suppressed while the recovery card is up: the catcher is rendered
|
|
1573
|
-
last and would paint over the centred Retry button, swallowing the
|
|
1574
|
-
click that is the ladder's only actionable terminal rung. */}
|
|
1575
|
-
{!isExpanded && !(recoveryExhausted && !error) && (
|
|
1576
|
-
<button
|
|
1577
|
-
type="button"
|
|
1578
|
-
onClick={(e) => {
|
|
1579
|
-
e.stopPropagation();
|
|
1580
|
-
expandNode(nodeId);
|
|
1581
|
-
}}
|
|
1582
|
-
class="ext-app-preview-catcher"
|
|
1583
|
-
style={{
|
|
1584
|
-
position: 'absolute',
|
|
1585
|
-
top: 0,
|
|
1586
|
-
right: '56px',
|
|
1587
|
-
bottom: '56px',
|
|
1588
|
-
left: 0,
|
|
1589
|
-
background: 'transparent',
|
|
1590
|
-
border: 'none',
|
|
1591
|
-
padding: 0,
|
|
1592
|
-
margin: 0,
|
|
1593
|
-
cursor: 'zoom-in',
|
|
1594
|
-
}}
|
|
1595
|
-
aria-label="Open full view to edit"
|
|
1596
|
-
/>
|
|
1597
|
-
)}
|
|
1598
|
-
</div>
|
|
403
|
+
<RefreshingViewerFrame
|
|
404
|
+
source={frameDocument}
|
|
405
|
+
identity={JSON.stringify([node.id, node.data.serverName, appSessionId, node.data.sessionStatus])}
|
|
406
|
+
iframeRef={iframeRef}
|
|
407
|
+
onMount={connect}
|
|
408
|
+
onDocumentLoad={async (frame, signal) => {
|
|
409
|
+
const connection = connections.current.get(frame);
|
|
410
|
+
if (!connection || !(await connection.loaded()) || signal.aborted) return false;
|
|
411
|
+
connection.theme();
|
|
412
|
+
return waitForViewerPaint(frame, axToken, signal);
|
|
413
|
+
}}
|
|
414
|
+
onOpen={expanded ? undefined : () => expandNode(node.id)}
|
|
415
|
+
sandbox={DEFAULT_EXT_APP_SANDBOX}
|
|
416
|
+
allow={buildAllowAttribute(resourceMeta?.permissions)}
|
|
417
|
+
className="ext-app-frame"
|
|
418
|
+
title={`Ext App: ${node.data.toolName ?? 'ext-app'}`}
|
|
419
|
+
/>
|
|
1599
420
|
</div>
|
|
1600
421
|
);
|
|
1601
422
|
}
|