pmx-canvas 0.3.3 → 0.4.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 (87) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/Readme.md +13 -4
  3. package/dist/canvas/index.js +71 -71
  4. package/dist/json-render/index.js +47 -47
  5. package/dist/types/cli/agent.d.ts +13 -8
  6. package/dist/types/cli/commands/apps.d.ts +1 -0
  7. package/dist/types/cli/commands/ax.d.ts +1 -0
  8. package/dist/types/cli/commands/copilot.d.ts +1 -0
  9. package/dist/types/cli/commands/edges.d.ts +1 -0
  10. package/dist/types/cli/commands/groups.d.ts +1 -0
  11. package/dist/types/cli/commands/history.d.ts +1 -0
  12. package/dist/types/cli/commands/nodes.d.ts +1 -0
  13. package/dist/types/cli/commands/pins.d.ts +1 -0
  14. package/dist/types/cli/commands/query.d.ts +1 -0
  15. package/dist/types/cli/commands/view.d.ts +1 -0
  16. package/dist/types/cli/commands/webview.d.ts +1 -0
  17. package/dist/types/cli/shared.d.ts +73 -0
  18. package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -1
  19. package/dist/types/client/index.d.ts +1 -1
  20. package/dist/types/client/nodes/ExtAppFrame.d.ts +10 -0
  21. package/dist/types/client/nodes/HtmlNode.d.ts +1 -1
  22. package/dist/types/client/nodes/McpAppNode.d.ts +1 -1
  23. package/dist/types/client/nodes/use-ax-surface-bridge.d.ts +20 -0
  24. package/dist/types/client/state/node-factory.d.ts +29 -0
  25. package/dist/types/client/uuid-polyfill.d.ts +0 -0
  26. package/dist/types/server/canvas-db.d.ts +0 -1
  27. package/dist/types/server/canvas-operations.d.ts +2 -0
  28. package/dist/types/server/canvas-state.d.ts +14 -15
  29. package/dist/types/server/diagram-presets.d.ts +8 -0
  30. package/dist/types/server/operations/composites.d.ts +6 -30
  31. package/dist/types/server/server.d.ts +1 -0
  32. package/dist/types/shared/ax-surface-protocol.d.ts +18 -0
  33. package/docs/bun-webview-integration.md +5 -0
  34. package/docs/cli.md +5 -0
  35. package/docs/environment.md +48 -0
  36. package/docs/http-api.md +25 -1
  37. package/docs/mcp.md +21 -30
  38. package/docs/node-types.md +5 -0
  39. package/docs/plans/plan-009-tech-debt-backlog.md +34 -15
  40. package/docs/screenshot.png +0 -0
  41. package/docs/tech-debt-assessment-2026-06.md +5 -5
  42. package/package.json +13 -10
  43. package/skills/pmx-canvas/SKILL.md +16 -6
  44. package/skills/pmx-canvas/evals/evals.json +7 -7
  45. package/skills/pmx-canvas/references/full-reference.md +25 -16
  46. package/src/cli/agent.ts +22 -3512
  47. package/src/cli/commands/apps.ts +165 -0
  48. package/src/cli/commands/ax.ts +604 -0
  49. package/src/cli/commands/copilot.ts +43 -0
  50. package/src/cli/commands/edges.ts +73 -0
  51. package/src/cli/commands/groups.ts +81 -0
  52. package/src/cli/commands/history.ts +220 -0
  53. package/src/cli/commands/nodes.ts +984 -0
  54. package/src/cli/commands/pins.ts +42 -0
  55. package/src/cli/commands/query.ts +223 -0
  56. package/src/cli/commands/view.ts +186 -0
  57. package/src/cli/commands/webview.ts +227 -0
  58. package/src/cli/index.ts +8 -2
  59. package/src/cli/shared.ts +861 -0
  60. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -2
  61. package/src/client/index.tsx +2 -0
  62. package/src/client/nodes/ExtAppFrame.tsx +70 -51
  63. package/src/client/nodes/HtmlNode.tsx +8 -51
  64. package/src/client/nodes/McpAppNode.tsx +12 -47
  65. package/src/client/nodes/use-ax-surface-bridge.ts +71 -0
  66. package/src/client/state/node-factory.ts +72 -0
  67. package/src/client/state/sse-bridge.ts +209 -86
  68. package/src/client/uuid-polyfill.ts +14 -0
  69. package/src/json-render/renderer/index.tsx +3 -2
  70. package/src/mcp/server.ts +0 -47
  71. package/src/server/canvas-db.ts +0 -8
  72. package/src/server/canvas-operations.ts +8 -0
  73. package/src/server/canvas-schema.ts +2 -8
  74. package/src/server/canvas-state.ts +51 -151
  75. package/src/server/diagram-presets.ts +15 -1
  76. package/src/server/html-surface.ts +11 -6
  77. package/src/server/index.ts +3 -9
  78. package/src/server/operations/composites.ts +25 -34
  79. package/src/server/operations/invoker.ts +10 -3
  80. package/src/server/operations/mcp.ts +51 -1
  81. package/src/server/operations/ops/app.ts +5 -0
  82. package/src/server/operations/ops/snapshots.ts +6 -7
  83. package/src/server/operations/registry.ts +4 -1
  84. package/src/server/server.ts +172 -74
  85. package/src/shared/ax-surface-protocol.ts +22 -0
  86. package/dist/types/client/canvas/ContextPinHud.d.ts +0 -1
  87. package/src/client/canvas/ContextPinHud.tsx +0 -17
