pmx-canvas 0.1.2 → 0.1.4

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 (42) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/Readme.md +35 -8
  3. package/dist/canvas/index.js +69 -69
  4. package/dist/json-render/index.css +1 -1
  5. package/dist/json-render/index.js +1 -1
  6. package/dist/types/client/nodes/ExtAppFrame.d.ts +12 -0
  7. package/dist/types/client/state/canvas-store.d.ts +2 -1
  8. package/dist/types/client/types.d.ts +3 -0
  9. package/dist/types/json-render/charts/components.d.ts +2 -1
  10. package/dist/types/server/canvas-serialization.d.ts +1 -0
  11. package/dist/types/server/diagram-presets.d.ts +13 -0
  12. package/dist/types/server/ext-app-lookup.d.ts +22 -0
  13. package/dist/types/server/index.d.ts +8 -1
  14. package/dist/types/server/web-artifacts.d.ts +1 -0
  15. package/package.json +2 -1
  16. package/skills/pmx-canvas/SKILL.md +35 -10
  17. package/skills/pmx-canvas/references/installing-pmx-canvas.md +66 -0
  18. package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +10 -0
  19. package/skills/web-artifacts-builder/scripts/init-artifact.sh +1 -1
  20. package/src/cli/agent.ts +114 -21
  21. package/src/cli/index.ts +3 -1
  22. package/src/client/App.tsx +2 -1
  23. package/src/client/canvas/CanvasNode.tsx +3 -2
  24. package/src/client/canvas/ExpandedNodeOverlay.tsx +6 -1
  25. package/src/client/nodes/ExtAppFrame.tsx +97 -26
  26. package/src/client/state/canvas-store.ts +63 -1
  27. package/src/client/state/sse-bridge.ts +19 -4
  28. package/src/client/types.ts +12 -0
  29. package/src/json-render/charts/components.tsx +6 -4
  30. package/src/json-render/charts/extra-components.tsx +5 -5
  31. package/src/json-render/renderer/index.css +14 -0
  32. package/src/mcp/server.ts +44 -5
  33. package/src/server/canvas-operations.ts +43 -5
  34. package/src/server/canvas-schema.ts +16 -14
  35. package/src/server/canvas-serialization.ts +19 -1
  36. package/src/server/diagram-presets.ts +219 -4
  37. package/src/server/ext-app-lookup.ts +49 -0
  38. package/src/server/index.ts +33 -25
  39. package/src/server/server.ts +199 -45
  40. package/src/server/web-artifacts/scripts/bundle-artifact.sh +10 -0
  41. package/src/server/web-artifacts/scripts/init-artifact.sh +1 -1
  42. package/src/server/web-artifacts.ts +44 -1
package/src/cli/agent.ts CHANGED
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { readFileSync, writeFileSync } from 'node:fs';
15
15
  import { openUrlInExternalBrowser } from '../server/server.js';
