pmx-canvas 0.1.23 → 0.1.25

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 (54) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +591 -0
  2. package/CHANGELOG.md +123 -0
  3. package/Readme.md +36 -5
  4. package/dist/canvas/global.css +36 -3
  5. package/dist/canvas/index.js +54 -54
  6. package/dist/types/client/nodes/ExtAppFrame.d.ts +1 -0
  7. package/dist/types/client/nodes/McpAppNode.d.ts +1 -0
  8. package/dist/types/client/nodes/iframe-document-url.d.ts +8 -0
  9. package/dist/types/client/state/intent-bridge.d.ts +4 -0
  10. package/dist/types/client/types.d.ts +1 -0
  11. package/dist/types/json-render/catalog.d.ts +1 -1
  12. package/dist/types/mcp/canvas-access.d.ts +9 -0
  13. package/dist/types/server/ax-context.d.ts +3 -0
  14. package/dist/types/server/ax-state.d.ts +43 -0
  15. package/dist/types/server/canvas-db.d.ts +5 -0
  16. package/dist/types/server/canvas-operations.d.ts +4 -0
  17. package/dist/types/server/canvas-state.d.ts +20 -3
  18. package/dist/types/server/index.d.ts +6 -0
  19. package/dist/types/server/mutation-history.d.ts +1 -1
  20. package/docs/cli.md +13 -0
  21. package/docs/http-api.md +24 -0
  22. package/docs/mcp.md +20 -2
  23. package/docs/plans/plan-004-pmx-ax-primitives.md +463 -0
  24. package/docs/screenshot.png +0 -0
  25. package/docs/sdk.md +5 -0
  26. package/package.json +2 -1
  27. package/skills/pmx-canvas/SKILL.md +14 -0
  28. package/skills/pmx-canvas/references/codex-app-adapter.md +110 -0
  29. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +125 -0
  30. package/src/cli/agent.ts +34 -0
  31. package/src/cli/index.ts +2 -1
  32. package/src/client/App.tsx +2 -0
  33. package/src/client/canvas/CanvasNode.tsx +7 -0
  34. package/src/client/canvas/CommandPalette.tsx +2 -1
  35. package/src/client/canvas/use-node-drag.ts +29 -7
  36. package/src/client/canvas/use-node-resize.ts +27 -7
  37. package/src/client/nodes/ExtAppFrame.tsx +51 -10
  38. package/src/client/nodes/HtmlNode.tsx +5 -2
  39. package/src/client/nodes/McpAppNode.tsx +13 -1
  40. package/src/client/nodes/iframe-document-url.ts +58 -0
  41. package/src/client/state/intent-bridge.ts +8 -0
  42. package/src/client/state/sse-bridge.ts +3 -3
  43. package/src/client/theme/global.css +36 -3
  44. package/src/client/types.ts +1 -0
  45. package/src/mcp/canvas-access.ts +38 -0
  46. package/src/mcp/server.ts +113 -4
  47. package/src/server/ax-context.ts +38 -0
  48. package/src/server/ax-state.ts +130 -0
  49. package/src/server/canvas-db.ts +36 -1
  50. package/src/server/canvas-operations.ts +96 -4
  51. package/src/server/canvas-state.ts +123 -4
  52. package/src/server/index.ts +29 -2
  53. package/src/server/mutation-history.ts +12 -0
  54. package/src/server/server.ts +312 -14
