pmx-canvas 0.7.2 → 0.8.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 (251) hide show
  1. package/.github/extensions/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
  2. package/.github/extensions/pmx-canvas/extension.mjs +25 -172
  3. package/.github/extensions/pmx-canvas/mcp-bootstrap.ts +198 -0
  4. package/.github/extensions/pmx-canvas/runtime.d.mts +41 -0
  5. package/.github/extensions/pmx-canvas/runtime.json +3 -0
  6. package/.github/extensions/pmx-canvas/runtime.mjs +194 -0
  7. package/.github/extensions/pmx-canvas/start-mcp.mjs +51 -0
  8. package/.github/extensions/pmx-canvas/tools.json +2560 -0
  9. package/CHANGELOG.md +60 -0
  10. package/Readme.md +44 -16
  11. package/dist/canvas/fonts/OFL.txt +93 -0
  12. package/dist/canvas/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  13. package/dist/canvas/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  14. package/dist/canvas/fonts/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  15. package/dist/canvas/fonts/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  16. package/dist/canvas/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
  17. package/dist/canvas/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
  18. package/dist/canvas/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
  19. package/dist/canvas/fonts/ibm-plex-sans-latin-ext-400-normal.woff2 +0 -0
  20. package/dist/canvas/fonts/ibm-plex-sans-latin-ext-500-normal.woff2 +0 -0
  21. package/dist/canvas/fonts/ibm-plex-sans-latin-ext-600-normal.woff2 +0 -0
  22. package/dist/canvas/global.css +844 -710
  23. package/dist/canvas/index.js +92 -90
  24. package/dist/canvas/mcp-app.html +7878 -0
  25. package/dist/canvas/mermaid-entry.js +1 -1
  26. package/dist/canvas/surface-theme.css +151 -46
  27. package/dist/json-render/index.css +1 -1
  28. package/dist/json-render/index.js +55 -55
  29. package/dist/types/cli/commands/record.d.ts +1 -1
  30. package/dist/types/client/canvas/ChoiceMenu.d.ts +13 -0
  31. package/dist/types/client/canvas/NodeContextMark.d.ts +24 -0
  32. package/dist/types/client/canvas/SessionReceipt.d.ts +3 -1
  33. package/dist/types/client/icons.d.ts +1 -2
  34. package/dist/types/client/mcp-app/index.d.ts +1 -0
  35. package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
  36. package/dist/types/client/nodes/ax-node-actions.d.ts +2 -2
  37. package/dist/types/client/nodes/markdown-sanitize.d.ts +2 -0
  38. package/dist/types/client/nodes/use-workbench-asset.d.ts +2 -0
  39. package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -0
  40. package/dist/types/client/state/attention-store.d.ts +0 -5
  41. package/dist/types/client/state/canvas-store.d.ts +2 -0
  42. package/dist/types/client/state/context-status-store.d.ts +53 -0
  43. package/dist/types/client/state/intent-bridge.d.ts +4 -1
  44. package/dist/types/client/state/near-pin-store.d.ts +16 -0
  45. package/dist/types/client/state/presence-store.d.ts +5 -0
  46. package/dist/types/client/state/session-store.d.ts +33 -3
  47. package/dist/types/client/state/workbench-transport.d.ts +16 -0
  48. package/dist/types/client/theme/tokens.d.ts +1 -3
  49. package/dist/types/client/types.d.ts +9 -0
  50. package/dist/types/json-render/renderer/index.d.ts +2 -1
  51. package/dist/types/json-render/server.d.ts +3 -1
  52. package/dist/types/mcp/app-server.d.ts +3 -0
  53. package/dist/types/mcp/canvas-access.d.ts +1 -1
  54. package/dist/types/mcp/canvas-app.d.ts +4 -0
  55. package/dist/types/mcp/workbench-app.d.ts +4 -0
  56. package/dist/types/server/agent-presence.d.ts +1 -1
  57. package/dist/types/server/ax-state.d.ts +1 -1
  58. package/dist/types/server/canvas-db.d.ts +8 -0
  59. package/dist/types/server/canvas-geometry.d.ts +25 -0
  60. package/dist/types/server/canvas-operations.d.ts +1 -11
  61. package/dist/types/server/canvas-state.d.ts +25 -3
  62. package/dist/types/server/context-brief.d.ts +8 -1
  63. package/dist/types/server/context-reads.d.ts +30 -7
  64. package/dist/types/server/index.d.ts +6 -1
  65. package/dist/types/server/mcp-app-runtime.d.ts +1 -0
  66. package/dist/types/server/operations/http.d.ts +2 -0
  67. package/dist/types/server/operations/invoker.d.ts +4 -2
  68. package/dist/types/server/operations/ops/nodes.d.ts +0 -6
  69. package/dist/types/server/server.d.ts +1 -0
  70. package/dist/types/server/spatial-analysis.d.ts +2 -0
  71. package/dist/types/shared/agent-context.d.ts +22 -0
  72. package/dist/types/shared/agent-presence.d.ts +3 -10
  73. package/dist/types/shared/ax-surface-protocol.d.ts +2 -0
  74. package/dist/types/shared/canvas-app.d.ts +54 -0
  75. package/dist/types/shared/themes.d.ts +1 -1
  76. package/docs/RELEASE.md +7 -3
  77. package/docs/agent-plugin.md +123 -0
  78. package/docs/ax-host-adapter-contract.md +13 -2
  79. package/docs/ax-state-contract.md +40 -0
  80. package/docs/cli.md +7 -2
  81. package/docs/design/AgentContext.dc.html +186 -0
  82. package/docs/design/Before.dc.html +208 -0
  83. package/docs/design/BoardPins.dc.html +151 -0
  84. package/docs/design/Chrome.dc.html +208 -0
  85. package/docs/design/Context.dc.html +239 -0
  86. package/docs/design/Flows.dc.html +181 -0
  87. package/docs/design/Home.dc.html +209 -0
  88. package/docs/design/HomeGraph.dc.html +211 -0
  89. package/docs/design/HomeMap.dc.html +117 -0
  90. package/docs/design/HomeTeam.dc.html +176 -0
  91. package/docs/design/Light.dc.html +28 -0
  92. package/docs/design/Main.dc.html +280 -0
  93. package/docs/design/NearPin.dc.html +260 -0
  94. package/docs/design/Pane600.dc.html +120 -0
  95. package/docs/design/Relations.dc.html +297 -0
  96. package/docs/design/Themes.dc.html +121 -0
  97. package/docs/design/TokenSystem.dc.html +136 -0
  98. package/docs/design/Tokens.dc.html +163 -0
  99. package/docs/design/Types1.dc.html +177 -0
  100. package/docs/design/Types2.dc.html +226 -0
  101. package/docs/design/Types3.dc.html +195 -0
  102. package/docs/design/Uses.dc.html +152 -0
  103. package/docs/design/Vision.dc.html +116 -0
  104. package/docs/design/Zoom.dc.html +82 -0
  105. package/docs/design/canvas.json +274 -0
  106. package/docs/design.md +214 -0
  107. package/docs/environment.md +1 -1
  108. package/docs/http-api.md +80 -15
  109. package/docs/mcp.md +198 -3
  110. package/docs/node-types.md +34 -10
  111. package/docs/plans/plan-009-tech-debt-backlog.md +1 -1
  112. package/docs/plans/plan-015-design-foundation-wave-0.md +177 -0
  113. package/docs/plans/plan-016-context-made-visible-wave-1.md +148 -0
  114. package/docs/product-vision-2026-09.md +47 -7
  115. package/docs/screenshot.png +0 -0
  116. package/docs/sdk.md +1 -0
  117. package/package.json +10 -4
  118. package/plugins/pmx-canvas/LICENSE +21 -0
  119. package/plugins/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
  120. package/plugins/pmx-canvas/assets/preview.png +0 -0
  121. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/extension.mjs +930 -0
  122. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +3 -0
  123. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.mjs +194 -0
  124. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/steering-delivery.mjs +79 -0
  125. package/plugins/pmx-canvas/mcp.json +12 -0
  126. package/plugins/pmx-canvas/plugin.json +24 -0
  127. package/plugins/pmx-canvas/runtime.json +3 -0
  128. package/plugins/pmx-canvas/runtime.mjs +194 -0
  129. package/plugins/pmx-canvas/start-mcp.mjs +51 -0
  130. package/plugins/pmx-canvas/tools.json +2560 -0
  131. package/skills/pmx-canvas/SKILL.md +31 -16
  132. package/skills/pmx-canvas/evals/evals.json +69 -0
  133. package/skills/pmx-canvas/references/full-reference.md +14 -10
  134. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +20 -8
  135. package/skills/pmx-canvas/references/installing-pmx-canvas.md +76 -4
  136. package/skills/pmx-canvas-testing/SKILL.md +54 -4
  137. package/src/cli/commands/copilot.ts +5 -0
  138. package/src/cli/commands/view.ts +1 -1
  139. package/src/cli/index.ts +8 -3
  140. package/src/client/App.tsx +6 -1
  141. package/src/client/canvas/AgentPresenceLayer.tsx +3 -3
  142. package/src/client/canvas/AnnotationLayer.tsx +1 -1
  143. package/src/client/canvas/CanvasNode.tsx +208 -220
  144. package/src/client/canvas/CanvasViewport.tsx +6 -2
  145. package/src/client/canvas/ChoiceMenu.tsx +122 -0
  146. package/src/client/canvas/CommandBar.tsx +25 -23
  147. package/src/client/canvas/CommandPalette.tsx +3 -2
  148. package/src/client/canvas/ContextMenu.tsx +11 -0
  149. package/src/client/canvas/EdgeLayer.tsx +1 -1
  150. package/src/client/canvas/ExportDialog.tsx +33 -6
  151. package/src/client/canvas/FeedbackDialog.tsx +10 -8
  152. package/src/client/canvas/IntentLayer.tsx +2 -2
  153. package/src/client/canvas/Minimap.tsx +12 -7
  154. package/src/client/canvas/NodeContextMark.tsx +156 -0
  155. package/src/client/canvas/SessionPanel.tsx +4 -7
  156. package/src/client/canvas/SessionReceipt.tsx +114 -17
  157. package/src/client/canvas/ShortcutOverlay.tsx +6 -1
  158. package/src/client/canvas/SnapshotPanel.tsx +11 -2
  159. package/src/client/canvas/ToolRail.tsx +58 -108
  160. package/src/client/canvas/TopBar.tsx +16 -11
  161. package/src/client/icons.tsx +2 -12
  162. package/src/client/mcp-app/index.tsx +137 -0
  163. package/src/client/mcp-app/style.css +10 -0
  164. package/src/client/nodes/AttachmentNode.tsx +8 -1
  165. package/src/client/nodes/AxStepControls.tsx +1 -1
  166. package/src/client/nodes/ContextNode.tsx +9 -9
  167. package/src/client/nodes/ExtAppFrame.tsx +13 -8
  168. package/src/client/nodes/FileNode.tsx +8 -5
  169. package/src/client/nodes/ImageNode.tsx +4 -2
  170. package/src/client/nodes/InlineMarkdownEditor.tsx +2 -1
  171. package/src/client/nodes/LedgerNode.tsx +2 -2
  172. package/src/client/nodes/MarkdownNode.tsx +44 -9
  173. package/src/client/nodes/McpAppNode.tsx +22 -4
  174. package/src/client/nodes/PromptNode.tsx +5 -17
  175. package/src/client/nodes/ResponseNode.tsx +3 -15
  176. package/src/client/nodes/StatusNode.tsx +2 -2
  177. package/src/client/nodes/StatusSummary.tsx +3 -1
  178. package/src/client/nodes/TraceNode.tsx +6 -4
  179. package/src/client/nodes/WebpageNode.tsx +1 -1
  180. package/src/client/nodes/ax-node-actions.ts +2 -2
  181. package/src/client/nodes/iframe-document-url.ts +3 -2
  182. package/src/client/nodes/markdown-sanitize.ts +10 -0
  183. package/src/client/nodes/surface-url.ts +10 -0
  184. package/src/client/nodes/use-ax-surface-bridge.ts +4 -2
  185. package/src/client/nodes/use-surface-frame.ts +3 -1
  186. package/src/client/nodes/use-workbench-asset.ts +26 -0
  187. package/src/client/nodes/viewer-runtime-state.ts +78 -0
  188. package/src/client/state/attention-bridge.ts +0 -8
  189. package/src/client/state/attention-store.ts +0 -17
  190. package/src/client/state/canvas-store.ts +7 -2
  191. package/src/client/state/context-status-store.ts +66 -0
  192. package/src/client/state/human-store.ts +2 -0
  193. package/src/client/state/iframe-mode.ts +5 -0
  194. package/src/client/state/intent-bridge.ts +24 -6
  195. package/src/client/state/near-pin-store.ts +53 -0
  196. package/src/client/state/presence-store.ts +5 -10
  197. package/src/client/state/session-store.ts +106 -9
  198. package/src/client/state/sse-bridge.ts +26 -7
  199. package/src/client/state/theme-override.ts +1 -1
  200. package/src/client/state/workbench-transport.ts +36 -0
  201. package/src/client/theme/global.css +844 -710
  202. package/src/client/theme/surface-theme.css +151 -46
  203. package/src/client/theme/tokens.ts +7 -11
  204. package/src/client/types.ts +10 -0
  205. package/src/json-render/renderer/index.tsx +58 -38
  206. package/src/json-render/server.ts +79 -15
  207. package/src/mcp/app-server.ts +64 -0
  208. package/src/mcp/canvas-access.ts +14 -11
  209. package/src/mcp/canvas-app.ts +318 -0
  210. package/src/mcp/server.ts +1 -1
  211. package/src/mcp/workbench-app.ts +211 -0
  212. package/src/server/agent-presence.ts +45 -6
  213. package/src/server/ax-context.ts +1 -1
  214. package/src/server/ax-state.ts +1 -1
  215. package/src/server/board-export.ts +5 -2
  216. package/src/server/canvas-db.ts +46 -12
  217. package/src/server/canvas-geometry.ts +64 -0
  218. package/src/server/canvas-operations.ts +6 -24
  219. package/src/server/canvas-state.ts +105 -15
  220. package/src/server/canvas-validation.ts +19 -4
  221. package/src/server/context-brief.ts +39 -2
  222. package/src/server/context-reads.ts +86 -15
  223. package/src/server/demo-state.json +31 -31
  224. package/src/server/html-surface.ts +4 -5
  225. package/src/server/index.ts +19 -3
  226. package/src/server/mcp-app-runtime.ts +11 -0
  227. package/src/server/operations/composites.ts +4 -3
  228. package/src/server/operations/http.ts +4 -4
  229. package/src/server/operations/invoker.ts +13 -4
  230. package/src/server/operations/ops/ax-read.ts +1 -1
  231. package/src/server/operations/ops/ax-timeline.ts +37 -0
  232. package/src/server/operations/ops/boards.ts +1 -1
  233. package/src/server/operations/ops/canvas-wire.ts +3 -6
  234. package/src/server/operations/ops/intent.ts +31 -2
  235. package/src/server/operations/ops/json-render.ts +1 -1
  236. package/src/server/operations/ops/nodes.ts +8 -76
  237. package/src/server/operations/ops/query.ts +8 -2
  238. package/src/server/operations/ops/snapshots.ts +4 -1
  239. package/src/server/operations/ops/viewport.ts +29 -5
  240. package/src/server/operations/registry.ts +38 -4
  241. package/src/server/scope-fence.ts +55 -10
  242. package/src/server/server.ts +86 -6
  243. package/src/server/spatial-analysis.ts +2 -2
  244. package/src/server/workboard.ts +6 -1
  245. package/src/{server → shared}/agent-context.ts +2 -2
  246. package/src/shared/agent-presence.ts +3 -15
  247. package/src/shared/ax-surface-protocol.ts +3 -0
  248. package/src/shared/canvas-app.ts +62 -0
  249. package/src/shared/themes.ts +5 -5
  250. package/src/client/canvas/FocusFieldLayer.tsx +0 -116
  251. package/src/client/canvas/kind-colors.ts +0 -23