16
+ import { DEFAULT_EXCALIDRAW_ELEMENTS } from '../server/diagram-presets.js';
16
17
  import {
17
18
  ALL_SEMANTIC_WATCH_EVENT_TYPES,
18
19
  formatCompactWatchEvent,
@@ -24,6 +25,7 @@ import {
24
25
  // ── Helpers ──────────────────────────────────────────────────
25
26
 
26
27
  const DEFAULT_PORT = 4313;
28
+ const defaultConsoleLog = console.log;
27
29
 
28
30
  interface CanvasSchemaField {
29
31
  name: string;
@@ -91,13 +93,19 @@ function die(message: string, hint?: string): never {
91
93
  }
92
94
 
93
95
  function output(data: unknown): void {
94
- console.log(JSON.stringify(data, null, 2));
96
+ const text = JSON.stringify(data, null, 2);
97
+ if (console.log !== defaultConsoleLog) {
98
+ console.log(text);
99
+ return;
100
+ }
101
+ process.stdout.write(`${text}\n`);
95
102
  }
96
103
 
97
104
  async function api(
98
105
  method: string,
99
106
  path: string,
100
107
  body?: Record<string, unknown>,
108
+ options?: { allowErrorJson?: boolean },
101
109
  ): Promise<unknown> {
102
110
  const base = getBaseUrl();
103
111
  const url = `${base}${path}`;
@@ -128,6 +136,7 @@ async function api(
128
136
  }
129
137
 
130
138
  if (!res.ok) {
139
+ if (options?.allowErrorJson) return json;
131
140
  const err = json as Record<string, unknown>;
132
141
  die(
133
142
  err.error ? String(err.error) : `HTTP ${res.status}`,
@@ -146,7 +155,7 @@ function parseFlags(args: string[]): { positional: string[]; flags: Record<strin
146
155
  const BOOL_FLAGS = new Set([
147
156
  'help', 'h', 'ids', 'stdin', 'yes', 'list', 'clear', 'set', 'animated', 'dry-run',
148
157
  'no-open-in-canvas', 'lock-arrange', 'unlock-arrange', 'json', 'compact', 'summary',
149
- 'verbose', 'include-logs',
158
+ 'verbose', 'include-logs', 'no-pan',
150
159
  ]);
151
160
  for (let i = 0; i < args.length; i++) {
152
161
  const arg = args[i];
@@ -228,8 +237,9 @@ function parseStringListFlag(
228
237
  flags: Record<string, string | true>,
229
238
  name: string,
230
239
  hint: string,
240
+ ...aliases: string[]
231
241
  ): string[] | undefined {
232
- const raw = getStringFlag(flags, name);
242
+ const raw = getStringFlag(flags, name, ...aliases);
233
243
  if (raw === undefined) return undefined;
234
244
  const trimmed = raw.trim();
235
245
  if (!trimmed) {
@@ -286,6 +296,7 @@ function summarizeNodeResult(node: Record<string, unknown>): Record<string, unkn
286
296
  ...(node.ok !== undefined ? { ok: node.ok } : {}),
287
297
  id: node.id ?? null,
288
298
  type: node.type ?? null,
299
+ ...(typeof node.kind === 'string' ? { kind: node.kind } : {}),
289
300
  title: node.title ?? null,
290
301
  ...(typeof node.content === 'string' ? { contentPreview: truncateText(node.content) } : {}),
291
302
  ...(node.position !== undefined ? { position: node.position } : {}),
@@ -532,37 +543,48 @@ async function buildGraphRequestBody(
532
543
  'Use: pmx-canvas node add --type graph --graph-type bar --data-file ./metrics.json --x-key label --y-key value';
533
544
  const rawData = await readTextInput(flags, {
534
545
  fileFlags: ['data-file'],
535
- valueFlags: ['data-json'],
546
+ valueFlags: ['data-json', 'data'],
536
547
  allowStdin: true,
537
548
  label: 'graph JSON dataset',
538
549
  hint,
539
- requiredMessage: 'Graph nodes require --data-file, --data-json, or --stdin JSON data.',
550
+ requiredMessage: 'Graph nodes require --data-file, --data-json, --data, or --stdin JSON data.',
540
551
  });
541
552
  const data = parseRecordArrayJson(rawData, hint);
542
553
 
543
554
  const body: Record<string, unknown> = {
544
- graphType: getStringFlag(flags, 'graph-type') ?? 'line',
555
+ graphType: getStringFlag(flags, 'graph-type', 'graphType') ?? 'line',
545
556
  data,
546
557
  };
547
558
  if (typeof flags.title === 'string') body.title = flags.title;
548
- if (typeof flags['x-key'] === 'string') body.xKey = flags['x-key'];
549
- if (typeof flags['y-key'] === 'string') body.yKey = flags['y-key'];
550
- if (typeof flags['z-key'] === 'string') body.zKey = flags['z-key'];
551
- if (typeof flags['name-key'] === 'string') body.nameKey = flags['name-key'];
552
- if (typeof flags['value-key'] === 'string') body.valueKey = flags['value-key'];
553
- if (typeof flags['axis-key'] === 'string') body.axisKey = flags['axis-key'];
559
+ const xKey = getStringFlag(flags, 'x-key', 'xKey');
560
+ const yKey = getStringFlag(flags, 'y-key', 'yKey');
561
+ const zKey = getStringFlag(flags, 'z-key', 'zKey');
562
+ const nameKey = getStringFlag(flags, 'name-key', 'nameKey');
563
+ const valueKey = getStringFlag(flags, 'value-key', 'valueKey');
564
+ const axisKey = getStringFlag(flags, 'axis-key', 'axisKey');
565
+ if (xKey) body.xKey = xKey;
566
+ if (yKey) body.yKey = yKey;
567
+ if (zKey) body.zKey = zKey;
568
+ if (nameKey) body.nameKey = nameKey;
569
+ if (valueKey) body.valueKey = valueKey;
570
+ if (axisKey) body.axisKey = axisKey;
554
571
  const metrics = parseStringListFlag(flags, 'metrics', 'Use a comma-separated list, e.g. --metrics north,south');
555
572
  const series = parseStringListFlag(flags, 'series', 'Use a comma-separated list, e.g. --series north,south');
556
573
  if (metrics) body.metrics = metrics;
557
574
  if (series) body.series = series;
558
- if (typeof flags['bar-key'] === 'string') body.barKey = flags['bar-key'];
559
- if (typeof flags['line-key'] === 'string') body.lineKey = flags['line-key'];
575
+ const barKey = getStringFlag(flags, 'bar-key', 'barKey');
576
+ const lineKey = getStringFlag(flags, 'line-key', 'lineKey');
577
+ if (barKey) body.barKey = barKey;
578
+ if (lineKey) body.lineKey = lineKey;
560
579
  if (flags.aggregate === 'sum' || flags.aggregate === 'count' || flags.aggregate === 'avg') {
561
580
  body.aggregate = flags.aggregate;
562
581
  }
563
- if (typeof flags.color === 'string') body.color = flags.color;
564
- if (typeof flags['bar-color'] === 'string') body.barColor = flags['bar-color'];
565
- if (typeof flags['line-color'] === 'string') body.lineColor = flags['line-color'];
582
+ const color = getStringFlag(flags, 'color');
583
+ const barColor = getStringFlag(flags, 'bar-color', 'barColor');
584
+ const lineColor = getStringFlag(flags, 'line-color', 'lineColor');
585
+ if (color) body.color = color;
586
+ if (barColor) body.barColor = barColor;
587
+ if (lineColor) body.lineColor = lineColor;
566
588
 
567
589
  const chartHeight = optionalPositiveFiniteFlag(flags, 'chart-height', 'Use a positive number, e.g. --chart-height 300');
568
590
  const x = optionalFiniteFlag(flags, 'x', 'Use a finite number, e.g. --x 500');
@@ -621,6 +643,8 @@ async function buildWebArtifactRequestBody(
621
643
  if (typeof flags['output-path'] === 'string') body.outputPath = flags['output-path'];
622
644
  if (typeof flags['init-script-path'] === 'string') body.initScriptPath = flags['init-script-path'];
623
645
  if (typeof flags['bundle-script-path'] === 'string') body.bundleScriptPath = flags['bundle-script-path'];
646
+ const deps = parseStringListFlag(flags, 'deps', 'Use a comma-separated list, e.g. --deps recharts,zod');
647
+ if (deps) body.deps = deps;
624
648
  if (flags['no-open-in-canvas']) body.openInCanvas = false;
625
649
  if (flags.verbose || flags['include-logs']) body.includeLogs = true;
626
650
 
@@ -631,8 +655,11 @@ async function buildWebArtifactRequestBody(
631
655
  }
632
656
 
633
657
  async function runWebArtifactBuildCommand(flags: Record<string, string | true>): Promise<void> {
634
- const result = await api('POST', '/api/canvas/web-artifact', await buildWebArtifactRequestBody(flags));
658
+ const result = await api('POST', '/api/canvas/web-artifact', await buildWebArtifactRequestBody(flags), { allowErrorJson: true });
635
659
  output(result);
660
+ if (isRecord(result) && result.ok === false) {
661
+ process.exit(1);
662
+ }
636
663
  }
637
664
 
638
665
  async function loadCanvasSchema(): Promise<CanvasSchemaResponse> {
@@ -915,6 +942,13 @@ cmd('node add', 'Add a node to the canvas', [
915
942
  return;
916
943
  }
917
944
 
945
+ if (type === 'mcp-app') {
946
+ die(
947
+ 'mcp-app nodes require tool-backed app metadata and cannot be created with generic node add.',
948
+ 'Use: pmx-canvas web-artifact build --title "Dashboard" --app-file ./App.tsx, or pmx-canvas external-app add --kind excalidraw --title "Diagram"',
949
+ );
950
+ }
951
+
918
952
  const body: Record<string, unknown> = { type };
919
953
  if (flags.title) body.title = flags.title;
920
954
  const webpageUrl = getStringFlag(flags, 'url');
@@ -1014,7 +1048,7 @@ cmd('node list', 'List all nodes on the canvas', [
1014
1048
  let nodes = layout.nodes;
1015
1049
 
1016
1050
  if (flags.type && flags.type !== true) {
1017
- nodes = nodes.filter((n) => n.type === flags.type);
1051
+ nodes = nodes.filter((n) => n.type === flags.type || n.kind === flags.type);
1018
1052
  }
1019
1053
 
1020
1054
  if (flags.ids) {
@@ -1269,7 +1303,12 @@ cmd('status', 'Quick canvas summary', [
1269
1303
 
1270
1304
  const typeCounts: Record<string, number> = {};
1271
1305
  for (const n of layout.nodes) {
1272
- const t = n.type as string;
1306
+ const data = isRecord(n.data) ? n.data : {};
1307
+ const t = typeof n.kind === 'string'
1308
+ ? n.kind
1309
+ : n.type === 'mcp-app' && data.hostMode === 'hosted' && typeof data.path === 'string'
1310
+ ? 'web-artifact'
1311
+ : n.type as string;
1273
1312
  typeCounts[t] = (typeCounts[t] || 0) + 1;
1274
1313
  }
1275
1314
 
@@ -1308,10 +1347,43 @@ cmd('focus', 'Pan viewport to center on a node', [
1308
1347
  const id = positional[0];
1309
1348
  if (!id) die('Missing node ID', 'pmx-canvas focus <node-id>');
1310
1349
 
1311
- const result = await api('POST', '/api/canvas/focus', { id });
1350
+ const result = await api('POST', '/api/canvas/focus', { id, ...(flags['no-pan'] ? { noPan: true } : {}) });
1312
1351
  output(result);
1313
1352
  });
1314
1353
 
1354
+ // ── external-app add ─────────────────────────────────────────
1355
+ cmd('external-app add', 'Create a hosted external app node', [
1356
+ 'pmx-canvas external-app add --kind excalidraw --title "Diagram"',
1357
+ ], async (args) => {
1358
+ const { flags } = parseFlags(args);
1359
+ if (flags.help || flags.h) return showCommandHelp('external-app add');
1360
+
1361
+ const kind = typeof flags.kind === 'string' ? flags.kind.trim() : '';
1362
+ if (kind !== 'excalidraw') {
1363
+ die('Unsupported external app kind.', 'Use: pmx-canvas external-app add --kind excalidraw --title "Diagram"');
1364
+ }
1365
+
1366
+ const body: Record<string, unknown> = {
1367
+ title: typeof flags.title === 'string' ? flags.title : 'Excalidraw Diagram',
1368
+ elements: DEFAULT_EXCALIDRAW_ELEMENTS,
1369
+ };
1370
+ const elementsJson = getStringFlag(flags, 'elements-json');
1371
+ if (elementsJson !== undefined) body.elements = parseJsonValue(elementsJson, 'Excalidraw elements', 'Use --elements-json \'[{"type":"rectangle","id":"r1","x":0,"y":0,"width":120,"height":80}]\'');
1372
+ const elementsFile = getStringFlag(flags, 'elements-file', 'initial-file');
1373
+ if (elementsFile) body.elements = parseJsonValue(readFileSync(elementsFile, 'utf-8'), 'Excalidraw elements file', 'Use --elements-file ./scene.excalidraw');
1374
+ applyCommonGeometryFlags(body, flags, {
1375
+ x: 'Use a finite number, e.g. --x 500',
1376
+ y: 'Use a finite number, e.g. --y 300',
1377
+ width: 'Use a positive number, e.g. --width 960',
1378
+ height: 'Use a positive number, e.g. --height 720',
1379
+ });
1380
+
1381
+ const result = await api('POST', '/api/canvas/diagram', body);
1382
+ output(result && typeof result === 'object' && !Array.isArray(result) && 'nodeId' in result && !('id' in result)
1383
+ ? { id: (result as { nodeId?: unknown }).nodeId, ...result }
1384
+ : result);
1385
+ });
1386
+
1315
1387
  // ── pin ──────────────────────────────────────────────────────
1316
1388
  cmd('pin', 'Manage context pins', [
1317
1389
  'pmx-canvas pin node1 node2 node3',
@@ -1966,8 +2038,13 @@ function showCommandHelp(name: string): void {
1966
2038
  console.log('\nSchema help:');
1967
2039
  console.log(' pmx-canvas node add --help --type webpage');
1968
2040
  console.log(' pmx-canvas node add --help --type json-render --component Table');
2041
+ console.log(' pmx-canvas node add --help --type graph');
1969
2042
  console.log(' pmx-canvas node add --help --type webpage --json');
1970
2043
  }
2044
+ if (name === 'node add' || name === 'validate spec') {
2045
+ console.log('\nGraph flags:');
2046
+ console.log(' Graph fields accept kebab-case CLI flags and camelCase schema names, e.g. --graph-type/--graphType and --x-key/--xKey');
2047
+ }
1971
2048
  if (name === 'node schema') {
1972
2049
  console.log('\nFilters:');
1973
2050
  console.log(' --summary Show compact schema summaries');
@@ -1979,10 +2056,24 @@ function showCommandHelp(name: string): void {
1979
2056
  console.log(' --summary Return only validation summary metadata');
1980
2057
  }
1981
2058
  if (name === 'web-artifact build') {
2059
+ console.log('\nDependencies:');
2060
+ console.log(' --deps <list> Add npm dependencies before bundling, e.g. --deps recharts,zod');
1982
2061
  console.log('\nOutput control:');
1983
2062
  console.log(' --include-logs Include raw build stdout/stderr in the response');
1984
2063
  console.log(' --verbose Alias for --include-logs');
1985
2064
  }
2065
+ if (name === 'focus') {
2066
+ console.log('\nViewport:');
2067
+ console.log(' --no-pan Select/raise the node without moving the viewport');
2068
+ }
2069
+ if (name === 'external-app add') {
2070
+ console.log('\nOptions:');
2071
+ console.log(' --kind excalidraw External app kind to create');
2072
+ console.log(' --title <title> Node title');
2073
+ console.log(' --elements-json <json> Optional Excalidraw elements array JSON');
2074
+ console.log(' --elements-file <path> Optional file containing Excalidraw elements JSON');
2075
+ console.log(' --initial-file <path> Alias for --elements-file');
2076
+ }
1986
2077
  console.log('');
1987
2078
  }
1988
2079
 
@@ -2023,6 +2114,7 @@ Canvas commands:
2023
2114
  pmx-canvas validate spec Validate json-render/graph payloads without creating nodes
2024
2115
  pmx-canvas watch [options] Watch semantic canvas changes over SSE
2025
2116
  pmx-canvas focus <id> Pan viewport to node
2117
+ pmx-canvas external-app add Add hosted external apps like Excalidraw
2026
2118
  pmx-canvas webview status Show WebView automation status
2027
2119
  pmx-canvas webview start [options] Start or replace automation session
2028
2120
  pmx-canvas webview evaluate Evaluate JS in automation session
@@ -2090,6 +2182,7 @@ Examples:
2090
2182
  pmx-canvas validate spec --type json-render --spec-file ./dashboard.json --summary
2091
2183
  pmx-canvas history --summary
2092
2184
  pmx-canvas web-artifact build --title "Dashboard" --app-file ./App.tsx
2185
+ pmx-canvas external-app add --kind excalidraw --title "Diagram"
2093
2186
  pmx-canvas web-artifact build --title "Dashboard" --app-file ./App.tsx --include-logs
2094
2187
  pmx-canvas webview evaluate --script "const title = document.title; return title"
2095
2188
  pmx-canvas snapshot save --name "pre-refactor"
package/src/cli/index.ts CHANGED
@@ -31,7 +31,7 @@ if (args.includes('--version') || args.includes('-v')) {
31
31
  const AGENT_COMMANDS = new Set([
32
32
  'node', 'edge', 'search', 'layout', 'status', 'arrange', 'focus',
33
33
  'pin', 'undo', 'redo', 'history', 'snapshot', 'diff', 'group', 'webview', 'open',
34
- 'clear', 'code-graph', 'spatial', 'watch', 'web-artifact', 'batch', 'validate', 'serve',
34
+ 'clear', 'code-graph', 'spatial', 'watch', 'web-artifact', 'external-app', 'batch', 'validate', 'serve',
35
35
  ]);
36
36
 
37
37
  const firstArg = args[0] ?? '';
@@ -501,6 +501,7 @@ Agent CLI (works against running server):
501
501
  validate spec Validate json-render/graph payloads without creating nodes
502
502
  watch [--json] [--events ...] Watch low-token semantic canvas changes
503
503
  focus <node-id> Pan to node
504
+ external-app add Add hosted external apps like Excalidraw
504
505
  pin <ids...> | --list | --clear Manage context pins
505
506
  undo / redo / history Time travel
506
507
  snapshot save|list|restore|diff|delete
@@ -542,6 +543,7 @@ Examples:
542
543
  pmx-canvas node list List all nodes
543
544
  pmx-canvas node schema --type json-render Show running-server schema info
544
545
  pmx-canvas web-artifact build --title "Dashboard" --app-file ./App.tsx
546
+ pmx-canvas external-app add --kind excalidraw --title "Diagram"
545
547
  pmx-canvas validate spec --type graph --graph-type bar --data-file ./metrics.json --x-key label --y-key value
546
548
  pmx-canvas open Open the workbench in a browser
547
549
  pmx-canvas webview status Show WebView automation status
@@ -28,6 +28,7 @@ import {
28
28
  forceDirectedArrange,
29
29
  hasInitialServerLayout,
30
30
  nodes,
31
+ pendingExpandedNodeCloseId,
31
32
  persistLayout,
32
33
  selectedNodeIds,
33
34
  sessionId,
@@ -366,7 +367,7 @@ export function App() {
366
367
  }
367
368
 
368
369
  // Esc always collapses expanded node first (even from inside inputs)
369
- if (e.key === 'Escape' && expandedNodeId.value) {
370
+ if (e.key === 'Escape' && expandedNodeId.value && !pendingExpandedNodeCloseId.value) {
370
371
  e.preventDefault();
371
372
  collapseExpandedNode();
372
373
  return;
@@ -143,9 +143,10 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
143
143
  const autoFitPersistTimer = useRef<number | null>(null);
144
144
  const AUTO_FIT_MAX = 600;
145
145
  const TITLEBAR_HEIGHT = 37;
146
+ const isExtAppNode = node.type === 'mcp-app' && node.data.mode === 'ext-app';
146
147
 
147
148
  useEffect(() => {
148
- if (hasAutoFit.current || node.collapsed || node.dockPosition || node.type === 'group') return;
149
+ if (hasAutoFit.current || node.collapsed || node.dockPosition || node.type === 'group' || isExtAppNode) return;
149
150
  const body = bodyRef.current;
150
151
  if (!body) return;
151
152
 
@@ -180,7 +181,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
180
181
  autoFitPersistTimer.current = null;
181
182
  }
182
183
  };
183
- }, [node.id, node.type, node.collapsed, node.dockPosition, node.size.width, node.size.height]);
184
+ }, [node.id, node.type, isExtAppNode, node.collapsed, node.dockPosition, node.size.width, node.size.height]);
184
185
 
185
186
  const isPinned = node.pinned;
186
187
  const isTrace = node.type === 'trace';
@@ -15,6 +15,7 @@ import {
15
15
  contextPinnedNodeIds,
16
16
  expandedNodeId,
17
17
  nodes,
18
+ pendingExpandedNodeCloseId,
18
19
  toggleContextPin,
19
20
  } from '../state/canvas-store';
20
21
  import { TYPE_LABELS } from '../types';
@@ -115,6 +116,7 @@ export function ExpandedNodeOverlay() {
115
116
  const words = wordCount(textContent);
116
117
  const isCtxPinned = nodeId ? contextPinnedNodeIds.value.has(nodeId) : false;
117
118
  const hasText = textContent.length > 0;
119
+ const pendingClose = pendingExpandedNodeCloseId.value === nodeId;
118
120
 
119
121
  return (
120
122
  <div
@@ -130,6 +132,7 @@ export function ExpandedNodeOverlay() {
130
132
  alignItems: 'stretch',
131
133
  justifyContent: 'center',
132
134
  padding: '32px',
135
+ pointerEvents: pendingClose ? 'none' : 'auto',
133
136
  }}
134
137
  >
135
138
  <div
@@ -217,7 +220,9 @@ export function ExpandedNodeOverlay() {
217
220
  )}
218
221
  </div>
219
222
 
220
- <span style={{ fontSize: '10px', color: 'var(--c-muted)' }}>Esc to close</span>
223
+ <span style={{ fontSize: '10px', color: pendingClose ? 'var(--c-warn)' : 'var(--c-muted)' }}>
224
+ {pendingClose ? 'Saving edits...' : 'Esc to close'}
225
+ </span>
221
226
  <button
222
227
  type="button"
223
228
  onClick={handleClose}
@@ -20,6 +20,11 @@ type IframeLoadTarget = Pick<
20
20
 
21
21
  type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
22
22
  type DisplayMode = 'inline' | 'fullscreen' | 'pip';
23
+ const DEFAULT_EXT_APP_SANDBOX = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
24
+
25
+ interface ExtAppHostDimensionsTarget {
26
+ getBoundingClientRect(): Pick<DOMRectReadOnly, 'width' | 'height'>;
27
+ }
23
28
 
24
29
  async function postJson<T>(url: string, body: Record<string, unknown>): Promise<T> {
25
30
  const response = await fetch(url, {
@@ -97,6 +102,33 @@ export async function sendExtAppBootstrapState(
97
102
  }
98
103
  }
99
104
 
105
+ export function resolveExtAppSandbox(value: unknown): string {
106
+ return typeof value === 'string' && value.trim().length > 0
107
+ ? value.trim()
108
+ : DEFAULT_EXT_APP_SANDBOX;
109
+ }
110
+
111
+ function positiveDimension(value: number, fallback: number): number {
112
+ if (Number.isFinite(value) && value > 0) return Math.round(value);
113
+ if (Number.isFinite(fallback) && fallback > 0) return Math.round(fallback);
114
+ return 1;
115
+ }
116
+
117
+ export function resolveExtAppContainerDimensions(
118
+ target: ExtAppHostDimensionsTarget | null | undefined,
119
+ fallback: { width: number; height: number },
120
+ ): { width: number; height: number } {
121
+ const rect = target?.getBoundingClientRect();
122
+ return {
123
+ width: positiveDimension(rect?.width ?? 0, fallback.width),
124
+ height: positiveDimension(rect?.height ?? 0, fallback.height),
125
+ };
126
+ }
127
+
128
+ export function shouldApplyExtAppSizeChange(height: unknown, isExpanded: boolean): height is number {
129
+ return typeof height === 'number' && Number.isFinite(height) && height > 0 && !isExpanded;
130
+ }
131
+
100
132
  export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
101
133
  const iframeRef = useRef<HTMLIFrameElement>(null);
102
134
  const bridgeRef = useRef<AppBridge | null>(null);
@@ -122,7 +154,10 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
122
154
  const rawToolCallId = node.data.toolCallId;
123
155
  const toolCallId: RequestId | undefined =
124
156
  typeof rawToolCallId === 'string' || typeof rawToolCallId === 'number' ? rawToolCallId : undefined;
125
- const resourceMeta = node.data.resourceMeta as { permissions?: Record<string, unknown> } | undefined;
157
+ const resourceMeta = node.data.resourceMeta as {
158
+ csp?: Record<string, unknown>;
159
+ permissions?: Record<string, unknown>;
160
+ } | undefined;
126
161
  const sessionStatus = node.data.sessionStatus as string | undefined;
127
162
  const sessionError = node.data.sessionError as string | undefined;
128
163
  const maxHeight = node.size.height;
@@ -191,6 +226,8 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
191
226
  if (!iframe) return;
192
227
  let disposed = false;
193
228
  let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
229
+ let hostContextResizeObserver: ResizeObserver | null = null;
230
+ let hostContextRaf: number | null = null;
194
231
  toolResultSentRef.current = false;
195
232
  lastSentToolResultRef.current = undefined;
196
233
  toolResultSendingRef.current = null;
@@ -213,6 +250,33 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
213
250
  throw new Error('Ext-app iframe window is unavailable');
214
251
  }
215
252
 
253
+ const buildHostContext = (displayMode: DisplayMode = expandedNodeId.value === nodeId ? 'fullscreen' : 'inline') => ({
254
+ theme: toMcpTheme(canvasTheme.value),
255
+ platform: 'web' as const,
256
+ containerDimensions: resolveExtAppContainerDimensions(iframe, {
257
+ width: node.size.width,
258
+ height: maxHeight,
259
+ }),
260
+ displayMode,
261
+ locale: navigator.language,
262
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
263
+ ...(toolDefinition ? {
264
+ toolInfo: {
265
+ id: toolCallId,
266
+ tool: toolDefinition,
267
+ },
268
+ } : {}),
269
+ });
270
+
271
+ const scheduleHostContextUpdate = () => {
272
+ if (hostContextRaf !== null) return;
273
+ hostContextRaf = requestAnimationFrame(() => {
274
+ hostContextRaf = null;
275
+ if (disposed || !bridgeReadyRef.current) return;
276
+ bridge.setHostContext?.(buildHostContext());
277
+ });
278
+ };
279
+
216
280
  const bridge = new AppBridge(
217
281
  null,
218
282
  { name: 'PMX Canvas', version: '1.0.0' },
@@ -224,26 +288,15 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
224
288
  updateModelContext: { text: {}, structuredContent: {} },
225
289
  },
226
290
  {
227
- hostContext: {
228
- theme: toMcpTheme(canvasTheme.value),
229
- platform: 'web',
230
- containerDimensions: { maxHeight },
231
- displayMode: isExpanded ? 'fullscreen' : 'inline',
232
- locale: navigator.language,
233
- timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
234
- ...(toolDefinition ? {
235
- toolInfo: {
236
- id: toolCallId,
237
- tool: toolDefinition,
238
- },
239
- } : {}),
240
- },
291
+ hostContext: buildHostContext(isExpanded ? 'fullscreen' : 'inline'),
241
292
  },
242
293
  );
243
294
 
244
295
  // Register handlers BEFORE connect
245
296
  bridge.onsizechange = async ({ height }) => {
246
- if (height && iframe) iframe.style.height = `${height}px`;
297
+ if (shouldApplyExtAppSizeChange(height, expandedNodeId.value === nodeId)) {
298
+ iframe.style.height = `${height}px`;
299
+ }
247
300
  return {};
248
301
  };
249
302
 
@@ -252,6 +305,15 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
252
305
  return {};
253
306
  };
254
307
 
308
+ bridge.onsandboxready = async () => {
309
+ await bridge.sendSandboxResourceReady({
310
+ html,
311
+ sandbox: DEFAULT_EXT_APP_SANDBOX,
312
+ ...(resourceMeta?.csp ? { csp: resourceMeta.csp } : {}),
313
+ ...(resourceMeta?.permissions ? { permissions: resourceMeta.permissions } : {}),
314
+ });
315
+ };
316
+
255
317
  // Handle native fullscreen requests from the widget (e.g. Excalidraw expand button)
256
318
  bridge.onrequestdisplaymode = async ({ mode }) => {
257
319
  const { nextMode, shouldExpand, shouldCollapse } = resolveExtAppDisplayModeRequest(mode, isExpanded);
@@ -333,6 +395,7 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
333
395
  bridgeReadyRef.current = true;
334
396
  setStatus('ready');
335
397
  setError(null);
398
+ scheduleHostContextUpdate();
336
399
  void sendExtAppBootstrapState(bridge, latestToolInputRef.current, undefined)
337
400
  .then(() => flushToolResult(bridge))
338
401
  .catch((err) => {
@@ -370,6 +433,9 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
370
433
  }
371
434
  bridgeRef.current = bridge;
372
435
  transportRef.current = transport;
436
+ hostContextResizeObserver = new ResizeObserver(scheduleHostContextUpdate);
437
+ hostContextResizeObserver.observe(iframe);
438
+ if (iframe.parentElement) hostContextResizeObserver.observe(iframe.parentElement);
373
439
 
374
440
  // Propagate theme changes to ext-app iframe. Read current expanded state
375
441
  // at fire time so the widget keeps its fullscreen/inline context accurate.
@@ -378,12 +444,8 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
378
444
  if (firstFire) { firstFire = false; return; }
379
445
  if (disposed) return;
380
446
  bridge.setHostContext?.({
447
+ ...buildHostContext(),
381
448
  theme: toMcpTheme(newTheme),
382
- platform: 'web',
383
- containerDimensions: { maxHeight },
384
- displayMode: expandedNodeId.value === nodeId ? 'fullscreen' : 'inline',
385
- locale: navigator.language,
386
- timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
387
449
  });
388
450
  });
389
451
 
@@ -399,6 +461,12 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
399
461
  return () => {
400
462
  disposed = true;
401
463
  clearFallbackTimer();
464
+ hostContextResizeObserver?.disconnect();
465
+ hostContextResizeObserver = null;
466
+ if (hostContextRaf !== null) {
467
+ cancelAnimationFrame(hostContextRaf);
468
+ hostContextRaf = null;
469
+ }
402
470
  bridgeReadyRef.current = false;
403
471
  toolResultSendingRef.current = null;
404
472
  themeUnsubRef.current?.();
@@ -431,7 +499,10 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
431
499
  bridge.setHostContext?.({
432
500
  theme: toMcpTheme(canvasTheme.value),
433
501
  platform: 'web',
434
- containerDimensions: { maxHeight },
502
+ containerDimensions: resolveExtAppContainerDimensions(iframeRef.current, {
503
+ width: node.size.width,
504
+ height: maxHeight,
505
+ }),
435
506
  displayMode: isExpanded ? 'fullscreen' : 'inline',
436
507
  locale: navigator.language,
437
508
  timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
@@ -470,7 +541,7 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
470
541
  }
471
542
 
472
543
  return (
473
- <div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
544
+ <div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
474
545
  {sessionStatus && sessionStatus !== 'ready' && (
475
546
  <div
476
547
  style={{
@@ -546,14 +617,14 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
546
617
  remounts the iframe in the overlay — forcing the user to click Edit
547
618
  a second time to actually enter edit mode. Routing all inline clicks
548
619
  to "expand" makes the flow "open → edit" instead of "edit → expand → edit". */}
549
- <div style={{ flex: 1, position: 'relative', display: 'flex', minHeight: 0 }}>
620
+ <div style={{ flex: 1, position: 'relative', display: 'flex', minHeight: 0, height: '100%' }}>
550
621
  <iframe
551
622
  key={frameKey}
552
623
  ref={iframeRef}
553
624
  srcdoc={html}
554
- sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
625
+ sandbox={resolveExtAppSandbox(null)}
555
626
  allow={buildAllowAttribute(resourceMeta?.permissions)}
556
- style={{ flex: 1, border: 'none', background: 'var(--c-panel)' }}
627
+ style={{ flex: 1, width: '100%', height: '100%', minHeight: 0, border: 'none', background: 'var(--c-panel)' }}
557
628
  title={`Ext App: ${toolName}`}
558
629
  />
559
630
  {!isExpanded && (