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.
Files changed (237) hide show
  1. package/.github/extensions/pmx-canvas/runtime.json +1 -1
  2. package/.github/extensions/pmx-canvas/tools.json +9418 -2016
  3. package/CHANGELOG.md +54 -1
  4. package/dist/canvas/chunks/abnfDiagram-N423BO3Z-p8dtp6va.js +1 -0
  5. package/dist/canvas/chunks/architecture-TIHT7OUA-9tffvpsv.js +1 -0
  6. package/dist/canvas/chunks/architectureDiagram-T3A2C74G-5jhmxrzf.js +36 -0
  7. package/dist/canvas/chunks/blockDiagram-VBNYF7ZC-pw1fa9yb.js +132 -0
  8. package/dist/canvas/chunks/c4Diagram-5PPSVZJV-x07nq0tt.js +10 -0
  9. package/dist/canvas/chunks/classDiagram-JCYQIIEL-nc6g5x1r.js +1 -0
  10. package/dist/canvas/chunks/classDiagram-v2-OCEON4UE-0qnbpwvh.js +1 -0
  11. package/dist/canvas/chunks/cose-bilkent-JH36ORCC-nm39npq0.js +1 -0
  12. package/dist/canvas/chunks/cynefin-VYW2F7L2-ys8p46ts.js +1 -0
  13. package/dist/canvas/chunks/cynefinDiagram-MW4NZA55-vdxrwahz.js +62 -0
  14. package/dist/canvas/chunks/dagre-VZM6K2ZE-bqdm7wpd.js +4 -0
  15. package/dist/canvas/chunks/diagram-7IWD3JNH-gs5gpr93.js +30 -0
  16. package/dist/canvas/chunks/diagram-B4RE2ZJO-cgwjg47s.js +3 -0
  17. package/dist/canvas/chunks/diagram-LBJQPF4R-w3vahpav.js +24 -0
  18. package/dist/canvas/chunks/diagram-Q27KOJAE-42837z6h.js +24 -0
  19. package/dist/canvas/chunks/diagram-UB23O5K3-4fz83q59.js +41 -0
  20. package/dist/canvas/chunks/ebnfDiagram-BXEA7PRR-zphtgegj.js +1 -0
  21. package/dist/canvas/chunks/erDiagram-JOGREHBK-zvbbdxp4.js +85 -0
  22. package/dist/canvas/chunks/eventmodeling-45OFAUF4-v9q6dn4c.js +1 -0
  23. package/dist/canvas/chunks/flowDiagram-UKHOOZJN-yrr5z8tc.js +1 -0
  24. package/dist/canvas/chunks/ganttDiagram-PKOTCBZU-282jzycm.js +292 -0
  25. package/dist/canvas/chunks/gitGraph-TEB2WS4Q-kdwb8m0a.js +1 -0
  26. package/dist/canvas/chunks/gitGraphDiagram-DS77QQ5N-vqqy6p1b.js +106 -0
  27. package/dist/canvas/chunks/index-0rk4gp1g.js +2 -0
  28. package/dist/canvas/chunks/index-194m3hc3.js +2 -0
  29. package/dist/canvas/chunks/index-1r72hdzb.js +301 -0
  30. package/dist/canvas/chunks/index-353m7b0p.js +2 -0
  31. package/dist/canvas/chunks/index-3cy1bzwh.js +2 -0
  32. package/dist/canvas/chunks/index-5tv8v1hk.js +1 -0
  33. package/dist/canvas/chunks/index-6ceya556.js +332 -0
  34. package/dist/canvas/chunks/index-6qmsnxwy.js +4 -0
  35. package/dist/canvas/chunks/index-d3b3gqe9.js +2 -0
  36. package/dist/canvas/chunks/index-fjtdggjn.js +2 -0
  37. package/dist/canvas/chunks/index-g3pwnqeb.js +2 -0
  38. package/dist/canvas/chunks/index-gf4e0et4.js +2 -0
  39. package/dist/canvas/chunks/index-hj99nz1f.js +1 -0
  40. package/dist/canvas/chunks/index-nprm96xp.js +16 -0
  41. package/dist/canvas/chunks/index-qj8fxwsf.js +2 -0
  42. package/dist/canvas/chunks/index-r2fyc9w1.js +89 -0
  43. package/dist/canvas/chunks/index-rft2hcz5.js +2 -0
  44. package/dist/canvas/chunks/index-rh7nv68n.js +2 -0
  45. package/dist/canvas/chunks/index-s4j678gj.js +2 -0
  46. package/dist/canvas/chunks/index-tb1wf525.js +179 -0
  47. package/dist/canvas/chunks/index-vv56h019.js +2 -0
  48. package/dist/canvas/chunks/index-w2mra17t.js +207 -0
  49. package/dist/canvas/chunks/index-wgv7en21.js +232 -0
  50. package/dist/canvas/chunks/index-yw8h9vha.js +157 -0
  51. package/dist/canvas/chunks/info-DKCQHKI2-wpzs6sz3.js +1 -0
  52. package/dist/canvas/chunks/infoDiagram-6WML65LV-v1yvggqj.js +2 -0
  53. package/dist/canvas/chunks/ishikawaDiagram-WSZJBQD7-5hnq1a9p.js +70 -0
  54. package/dist/canvas/chunks/journeyDiagram-NVQOT4AX-zjcffveh.js +139 -0
  55. package/dist/canvas/chunks/kanban-definition-27J2QSJJ-dakqtfp1.js +89 -0
  56. package/dist/canvas/chunks/katex-dmg8k4zm.js +257 -0
  57. package/dist/canvas/chunks/mermaid.core-j2geswrd.js +12 -0
  58. package/dist/canvas/chunks/mindmap-definition-FAOFIHXS-142xhr3k.js +96 -0
  59. package/dist/canvas/chunks/packet-7NZHBO7P-xn439f0s.js +1 -0
  60. package/dist/canvas/chunks/pegDiagram-VL7TDLO6-5wvk7vsd.js +1 -0
  61. package/dist/canvas/chunks/pie-RZYD4A2V-0kf16rwh.js +1 -0
  62. package/dist/canvas/chunks/pieDiagram-7S7Q4E2Y-ztdhwycc.js +39 -0
  63. package/dist/canvas/chunks/quadrantDiagram-CIZ2JOQS-c5p9gmg6.js +7 -0
  64. package/dist/canvas/chunks/radar-I7S5WNFK-0g2pz36t.js +1 -0
  65. package/dist/canvas/chunks/railroad-3IZDKUUU-fcaran6j.js +1 -0
  66. package/dist/canvas/chunks/railroad-abnf-AHOZXSZD-wgdya41q.js +1 -0
  67. package/dist/canvas/chunks/railroad-ebnf-EBAXGLYW-gy07a4ka.js +1 -0
  68. package/dist/canvas/chunks/railroad-peg-LSFZ7HO6-ewk6wtkh.js +1 -0
  69. package/dist/canvas/chunks/railroadDiagram-AXF67PYL-xkmqkjwf.js +1 -0
  70. package/dist/canvas/chunks/requirementDiagram-LRYGKXZP-9wm2dyaw.js +84 -0
  71. package/dist/canvas/chunks/sankeyDiagram-W5VNT64P-2w19etgb.js +40 -0
  72. package/dist/canvas/chunks/sequenceDiagram-SI44F4Z6-0jn0vwmz.js +162 -0
  73. package/dist/canvas/chunks/sizeCapture-X5ZJPWSS-x75epwdq.js +1 -0
  74. package/dist/canvas/chunks/stateDiagram-OKZ733FA-schjpy6d.js +1 -0
  75. package/dist/canvas/chunks/stateDiagram-v2-UEYNNEHI-k8sfp2e7.js +1 -0
  76. package/dist/canvas/chunks/swimlanes-SLNWSIFB-gkw4xce3.js +1 -0
  77. package/dist/canvas/chunks/swimlanesDiagram-ULZ7WXOC-nqysw67j.js +8 -0
  78. package/dist/canvas/chunks/timeline-definition-Z64GVDOM-x7nkcy01.js +120 -0
  79. package/dist/canvas/chunks/treeView-QDETBFTQ-cesw6gp0.js +1 -0
  80. package/dist/canvas/chunks/treemap-6X3UGDF4-zdv4knp9.js +1 -0
  81. package/dist/canvas/chunks/vennDiagram-T6HMQDX7-x8dqn9yf.js +34 -0
  82. package/dist/canvas/chunks/wardley-OPB4EBWU-44pdddez.js +1 -0
  83. package/dist/canvas/chunks/wardleyDiagram-T6FBY63Y-t1j52t9r.js +78 -0
  84. package/dist/canvas/chunks/xychartDiagram-ELKLHX3M-38t16pme.js +7 -0
  85. package/dist/canvas/global.css +389 -229
  86. package/dist/canvas/index.js +141 -211
  87. package/dist/canvas/mcp-app.html +4205 -554
  88. package/dist/canvas/mermaid-entry.js +338 -338
  89. package/dist/canvas/surface-theme.css +6 -6
  90. package/dist/json-render/index.css +1 -1
  91. package/dist/json-render/index.js +49 -49
  92. package/dist/types/client/canvas/BarHint.d.ts +5 -3
  93. package/dist/types/client/canvas/CanvasNode.d.ts +0 -11
  94. package/dist/types/client/canvas/EdgeLayer.d.ts +1 -2
  95. package/dist/types/client/canvas/auto-fit.d.ts +4 -5
  96. package/dist/types/client/mermaid-entry.d.ts +1 -2
  97. package/dist/types/client/mermaid-render.d.ts +1 -0
  98. package/dist/types/client/nodes/BoardNode.d.ts +1 -1
  99. package/dist/types/client/nodes/ExtAppFrame.d.ts +7 -90
  100. package/dist/types/client/nodes/McpAppNode.d.ts +1 -25
  101. package/dist/types/client/nodes/MermaidNode.d.ts +1 -8
  102. package/dist/types/client/nodes/ViewerFrame.d.ts +29 -0
  103. package/dist/types/client/nodes/ViewerHost.d.ts +6 -0
  104. package/dist/types/client/nodes/iframe-document-url.d.ts +3 -9
  105. package/dist/types/client/nodes/primitive-copy.d.ts +5 -0
  106. package/dist/types/client/nodes/use-iframe-content-height.d.ts +1 -1
  107. package/dist/types/client/nodes/use-surface-frame.d.ts +4 -6
  108. package/dist/types/client/nodes/viewer-runtime-state.d.ts +2 -1
  109. package/dist/types/client/state/canvas-store.d.ts +3 -1
  110. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  111. package/dist/types/client/state/presentation.d.ts +17 -6
  112. package/dist/types/json-render/server.d.ts +2 -1
  113. package/dist/types/mcp/canvas-access.d.ts +1 -12
  114. package/dist/types/mcp/resource-subscriptions.d.ts +7 -0
  115. package/dist/types/mcp/server.d.ts +2 -0
  116. package/dist/types/server/ax-wait.d.ts +1 -0
  117. package/dist/types/server/canvas-operations.d.ts +1 -0
  118. package/dist/types/server/canvas-state.d.ts +2 -0
  119. package/dist/types/server/index.d.ts +11 -6
  120. package/dist/types/server/operations/http.d.ts +10 -0
  121. package/dist/types/server/operations/index.d.ts +2 -2
  122. package/dist/types/server/operations/invoker.d.ts +4 -3
  123. package/dist/types/server/operations/mcp-contracts.d.ts +20 -0
  124. package/dist/types/server/operations/mcp.d.ts +2 -0
  125. package/dist/types/server/operations/ops/batch.d.ts +1 -0
  126. package/dist/types/server/operations/ops/boards.d.ts +0 -1
  127. package/dist/types/server/operations/registry.d.ts +2 -2
  128. package/dist/types/server/operations/types.d.ts +12 -2
  129. package/dist/types/server/server.d.ts +2 -0
  130. package/dist/types/server/tour-control.d.ts +13 -3
  131. package/dist/types/server/webpage-node.d.ts +1 -0
  132. package/dist/types/shared/ax-surface-protocol.d.ts +0 -9
  133. package/dist/types/shared/tour.d.ts +8 -0
  134. package/docs/api-reference.md +2 -2
  135. package/docs/cli.md +1 -1
  136. package/docs/design/Chrome.dc.html +32 -6
  137. package/docs/design/Types1.dc.html +3 -2
  138. package/docs/design/Types2.dc.html +1 -1
  139. package/docs/design/Types3.dc.html +1 -1
  140. package/docs/design/Zoom.dc.html +37 -60
  141. package/docs/design/canvas.json +1 -1
  142. package/docs/design.md +169 -4
  143. package/docs/http-api.md +82 -1
  144. package/docs/mcp.md +101 -21
  145. package/docs/node-types.md +38 -3
  146. package/docs/plans/plan-009-tech-debt-backlog.md +2 -2
  147. package/docs/plans/plan-019-post-0.9-roadmap.md +402 -13
  148. package/docs/product-vision-2026-09.md +9 -0
  149. package/docs/sdk.md +5 -2
  150. package/package.json +2 -2
  151. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
  152. package/plugins/pmx-canvas/plugin.json +1 -1
  153. package/plugins/pmx-canvas/runtime.json +1 -1
  154. package/plugins/pmx-canvas/tools.json +9418 -2016
  155. package/skills/pmx-canvas/SKILL.md +19 -21
  156. package/skills/pmx-canvas/references/full-reference.md +44 -6
  157. package/skills/pmx-canvas/references/installing-pmx-canvas.md +7 -7
  158. package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +3 -5
  159. package/src/client/canvas/BarHint.tsx +6 -3
  160. package/src/client/canvas/CanvasNode.tsx +34 -47
  161. package/src/client/canvas/CanvasViewport.tsx +6 -7
  162. package/src/client/canvas/ContextMenu.tsx +63 -3
  163. package/src/client/canvas/EdgeLayer.tsx +365 -169
  164. package/src/client/canvas/ExpandedNodeOverlay.tsx +1 -2
  165. package/src/client/canvas/Presentation.tsx +53 -3
  166. package/src/client/canvas/ToolRail.tsx +2 -0
  167. package/src/client/canvas/TopBar.tsx +24 -9
  168. package/src/client/canvas/auto-fit.ts +9 -11
  169. package/src/client/canvas/canvas-area.ts +1 -1
  170. package/src/client/canvas/use-focus-trap.ts +6 -1
  171. package/src/client/mermaid-entry.ts +6 -20
  172. package/src/client/mermaid-render.ts +39 -0
  173. package/src/client/nodes/AxStepControls.tsx +45 -69
  174. package/src/client/nodes/BoardNode.tsx +65 -9
  175. package/src/client/nodes/ContextNode.tsx +88 -396
  176. package/src/client/nodes/ExtAppFrame.tsx +283 -1462
  177. package/src/client/nodes/FileNode.tsx +92 -81
  178. package/src/client/nodes/HtmlNode.tsx +8 -36
  179. package/src/client/nodes/LedgerNode.tsx +31 -17
  180. package/src/client/nodes/MarkdownNode.tsx +8 -17
  181. package/src/client/nodes/McpAppNode.tsx +48 -154
  182. package/src/client/nodes/MermaidNode.tsx +80 -69
  183. package/src/client/nodes/PromptNode.tsx +12 -14
  184. package/src/client/nodes/ResponseNode.tsx +8 -6
  185. package/src/client/nodes/StatusNode.tsx +22 -7
  186. package/src/client/nodes/StatusSummary.tsx +5 -3
  187. package/src/client/nodes/TraceNode.tsx +8 -8
  188. package/src/client/nodes/ViewerFrame.tsx +262 -0
  189. package/src/client/nodes/ViewerHost.tsx +41 -0
  190. package/src/client/nodes/WebpageNode.tsx +22 -24
  191. package/src/client/nodes/iframe-document-url.ts +20 -27
  192. package/src/client/nodes/primitive-copy.ts +97 -0
  193. package/src/client/nodes/use-iframe-content-height.ts +34 -10
  194. package/src/client/nodes/use-surface-frame.ts +22 -11
  195. package/src/client/nodes/viewer-runtime-state.ts +41 -35
  196. package/src/client/state/canvas-store.ts +18 -12
  197. package/src/client/state/intent-bridge.ts +1 -0
  198. package/src/client/state/presentation.ts +135 -15
  199. package/src/client/state/sse-bridge.ts +18 -19
  200. package/src/client/theme/global.css +389 -229
  201. package/src/client/theme/surface-theme.css +6 -6
  202. package/src/json-render/charts/tufte-components.tsx +44 -2
  203. package/src/json-render/renderer/index.css +62 -2
  204. package/src/json-render/renderer/index.tsx +31 -7
  205. package/src/json-render/server.ts +3 -2
  206. package/src/mcp/canvas-access.ts +6 -47
  207. package/src/mcp/resource-subscriptions.ts +181 -0
  208. package/src/mcp/server.ts +185 -219
  209. package/src/server/ax-wait.ts +21 -6
  210. package/src/server/board-export.ts +35 -24
  211. package/src/server/canvas-geometry.ts +1 -0
  212. package/src/server/canvas-operations.ts +6 -2
  213. package/src/server/canvas-state.ts +5 -0
  214. package/src/server/demo-state.json +106 -100
  215. package/src/server/html-primitives.ts +3 -9
  216. package/src/server/html-surface.ts +8 -0
  217. package/src/server/index.ts +30 -12
  218. package/src/server/operations/http.ts +69 -21
  219. package/src/server/operations/index.ts +8 -1
  220. package/src/server/operations/invoker.ts +53 -5
  221. package/src/server/operations/mcp-contracts.ts +354 -0
  222. package/src/server/operations/mcp.ts +153 -18
  223. package/src/server/operations/ops/ax-await.ts +18 -10
  224. package/src/server/operations/ops/ax-read.ts +2 -0
  225. package/src/server/operations/ops/ax-timeline.ts +2 -2
  226. package/src/server/operations/ops/batch.ts +27 -8
  227. package/src/server/operations/ops/boards.ts +1 -3
  228. package/src/server/operations/ops/nodes.ts +18 -3
  229. package/src/server/operations/ops/viewport.ts +11 -9
  230. package/src/server/operations/registry.ts +24 -4
  231. package/src/server/operations/types.ts +16 -2
  232. package/src/server/server.ts +17 -59
  233. package/src/server/tour-control.ts +66 -6
  234. package/src/server/webpage-node.ts +9 -4
  235. package/src/shared/ax-surface-protocol.ts +0 -11
  236. package/src/shared/content-height-reporter.ts +4 -3
  237. 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 { AppBridge, PostMessageTransport, buildAllowAttribute } from '@modelcontextprotocol/ext-apps/app-bridge';