@@ -0,0 +1,137 @@
1
+ import '../uuid-polyfill';
2
+ import { App as McpApp } from '@modelcontextprotocol/ext-apps';
3
+ import { render } from 'preact';
4
+ import { useEffect, useRef, useState } from 'preact/hooks';
5
+ import { z } from 'zod';
6
+ import { App as Workbench } from '../App';
7
+ import { activeBoardId, boardList, boardsLoaded } from '../state/boards-store';
8
+ import { axSurfaceState, contextPinnedNodeIds, nodes, selectedNodeIds } from '../state/canvas-store';
9
+ import { installWorkbenchDownload, installWorkbenchTransport } from '../state/workbench-transport';
10
+ import { resolveIframeMode } from '../state/iframe-mode';
11
+ import { summarizeNodeForAgentContext } from '../../shared/agent-context';
12
+ import { canvasAppContext } from '../../shared/canvas-app';
13
+
14
+ const bridge = new McpApp(
15
+ { name: 'PMX Canvas', version: '1.0.0' },
16
+ { availableDisplayModes: ['inline', 'fullscreen'] },
17
+ );
18
+ const responseSchema = z.object({
19
+ status: z.number(),
20
+ body: z.string(),
21
+ contentType: z.string(),
22
+ encoding: z.enum(['text', 'base64']),
23
+ });
24
+
25
+ // Preserve the workbench's fetch/Response contracts, without passing its human
26
+ // headers or exposing the daemon URL to the embedded browser.
27
+ installWorkbenchTransport(async (input, init) => {
28
+ const path = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
29
+ const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
30
+ if (init?.body !== undefined && typeof init.body !== 'string')
31
+ return Response.json({ ok: false, error: 'Document uploads require the local PMX workbench.' }, { status: 403 });
32
+ init?.signal?.throwIfAborted();
33
+ const result = await bridge.callServerTool({
34
+ name: 'pmx_workbench_request',
35
+ arguments: {
36
+ path,
37
+ method,
38
+ ...(init?.body === undefined ? {} : { body: init.body }),
39
+ expectedBoardId: activeBoardId.value,
40
+ },
41
+ });
42
+ init?.signal?.throwIfAborted();
43
+ const response = responseSchema.parse(result._meta?.response);
44
+ const body =
45
+ response.encoding === 'base64' ? Uint8Array.from(atob(response.body), (char) => char.charCodeAt(0)) : response.body;
46
+ return new Response(method === 'HEAD' || response.status === 204 ? null : body, {
47
+ status: response.status,
48
+ headers: { 'Content-Type': response.contentType },
49
+ });
50
+ });
51
+
52
+ const focusSchema = z.object({ focus: z.array(z.string()) });
53
+
54
+ function selectionContext() {
55
+ const focus = focusSchema.safeParse(axSurfaceState.value);
56
+ return canvasAppContext(
57
+ {
58
+ boardId: activeBoardId.value,
59
+ boardName: boardList.value.find((board) => board.id === activeBoardId.value)?.name ?? 'Home',
60
+ pinnedNodeIds: [...contextPinnedNodeIds.value],
61
+ nodes: [...nodes.value.values()].map((node) => ({
62
+ id: node.id,
63
+ type: node.type,
64
+ title: typeof node.data.title === 'string' ? node.data.title : node.type,
65
+ contentRevision: node.contentRevision ?? 0,
66
+ text: summarizeNodeForAgentContext(node),
67
+ })),
68
+ },
69
+ [...selectedNodeIds.value],
70
+ focus.success ? focus.data.focus : [],
71
+ );
72
+ }
73
+
74
+ function HostedWorkbench() {
75
+ const [acknowledged, setAcknowledged] = useState('');
76
+ const [retry, setRetry] = useState(0);
77
+ const queue = useRef(Promise.resolve());
78
+ const context = selectionContext();
79
+ const key = JSON.stringify(context);
80
+ const desired = useRef(key);
81
+ desired.current = key;
82
+ const caps = bridge.getHostCapabilities();
83
+ const ready = boardsLoaded.value;
84
+ useEffect(() => {
85
+ if (!ready || !caps?.updateModelContext || acknowledged === key) return;
86
+ let cancelled = false;
87
+ let timer: ReturnType<typeof setTimeout> | undefined;
88
+ queue.current = queue.current
89
+ .then(async () => {
90
+ if (cancelled || desired.current !== key) return;
91
+ setAcknowledged('');
92
+ await bridge.updateModelContext({ structuredContent: context });
93
+ if (cancelled || desired.current !== key) return;
94
+ setAcknowledged(key);
95
+ })
96
+ .catch(() => {
97
+ // Context is background synchronization, not extra workbench chrome.
98
+ // Retry unchanged context too, and only consider a host ACK successful.
99
+ if (!cancelled && desired.current === key) timer = setTimeout(() => setRetry((value) => value + 1), 2000);
100
+ });
101
+ return () => {
102
+ cancelled = true;
103
+ clearTimeout(timer);
104
+ };
105
+ }, [key, ready, retry]);
106
+ return (
107
+ <div class="pmx-host-app">
108
+ <Workbench />
109
+ </div>
110
+ );
111
+ }
112
+
113
+ // Polling is the sole state owner. Launcher tool results never rehydrate the
114
+ // workbench, so delayed initial notifications cannot roll back its live state.
115
+ bridge.ontoolresult = () => {};
116
+ void bridge
117
+ .connect()
118
+ .then(async () => {
119
+ if (!bridge.getHostCapabilities()?.serverTools) throw new Error('This host does not support app tool calls.');
120
+ if (bridge.getHostCapabilities()?.downloadFile) {
121
+ installWorkbenchDownload((file) =>
122
+ bridge.downloadFile({
123
+ contents: [
124
+ {
125
+ type: 'resource',
126
+ resource: { uri: `file:///${encodeURIComponent(file.name)}`, mimeType: file.mimeType, text: file.text },
127
+ },
128
+ ],
129
+ }),
130
+ );
131
+ }
132
+ await resolveIframeMode();
133
+ render(<HostedWorkbench />, document.getElementById('app')!);
134
+ })
135
+ .catch((error: unknown) => {
136
+ document.getElementById('app')!.textContent = `Could not connect PMX: ${String(error)}`;
137
+ });
@@ -0,0 +1,10 @@
1
+ .pmx-host-app { height: 100%; display: flex; flex-direction: column; }
2
+ .pmx-host-app > .app-shell { flex: 1; min-height: 0; height: auto; }
3
+ /* Keep context controls clear of the host's floating chat composer. */
4
+ .pmx-host-app .context-pin-bar {
5
+ bottom: 16px;
6
+ left: 16px;
7
+ transform: none;
8
+ }
9
+ /* Guard refusals must remain readable while the full source editor is open. */
10
+ .pmx-host-app .attention-toast.attention-tone-remove { z-index: 100100; }
@@ -4,10 +4,12 @@ import type { CanvasNodeState } from '../types';
4
4
  import { activeBoardId } from '../state/boards-store';