@@ -0,0 +1,125 @@
1
+ # GitHub Copilot App Adapter
2
+
3
+ Use this reference when PMX Canvas is running inside the GitHub Copilot app as a project canvas
4
+ extension. The adapter is intentionally thin: PMX Canvas remains the state owner, and the extension
5
+ maps Copilot SDK features onto PMX AX primitives.
6
+
7
+ ## Adapter Identity
8
+
9
+ - Extension path: `.github/extensions/pmx-canvas/extension.mjs`
10
+ - Extension ID: `project:pmx-canvas`
11
+ - Canvas ID: `pmx-canvas`
12
+ - Display name: `PMX Canvas`
13
+
14
+ ## Quick Start
15
+
16
+ 1. Install the project adapter by copying the packaged extension into the repository:
17
+ `mkdir -p .github/extensions/pmx-canvas && cp node_modules/pmx-canvas/.github/extensions/pmx-canvas/extension.mjs .github/extensions/pmx-canvas/extension.mjs`
18
+ 2. Reload Copilot app extensions with `extensions_reload` so `project:pmx-canvas` is registered.
19
+ 3. Start or confirm a PMX Canvas daemon for the workspace: `pmx-canvas serve --daemon`
20
+ and `pmx-canvas serve status`. The adapter can auto-start in many local sessions, but a running
21
+ daemon is the most reliable setup for fresh agents.
22
+ 4. Open the canvas with `extensionId: "project:pmx-canvas"`, `canvasId: "pmx-canvas"`, and a stable
23
+ `instanceId`.
24
+ 5. If the first `invoke_canvas_action` immediately after `open_canvas` returns
25
+ `Canvas instance not open`, retry the same action once. This is a known Copilot app timing race
26
+ during panel initialization, not a PMX server failure.
27
+
28
+ Open it with:
29
+
30
+ ```json
31
+ {
32
+ "extensionId": "project:pmx-canvas",
33
+ "canvasId": "pmx-canvas",
34
+ "instanceId": "pmx-canvas"
35
+ }
36
+ ```
37
+
38
+ Use a different `instanceId` for parallel panels. Reusing an `instanceId` focuses/reloads the same
39
+ panel.
40
+
41
+ ## What the Adapter Does
42
+
43
+ - Opens the live PMX workbench directly in a native Copilot canvas panel.
44
+ - Uses PMX-served same-origin frame documents for iframe-backed nodes (`html` and hosted MCP apps).
45
+ The Copilot app webview can leave nested `srcdoc` and `blob:` iframes blank, so PMX should route
46
+ generated frame HTML through `/api/canvas/frame-documents/...` instead.
47
+ - Connects to a matching local PMX server for the current workspace, or starts one when needed.
48
+ - Reads `/api/canvas/ax/context` and injects pinned/focused context from
49
+ `onUserPromptSubmitted`.
50
+ - Exposes adapter actions for status, AX context refresh, AX focus, and explicit session steering.
51
+ - Keeps all persistent PMX state in `.pmx-canvas/canvas.db`; the extension does not own canvas
52
+ state.
53
+
54
+ ## Open Input
55
+
56
+ All fields are optional:
57
+
58
+ ```json
59
+ {
60
+ "serverUrl": "http://127.0.0.1:4313",
61
+ "port": 4313,
62
+ "autoStart": true,
63
+ "allowWorkspaceMismatch": false,
64
+ "workspaceRoot": "/path/to/repo"
65
+ }
66
+ ```
67
+
68
+ Default discovery order:
69
+
70
+ 1. `serverUrl` input.
71
+ 2. `PMX_CANVAS_URL`.
72
+ 3. `PMX_CANVAS_PORT` / `PMX_WEB_CANVAS_PORT` / `4313` on loopback.
73
+ 4. Managed server startup for the current workspace when `autoStart` is not `false`.
74
+
75
+ The adapter rejects an unrelated running PMX server unless `serverUrl` is explicit or
76
+ `allowWorkspaceMismatch` is true.
77
+
78
+ ## Actions
79
+
80
+ | Action | Purpose |
81
+ |---|---|
82
+ | `status` | Return PMX server health and persisted AX state. |
83
+ | `get_ax_context` | Return current pinned + focused AX context. |
84
+ | `focus_nodes` | Set AX focus with `source: "copilot"`. |
85
+ | `send_instruction` | Send an explicit prompt into the active Copilot session. |
86
+
87
+ Example focus action:
88
+
89
+ ```json
90
+ {
91
+ "nodeIds": ["node-123"]
92
+ }
93
+ ```
94
+
95
+ Use `nodeIds: []` to clear adapter-set AX focus after a live test.
96
+
97
+ ## Live-Test Checklist
98
+
99
+ After changing the adapter:
100
+
101
+ 1. Reload extensions.
102
+ 2. Inspect `pmx-canvas` and confirm status is `running`.
103
+ 3. Call `list_canvas_capabilities` for `extensionId: "project:pmx-canvas"` and
104
+ `canvasId: "pmx-canvas"`.
105
+ 4. Open the canvas with a stable `instanceId`.
106
+ 5. Invoke `status`.
107
+ 6. Invoke `get_ax_context`.
108
+ 7. If at least one node exists, invoke `focus_nodes` for one node ID and confirm
109
+ `get_ax_context.focus.nodeIds` includes it.
110
+ 8. Clear the focus with `focus_nodes` and `nodeIds: []`.
111
+ 9. Add or reuse one `html` node and one hosted MCP app node and confirm both render visibly in the
112
+ native PMX panel, not only in an external browser.
113
+ 10. Inspect the extension log tail and confirm there are no runtime errors.
114
+
115
+ ## Agent Behavior
116
+
117
+ When this adapter is loaded, the next user prompt may include hidden AX context generated from PMX
118
+ pins and focus. Treat pinned nodes and focused nodes as human-selected working context, not as a
119
+ global instruction to ignore the rest of the repository.
120
+
121
+ For non-Copilot agents, use the same core primitives directly:
122
+
123
+ - HTTP: `/api/canvas/ax`, `/api/canvas/ax/context`, `/api/canvas/ax/focus`
124
+ - MCP: `canvas://ax`, `canvas://ax-context`, `canvas_get_ax`, `canvas_set_ax_focus`
125
+ - CLI: `pmx-canvas ax status|context|focus`
package/src/cli/agent.ts CHANGED
@@ -1767,6 +1767,40 @@ cmd('pin', 'Manage context pins', [
1767
1767
  output(result);
1768
1768
  });