@@ -1,3 +1,4 @@
1
+ import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
1
2
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
2
3
  import { ContextNode } from '../nodes/ContextNode';
3
4
  import { FileNode } from '../nodes/FileNode';
@@ -86,7 +87,7 @@ function isPresentationExitMessage(value: unknown, token: string): boolean {
86
87
  return (
87
88
  value !== null &&
88
89
  typeof value === 'object' &&
89
- (value as { source?: unknown }).source === 'pmx-canvas-html-node' &&
90
+ (value as { source?: unknown }).source === HTML_SURFACE_PUSH_SOURCE &&
90
91
  (value as { type?: unknown }).type === 'presentation-exit' &&
91
92
  (value as { token?: unknown }).token === token
92
93
  );
@@ -134,7 +135,7 @@ export function ExpandedNodeOverlay() {
134
135
  );
135
136
  frame?.contentWindow?.postMessage(
136
137
  {
137
- source: 'pmx-canvas-html-node',
138
+ source: HTML_SURFACE_PUSH_SOURCE,
138
139
  token: presentationExitToken,
139
140
  ...message,
140
141
  },
@@ -1,3 +1,5 @@
1
+ // Must run before any module that mints per-mount nonces at import/render time.
2
+ import './uuid-polyfill';
1
3
  import { render } from 'preact';
2
4
  import { App } from './App';
3
5
 
@@ -2,10 +2,13 @@ import type { CallToolResult, ListToolsResult, RequestId, Tool } from '@modelcon
2
2
  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
+ import {
6
+ AX_SURFACE_ACK_SOURCE,
7
+ AX_SURFACE_EMIT_SOURCE,
8
+ EXT_APP_BOOT_BEACON_SOURCE,
9
+ } from '../../shared/ax-surface-protocol.js';
5
10
  import { extAppToolResultsMatch } from '../../shared/ext-app-tool-result.js';
6
11
  import { DEFAULT_EXT_APP_SANDBOX } from '../../shared/surface.js';
7
- import { submitAxInteractionFromClient } from '../state/intent-bridge';
8
- import { showToast } from '../state/attention-bridge';
9
12
  import {
10
13
  canvasTheme,
11
14
  collapseExpandedNode,
@@ -18,6 +21,7 @@ import {
18
21
  import type { CanvasNodeState } from '../types';
19
22
  import { AUTO_FIT_TITLEBAR_HEIGHT } from '../canvas/auto-fit';
20
23
  import { useIframeDocument } from './iframe-document-url';
24
+ import { useAxSurfaceBridge } from './use-ax-surface-bridge';
21
25
 
22
26
  type McpUiTheme = 'light' | 'dark';
23
27
 
@@ -205,7 +209,7 @@ export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): stri
205
209
  }, 10000);
206
210
  pending.set(correlationId, function (result) { clearTimeout(timer); resolve(result); });
207
211
  window.parent.postMessage({
208
- source: 'pmx-canvas-ax',
212
+ source: '${AX_SURFACE_EMIT_SOURCE}',
209
213
  token: PMX_AX_TOKEN,
210
214
  nodeId: PMX_AX_NODE_ID,
211
215
  correlationId: correlationId,
@@ -218,7 +222,7 @@ export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): stri
218
222
  };
219
223
  window.addEventListener('message', function (event) {
220
224
  const m = event.data;
221
- if (!m || m.source !== 'pmx-canvas-ax-ack' || m.token !== PMX_AX_TOKEN) return;
225
+ if (!m || m.source !== '${AX_SURFACE_ACK_SOURCE}' || m.token !== PMX_AX_TOKEN) return;
222
226
  const result = m.result || { ok: false };
223
227
  const resolver = m.correlationId ? pending.get(m.correlationId) : undefined;
224
228
  if (resolver) { pending.delete(m.correlationId); resolver(result); }
@@ -245,10 +249,23 @@ export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): stri
245
249
  */
246
250
  export function buildExtAppBootBeaconScript(frameToken: string, nodeId: string): string {
247
251
  return `<script data-pmx-canvas-boot-beacon>
248
- window.parent.postMessage({ source: 'pmx-canvas-ext-app-alive', token: ${JSON.stringify(frameToken)}, nodeId: ${JSON.stringify(nodeId)} }, '*');
252
+ window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: ${JSON.stringify(frameToken)}, nodeId: ${JSON.stringify(nodeId)} }, '*');
249
253
  </script>`;
250
254
  }
251
255
 
256
+ /**
257
+ * Base styles injected into every ext-app document (Finding Q, 0.3.4 report):
258
+ * apps aspect-fit their drawn surface to the frame width, and on narrow/tall
259
+ * tiles the leftover region shows the app document's own body background —
260
+ * pure black for the hosted Excalidraw bundle, in every engine. Making the
261
+ * app document transparent lets the host iframe's theme background
262
+ * (var(--c-panel)) show through the letterbox instead. Background only — app
263
+ * content, its drawn scene, and its own foreground styling are untouched.
264
+ */
265
+ export function buildExtAppSurfaceBaseStyles(): string {
266
+ return '<style data-pmx-canvas-ext-app-base>html, body { background: transparent !important; }</style>';
267
+ }
268
+
252
269
  export function injectExtAppAxBridgeScript(html: string, axBridgeScript: string): string {
253
270
  if (!axBridgeScript) return html;
254
271
  const headMatch = /<head\b[^>]*>/i.exec(html);
@@ -355,7 +372,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
355
372
  const bootBeaconScript =
356
373
  typeof html === 'string' && html.length > 0 ? buildExtAppBootBeaconScript(axToken, nodeId) : '';
357
374
  const iframeDocument = useIframeDocument(
358
- injectExtAppAxBridgeScript(html ?? '', bootBeaconScript + axBridgeScript),
375
+ injectExtAppAxBridgeScript(html ?? '', buildExtAppSurfaceBaseStyles() + bootBeaconScript + axBridgeScript),
359
376
  iframeSandbox,
360
377
  );
361
378
 
@@ -363,7 +380,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
363
380
  function onBootBeacon(event: MessageEvent) {
364
381
  if (event.source !== iframeRef.current?.contentWindow) return;
365
382
  const data = event.data as { source?: string; token?: string; nodeId?: string } | null;
366
- if (!data || data.source !== 'pmx-canvas-ext-app-alive' || data.token !== axToken || data.nodeId !== nodeId)
383
+ if (!data || data.source !== EXT_APP_BOOT_BEACON_SOURCE || data.token !== axToken || data.nodeId !== nodeId)
367
384
  return;
368
385
  appScriptsRanRef.current = true;
369
386
  }
@@ -371,46 +388,8 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
371
388
  return () => window.removeEventListener('message', onBootBeacon);
372
389
  }, [axToken, nodeId]);
373
390
 
374
- useEffect(() => {
375
- if (!axEnabled) return;
376
- function onAxMessage(event: MessageEvent) {
377
- if (event.source !== iframeRef.current?.contentWindow) return;
378
- const data = event.data as {
379
- source?: string;
380
- token?: string;
381
- nodeId?: string;
382
- correlationId?: string;
383
- interaction?: { type?: unknown; payload?: unknown };
384
- } | null;
385
- if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== nodeId) return;
386
- const interaction = data.interaction;
387
- if (!interaction || typeof interaction.type !== 'string') return;
388
- const interactionType = interaction.type;
389
- void submitAxInteractionFromClient({
390
- type: interactionType,
391
- sourceNodeId: nodeId,
392
- sourceSurface: 'mcp-app',
393
- ...(interaction.payload && typeof interaction.payload === 'object'
394
- ? { payload: interaction.payload as Record<string, unknown> }
395
- : {}),
396
- }).then((res) => {
397
- if (res.ok) showToast('context', 'AX interaction', interactionType, [nodeId]);
398
- else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [nodeId]);
399
- iframeRef.current?.contentWindow?.postMessage(
400
- {
401
- source: 'pmx-canvas-ax-ack',
402
- token: axToken,
403
- ...(data.correlationId ? { correlationId: data.correlationId } : {}),
404
- interaction: { type: interactionType },
405
- result: res,
406
- },
407
- '*',
408
- );
409
- });
410
- }
411
- window.addEventListener('message', onAxMessage);
412
- return () => window.removeEventListener('message', onAxMessage);
413
- }, [axEnabled, axToken, nodeId]);
391
+ // Ext-app AX emits — the shared sandboxed-surface trust boundary (M2).
392
+ useAxSurfaceBridge({ enabled: axEnabled, token: axToken, nodeId, sourceSurface: 'mcp-app', iframeRef });
414
393
 