5
5
  import { requestJson } from '../state/intent-bridge';
6
6
  import { expandNode, collapseExpandedNode } from '../state/canvas-store';
7
+ import { useWorkbenchAsset } from './use-workbench-asset';
7
8
 
8
9
  /** Originals stay useful even when the connected host cannot extract them. */
9
10
  export function AttachmentNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
10
11
  const attachmentId = String(node.data.attachmentId);
12
+ const downloadUrl = useWorkbenchAsset(`/api/canvas/attachments/${encodeURIComponent(attachmentId)}/bytes`);
11
13
  const boardId = activeBoardId.value;
12
14
  const [job, setJob] = useState<DocumentImport | null>(null);
13
15
  const [review, setReview] = useState(expanded);
@@ -68,7 +70,12 @@ export function AttachmentNode({ node, expanded = false }: { node: CanvasNodeSta
68
70
  ? 'Markdown added'
69
71
  : 'Original attached'}
70
72
  </div>
71
- <a href={`/api/canvas/attachments/${attachmentId}/bytes`} target="_blank" rel="noopener noreferrer">
73
+ <a
74
+ href={downloadUrl}
75
+ download={String(node.data.title ?? 'attachment')}
76
+ target="_blank"
77
+ rel="noopener noreferrer"
78
+ >
72
79
  Download original · {Math.ceil(Number(node.data.size ?? 0) / 1024)} KB
73
80
  </a>
74
81
  {job?.reason && <p>{job.reason}</p>}
@@ -65,7 +65,7 @@ const labelStyle = {
65
65
 
66
66
  const activeButtonStyle = {
67
67
  ...axNodeActionButtonStyle,
68
- background: 'var(--c-accent-25)',
68
+ background: 'color-mix(in srgb, var(--c-accent) 25%, transparent)',
69
69
  color: 'var(--c-text)',
70
70
  } as const;
71
71
 
@@ -298,8 +298,8 @@ export function ContextNode({
298
298
  style={{
299
299
  padding: '4px 8px',
300
300
  fontSize: '10px',
301
- background: 'var(--c-accent-12)',
302
- border: '1px solid var(--c-accent-25)',
301
+ background: 'color-mix(in srgb, var(--c-accent) 12%, transparent)',
302
+ border: '1px solid color-mix(in srgb, var(--c-accent) 25%, transparent)',
303
303
  borderRadius: '4px',
304
304
  color: 'var(--c-text-soft)',
305
305
  cursor: 'pointer',
@@ -363,7 +363,7 @@ export function ContextNode({
363
363
  style={{
364
364
  fontSize: '9px',
365
365
  padding: '1px 4px',
366
- background: 'var(--c-accent-10)',
366
+ background: 'color-mix(in srgb, var(--c-accent) 10%, transparent)',
367
367
  color: 'var(--c-accent)',
368
368
  borderRadius: '3px',
369
369
  display: 'inline-block',
@@ -402,8 +402,8 @@ export function ContextNode({
402
402
  style={{
403
403
  fontSize: '9px',
404
404
  padding: '1px 4px',
405
- background: 'var(--c-warn-12)',
406
- color: 'var(--c-warn-alt)',
405
+ background: 'color-mix(in srgb, var(--c-accent) 12%, transparent)',
406
+ color: 'var(--c-accent)',
407
407
  borderRadius: '3px',
408
408
  display: 'inline-block',
409
409
  }}
@@ -420,8 +420,8 @@ export function ContextNode({
420
420
  style={{
421
421
  padding: '4px 8px',
422
422
  fontSize: '10px',
423
- background: 'var(--c-accent-12)',
424
- border: '1px solid var(--c-accent-25)',
423
+ background: 'color-mix(in srgb, var(--c-accent) 12%, transparent)',
424
+ border: '1px solid color-mix(in srgb, var(--c-accent) 25%, transparent)',
425
425
  borderRadius: '4px',
426
426
  color: 'var(--c-text-soft)',
427
427
  cursor: 'pointer',
@@ -518,8 +518,8 @@ export function ContextNode({
518
518
  style={{
519
519
  padding: '4px 8px',
520
520
  fontSize: '10px',
521
- background: 'var(--c-accent-12)',
522
- border: '1px solid var(--c-accent-25)',
521
+ background: 'color-mix(in srgb, var(--c-accent) 12%, transparent)',
522
+ border: '1px solid color-mix(in srgb, var(--c-accent) 25%, transparent)',
523
523
  borderRadius: '4px',
524
524
  color: 'var(--c-text-soft)',
525
525
  cursor: 'pointer',
@@ -3,6 +3,7 @@ import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
3
3
  import { AppBridge, PostMessageTransport, buildAllowAttribute } from '@modelcontextprotocol/ext-apps/app-bridge';
4
4
  import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
5
5
  import { IconClose } from '../icons';
6
+ import { workbenchFetch } from '../state/workbench-transport';
6
7
  import {
7
8
  AX_SURFACE_ACK_SOURCE,
8
9
  AX_SURFACE_EMIT_SOURCE,
@@ -41,7 +42,7 @@ interface ExtAppHostDimensionsTarget {
41
42
  }
42
43
 
43
44
  async function postJson<T>(url: string, body: Record<string, unknown>): Promise<T> {
44
- const response = await fetch(url, {
45
+ const response = await workbenchFetch(url, {
45
46
  method: 'POST',
46
47
  headers: { 'Content-Type': 'application/json' },
47
48
  body: JSON.stringify(body),
@@ -150,7 +151,7 @@ function queueExtAppRecoveryReport(entry: { t: number; nodeId: string; event: st
150
151
  extAppReportTimer = null;
151
152
  const entries = extAppReportQueue.splice(0);
152
153
  if (entries.length === 0) return;
153
- void fetch('/api/canvas/debug/ext-app-recovery', {
154
+ void workbenchFetch('/api/canvas/debug/ext-app-recovery', {
154
155
  method: 'POST',
155
156
  headers: { 'Content-Type': 'application/json' },
156
157
  body: JSON.stringify({ entries }),
@@ -1383,10 +1384,14 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1383
1384
  padding: '6px 10px',
1384
1385
  fontSize: '11px',
1385
1386
  background:
1386
- sessionStatus === 'error' && !sessionErrorWithContent ? 'var(--c-danger-12)' : 'var(--c-warn-10)',
1387
+ sessionStatus === 'error' && !sessionErrorWithContent
1388
+ ? 'color-mix(in srgb, var(--c-danger) 12%, transparent)'
1389
+ : 'color-mix(in srgb, var(--c-warn) 10%, transparent)',
1387
1390
  color: sessionStatus === 'error' && !sessionErrorWithContent ? 'var(--c-danger)' : 'var(--c-warn)',
1388
1391
  borderBottom: `1px solid ${
1389
- sessionStatus === 'error' && !sessionErrorWithContent ? 'var(--c-danger-12)' : 'var(--c-warn-15)'
1392
+ sessionStatus === 'error' && !sessionErrorWithContent
1393
+ ? 'color-mix(in srgb, var(--c-danger) 12%, transparent)'
1394
+ : 'color-mix(in srgb, var(--c-warn) 15%, transparent)'
1390
1395
  }`,
1391
1396
  }}
1392
1397
  >
@@ -1398,9 +1403,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1398
1403
  style={{
1399
1404
  padding: '6px 10px',
1400
1405
  fontSize: '11px',
1401
- background: 'var(--c-danger-12)',
1406
+ background: 'color-mix(in srgb, var(--c-danger) 12%, transparent)',
1402
1407
  color: 'var(--c-danger)',
1403
- borderBottom: '1px solid var(--c-danger-12)',
1408
+ borderBottom: '1px solid color-mix(in srgb, var(--c-danger) 12%, transparent)',
1404
1409
  display: 'flex',
1405
1410
  alignItems: 'center',
1406
1411
  gap: '6px',
@@ -1417,7 +1422,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1417
1422
  }}
1418
1423
  style={{
1419
1424
  background: 'var(--c-surface-hover)',
1420
- border: '1px solid var(--c-danger-12)',
1425
+ border: '1px solid color-mix(in srgb, var(--c-danger) 12%, transparent)',
1421
1426
  borderRadius: '3px',
1422
1427
  color: 'var(--c-danger)',
1423
1428
  cursor: 'pointer',
@@ -1512,7 +1517,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1512
1517
  }}
1513
1518
  style={{
1514
1519
  background: 'var(--c-surface-hover)',
1515
- border: '1px solid var(--c-warn-15)',
1520
+ border: '1px solid color-mix(in srgb, var(--c-warn) 15%, transparent)',
1516
1521
  borderRadius: '4px',
1517
1522
  color: 'var(--c-warn)',
1518
1523
  cursor: 'pointer',
@@ -7,6 +7,7 @@ import { fetchFile, updateNodeFromClient } from '../state/intent-bridge';
7
7
  import type { CanvasNodeState } from '../types';
8
8
  import { runNodeAxInteraction } from './ax-node-actions';
9
9
  import { AttachmentNode } from './AttachmentNode';
10
+ import { useWorkbenchAsset } from './use-workbench-asset';
10
11
 
11
12
  /** Guess a language label from a file extension for display. */
12
13
  function langFromPath(path: string): string {
@@ -180,9 +181,11 @@ function TextFileNode({ node, expanded = false }: { node: CanvasNodeState; expan
180
181
  const parsed = parseDelimitedText(content, delimiter);
181
182
  return parsed.header.length >= 2 ? parsed : null;
182
183
  }, [content, delimiter, servesBytes]);
183
- const bytesUrl = `/api/canvas/file-bytes?nodeId=${encodeURIComponent(node.id)}${
184
- bytesVersion ? `&v=${bytesVersion}` : ''
185
- }`;
184
+ const bytesUrl = useWorkbenchAsset(
185
+ servesBytes
186
+ ? `/api/canvas/file-bytes?nodeId=${encodeURIComponent(node.id)}${bytesVersion ? `&v=${bytesVersion}` : ''}`
187
+ : '',
188
+ );
186
189
  // Amp orb portals block src-URL child iframes and force srcdoc, which cannot
187
190
  // inline a PDF — offer the raw URL instead of a dead frame.
188
191
  const pdfFrameBlocked = iframeMode.value === 'srcdoc';
@@ -204,7 +207,7 @@ function TextFileNode({ node, expanded = false }: { node: CanvasNodeState; expan
204
207
  display: 'flex',
205
208
  flexDirection: 'column',
206
209
  height: '100%',
207
- fontFamily: 'var(--mono)',
210
+ fontFamily: 'var(--font-code)',
208
211
  fontSize: expanded ? '13px' : '11px',
209
212
  }}
210
213
  >
@@ -223,7 +226,7 @@ function TextFileNode({ node, expanded = false }: { node: CanvasNodeState; expan
223
226
  style={{
224
227
  fontSize: '9px',
225
228
  padding: '1px 5px',
226
- background: 'var(--c-accent-12)',
229
+ background: 'color-mix(in srgb, var(--c-accent) 12%, transparent)',
227
230
  color: 'var(--c-accent)',
228
231
  borderRadius: '3px',
229
232
  fontWeight: 600,
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
2
  import { IconRefresh } from '../icons';
3
3
  import type { CanvasNodeState } from '../types';
4
4
  import { getImageNodeWarnings } from './image-warnings';
5
+ import { useWorkbenchAsset } from './use-workbench-asset';
5
6
 
6
7
  /**
7
8
  * Image node renderer.
@@ -15,10 +16,11 @@ export function ImageNode({ node, expanded = false }: { node: CanvasNodeState; e
15
16
  const warnings = getImageNodeWarnings(node);
16
17
 
17
18
  // Determine the image source URL
18
- const imageSrc =
19
+ const imageSrc = useWorkbenchAsset(
19
20
  src.startsWith('data:') || src.startsWith('http://') || src.startsWith('https://')
20
21
  ? src
21
- : `/api/canvas/image/${node.id}`;
22
+ : `/api/canvas/image/${node.id}`,
23
+ );
22
24
 
23
25
  const [loaded, setLoaded] = useState(false);
24
26
  const [error, setError] = useState(false);
@@ -3,6 +3,7 @@ import TurndownService from 'turndown';
3
3
  import { useCallback, useEffect, useRef } from 'preact/hooks';
4
4
  import { InlineFormatBar } from './InlineFormatBar';
5
5
  import { promptAndInsertLink } from './inline-editor-commands';
6
+ import { sanitizeMarkdownHtml } from './markdown-sanitize';
6
7
 
7
8
  let _turndown: TurndownService | null = null;
8
9
  function getTurndown(): TurndownService {
@@ -45,7 +46,7 @@ export function InlineMarkdownEditor({
45
46
  useEffect(() => {
46
47
  const el = rootRef.current;
47
48
  if (!el) return;
48
- el.innerHTML = initialHtmlRef.current;
49
+ el.innerHTML = sanitizeMarkdownHtml(initialHtmlRef.current);
49
50
  // GFM task checkboxes are live in the editor too: marked emits them
50
51
  // `disabled`, which also swallows clicks in the expanded view.
51
52
  for (const box of el.querySelectorAll('input[type="checkbox"][disabled]')) {
@@ -34,7 +34,7 @@ export function LedgerNode({ node }: { node: CanvasNodeState }) {
34
34
  padding: '3px 0',
35
35
  borderBottom: i < lines.length - 1 ? '1px solid rgba(45,55,90,0.3)' : 'none',
36
36
  color: 'var(--c-text)',
37
- fontFamily: 'var(--mono)',
37
+ fontFamily: 'var(--font-code)',
38
38
  fontSize: '11px',
39
39
  whiteSpace: 'pre-wrap',
40
40
  wordBreak: 'break-word',
@@ -62,7 +62,7 @@ export function LedgerNode({ node }: { node: CanvasNodeState }) {
62
62
  <span
63
63
  style={{
64
64
  color: 'var(--c-text)',
65
- fontFamily: 'var(--mono)',
65
+ fontFamily: 'var(--font-code)',
66
66
  fontSize: '11px',
67
67
  textAlign: 'right',
68
68
  wordBreak: 'break-word',
@@ -1,13 +1,16 @@
1
1
  import type { JSX } from 'preact';
2
2
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
3
3
  import { IconArrowLeft } from '../icons';
4
- import { expandNode, updateNodeData } from '../state/canvas-store';
4
+ import { expandNode, nodes, updateNodeData } from '../state/canvas-store';
5
5
  import { fetchFile, renderMarkdown, saveFile, updateNodeFromClient } from '../state/intent-bridge';
6
6
  import type { CanvasNodeState } from '../types';
7
7
  import { activeBoard, setBoardReadme } from '../state/boards-store';
8
8
  import { MdFormatBar } from './MdFormatBar';
9
9
  import { handleFormatShortcut, handleTab } from './md-format';
10
10
  import { InlineMarkdownEditor } from './InlineMarkdownEditor';
11
+ import { isHostedWorkbench } from '../state/workbench-transport';
12
+ import { activeBoardId } from '../state/boards-store';
13
+ import { sanitizeMarkdownHtml } from './markdown-sanitize';
11
14
 
12
15
  function RenderedMarkdown({
13
16
  html,
@@ -37,7 +40,7 @@ function RenderedMarkdown({
37
40
  container.replaceChildren();
38
41
  if (!html) return;
39
42
  const template = document.createElement('template');
40
- template.innerHTML = html;
43
+ template.innerHTML = sanitizeMarkdownHtml(html);
41
44
  container.append(template.content.cloneNode(true));
42
45
  if (onTaskToggleRef.current) {
43
46
  for (const box of container.querySelectorAll('input[type="checkbox"][disabled]')) {
@@ -98,9 +101,14 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
98
101
  // debounced save without capturing stale closures.
99
102
  const latestMdRef = useRef<string>('');
100
103
  const persistFnRef = useRef<((md: string) => Promise<void>) | null>(null);
104
+ const dirtyRef = useRef(false);
105
+ const editBase = useRef({ boardId: activeBoardId.value, revision: node.contentRevision ?? 0 });
106
+ const saveQueue = useRef(Promise.resolve());
101
107
  const reviewActive = node.data.reviewActive as boolean | undefined;
102
108
 
103
109
  useEffect(() => {
110
+ if (isHostedWorkbench() && dirtyRef.current) return;
111
+ editBase.current = { boardId: activeBoardId.value, revision: node.contentRevision ?? 0 };
104
112
  let cancelled = false;
105
113
  (async () => {
106
114
  let raw: string;
@@ -116,7 +124,7 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
116
124
  }
117
125
  setContent(raw);
118
126
  const html = await renderMarkdown(raw);
119
- if (cancelled) return;
127
+ if (cancelled || (isHostedWorkbench() && dirtyRef.current)) return;
120
128
  setRendered(html);
121
129
  setLoaded(true);
122
130
  updateNodeData(node.id, { content: raw, rendered: html });
@@ -128,6 +136,8 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
128
136
 
129
137
  const handleInput = useCallback(async (e: Event) => {
130
138
  const value = (e.target as HTMLTextAreaElement).value;
139
+ dirtyRef.current = true;
140
+ latestMdRef.current = value;
131
141
  setContent(value);
132
142
  setDirty(true);
133
143
  const html = await renderMarkdown(value);
@@ -136,6 +146,32 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
136
146
 
137
147
  const persistContent = useCallback(
138
148
  async (newContent: string) => {
149
+ if (isHostedWorkbench()) {
150
+ // Serialize autosaves against the captured source revision, not whatever
151
+ // newer revision a background poll has delivered while typing.
152
+ saveQueue.current = saveQueue.current.then(async () => {
153
+ setSaving(true);
154
+ const result = await updateNodeFromClient(node.id, {
155
+ content: newContent,
156
+ expectedBoardId: editBase.current.boardId,
157
+ expectedContentRevision: editBase.current.revision,
158
+ });
159
+ setSaving(false);
160
+ if (!result.ok) return; // Refusal toast is shown by the request helper; keep the draft.
161
+ editBase.current.revision = result.contentRevision ?? editBase.current.revision;
162
+ const current = nodes.value.get(node.id);
163
+ if (current)
164
+ nodes.value = new Map(nodes.value).set(node.id, { ...current, contentRevision: editBase.current.revision });
165
+ if (latestMdRef.current !== newContent) return;
166
+ dirtyRef.current = false;
167
+ setDirty(false);
168
+ const html = await renderMarkdown(newContent);
169
+ setRendered(html);
170
+ updateNodeData(node.id, { content: newContent, rendered: html });
171
+ });
172
+ await saveQueue.current;
173
+ return;
174
+ }
139
175
  if (!path) {
140
176
  const html = await renderMarkdown(newContent);
141
177
  setRendered(html);
@@ -191,6 +227,7 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
191
227
  // don't hit the backend on every letter.
192
228
  const handleInlineChange = useCallback(
193
229
  (md: string) => {
230
+ dirtyRef.current = true;
194
231
  setContent(md);
195
232
  setDirty(true);
196
233
  latestMdRef.current = md;
@@ -208,6 +245,7 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
208
245
  // write twice with slightly different content.
209
246
  const handleInlineSave = useCallback(
210
247
  (md: string) => {
248
+ dirtyRef.current = true;
211
249
  setContent(md);
212
250
  latestMdRef.current = md;
213
251
  if (persistTimerRef.current !== null) {
@@ -236,9 +274,9 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
236
274
  style={{
237
275
  padding: '4px 8px',
238
276
  fontSize: '10px',
239
- background: 'var(--c-ok-10)',
277
+ background: 'color-mix(in srgb, var(--c-ok) 10%, transparent)',
240
278
  color: 'var(--c-ok)',
241
- borderBottom: '1px solid var(--c-ok-20)',
279
+ borderBottom: '1px solid color-mix(in srgb, var(--c-ok) 20%, transparent)',
242
280
  textTransform: 'uppercase',
243
281
  letterSpacing: '0.05em',
244
282
  fontWeight: 600,
@@ -274,9 +312,6 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
274
312
  <textarea ref={textareaRef} value={content} onInput={handleInput} spellcheck={false} />
275
313
  <MdFormatBar textareaRef={textareaRef} />
276
314
  </div>
277
- {/* D5/H4: Trust boundary — rendered HTML comes from server-side marked()
278
- on the user's own markdown files, served only on 127.0.0.1. No DOMPurify
279
- needed for this localhost-only rendering of user-owned content. */}
280
315
  <RenderedMarkdown html={rendered} className="md-preview" />
281
316
  </div>
282
317
  </div>
@@ -322,7 +357,7 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
322
357
  style={{
323
358
  padding: '4px 10px',
324
359
  fontSize: '11px',
325
- background: dirty ? 'var(--c-accent-25)' : 'var(--c-input-bg)',
360
+ background: dirty ? 'color-mix(in srgb, var(--c-accent) 25%, transparent)' : 'var(--c-input-bg)',
326
361
  border: `1px solid ${dirty ? 'var(--c-accent)' : 'var(--c-line)'}`,
327
362
  borderRadius: '6px',
328
363
  color: dirty ? 'var(--c-text)' : 'var(--c-dim)',
@@ -10,6 +10,8 @@ import { ExtAppFrame } from './ExtAppFrame';
10
10
  import { useAxSurfaceBridge } from './use-ax-surface-bridge';
11
11
  import { useIframeContentHeight } from './use-iframe-content-height';
12
12
  import { useSurfaceFrame } from './use-surface-frame';
13
+ import { restoreViewerRuntimeState } from './viewer-runtime-state';
14
+ import { isHostedWorkbench } from '../state/workbench-transport';
13
15
 
14
16
  function withViewerParams(
15
17
  url: string,
@@ -19,10 +21,12 @@ function withViewerParams(
19
21
  axNodeId?: string,
20
22
  frameToken?: string,
21
23
  fitContent?: boolean,
24
+ stateToken?: string,
22
25
  ): string {
23
26
  if (!url) return url;
24
27
  try {
25
- const resolved = new URL(url, window.location.origin);
28
+ const hosted = isHostedWorkbench() && url.startsWith('/');
29
+ const resolved = new URL(url, hosted ? 'https://pmx.invalid' : window.location.origin);
26
30
  // The json-render viewer + artifacts understand dark/light — collapse named
27
31
  // themes (sepia → light, midnight/arctic/ember/forest → dark) to a scheme.
28
32
  resolved.searchParams.set('theme', canvasThemeScheme(canvasTheme.value));
@@ -38,7 +42,8 @@ function withViewerParams(
38
42
  // Content-fit: report natural height (charts render intrinsic) so the node grows.
39
43
  if (frameToken) resolved.searchParams.set('frameToken', frameToken);
40
44
  if (fitContent) resolved.searchParams.set('fit', 'content');
41
- return resolved.toString();
45
+ if (stateToken) resolved.searchParams.set('stateToken', stateToken);
46
+ return hosted ? `${resolved.pathname}${resolved.search}` : resolved.toString();
42
47
  } catch {
43
48
  return url;
44
49
  }
@@ -68,6 +73,7 @@ export function RefreshingViewerFrame({
68
73
  source,
69
74
  iframeRef,
70
75
  onLoad,
76
+ onDocumentLoad,
71
77
  title,
72
78
  className = 'mcp-app-frame',
73
79
  sandbox = 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox',
@@ -77,6 +83,7 @@ export function RefreshingViewerFrame({
77
83
  source: ViewerFrameSource;
78
84
  iframeRef: RefObject<HTMLIFrameElement>;
79
85
  onLoad: () => void;
86
+ onDocumentLoad?: (frame: HTMLIFrameElement) => void;
80
87
  title: string;
81
88
  className?: string;
82
89
  sandbox?: string;
@@ -119,6 +126,9 @@ export function RefreshingViewerFrame({
119
126
  tabIndex={tabIndex}
120
127
  loading={iframeMode.value === 'srcdoc' ? undefined : 'lazy'}
121
128
  onLoad={(event) => {
129
+ // Seed runtime state while a replacement is still behind the painted
130
+ // frame, rather than mounting an empty viewer after promotion.
131
+ onDocumentLoad?.(event.currentTarget);
122
132
  if (!isPending) {
123
133
  onLoad();
124
134
  return;
@@ -214,6 +224,10 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
214
224
  useEffect(pushAxState, [isAxViewer, axToken, axStateValue]);
215
225
 
216
226
  const specVersion = typeof node.data.specVersion === 'number' ? node.data.specVersion : undefined;
227
+ const stateToken = useMemo(
228
+ () => (isJsonViewer ? `ui-${crypto.randomUUID()}` : ''),
229
+ [isJsonViewer, specVersion, node.data.url],
230
+ );
217
231
  const url = withViewerParams(
218
232
  (node.data.url as string) || '',
219
233
  expanded,
@@ -222,6 +236,7 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
222
236
  isAxViewer ? node.id : undefined,
223
237
  frameToken || undefined,
224
238
  contentFit,
239
+ stateToken || undefined,
225
240
  );
226
241
  // src vs fetch()+srcdoc, decided by the boot-wide embed probe (Amp portals).
227
242
  // External viewer URLs always stay src — the hook passes them through.
@@ -270,9 +285,9 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
270
285
  style={{
271
286
  padding: '4px 8px',
272
287
  fontSize: '10px',
273
- background: 'var(--c-warn-10)',
288
+ background: 'color-mix(in srgb, var(--c-warn) 10%, transparent)',
274
289
  color: 'var(--c-warn)',
275
- borderBottom: '1px solid var(--c-warn-15)',
290
+ borderBottom: '1px solid color-mix(in srgb, var(--c-warn) 15%, transparent)',
276
291
  }}
277
292
  >
278
293
  Unverified domain
@@ -284,6 +299,9 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
284
299
  <RefreshingViewerFrame
285
300
  source={surfaceFrame}
286
301
  iframeRef={iframeRef}
302
+ onDocumentLoad={(frame) => {
303
+ if (stateToken && frame.contentWindow) restoreViewerRuntimeState(node.id, stateToken, frame.contentWindow);
304
+ }}
287
305
  onLoad={pushAxState}
288
306
  title={`MCP App: ${sourceServer}`}
289
307
  />