1769
1769
 
1770
+ // ── AX ────────────────────────────────────────────────────────
1771
+ cmd('ax status', 'Read host-agnostic PMX AX state', [
1772
+ 'pmx-canvas ax status',
1773
+ ], async (args) => {
1774
+ const { flags } = parseFlags(args);
1775
+ if (flags.help || flags.h) return showCommandHelp('ax status');
1776
+
1777
+ output(await api('GET', '/api/canvas/ax'));
1778
+ });
1779
+
1780
+ cmd('ax context', 'Read agent-ready PMX AX context', [
1781
+ 'pmx-canvas ax context',
1782
+ ], async (args) => {
1783
+ const { flags } = parseFlags(args);
1784
+ if (flags.help || flags.h) return showCommandHelp('ax context');
1785
+
1786
+ output(await api('GET', '/api/canvas/ax/context'));
1787
+ });
1788
+
1789
+ cmd('ax focus', 'Set or clear PMX AX focus without moving the viewport', [
1790
+ 'pmx-canvas ax focus node1 node2',
1791
+ 'pmx-canvas ax focus --clear',
1792
+ ], async (args) => {
1793
+ const { positional, flags } = parseFlags(args);
1794
+ if (flags.help || flags.h) return showCommandHelp('ax focus');
1795
+
1796
+ const nodeIds = flags.clear ? [] : positional;
1797
+ if (!flags.clear && nodeIds.length === 0) {
1798
+ die('Missing node ID', 'pmx-canvas ax focus <node-id> [more-node-ids]');
1799
+ }
1800
+
1801
+ output(await api('POST', '/api/canvas/ax/focus', { nodeIds, source: 'cli' }));
1802
+ });
1803
+
1770
1804
  // ── undo ─────────────────────────────────────────────────────