415
394
  // Enqueue one serialized remount attempt for this node (Finding F recovery).
416
395
  // Attempts are capped so a persistently-failing app degrades to the manual
@@ -439,6 +418,11 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
439
418
  // every scheduling path (post-boot repaint, boot watchdog, re-arm).
440
419
  bootedWhileHiddenRef.current = typeof document !== 'undefined' && document.visibilityState === 'hidden';
441
420
  extAppRecoveryLog(nodeId, `remount-run #${webkitRemountAttemptsRef.current}`);
421
+ // Back to 'loading' so the connecting overlay covers this recovery
422
+ // remount's boot window too (the fallback boot flipped status to
423
+ // ready/done even when the previous frame was dead — without this the
424
+ // overlay only ever covered the INITIAL boot, not recovery).
425
+ setStatus('loading');
442
426
  setRetryKey((k) => k + 1);
443
427
  return true;
444
428
  },
@@ -717,7 +701,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
717
701
 
718
702
  bridge.onsandboxready = async () => {
719
703
  await bridge.sendSandboxResourceReady({
720
- html,
704
+ // Same Finding Q base styles for the sandbox-proxy path: the inner
705
+ // resource document is the one that letterboxes on narrow tiles.
706
+ html: injectExtAppAxBridgeScript(html, buildExtAppSurfaceBaseStyles()),
721
707
  sandbox: DEFAULT_EXT_APP_SANDBOX,
722
708
  ...(resourceMeta?.csp ? { csp: resourceMeta.csp } : {}),
723
709
  ...(resourceMeta?.permissions ? { permissions: resourceMeta.permissions } : {}),
@@ -1077,9 +1063,6 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1077
1063
  </button>
1078
1064
  </div>
1079
1065
  )}
1080
- {status === 'loading' && (
1081
- <div style={{ padding: '8px', fontSize: '11px', color: 'var(--c-muted)' }}>Connecting to ext-app viewer...</div>
1082
- )}
1083
1066
  {/* Iframe stack: the widget renders a preview; when not expanded, a
1084
1067
  transparent click-catcher sits on top so the first click always
1085
1068
  expands the node. Without this, widgets like Excalidraw show their
@@ -1109,6 +1092,42 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1109
1092
  }}
1110
1093
  title={`Ext App: ${toolName}`}
1111
1094
  />
1095
+ {/* Connecting overlay: an opaque sibling ABOVE the iframe while this
1096
+ frame boots. During WebKit recovery remounts the iframe's own layer
1097
+ can composite black (Finding N), but sibling DOM always paints — so
1098
+ the multi-second recovery window reads as an intentional loading
1099
+ state instead of a broken black tile. pointer-events pass through
1100
+ so the expand click-catcher keeps working. */}
1101
+ {status === 'loading' && !error && (
1102
+ <div
1103
+ style={{
1104
+ position: 'absolute',
1105
+ inset: 0,
1106
+ display: 'flex',
1107
+ flexDirection: 'column',
1108
+ alignItems: 'center',
1109
+ justifyContent: 'center',
1110
+ gap: '10px',
1111
+ background: 'var(--c-panel)',
1112
+ color: 'var(--c-muted)',
1113
+ fontSize: '12px',
1114
+ pointerEvents: 'none',
1115
+ }}
1116
+ >
1117
+ <div
1118
+ style={{
1119
+ width: '22px',
1120
+ height: '22px',
1121
+ border: '2px solid var(--c-line)',
1122
+ borderTopColor: 'var(--c-muted)',
1123
+ borderRadius: '50%',
1124
+ animation: 'spin 1s linear infinite',
1125
+ }}
1126
+ />
1127
+ <div>Connecting to {toolName}...</div>
1128
+ <style>{'@keyframes spin { to { transform: rotate(360deg); } }'}</style>
1129
+ </div>
1130
+ )}
1112
1131
  {!isExpanded && (
1113
1132
  <button
1114
1133
  type="button"
@@ -1,9 +1,9 @@
1
1
  import { useEffect, useMemo, useRef } from 'preact/hooks';
2
+ import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
2
3
  import { axSurfaceState, canvasTheme } from '../state/canvas-store';
3
- import { submitAxInteractionFromClient } from '../state/intent-bridge';
4
- import { showToast } from '../state/attention-bridge';
5
4
  import type { CanvasNodeState } from '../types';
6
5
  import { nodeSurfaceUrl, surfaceContentHash } from './surface-url';
6
+ import { useAxSurfaceBridge } from './use-ax-surface-bridge';
7
7
  import { useIframeContentHeight } from './use-iframe-content-height';
8
8
 
9
9
  export function shouldShowPresentationControls(node: CanvasNodeState): boolean {
@@ -64,56 +64,13 @@ export function HtmlNode({
64
64
  // Never in the expanded overlay — there the surface fills the large overlay frame.
65
65
  useIframeContentHeight(node, iframeRef, expanded ? '' : frameToken);
66
66
 
67
- // Phase 3 HTML bridge: receive window.PMX_AX.emit(...) messages from the
68
- // sandboxed iframe, validate the nonce + node id, and submit the interaction
69
- // through the capability-gated endpoint (the server re-validates capabilities).
70
- useEffect(() => {
71
- function onAxMessage(event: MessageEvent) {
72
- // Bind to THIS node's own iframe (matches the ext-app bridge); the nonce +
73
- // nodeId are a second gate, not the only one.
74
- if (event.source !== iframeRef.current?.contentWindow) return;
75
- const data = event.data as {
76
- source?: string;
77
- token?: string;
78
- nodeId?: string;
79
- correlationId?: string;
80
- interaction?: { type?: unknown; payload?: unknown };
81
- } | null;
82
- if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== node.id) return;
83
- const interaction = data.interaction;
84
- if (!interaction || typeof interaction.type !== 'string') return;
85
- const interactionType = interaction.type;
86
- void submitAxInteractionFromClient({
87
- type: interactionType,
88
- sourceNodeId: node.id,
89
- sourceSurface: 'html-node',
90
- ...(interaction.payload && typeof interaction.payload === 'object'
91
- ? { payload: interaction.payload as Record<string, unknown> }
92
- : {}),
93
- }).then((res) => {
94
- if (res.ok) showToast('context', 'AX interaction', interactionType, [node.id]);
95
- else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [node.id]);
96
- // Report #55: ack back to the surface so it can self-confirm (e.g. "queued ✓").
97
- iframeRef.current?.contentWindow?.postMessage(
98
- {
99
- source: 'pmx-canvas-ax-ack',
100
- token: axToken,
101
- ...(data.correlationId ? { correlationId: data.correlationId } : {}),
102
- interaction: { type: interactionType },
103
- result: res,
104
- },
105
- '*',
106
- );
107
- });
108
- }
109
- window.addEventListener('message', onAxMessage);
110
- return () => window.removeEventListener('message', onAxMessage);
111
- }, [axToken, node.id]);
67
+ // Phase 3 HTML bridge — the shared sandboxed-surface trust boundary (M2).
68
+ useAxSurfaceBridge({ enabled: true, token: axToken, nodeId: node.id, sourceSurface: 'html-node', iframeRef });
112
69
 
113
70
  useEffect(() => {
114
71
  iframeRef.current?.contentWindow?.postMessage(
115
72
  {
116
- source: 'pmx-canvas-html-node',
73
+ source: HTML_SURFACE_PUSH_SOURCE,
117
74
  type: 'theme-update',
118
75
  token: themeToken,
119
76
  theme,
@@ -134,7 +91,7 @@ export function HtmlNode({
134
91
  if (!axEnabled || axStateValue == null) return;
135
92
  iframeRef.current?.contentWindow?.postMessage(
136
93
  {
137
- source: 'pmx-canvas-html-node',
94
+ source: HTML_SURFACE_PUSH_SOURCE,
138
95
  type: 'ax-update',
139
96
  token: axToken,
140
97
  state: axStateValue,
@@ -152,7 +109,7 @@ export function HtmlNode({
152
109
  const handleFrameLoad = () => {
153
110
  iframeRef.current?.contentWindow?.postMessage(
154
111
  {
155
- source: 'pmx-canvas-html-node',
112
+ source: HTML_SURFACE_PUSH_SOURCE,
156
113
  type: 'theme-update',
157
114
  token: themeToken,
158
115
  theme,
@@ -162,7 +119,7 @@ export function HtmlNode({
162
119
  if (axEnabled && axSurfaceState.value != null) {
163
120
  iframeRef.current?.contentWindow?.postMessage(
164
121
  {
165
- source: 'pmx-canvas-html-node',
122
+ source: HTML_SURFACE_PUSH_SOURCE,
166
123
  type: 'ax-update',
167
124
  token: axToken,
168
125
  state: axSurfaceState.value,
@@ -1,10 +1,10 @@
1
1
  import { useEffect, useMemo, useRef } from 'preact/hooks';
2
+ import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
2
3
  import type { CanvasNodeState } from '../types';
3
4
  import { axSurfaceState, canvasTheme } from '../state/canvas-store';
4
5
  import { shouldContentFitIframeNode } from '../canvas/auto-fit';
5
- import { submitAxInteractionFromClient } from '../state/intent-bridge';
6
- import { showToast } from '../state/attention-bridge';
7
6
  import { ExtAppFrame } from './ExtAppFrame';
7
+ import { useAxSurfaceBridge } from './use-ax-surface-bridge';
8
8
  import { useIframeContentHeight } from './use-iframe-content-height';
9
9
 
10
10
  function withViewerParams(
@@ -80,50 +80,15 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
80
80
  const frameToken = useMemo(() => (contentFit ? `frame-${crypto.randomUUID()}` : ''), [contentFit]);
81
81
  useIframeContentHeight(node, iframeRef, frameToken);
82
82
 
83
- // Receive AX emits forwarded by the json-render viewer; validate (bound to this
84
- // node's iframe + nonce + node id) and submit through the capability-gated
85
- // endpoint, which re-validates server-side.
86
- useEffect(() => {
87
- if (!isAxViewer || !axToken) return;
88
- function onAxMessage(event: MessageEvent) {
89
- if (event.source !== iframeRef.current?.contentWindow) return;
90
- const data = event.data as {
91
- source?: string;
92
- token?: string;
93
- nodeId?: string;
94
- correlationId?: string;
95
- interaction?: { type?: unknown; payload?: unknown };
96
- } | null;
97
- if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== node.id) return;
98
- const interaction = data.interaction;
99
- if (!interaction || typeof interaction.type !== 'string') return;
100
- const interactionType = interaction.type;
101
- void submitAxInteractionFromClient({
102
- type: interactionType,
103
- sourceNodeId: node.id,
104
- sourceSurface: axSurface,
105
- ...(interaction.payload && typeof interaction.payload === 'object'
106
- ? { payload: interaction.payload as Record<string, unknown> }
107
- : {}),
108
- }).then((res) => {
109
- if (res.ok) showToast('context', 'AX interaction', interactionType, [node.id]);
110
- else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [node.id]);
111
- // Report #55: ack back to the viewer so the surface can self-confirm.
112
- iframeRef.current?.contentWindow?.postMessage(
113
- {
114
- source: 'pmx-canvas-ax-ack',
115
- token: axToken,
116
- ...(data.correlationId ? { correlationId: data.correlationId } : {}),
117
- interaction: { type: interactionType },
118
- result: res,
119
- },
120
- '*',
121
- );
122
- });
123
- }
124
- window.addEventListener('message', onAxMessage);
125
- return () => window.removeEventListener('message', onAxMessage);
126
- }, [isAxViewer, axToken, node.id]);
83
+ // AX emits forwarded by the json-render viewer — the shared sandboxed-surface
84
+ // trust boundary (M2).
85
+ useAxSurfaceBridge({
86
+ enabled: isAxViewer && Boolean(axToken),
87
+ token: axToken,
88
+ nodeId: node.id,
89
+ sourceSurface: axSurface,
90
+ iframeRef,
91
+ });
127
92
 
128
93
  // Read-side: push live AX state into the json-render viewer so a spec bound to
129
94
  // /ax reflects the work queue. Validated by the viewer against axToken.
@@ -132,7 +97,7 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
132
97
  if (!isAxViewer || !axToken || axStateValue == null) return;
133
98
  iframeRef.current?.contentWindow?.postMessage(
134
99
  {
135
- source: 'pmx-canvas-html-node',
100
+ source: HTML_SURFACE_PUSH_SOURCE,
136
101
  type: 'ax-update',
137
102
  token: axToken,
138
103
  state: axStateValue,
@@ -0,0 +1,71 @@
1
+ import type { RefObject } from 'preact';
2
+ import { useEffect } from 'preact/hooks';
3
+ import { AX_SURFACE_ACK_SOURCE, AX_SURFACE_EMIT_SOURCE } from '../../shared/ax-surface-protocol.js';
4
+ import { showToast } from '../state/attention-bridge';
5
+ import { submitAxInteractionFromClient } from '../state/intent-bridge';
6
+
7
+ /** Sandboxed surfaces that emit through this bridge (clamped to their own node server-side). */
8
+ export type AxSurfaceBridgeSurface = 'json-render' | 'html-node' | 'mcp-app';
9
+
10
+ /**
11
+ * The sandboxed-surface AX trust boundary, single-sited (plan-009 M2; the
12
+ * listener was copy-pasted across HtmlNode, McpAppNode, and ExtAppFrame — a
13
+ * prior near-miss lived exactly in that duplication). Receives a
14
+ * `window.PMX_AX.emit(...)` message from THIS node's iframe, validates the
15
+ * event source against the live contentWindow plus the per-mount nonce token
16
+ * and node id, submits the interaction through the capability-gated endpoint
17
+ * (the server re-validates capabilities and clamps sandboxed surfaces to their
18
+ * own node), and acks the result back so the surface can self-confirm (#55).
19
+ */
20
+ export function useAxSurfaceBridge(options: {
21
+ enabled: boolean;
22
+ token: string;
23
+ nodeId: string;
24
+ sourceSurface: AxSurfaceBridgeSurface;
25
+ iframeRef: RefObject<HTMLIFrameElement>;
26
+ }): void {
27
+ const { enabled, token, nodeId, sourceSurface, iframeRef } = options;
28
+ useEffect(() => {
29
+ if (!enabled || !token) return;
30
+ function onAxMessage(event: MessageEvent) {
31
+ // Bind to THIS node's own iframe; the nonce + nodeId are a second gate,
32
+ // not the only one.
33
+ if (event.source !== iframeRef.current?.contentWindow) return;
34
+ const data = event.data as {
35
+ source?: string;
36
+ token?: string;
37
+ nodeId?: string;
38
+ correlationId?: string;
39
+ interaction?: { type?: unknown; payload?: unknown };
40
+ } | null;
41
+ if (!data || data.source !== AX_SURFACE_EMIT_SOURCE || data.token !== token || data.nodeId !== nodeId) return;
42
+ const interaction = data.interaction;
43
+ if (!interaction || typeof interaction.type !== 'string') return;
44
+ const interactionType = interaction.type;
45
+ void submitAxInteractionFromClient({
46
+ type: interactionType,
47
+ sourceNodeId: nodeId,
48
+ sourceSurface,
49
+ ...(interaction.payload && typeof interaction.payload === 'object'
50
+ ? { payload: interaction.payload as Record<string, unknown> }
51
+ : {}),
52
+ }).then((res) => {
53
+ if (res.ok) showToast('context', 'AX interaction', interactionType, [nodeId]);
54
+ else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [nodeId]);
55
+ iframeRef.current?.contentWindow?.postMessage(
56
+ {
57
+ source: AX_SURFACE_ACK_SOURCE,
58
+ token,
59
+ ...(data.correlationId ? { correlationId: data.correlationId } : {}),
60
+ interaction: { type: interactionType },
61
+ result: res,
62
+ },
63
+ '*',
64
+ );
65
+ });
66
+ }
67
+ window.addEventListener('message', onAxMessage);
68
+ return () => window.removeEventListener('message', onAxMessage);
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ }, [enabled, token, nodeId, sourceSurface]);
71
+ }
@@ -0,0 +1,72 @@
1
+ import type { CanvasNodeState } from '../types';
2
+
3
+ // ── Client node-creation defaults (plan-009 H5, client half) ──
4
+ // Single source of truth for the frame (position/size/zIndex/flags) a NEW
5
+ // client-created node gets when its creating event carries no explicit
6
+ // geometry. Extracted from sse-bridge, which kept one `makeNode` helper plus
7
+ // three inline node literals (ext-app open, prompt, response) duplicating
8
+ // these defaults.
9
+ //
10
+ // Divergences found during extraction — documented, NOT silently unified:
11
+ // - `canvas-store.ts` holds no node-creation defaults today. Human-created
12
+ // nodes go through `createNodeFromClient` (intent-bridge → POST
13
+ // /api/canvas/node) and get the SERVER's `defaultNodeSize`
14
+ // (src/server/operations/ops/nodes.ts), which differs from this table for
15
+ // most types (e.g. its fallback is 360x200). The server half of H5
16
+ // (`syncEventToCanvasState`, `ensureDefaultDockedNodes`) is deliberately
17
+ // deferred — do not "fix" that mismatch from this file.
18
+ // - The server seeds `status-main`/`context-main` docked AND collapsed
19
+ // (`ensureDefaultDockedNodes` in src/server/server.ts); the SSE bridge
20
+ // creates the same ids expanded, with `context-main` undocked. Client
21
+ // behavior is preserved as-is.
22
+ // - sse-bridge's prompt literal hardcoded `height: 400` beside a table entry
23
+ // of the same value; both now read the table (no rendering change).
24
+
25
+ export const DEFAULT_POSITIONS: Record<CanvasNodeState['type'], { x: number; y: number; w: number; h: number }> &
26
+ Record<'prompt' | 'response', { x: number; y: number; w: number; h: number }> = {
27
+ status: { x: 40, y: 80, w: 300, h: 120 },
28
+ markdown: { x: 380, y: 80, w: 720, h: 600 },
29
+ context: { x: 1130, y: 80, w: 320, h: 400 },
30
+ 'mcp-app': { x: 380, y: 720, w: 960, h: 600 },
31
+ webpage: { x: 380, y: 80, w: 520, h: 420 },
32
+ 'json-render': { x: 380, y: 720, w: 840, h: 620 },
33
+ graph: { x: 380, y: 720, w: 760, h: 520 },
34
+ ledger: { x: 1130, y: 520, w: 320, h: 280 },
35
+ trace: { x: 40, y: 900, w: 200, h: 56 },
36
+ file: { x: 380, y: 80, w: 720, h: 600 },
37
+ image: { x: 380, y: 80, w: 720, h: 520 },
38
+ html: { x: 380, y: 80, w: 720, h: 640 },
39
+ group: { x: 220, y: 60, w: 840, h: 560 },
40
+ prompt: { x: 380, y: 1260, w: 520, h: 400 },
41
+ response: { x: 380, y: 1480, w: 720, h: 400 },
42
+ };
43
+
44
+ /**
45
+ * Build a canvas node with the shared client defaults. `position`/`size`
46
+ * default to the type's DEFAULT_POSITIONS entry; callers that compute
47
+ * placement (auto-placement, event-supplied geometry) pass explicit values.
48
+ * Status nodes sit at zIndex 0 (background chrome), everything else at 1.
49
+ */
50
+ export function makeNodeState(
51
+ id: string,
52
+ type: CanvasNodeState['type'],
53
+ data: Record<string, unknown>,
54
+ options: {
55
+ position?: { x: number; y: number };
56
+ size?: { width: number; height: number };
57
+ dockPosition?: 'left' | 'right' | null;
58
+ } = {},
59
+ ): CanvasNodeState {
60
+ const defaults = DEFAULT_POSITIONS[type];
61
+ return {
62
+ id,
63
+ type,
64
+ position: options.position ?? { x: defaults.x, y: defaults.y },
65
+ size: options.size ?? { width: defaults.w, height: defaults.h },
66
+ zIndex: type === 'status' ? 0 : 1,
67
+ collapsed: false,
68
+ pinned: false,
69
+ dockPosition: options.dockPosition ?? null,
70
+ data,
71
+ };
72
+ }