4
- import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
5
- import { IconClose } from '../icons';
6
- import { workbenchFetch } from '../state/workbench-transport';
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
- EXT_APP_BOOT_BEACON_SOURCE,
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: false,
206
- shouldCollapse: false,
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, axBridgeScript: string): string {
384
- if (!axBridgeScript) return html;
385
- const headMatch = /<head\b[^>]*>/i.exec(html);
386
- if (headMatch?.index !== undefined) {
387
- const insertAt = headMatch.index + headMatch[0].length;
388
- return `${html.slice(0, insertAt)}${axBridgeScript}${html.slice(insertAt)}`;
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: ExtAppHostDimensionsTarget | null | undefined,
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
- ): { width: number; height: number } {
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
- export function resolveExtAppInlineFrameHeight(appHeight: number, hostHeight: number): number {
420
- return Math.max(positiveDimension(appHeight, 1), positiveDimension(hostHeight, 1));
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 bridgeRef = useRef<AppBridge | null>(null);
426
- const transportRef = useRef<PostMessageTransport | null>(null);
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
- const html = node.data.html as string | null;
479
- const serverName = node.data.serverName as string | undefined;
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 axBridgeScript = axEnabled ? buildExtAppAxBridgeScript(axToken, nodeId) : '';
506
- // The boot beacon precedes the AX bridge so it runs first at parse time; it
507
- // shares the per-component token (used purely to authenticate the message).
508
- const bootBeaconScript =
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
- useEffect(() => {
516
- function onBootBeacon(event: MessageEvent) {
517
- if (event.source !== iframeRef.current?.contentWindow) return;
518
- const data = event.data as { source?: string; token?: string; nodeId?: string; kind?: string } | null;
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
- // Acquire a serialized mount slot (Finding N). Reuses the boot-aware queue:
536
- // the next present-at-load app's iframe is not even created until this one
537
- // has settled (or timed out), so every WebKit boot lands in an idle panel —
538
- // the empirically always-successful condition. The expanded overlay instance
539
- // and non-WebKit hosts mount immediately (mountGranted starts true).
540
- useEffect(() => {
541
- if (mountGranted) return;
542
- enqueueWebkitRemount({
543
- remount: () => {
544
- if (unmountedRef.current) return false;
545
- extAppRecoveryLog(nodeId, 'mount-slot');
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
- // Bounded wait for the frame's content oracle to turn green — the mount
659
- // queue's release gate (guidance: never green-light the NEXT app while the
660
- // previous document is still contentless; D-then-E was the repro).
661
- const awaitContentVerified = async (maxMs: number): Promise<boolean> => {
662
- const deadline = Date.now() + maxMs;
663
- while (Date.now() < deadline && !unmountedRef.current) {
664
- if ((await probePaint()) === 'content-verified') return true;
665
- await new Promise((resolve) => window.setTimeout(resolve, 400));
666
- }
667
- return false;
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
- // Finding N (0.4.7): the Copilot WKWebView never reports 'visible', so the
694
- // visibilitychange re-arm below is dead code there and the ladder's own
695
- // paint-ok cannot be trusted. When the document claims hidden but this frame
696
- // is on-screen, spend one more full recovery round with a fresh budget —
697
- // the same round the visibilitychange path would have run.
698
- const ASSUME_VISIBLE_REARM_DELAY_MS = 600;
699
- const maybeAssumeVisibleRearm = (): void => {
700
- if (typeof document === 'undefined' || typeof navigator === 'undefined' || typeof window === 'undefined') return;
701
- const frame = iframeRef.current;
702
- if (!frame || unmountedRef.current) return;
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
- await runSoftExpandCycle();
758
- verdict = await probePaint();
759
- if (verdict === 'content-verified') {
760
- extAppRecoveryLog(nodeId, paintOkLabel(' (after soft-expand)'));
761
- setPaintPending(false);
762
- maybeAssumeVisibleRearm();
763
- return;
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
- const init = async () => {
934
- if (!html) return;
935
- const contentWindow = iframe.contentWindow;
936
- if (!contentWindow) {
937
- throw new Error('Ext-app iframe window is unavailable');
938
- }
939
-
940
- const buildHostContext = (
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
- bridge.setRequestHandler(ListToolsRequestSchema, async () => {
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
- bridge.onlistresources = async () =>
1103
- appSessionId ? postJson('/api/ext-app/list-resources', { sessionId: appSessionId }) : { resources: [] };
1104
-
1105
- bridge.onlistresourcetemplates = async () =>
1106
- appSessionId
1107
- ? postJson('/api/ext-app/list-resource-templates', { sessionId: appSessionId })
1108
- : { resourceTemplates: [] };
1109
-
1110
- bridge.onreadresource = async (params) => {
1111
- if (!appSessionId) {
1112
- throw new Error(sessionUnavailableMessage);
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
- bridgeRef.current = bridge;
1226
- transportRef.current = transport;
1227
- hostContextResizeObserver = new ResizeObserver(scheduleHostContextUpdate);
1228
- hostContextResizeObserver.observe(iframe);
1229
- if (iframe.parentElement) hostContextResizeObserver.observe(iframe.parentElement);
1230
-
1231
- // Propagate theme changes to ext-app iframe. Read current expanded state
1232
- // at fire time so the widget keeps its fullscreen/inline context accurate.
1233
- let firstFire = true;
1234
- themeUnsubRef.current = canvasTheme.subscribe((newTheme) => {
1235
- if (firstFire) {
1236
- firstFire = false;
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
- init().catch((err) => {
1251
- clearFallbackTimer();
1252
- console.error('[ext-app] Bridge init failed:', err);
1253
- setError(err?.message ?? 'Bridge initialization failed');
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
- }, [frameKey, iframeDocument.key, mountGranted]);
1290
-
1291
- // Forward tool result when it arrives after bridge is ready
1292
- useEffect(() => {
1293
- if (toolResult && bridgeRef.current && (status === 'ready' || status === 'done')) {
1294
- void flushToolResult(bridgeRef.current);
1295
- }
1296
- }, [toolResult, status]);
1297
-
1298
- // Keep the widget's displayMode in sync when the host expands or collapses
1299
- // the node. Without this, a widget that opened in inline mode would never
1300
- // learn that it is now fullscreen (and vice versa), so features gated on
1301
- // fullscreen (like Excalidraw's edit mode) would not activate on the same
1302
- // click that triggered the expansion.
1303
- useEffect(() => {
1304
- const bridge = bridgeRef.current;
1305
- if (iframeRef.current) {
1306
- iframeRef.current.style.height = '100%';
1307
- }
1308
- if (!bridge || !bridgeReadyRef.current) return undefined;
1309
- // Measure + send AFTER the expand/collapse overlay has laid out (double rAF).
1310
- // Measuring synchronously here reads the iframe at its OLD inline size, so an app
1311
- // like Excalidraw reflows bound text against stale dimensions and clips the start
1312
- // of labels in expanded mode (report #62). A double rAF lands after layout+paint so
1313
- // resolveExtAppContainerDimensions reads the real expanded frame.
1314
- let raf1: number | null = null;
1315
- let raf2: number | null = null;
1316
- raf1 = requestAnimationFrame(() => {
1317
- raf1 = null;
1318
- raf2 = requestAnimationFrame(() => {
1319
- raf2 = null;
1320
- if (!bridgeReadyRef.current) return;
1321
- const hostContext = {
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
- if (raf1 !== null) cancelAnimationFrame(raf1);
1338
- if (raf2 !== null) cancelAnimationFrame(raf2);
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
- }, [isExpanded, maxHeight]);
392
+ };
1341
393
 
1342
- // Loading state — HTML not yet fetched
1343
- if (!html) {
1344
- return (
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
- style={{
1376
- flex: 1,
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
- {/* Iframe stack: the widget renders a preview; when not expanded, a
1457
- transparent click-catcher sits on top so the first click always
1458
- expands the node. Without this, widgets like Excalidraw show their
1459
- own "Edit" button inline, which triggers a fullscreen request and
1460
- remounts the iframe in the overlay — forcing the user to click Edit
1461
- a second time to actually enter edit mode. Routing all inline clicks
1462
- to "expand" makes the flow "open → edit" instead of "edit → expand → edit". */}
1463
- <div style={{ flex: 1, position: 'relative', display: 'flex', minHeight: 0, height: '100%' }}>
1464
- {mountGranted && (
1465
- <iframe
1466
- key={frameKey}
1467
- ref={iframeRef}
1468
- {...iframeDocument.attributes}
1469
- sandbox={iframeSandbox}
1470
- allow={buildAllowAttribute(resourceMeta?.permissions)}
1471
- // NB: do NOT add the `.mcp-app-frame` GPU-layer class (translateZ(0)) here —
1472
- // it creates a stacking context that breaks the AX emit→ack round-trip in the
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
  }