1771
1805
  cmd('undo', 'Undo the last canvas mutation', [
1772
1806
  'pmx-canvas undo',
package/src/cli/index.ts CHANGED
@@ -31,7 +31,7 @@ if (args.includes('--version') || args.includes('-v')) {
31
31
  // If first arg is a known subcommand (not a --flag), route to the agent CLI.
32
32
  const AGENT_COMMANDS = new Set([
33
33
  'node', 'edge', 'json-render', 'search', 'layout', 'status', 'arrange', 'focus',
34
- 'fit', 'screenshot', 'pin', 'undo', 'redo', 'history', 'snapshot', 'diff', 'group', 'webview', 'open',
34
+ 'fit', 'screenshot', 'pin', 'ax', 'undo', 'redo', 'history', 'snapshot', 'diff', 'group', 'webview', 'open',
35
35
  'clear', 'code-graph', 'spatial', 'watch', 'web-artifact', 'external-app', 'diagram', 'graph', 'html', 'batch', 'validate', 'serve',
36
36
  ]);
37
37
 
@@ -505,6 +505,7 @@ Agent CLI (works against running server):
505
505
  validate spec Validate json-render/graph payloads without creating nodes
506
506
  watch [--json] [--events ...] Watch low-token semantic canvas changes
507
507
  focus <node-id> Pan to node
508
+ ax status|context|focus Inspect AX state and focus
508
509
  external-app add Add hosted external apps like Excalidraw
509
510
  diagram add Add an Excalidraw diagram node
510
511
  pin <ids...> | --list | --clear Manage context pins
@@ -38,6 +38,7 @@ import {
38
38
  walkGraph,
39
39
  } from './state/canvas-store';
40
40
  import { connectSSE } from './state/sse-bridge';
41
+ import { saveCanvasTheme } from './state/intent-bridge';
41
42
  import {
42
43
  IconArrange,
43
44
  IconClearTrace,
@@ -240,6 +241,7 @@ function Toolbar({
240
241
  document.documentElement.setAttribute('data-theme', next);
241
242
  invalidateTokenCache();
242
243
  canvasTheme.value = next;
244
+ void saveCanvasTheme(next);
243
245
  }}
244
246
  aria-label={`Switch to ${canvasTheme.value === 'dark' ? 'light' : 'dark'} theme`}
245
247
  >
@@ -56,6 +56,11 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
56
56
  // ── Drag (with snap alignment) ──────────────────────
57
57
  const handleMove = useCallback((id: string, x: number, y: number) => {
58
58
  const snap = snapToGuides(x, y, node.size.width, node.size.height);
59
+ const current = nodes.value.get(id);
60
+ if (current?.position.x === snap.x && current.position.y === snap.y) {
61
+ activeGuides.value = snap.guides.length > 0 ? snap.guides : null;
62
+ return;
63
+ }
59
64
  updateNode(id, { position: { x: snap.x, y: snap.y } });
60
65
  activeGuides.value = snap.guides.length > 0 ? snap.guides : null;
61
66
  }, [node.size.width, node.size.height]);
@@ -75,6 +80,8 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
75
80
 
76
81
  // ── Resize ────────────────────────────────────────────
77
82
  const handleResize = useCallback((id: string, width: number, height: number) => {
83
+ const current = nodes.value.get(id);
84
+ if (current?.size.width === width && current.size.height === height) return;
78
85
  updateNode(id, { size: { width, height } });
79
86
  }, []);
80
87
 
@@ -8,7 +8,7 @@ import {
8
8
  nodes,
9
9
  searchHighlightIds,
10
10
  } from '../state/canvas-store';
11
- import { createNodeFromClient } from '../state/intent-bridge';
11
+ import { createNodeFromClient, saveCanvasTheme } from '../state/intent-bridge';
12
12
  import { TYPE_LABELS, type CanvasNodeState } from '../types';
13
13
  import { invalidateTokenCache } from '../theme/tokens';
14
14
 
@@ -148,6 +148,7 @@ export function CommandPalette({
148
148
  document.documentElement.setAttribute('data-theme', next);
149
149
  invalidateTokenCache();
150
150
  canvasTheme.value = next;
151
+ void saveCanvasTheme(next);
151
152
  onClose();
152
153
  },
153
154
  },
@@ -22,27 +22,49 @@ export function useNodeDrag({ nodeId, viewport, onMove, onDragEnd }: NodeDragOpt
22
22
  const handlePointerDown = useCallback(
23
23
  (e: PointerEvent, currentX: number, currentY: number) => {
24
24
  e.stopPropagation();
25
+ e.preventDefault();
25
26
  isDragging.current = true;
27
+ document.documentElement.classList.add('is-node-dragging');
28
+ window.getSelection()?.removeAllRanges();
26
29
  startPointer.current = { x: e.clientX, y: e.clientY };
27
30
  startPosition.current = { x: currentX, y: currentY };
31
+ let pendingPointer: { x: number; y: number } | null = null;
32
+ let frameId: number | null = null;
28
33
 
29
- const onPointerMove = (ev: PointerEvent) => {
30
- if (!isDragging.current) return;
34
+ const flushMove = () => {
35
+ frameId = null;
36
+ if (!isDragging.current || !pendingPointer) return;
37
+ const pointer = pendingPointer;
38
+ pendingPointer = null;
31
39
  const scale = viewport.value.scale;
32
- const dx = (ev.clientX - startPointer.current.x) / scale;
33
- const dy = (ev.clientY - startPointer.current.y) / scale;
40
+ const dx = (pointer.x - startPointer.current.x) / scale;
41
+ const dy = (pointer.y - startPointer.current.y) / scale;
34
42
  onMove(nodeId, startPosition.current.x + dx, startPosition.current.y + dy);
35
43
  };
36
44
 
37
- const onPointerUp = () => {
45
+ const onPointerMove = (ev: PointerEvent) => {
46
+ if (!isDragging.current) return;
47
+ pendingPointer = { x: ev.clientX, y: ev.clientY };
48
+ if (frameId !== null) return;
49
+ frameId = window.requestAnimationFrame(flushMove);
50
+ };
51
+
52
+ const finishDrag = () => {
53
+ if (frameId !== null) {
54
+ window.cancelAnimationFrame(frameId);
55
+ flushMove();
56
+ }
38
57
  isDragging.current = false;
58
+ document.documentElement.classList.remove('is-node-dragging');
39
59
  document.removeEventListener('pointermove', onPointerMove);
40
- document.removeEventListener('pointerup', onPointerUp);
60
+ document.removeEventListener('pointerup', finishDrag);
61
+ document.removeEventListener('pointercancel', finishDrag);
41
62
  onDragEnd();
42
63
  };
43
64
 
44
65
  document.addEventListener('pointermove', onPointerMove);
45
- document.addEventListener('pointerup', onPointerUp);
66
+ document.addEventListener('pointerup', finishDrag);
67
+ document.addEventListener('pointercancel', finishDrag);
46
68
  },
47
69
  [nodeId, viewport, onMove, onDragEnd],
48
70
  );
@@ -29,12 +29,18 @@ export function useNodeResize({ nodeId, viewport, onResize, onResizeEnd }: NodeR
29
29
  isResizing.current = true;
30
30
  startPointer.current = { x: e.clientX, y: e.clientY };
31
31
  startSize.current = { w: currentWidth, h: currentHeight };
32
+ document.documentElement.classList.add('is-node-resizing');
33
+ let pendingPointer: { x: number; y: number } | null = null;
34
+ let frameId: number | null = null;
32
35
 
33
- const onPointerMove = (ev: PointerEvent) => {
34
- if (!isResizing.current) return;
36
+ const flushResize = () => {
37
+ frameId = null;
38
+ if (!isResizing.current || !pendingPointer) return;
39
+ const pointer = pendingPointer;
40
+ pendingPointer = null;
35
41
  const scale = viewport.value.scale;
36
- const dw = (ev.clientX - startPointer.current.x) / scale;
37
- const dh = (ev.clientY - startPointer.current.y) / scale;
42
+ const dw = (pointer.x - startPointer.current.x) / scale;
43
+ const dh = (pointer.y - startPointer.current.y) / scale;
38
44
  onResize(
39
45
  nodeId,
40
46
  Math.max(MIN_WIDTH, startSize.current.w + dw),
@@ -42,15 +48,29 @@ export function useNodeResize({ nodeId, viewport, onResize, onResizeEnd }: NodeR
42
48
  );
43
49
  };
44
50
 
45
- const onPointerUp = () => {
51
+ const onPointerMove = (ev: PointerEvent) => {
52
+ if (!isResizing.current) return;
53
+ pendingPointer = { x: ev.clientX, y: ev.clientY };
54
+ if (frameId !== null) return;
55
+ frameId = window.requestAnimationFrame(flushResize);
56
+ };
57
+
58
+ const finishResize = () => {
59
+ if (frameId !== null) {
60
+ window.cancelAnimationFrame(frameId);
61
+ flushResize();
62
+ }
46
63
  isResizing.current = false;
64
+ document.documentElement.classList.remove('is-node-resizing');
47
65
  document.removeEventListener('pointermove', onPointerMove);
48
- document.removeEventListener('pointerup', onPointerUp);
66
+ document.removeEventListener('pointerup', finishResize);
67
+ document.removeEventListener('pointercancel', finishResize);
49
68
  onResizeEnd();
50
69
  };
51
70
 
52
71
  document.addEventListener('pointermove', onPointerMove);
53
- document.addEventListener('pointerup', onPointerUp);
72
+ document.addEventListener('pointerup', finishResize);
73
+ document.addEventListener('pointercancel', finishResize);
54
74
  },
55
75
  [nodeId, viewport, onResize, onResizeEnd],
56
76
  );
@@ -10,6 +10,7 @@ import {
10
10
  expandedNodeId,
11
11
  } from '../state/canvas-store';
12
12
  import type { CanvasNodeState } from '../types';
13
+ import { useIframeDocument } from './iframe-document-url';
13
14
 
14
15
  type McpUiTheme = 'light' | 'dark';
15
16
 
@@ -43,7 +44,7 @@ export function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number):
43
44
  const serverName = typeof node.data.serverName === 'string' ? node.data.serverName : '';
44
45
  const appSessionId = typeof node.data.appSessionId === 'string' ? node.data.appSessionId : '';
45
46
  const sessionStatus = typeof node.data.sessionStatus === 'string' ? node.data.sessionStatus : '';
46
- return `${node.id}:${retryKey}:${node.size.height}:${serverName}:${appSessionId}:${sessionStatus}:${html}`;
47
+ return `${node.id}:${retryKey}:${serverName}:${appSessionId}:${sessionStatus}:${html}`;
47
48
  }
48
49
 
49
50
  export function resolveExtAppDisplayModeRequest(
@@ -111,6 +112,10 @@ export function shouldApplyExtAppSizeChange(height: unknown, isExpanded: boolean
111
112
  return typeof height === 'number' && Number.isFinite(height) && height > 0 && !isExpanded;
112
113
  }
113
114
 
115
+ export function resolveExtAppInlineFrameHeight(appHeight: number, hostHeight: number): number {
116
+ return Math.max(positiveDimension(appHeight, 1), positiveDimension(hostHeight, 1));
117
+ }
118
+
114
119
  export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
115
120
  const iframeRef = useRef<HTMLIFrameElement>(null);
116
121
  const bridgeRef = useRef<AppBridge | null>(null);
@@ -145,6 +150,8 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
145
150
  const maxHeight = node.size.height;
146
151
  const nodeId = node.id;
147
152
  const frameKey = getExtAppBridgeInitKey(node, retryKey);
153
+ const iframeSandbox = resolveExtAppSandbox(null);
154
+ const iframeDocument = useIframeDocument(html ?? '', iframeSandbox);
148
155
  const toMcpTheme = (theme: string): McpUiTheme => (theme === 'light' ? 'light' : 'dark');
149
156
  const isExpanded = expanded || expandedNodeId.value === nodeId;
150
157
 
@@ -202,7 +209,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
202
209
 
203
210
  // Initialize as soon as HTML is mounted; some apps send initialize before iframe load fires.
204
211
  useEffect(() => {
205
- if (!html) return;
212
+ if (!html || !iframeDocument.ready) return;
206
213
  const iframe = iframeRef.current;
207
214
  if (!iframe) return;
208
215
  let disposed = false;
@@ -272,7 +279,11 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
272
279
  // Register handlers BEFORE connect
273
280
  bridge.onsizechange = async ({ height }) => {
274
281
  if (shouldApplyExtAppSizeChange(height, expandedNodeId.value === nodeId)) {
275
- iframe.style.height = `${height}px`;
282
+ const hostDimensions = resolveExtAppContainerDimensions(iframe.parentElement ?? iframe, {
283
+ width: node.size.width,
284
+ height: maxHeight,
285
+ });
286
+ iframe.style.height = `${resolveExtAppInlineFrameHeight(height, hostDimensions.height)}px`;
276
287
  }
277
288
  return {};
278
289
  };
@@ -387,7 +398,11 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
387
398
  fallbackTimer = setTimeout(() => {
388
399
  if (disposed || bridgeReadyRef.current) return;
389
400
  const bootstrapToolResult = latestToolResultRef.current;
390
- void sendExtAppBootstrapState(bridge, latestToolInputRef.current, bootstrapToolResult)
401
+ const hostContext = buildHostContext(isExpanded ? 'fullscreen' : 'inline');
402
+ bridgeReadyRef.current = true;
403
+ bridge.setHostContext?.(hostContext);
404
+ void Promise.resolve(bridge.sendHostContextChange(hostContext))
405
+ .then(() => sendExtAppBootstrapState(bridge, latestToolInputRef.current, bootstrapToolResult))
391
406
  .then(() => {
392
407
  toolResultSentRef.current = Boolean(bootstrapToolResult);
393
408
  if (bootstrapToolResult) {
@@ -424,6 +439,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
424
439
  ...buildHostContext(),
425
440
  theme: toMcpTheme(newTheme),
426
441
  });
442
+ void bridge.sendHostContextChange?.(buildHostContext());
427
443
  });
428
444
 
429
445
  void flushToolResult(bridge);
@@ -456,7 +472,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
456
472
  transportRef.current = null;
457
473
  }
458
474
  };
459
- }, [frameKey]);
475
+ }, [frameKey, iframeDocument.key]);
460
476
 
461
477
  // Forward tool result when it arrives after bridge is ready
462
478
  useEffect(() => {
@@ -472,6 +488,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
472
488
  // click that triggered the expansion.
473
489
  useEffect(() => {
474
490
  const bridge = bridgeRef.current;
491
+ if (iframeRef.current) {
492
+ iframeRef.current.style.height = '100%';
493
+ }
475
494
  if (!bridge || !bridgeReadyRef.current) return;
476
495
  bridge.setHostContext?.({
477
496
  theme: toMcpTheme(canvasTheme.value),
@@ -484,6 +503,17 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
484
503
  locale: navigator.language,
485
504
  timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
486
505
  });
506
+ void bridge.sendHostContextChange?.({
507
+ theme: toMcpTheme(canvasTheme.value),
508
+ platform: 'web',
509
+ containerDimensions: resolveExtAppContainerDimensions(iframeRef.current, {
510
+ width: node.size.width,
511
+ height: maxHeight,
512
+ }),
513
+ displayMode: isExpanded ? 'fullscreen' : 'inline',
514
+ locale: navigator.language,
515
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
516
+ });
487
517
  }, [isExpanded, maxHeight]);
488
518
 
489
519
  // Loading state — HTML not yet fetched
@@ -518,7 +548,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
518
548
  }
519
549
 
520
550
  return (
521
- <div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
551
+ <div style={{ flex: 1, width: '100%', height: '100%', minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
522
552
  {sessionStatus && sessionStatus !== 'ready' && (
523
553
  <div
524
554
  style={{
@@ -598,10 +628,18 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
598
628
  <iframe
599
629
  key={frameKey}
600
630
  ref={iframeRef}
601
- srcdoc={html}
602
- sandbox={resolveExtAppSandbox(null)}
631
+ {...iframeDocument.attributes}
632
+ sandbox={iframeSandbox}
603
633
  allow={buildAllowAttribute(resourceMeta?.permissions)}
604
- style={{ flex: 1, width: '100%', height: '100%', minHeight: 0, border: 'none', background: 'var(--c-panel)' }}
634
+ style={{
635
+ flex: 1,
636
+ width: '100%',
637
+ height: '100%',
638
+ minHeight: 0,
639
+ border: 'none',
640
+ background: 'var(--c-panel)',
641
+ pointerEvents: isExpanded ? 'auto' : 'none',
642
+ }}
605
643
  title={`Ext App: ${toolName}`}
606
644
  />
607
645
  {!isExpanded && (
@@ -615,7 +653,10 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
615
653
  title="Click to open"
616
654
  style={{
617
655
  position: 'absolute',
618
- inset: 0,
656
+ top: 0,
657
+ right: '56px',
658
+ bottom: '56px',
659
+ left: 0,
619
660
  background: 'transparent',
620
661
  border: 'none',
621
662
  padding: 0,
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from 'preact/hooks';
2
2
  import { canvasTheme } from '../state/canvas-store';
3
3
  import { getCanvasTokens } from '../theme/tokens';
4
4
  import type { CanvasNodeState } from '../types';
5
+ import { useIframeDocument } from './iframe-document-url';
5
6
 
6
7
  /**
7
8
  * Strip characters that could break out of a CSS custom-property value context
@@ -197,6 +198,8 @@ export function HtmlNode({
197
198
  ? node.data.content
198
199
  : '';
199
200
  const srcDoc = useMemo(() => (html ? buildSrcDoc(html, { presentation, presentationExitToken, themeToken, themeCss, theme }) : ''), [html, presentation, presentationExitToken, themeToken]);
201
+ const iframeSandbox = 'allow-scripts';
202
+ const iframeDocument = useIframeDocument(srcDoc, iframeSandbox);
200
203
 
201
204
  useEffect(() => {
202
205
  iframeRef.current?.contentWindow?.postMessage({
@@ -245,8 +248,8 @@ export function HtmlNode({
245
248
  ref={iframeRef}
246
249
  class={presentation ? 'html-node-frame html-node-frame-presentation' : 'html-node-frame'}
247
250
  title={typeof node.data.title === 'string' ? node.data.title : 'HTML node'}
248
- sandbox="allow-scripts"
249
- srcdoc={srcDoc}
251
+ sandbox={iframeSandbox}
252
+ {...iframeDocument.attributes}
250
253
  tabIndex={autoFocus ? 0 : undefined}
251
254
  onLoad={handleFrameLoad}
252
255
  style={{
@@ -14,6 +14,18 @@ function withViewerParams(url: string, expanded: boolean): string {
14
14
  }
15
15
  }
16
16
 
17
+ export function isSameOriginFrameDocumentUrl(url: string, origin = window.location.origin): boolean {
18
+ if (!url) return false;
19
+ try {
20
+ const baseOrigin = new URL(origin).origin;
21
+ const resolved = new URL(url, baseOrigin);
22
+ return resolved.origin === baseOrigin &&
23
+ resolved.pathname.startsWith('/api/canvas/frame-documents/');
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
17
29
  export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
18
30
  if (node.data.mode === 'ext-app') {
19
31
  return <ExtAppFrame node={node} expanded={expanded} />;
@@ -23,7 +35,7 @@ export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState;
23
35
  const sourceServer = (node.data.sourceServer as string) || '';
24
36
  const hostMode = (node.data.hostMode as string) || 'hosted';
25
37
  const fallbackReason = node.data.fallbackReason as string | undefined;
26
- const trustedDomain = node.data.trustedDomain as boolean | undefined;
38
+ const trustedDomain = node.data.trustedDomain === true || isSameOriginFrameDocumentUrl(url);
27
39
 
28
40
  if (hostMode === 'fallback') {
29
41
  return (
@@ -0,0 +1,58 @@
1
+ import { useEffect, useMemo, useState } from 'preact/hooks';
2
+
3
+ interface FrameDocumentCreateResponse {
4
+ ok: boolean;
5
+ url?: string;
6
+ error?: string;
7
+ }
8
+
9
+ function isFrameDocumentCreateResponse(value: unknown): value is FrameDocumentCreateResponse {
10
+ return Boolean(value)
11
+ && typeof value === 'object'
12
+ && value !== null
13
+ && !Array.isArray(value)
14
+ && 'ok' in value
15
+ && typeof (value as { ok: unknown }).ok === 'boolean';
16
+ }
17
+
18
+ export async function createIframeDocumentUrl(html: string, sandbox: string): Promise<string> {
19
+ const response = await fetch('/api/canvas/frame-documents', {
20
+ method: 'POST',
21
+ headers: { 'Content-Type': 'application/json' },
22
+ body: JSON.stringify({ html, sandbox }),
23
+ });
24
+ const json = await response.json() as unknown;
25
+ if (!response.ok || !isFrameDocumentCreateResponse(json) || !json.ok || typeof json.url !== 'string') {
26
+ const message = isFrameDocumentCreateResponse(json) && json.error
27
+ ? json.error
28
+ : `Frame document request failed with HTTP ${response.status}`;
29
+ throw new Error(message);
30
+ }
31
+ return json.url;
32
+ }
33
+
34
+ export function useIframeDocument(html: string, sandbox: string): { attributes: { src?: string }; ready: boolean; key: string } {
35
+ const [src, setSrc] = useState<string | null>(null);
36
+
37
+ useEffect(() => {
38
+ setSrc(null);
39
+ if (!html) return;
40
+ let cancelled = false;
41
+ void createIframeDocumentUrl(html, sandbox)
42
+ .then((url) => {
43
+ if (!cancelled) setSrc(url);
44
+ })
45
+ .catch((error) => {
46
+ console.error('[iframe-document] failed to create frame document:', error);
47
+ });
48
+ return () => {
49
+ cancelled = true;
50
+ };
51
+ }, [html, sandbox]);
52
+
53
+ return useMemo(() => ({
54
+ attributes: src ? { src } : {},
55
+ ready: Boolean(src),
56
+ key: src ?? '',
57
+ }), [src]);
58
+ }
@@ -104,6 +104,14 @@ export async function fetchCanvasState(): Promise<Record<string, unknown>> {
104
104
  return requestJson('fetchCanvasState', '/api/canvas/state?includeBlobs=true', {});
105
105
  }
106
106
 
107
+ export async function saveCanvasTheme(theme: string): Promise<{ ok: boolean; theme?: string }> {
108
+ return requestJson('saveCanvasTheme', '/api/canvas/theme', { ok: false }, {
109
+ method: 'POST',
110
+ headers: { 'Content-Type': 'application/json' },
111
+ body: JSON.stringify({ theme }),
112
+ });
113
+ }
114
+
107
115
  /** Fetch available slash commands for prompt completion. */
108
116
  export async function fetchSlashCommands(): Promise<Array<{ name: string; description: string }>> {
109
117
  return [];
@@ -75,7 +75,7 @@ const DEFAULT_POSITIONS: Record<
75
75
  status: { x: 40, y: 80, w: 300, h: 120 },
76
76
  markdown: { x: 380, y: 80, w: 720, h: 600 },
77
77
  context: { x: 1130, y: 80, w: 320, h: 400 },
78
- 'mcp-app': { x: 380, y: 720, w: 720, h: 500 },
78
+ 'mcp-app': { x: 380, y: 720, w: 960, h: 600 },
79
79
  webpage: { x: 380, y: 80, w: 520, h: 420 },
80
80
  'json-render': { x: 380, y: 720, w: 840, h: 620 },
81
81
  graph: { x: 380, y: 720, w: 760, h: 520 },
@@ -290,10 +290,10 @@ function ensureLedgerNode(summary: Record<string, unknown>): void {
290
290
 
291
291
  function applyCanvasTheme(theme: string): void {
292
292
  const valid = theme === 'dark' || theme === 'light' || theme === 'high-contrast';
293
- if (!valid || canvasTheme.value === theme) return;
293
+ if (!valid) return;
294
294
  document.documentElement.setAttribute('data-theme', theme);
295
295
  invalidateTokenCache();
296
- canvasTheme.value = theme;
296
+ if (canvasTheme.value !== theme) canvasTheme.value = theme;
297
297
  }
298
298
 
299
299
  function isCanvasNodeType(value: unknown): value is CanvasNodeState['type'] {