pmx-canvas 0.1.13 → 0.1.15

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 (58) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/Readme.md +108 -1058
  3. package/dist/canvas/global.css +141 -0
  4. package/dist/canvas/index.js +137 -87
  5. package/dist/json-render/index.css +1 -1
  6. package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -3
  7. package/dist/types/client/nodes/HtmlNode.d.ts +5 -0
  8. package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
  9. package/dist/types/client/state/canvas-store.d.ts +5 -1
  10. package/dist/types/client/state/intent-bridge.d.ts +3 -1
  11. package/dist/types/client/types.d.ts +2 -2
  12. package/dist/types/json-render/catalog.d.ts +1 -1
  13. package/dist/types/mcp/canvas-access.d.ts +7 -1
  14. package/dist/types/server/agent-context.d.ts +1 -0
  15. package/dist/types/server/canvas-operations.d.ts +12 -2
  16. package/dist/types/server/canvas-provenance.d.ts +1 -1
  17. package/dist/types/server/canvas-serialization.d.ts +3 -0
  18. package/dist/types/server/canvas-state.d.ts +51 -4
  19. package/dist/types/server/demo.d.ts +5 -0
  20. package/dist/types/server/diagram-presets.d.ts +4 -0
  21. package/dist/types/server/index.d.ts +21 -3
  22. package/dist/types/server/mcp-app-runtime.d.ts +1 -0
  23. package/dist/types/server/web-artifacts.d.ts +18 -0
  24. package/dist/types/shared/canvas-node-kind.d.ts +5 -0
  25. package/package.json +1 -1
  26. package/skills/pmx-canvas/SKILL.md +43 -0
  27. package/skills/pmx-canvas-testing/SKILL.md +17 -0
  28. package/src/cli/agent.ts +66 -5
  29. package/src/cli/index.ts +2 -23
  30. package/src/client/canvas/AttentionHistory.tsx +14 -1
  31. package/src/client/canvas/CanvasNode.tsx +1 -1
  32. package/src/client/canvas/CanvasViewport.tsx +3 -0
  33. package/src/client/canvas/DockedNode.tsx +110 -12
  34. package/src/client/canvas/ExpandedNodeOverlay.tsx +8 -3
  35. package/src/client/canvas/Minimap.tsx +1 -0
  36. package/src/client/icons.tsx +1 -0
  37. package/src/client/nodes/ExtAppFrame.tsx +10 -35
  38. package/src/client/nodes/HtmlNode.tsx +151 -0
  39. package/src/client/nodes/McpAppNode.tsx +2 -2
  40. package/src/client/state/canvas-store.ts +24 -2
  41. package/src/client/state/intent-bridge.ts +4 -3
  42. package/src/client/state/sse-bridge.ts +2 -0
  43. package/src/client/theme/global.css +141 -0
  44. package/src/client/types.ts +3 -0
  45. package/src/mcp/canvas-access.ts +34 -7
  46. package/src/mcp/server.ts +199 -26
  47. package/src/server/agent-context.ts +50 -3
  48. package/src/server/canvas-operations.ts +55 -3
  49. package/src/server/canvas-provenance.ts +2 -1
  50. package/src/server/canvas-serialization.ts +38 -13
  51. package/src/server/canvas-state.ts +305 -34
  52. package/src/server/demo.ts +792 -0
  53. package/src/server/diagram-presets.ts +45 -25
  54. package/src/server/index.ts +64 -7
  55. package/src/server/mcp-app-runtime.ts +15 -5
  56. package/src/server/server.ts +169 -63
  57. package/src/server/web-artifacts.ts +116 -3
  58. package/src/shared/canvas-node-kind.ts +14 -0
package/src/cli/agent.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
 
27
27
  const DEFAULT_PORT = 4313;
28
28
  const defaultConsoleLog = console.log;
29
+ const TRACE_NODE_FIELDS = ['toolName', 'category', 'status', 'duration', 'resultSummary', 'error'] as const;
29
30
 
30
31
  interface CanvasSchemaField {
31
32
  name: string;
@@ -153,7 +154,7 @@ function parseFlags(args: string[]): { positional: string[]; flags: Record<strin
153
154
  const flags: Record<string, string | true> = {};
154
155
  // Boolean-only flags (never take a value argument)
155
156
  const BOOL_FLAGS = new Set([
156
- 'help', 'h', 'ids', 'stdin', 'yes', 'list', 'clear', 'set', 'animated', 'dry-run',
157
+ 'help', 'h', 'ids', 'stdin', 'yes', 'list', 'clear', 'set', 'animated', 'dry-run', 'all',
157
158
  'no-open-in-canvas', 'lock-arrange', 'unlock-arrange', 'json', 'compact', 'summary',
158
159
  'verbose', 'include-logs', 'no-pan', 'schema', 'example', 'examples', 'strict-size', 'scroll-overflow',
159
160
  ]);
@@ -1078,6 +1079,12 @@ cmd('node add', 'Add a node to the canvas', [
1078
1079
  height: 'Use a positive number, e.g. --height 280',
1079
1080
  });
1080
1081
  applyStrictSizeFlags(body, flags);
1082
+ if (type === 'trace') {
1083
+ for (const field of TRACE_NODE_FIELDS) {
1084
+ const value = getStringFlag(flags, field, field.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`));
1085
+ if (value !== undefined) body[field] = value;
1086
+ }
1087
+ }
1081
1088
 
1082
1089
  // Support --stdin for piping content
1083
1090
  if (flags.stdin) {
@@ -1308,6 +1315,11 @@ cmd('node update', 'Update a node by ID', [
1308
1315
 
1309
1316
  applyStrictSizeFlags(body, flags);
1310
1317
 
1318
+ for (const field of TRACE_NODE_FIELDS) {
1319
+ const value = getStringFlag(flags, field, field.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`));
1320
+ if (value !== undefined) body[field] = value;
1321
+ }
1322
+
1311
1323
  if (x !== undefined || y !== undefined || width !== undefined || frameHeight !== undefined || arrangeLocked !== undefined) {
1312
1324
  const existing = await api('GET', `/api/canvas/node/${encodeURIComponent(id)}`) as {
1313
1325
  position: { x: number; y: number };
@@ -1339,7 +1351,7 @@ cmd('node update', 'Update a node by ID', [
1339
1351
  if (Object.keys(body).length === 0) {
1340
1352
  die(
1341
1353
  'No updates specified',
1342
- 'Use --title, --content, --x, --y, --width, --height, --strict-size, --pinned, --lock-arrange, --unlock-arrange, or --stdin',
1354
+ 'Use --title, --content, --x, --y, --width, --height, --strict-size, --pinned, trace fields, --lock-arrange, --unlock-arrange, or --stdin',
1343
1355
  );
1344
1356
  }
1345
1357
 
@@ -1565,7 +1577,9 @@ cmd('external-app add', 'Create a hosted external app node', [
1565
1577
  title: typeof flags.title === 'string' ? flags.title : 'Excalidraw Diagram',
1566
1578
  elements: DEFAULT_EXCALIDRAW_ELEMENTS,
1567
1579
  };
1568
- const elementsJson = getStringFlag(flags, 'elements-json');
1580
+ const nodeId = getStringFlag(flags, 'node-id', 'nodeId', 'id');
1581
+ if (nodeId) body.nodeId = nodeId;
1582
+ const elementsJson = getStringFlag(flags, 'elements-json', 'elements');
1569
1583
  if (elementsJson !== undefined) body.elements = parseJsonValue(elementsJson, 'Excalidraw elements', 'Use --elements-json \'[{"type":"rectangle","id":"r1","x":0,"y":0,"width":120,"height":80}]\'');
1570
1584
  const elementsFile = getStringFlag(flags, 'elements-file', 'initial-file');
1571
1585
  if (elementsFile) body.elements = parseJsonValue(readFileSync(elementsFile, 'utf-8'), 'Excalidraw elements file', 'Use --elements-file ./scene.excalidraw');
@@ -1575,6 +1589,8 @@ cmd('external-app add', 'Create a hosted external app node', [
1575
1589
  width: 'Use a positive number, e.g. --width 960',
1576
1590
  height: 'Use a positive number, e.g. --height 720',
1577
1591
  });
1592
+ const timeoutMs = optionalPositiveFiniteFlag(flags, 'timeout-ms', 'Use a positive number, e.g. --timeout-ms 120000');
1593
+ if (timeoutMs !== undefined) body.timeoutMs = timeoutMs;
1578
1594
 
1579
1595
  const result = await api('POST', '/api/canvas/diagram', body);
1580
1596
  output(result && typeof result === 'object' && !Array.isArray(result) && 'nodeId' in result && !('id' in result)
@@ -1673,13 +1689,41 @@ cmd('snapshot save', 'Save a named snapshot of the current canvas', [
1673
1689
  });
1674
1690
 
1675
1691
  // ── snapshot list ────────────────────────────────────────────
1676
- cmd('snapshot list', 'List all saved snapshots', [
1692
+ cmd('snapshot list', 'List saved snapshots', [
1677
1693
  'pmx-canvas snapshot list',
1694
+ 'pmx-canvas snapshot list --limit 50 --query baseline',
1695
+ 'pmx-canvas snapshot list --all',
1678
1696
  ], async (args) => {
1679
1697
  const { flags } = parseFlags(args);
1680
1698
  if (flags.help || flags.h) return showCommandHelp('snapshot list');
1681
1699
 
1682
- const result = await api('GET', '/api/canvas/snapshots');
1700
+ const params = new URLSearchParams();
1701
+ const limit = optionalNumberFlag(flags, 'limit', 'Use a positive integer, e.g. --limit 50');
1702
+ const query = getStringFlag(flags, 'query', 'q');
1703
+ if (limit !== undefined) params.set('limit', String(limit));
1704
+ if (query) params.set('q', query);
1705
+ if (flags.all) params.set('all', 'true');
1706
+ const result = await api('GET', `/api/canvas/snapshots${params.size > 0 ? `?${params.toString()}` : ''}`);
1707
+ output(result);
1708
+ });
1709
+
1710
+ // ── snapshot gc ──────────────────────────────────────────────
1711
+ cmd('snapshot gc', 'Delete old snapshots, keeping the newest N', [
1712
+ 'pmx-canvas snapshot gc --keep 20 --dry-run',
1713
+ 'pmx-canvas snapshot gc --keep 50 --yes',
1714
+ ], async (args) => {
1715
+ const { flags } = parseFlags(args);
1716
+ if (flags.help || flags.h) return showCommandHelp('snapshot gc');
1717
+
1718
+ const keep = optionalNumberFlag(flags, 'keep', 'Use a positive integer, e.g. --keep 20');
1719
+ const dryRun = flags['dry-run'] === true;
1720
+ if (!dryRun && !flags.yes) {
1721
+ die('Destructive operation requires --yes flag', 'Preview with: pmx-canvas snapshot gc --keep 20 --dry-run');
1722
+ }
1723
+ const result = await api('POST', '/api/canvas/snapshots/gc', {
1724
+ ...(keep !== undefined ? { keep } : {}),
1725
+ dryRun,
1726
+ });
1683
1727
  output(result);
1684
1728
  });
1685
1729
 
@@ -2274,12 +2318,25 @@ function showCommandHelp(name: string): void {
2274
2318
  console.log('\nOutput control:');
2275
2319
  console.log(' --summary Return only validation summary metadata');
2276
2320
  }
2321
+ if (name === 'snapshot list') {
2322
+ console.log('\nOptions:');
2323
+ console.log(' --limit <number> Maximum snapshots to return (default 20)');
2324
+ console.log(' --query <text> Case-insensitive ID/name filter');
2325
+ console.log(' --all Return all snapshots');
2326
+ }
2327
+ if (name === 'snapshot gc') {
2328
+ console.log('\nOptions:');
2329
+ console.log(' --keep <number> Number of newest snapshots to keep (default 20)');
2330
+ console.log(' --dry-run Preview deletions without removing files');
2331
+ console.log(' --yes Confirm deletion');
2332
+ }
2277
2333
  if (name === 'web-artifact build') {
2278
2334
  console.log('\nDependencies:');
2279
2335
  console.log(' --deps <list> Add npm dependencies before bundling, e.g. --deps recharts,zod');
2280
2336
  console.log('\nOutput control:');
2281
2337
  console.log(' --include-logs Include raw build stdout/stderr in the response');
2282
2338
  console.log(' --verbose Alias for --include-logs');
2339
+ console.log(' --timeout-ms <number> Optional init/install/build timeout in milliseconds');
2283
2340
  }
2284
2341
  if (name === 'focus') {
2285
2342
  console.log('\nViewport:');
@@ -2303,9 +2360,12 @@ function showCommandHelp(name: string): void {
2303
2360
  console.log('\nOptions:');
2304
2361
  console.log(' --kind excalidraw External app kind to create');
2305
2362
  console.log(' --title <title> Node title');
2363
+ console.log(' --node-id <id> Existing Excalidraw app node to update in place');
2364
+ console.log(' --elements <json> Optional Excalidraw elements array JSON');
2306
2365
  console.log(' --elements-json <json> Optional Excalidraw elements array JSON');
2307
2366
  console.log(' --elements-file <path> Optional file containing Excalidraw elements JSON');
2308
2367
  console.log(' --initial-file <path> Alias for --elements-file');
2368
+ console.log(' --timeout-ms <number> Optional downstream MCP timeout for cold starts');
2309
2369
  }
2310
2370
  console.log('');
2311
2371
  }
@@ -2375,6 +2435,7 @@ History:
2375
2435
  Snapshots:
2376
2436
  pmx-canvas snapshot save --name X Save a named snapshot
2377
2437
  pmx-canvas snapshot list List snapshots
2438
+ pmx-canvas snapshot gc --keep 20 Delete old snapshots
2378
2439
  pmx-canvas snapshot restore <id> Restore from snapshot
2379
2440
  pmx-canvas snapshot diff <id> Compare current canvas to snapshot
2380
2441
  pmx-canvas snapshot delete <id> Delete a snapshot
package/src/cli/index.ts CHANGED
@@ -5,6 +5,7 @@ import { dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { runAgentCli } from './agent.js';
7
7
  import { createCanvas } from '../server/index.js';
8
+ import { seedDemoCanvas } from '../server/demo.js';
8
9
 
9
10
  const args = process.argv.slice(2);
10
11
 
@@ -594,29 +595,7 @@ Examples:
594
595
  process.exit(1);
595
596
  }
596
597
 
597
- if (demo && canvas.getLayout().nodes.length === 0) {
598
- const n1 = canvas.addNode({
599
- type: 'markdown',
600
- title: 'Welcome to PMX Canvas',
601
- content: '# PMX Canvas Workbench\n\nA spatial canvas for coding agents.\n\n## Features\n- Infinite 2D canvas with pan/zoom\n- Multiple node types\n- Edges between nodes\n- Real-time SSE updates\n- HTTP API for agent control',
602
- });
603
-
604
- const n2 = canvas.addNode({
605
- type: 'markdown',
606
- title: 'Getting Started',
607
- content: `# Quick Start\n\n\`\`\`bash\n# Add a node via CLI\npmx-canvas node add --type markdown --title "Hello" --content "# World"\n\n# List nodes\npmx-canvas node list\n\n# Get canvas state\npmx-canvas layout\n\`\`\``,
608
- });
609
-
610
- const n3 = canvas.addNode({
611
- type: 'status',
612
- title: 'Agent Status',
613
- content: 'Ready',
614
- });
615
-
616
- canvas.addEdge({ from: n1, to: n2, type: 'flow', label: 'next' });
617
- canvas.addEdge({ from: n2, to: n3, type: 'flow' });
618
- canvas.arrange('grid');
619
- }
598
+ if (demo && canvas.getLayout().nodes.length === 0) seedDemoCanvas();
620
599
 
621
600
  console.log(`\n PMX Canvas running at http://localhost:${canvas.port}`);
622
601
  console.log(` Health: http://localhost:${canvas.port}/health\n`);
@@ -5,6 +5,7 @@ import {
5
5
  closeAttentionHistory,
6
6
  openAttentionHistory,
7
7
  } from '../state/attention-store';
8
+ import { collapseDockedContextNodes, hasOpenDockedContextPanel } from '../state/canvas-store';
8
9
 
9
10
  function formatTimestamp(timestamp: number): string {
10
11
  return new Date(timestamp).toLocaleTimeString([], {
@@ -13,19 +14,31 @@ function formatTimestamp(timestamp: number): string {
13
14
  });
14
15
  }
15
16
 
17
+ function handleOpenUpdates(): void {
18
+ // Mutual exclusion with the Context panel — only one side panel open at a
19
+ // time (they share the same right-edge anchor).
20
+ collapseDockedContextNodes();
21
+ openAttentionHistory();
22
+ }
23
+
16
24
  export function AttentionHistory() {
17
25
  const entries = attentionHistory.value;
18
26
  if (entries.length === 0) return null;
19
27
 
20
28
  const isOpen = attentionHistoryOpen.value;
21
29
  const unread = attentionHistoryUnread.value;
30
+ // Hide the collapsed Updates pill while the Context side panel is open —
31
+ // the panel sits at the same right-edge and would visually cover the pill.
32
+ // Mutual exclusion guarantees both can't be expanded simultaneously, so the
33
+ // pill only needs to hide while context is expanded.
34
+ if (!isOpen && hasOpenDockedContextPanel.value) return null;
22
35
 
23
36
  if (!isOpen) {
24
37
  return (
25
38
  <button
26
39
  type="button"
27
40
  class="attention-history-tab"
28
- onClick={openAttentionHistory}
41
+ onClick={handleOpenUpdates}
29
42
  aria-label={unread > 0 ? `Recent updates — ${unread} new` : 'Recent updates'}
30
43
  title={unread > 0 ? `${unread} new updates since last viewed` : 'Recent updates'}
31
44
  >
@@ -164,7 +164,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
164
164
  window.clearTimeout(autoFitPersistTimer.current);
165
165
  }
166
166
  autoFitPersistTimer.current = window.setTimeout(() => {
167
- persistLayout();
167
+ persistLayout({ recordHistory: false });
168
168
  autoFitPersistTimer.current = null;
169
169
  }, 0);
170
170
  }
@@ -8,6 +8,7 @@ import { StatusNode } from '../nodes/StatusNode';
8
8
  import { ImageNode } from '../nodes/ImageNode';
9
9
  import { GroupNode } from '../nodes/GroupNode';
10
10
  import { WebpageNode } from '../nodes/WebpageNode';
11
+ import { HtmlNode } from '../nodes/HtmlNode';
11
12
  import { PromptNode } from '../nodes/PromptNode';
12
13
  import { ResponseNode } from '../nodes/ResponseNode';
13
14
  import { TraceNode } from '../nodes/TraceNode';
@@ -60,6 +61,8 @@ function renderNodeContent(node: CanvasNodeState) {
60
61
  return <FileNode node={node} />;
61
62
  case 'image':
62
63
  return <ImageNode node={node} />;
64
+ case 'html':
65
+ return <HtmlNode node={node} />;
63
66
  case 'group':
64
67
  return <GroupNode node={node} />;
65
68
  default:
@@ -2,6 +2,7 @@ import { ContextNode } from '../nodes/ContextNode';
2
2
  import { LedgerNode } from '../nodes/LedgerNode';
3
3
  import { StatusNode } from '../nodes/StatusNode';
4
4
  import { StatusSummary } from '../nodes/StatusSummary';
5
+ import { attentionHistoryOpen, closeAttentionHistory } from '../state/attention-store';
5
6
  import { toggleCollapsed, undockNode } from '../state/canvas-store';
6
7
  import { TYPE_LABELS } from '../types';
7
8
  import type { CanvasNodeState } from '../types';
@@ -19,20 +20,119 @@ function renderDockedContent(node: CanvasNodeState) {
19
20
  }
20
21
  }
21
22
 
23
+ function getContextItemCount(node: CanvasNodeState): number {
24
+ const cards = Array.isArray(node.data.cards) ? (node.data.cards as unknown[]) : [];
25
+ const auxTabs = Array.isArray(node.data.auxTabs) ? (node.data.auxTabs as unknown[]) : [];
26
+ return cards.length + auxTabs.length;
27
+ }
28
+
29
+ function ContextDockedNode({ node }: { node: CanvasNodeState }) {
30
+ const count = getContextItemCount(node);
31
+ const hasItems = count > 0;
32
+ const collapsed = node.collapsed === true;
33
+
34
+ const expand = () => {
35
+ // Mutual exclusion with the Updates panel — only one side panel open at a
36
+ // time. They share the same right-edge anchor, so opening both at once
37
+ // would visually collide.
38
+ closeAttentionHistory();
39
+ toggleCollapsed(node.id);
40
+ };
41
+
42
+ // Hide the collapsed Context pill while the Updates side panel is open.
43
+ // Mutual exclusion guarantees both panels can't be expanded simultaneously,
44
+ // but the pill itself would otherwise sit beneath/beside the Updates panel
45
+ // at the same right edge — better to hide until Updates is closed.
46
+ if (collapsed && attentionHistoryOpen.value) return null;
47
+
48
+ if (collapsed) {
49
+ return (
50
+ <button
51
+ type="button"
52
+ class="context-dock-tab"
53
+ data-docked-node="true"
54
+ onClick={expand}
55
+ aria-label={hasItems ? `Context — ${count} item${count === 1 ? '' : 's'}` : 'Context'}
56
+ title={hasItems ? `${count} item${count === 1 ? '' : 's'} in agent context` : 'Agent context'}
57
+ >
58
+ <svg
59
+ width="14"
60
+ height="14"
61
+ viewBox="0 0 16 16"
62
+ fill="none"
63
+ stroke="currentColor"
64
+ stroke-width="1.5"
65
+ stroke-linecap="round"
66
+ stroke-linejoin="round"
67
+ aria-hidden="true"
68
+ >
69
+ <rect x="1.5" y="2.5" width="13" height="11" rx="1.5" />
70
+ <line x1="1.5" y1="6" x2="14.5" y2="6" />
71
+ <circle cx="4" cy="4.25" r="0.6" fill="currentColor" stroke="none" />
72
+ </svg>
73
+ <span class="context-dock-tab-label">Context</span>
74
+ {hasItems && (
75
+ <span class="context-dock-tab-badge" aria-hidden="true">
76
+ {count > 99 ? '99+' : count}
77
+ </span>
78
+ )}
79
+ </button>
80
+ );
81
+ }
82
+
83
+ return (
84
+ <aside class="context-dock-panel" data-docked-node="true" aria-label="Agent context">
85
+ <div class="context-dock-header">
86
+ <div class="context-dock-header-text">
87
+ <span class="context-dock-title">Context</span>
88
+ <span class="context-dock-subtitle">
89
+ {hasItems ? `${count} item${count === 1 ? '' : 's'} in agent context` : 'Active agent context'}
90
+ </span>
91
+ </div>
92
+ <div class="context-dock-controls">
93
+ <button
94
+ type="button"
95
+ class="context-dock-icon-button"
96
+ onClick={(e) => {
97
+ e.stopPropagation();
98
+ undockNode(node.id);
99
+ }}
100
+ aria-label="Undock to canvas"
101
+ title="Undock to canvas"
102
+ >
103
+ {'\u2299'}
104
+ </button>
105
+ <button
106
+ type="button"
107
+ class="context-dock-icon-button"
108
+ onClick={(e) => {
109
+ e.stopPropagation();
110
+ toggleCollapsed(node.id);
111
+ }}
112
+ aria-label="Collapse context panel"
113
+ title="Collapse"
114
+ >
115
+ ×
116
+ </button>
117
+ </div>
118
+ </div>
119
+ <div class="context-dock-body">
120
+ <ContextNode node={node} />
121
+ </div>
122
+ </aside>
123
+ );
124
+ }
125
+
22
126
  export function DockedNode({ node }: { node: CanvasNodeState }) {
127
+ if (node.type === 'context') {
128
+ return <ContextDockedNode node={node} />;
129
+ }
130
+
23
131
  return (
24
- <div class="docked-node">
132
+ <div class="docked-node" data-docked-node="true">
25
133
  <div class="docked-node-header">
26
134
  <span class="node-type-badge">{TYPE_LABELS[node.type] ?? node.type}</span>
27
135
  {node.type === 'status' && node.collapsed && <StatusSummary node={node} />}
28
- {node.type === 'context' && node.collapsed && (
29
- <span style={{ fontSize: '11px', color: 'var(--c-muted)' }}>
30
- Active Agent Context
31
- {typeof node.data.utilization === 'number' && (
32
- <> · {Math.round(Number(node.data.utilization) * 100)}%</>
33
- )}
34
- </span>
35
- )}
36
136
  <div class="docked-node-controls">
37
137
  <button
38
138
  type="button"
@@ -57,9 +157,7 @@ export function DockedNode({ node }: { node: CanvasNodeState }) {
57
157
  </div>
58
158
  </div>
59
159
  {!node.collapsed && (
60
- <div class={`docked-node-body${node.type === 'context' ? ' context-body' : ''}`}>
61
- {renderDockedContent(node)}
62
- </div>
160
+ <div class="docked-node-body">{renderDockedContent(node)}</div>
63
161
  )}
64
162
  </div>
65
163
  );
@@ -7,6 +7,7 @@ import { McpAppNode } from '../nodes/McpAppNode';
7
7
  import { StatusNode } from '../nodes/StatusNode';
8
8
  import { ImageNode } from '../nodes/ImageNode';
9
9
  import { WebpageNode } from '../nodes/WebpageNode';
10
+ import { HtmlNode } from '../nodes/HtmlNode';
10
11
  import { PromptNode } from '../nodes/PromptNode';
11
12
  import { ResponseNode } from '../nodes/ResponseNode';
12
13
  import { TraceNode } from '../nodes/TraceNode';
@@ -26,13 +27,13 @@ function renderContent(node: CanvasNodeState, expanded: boolean) {
26
27
  case 'markdown':
27
28
  return <MarkdownNode node={node} expanded={expanded} />;
28
29
  case 'mcp-app':
29
- return <McpAppNode node={node} />;
30
+ return <McpAppNode node={node} expanded={expanded} />;
30
31
  case 'webpage':
31
32
  return <WebpageNode node={node} expanded={expanded} />;
32
33
  case 'json-render':
33
- return <McpAppNode node={node} />;
34
+ return <McpAppNode node={node} expanded={expanded} />;
34
35
  case 'graph':
35
- return <McpAppNode node={node} />;
36
+ return <McpAppNode node={node} expanded={expanded} />;
36
37
  case 'prompt':
37
38
  return <PromptNode node={node} />;
38
39
  case 'response':
@@ -49,6 +50,8 @@ function renderContent(node: CanvasNodeState, expanded: boolean) {
49
50
  return <FileNode node={node} expanded={expanded} />;
50
51
  case 'image':
51
52
  return <ImageNode node={node} expanded={expanded} />;
53
+ case 'html':
54
+ return <HtmlNode node={node} expanded={expanded} />;
52
55
  default:
53
56
  return <div>Unknown node type</div>;
54
57
  }
@@ -63,6 +66,8 @@ function getNodeTextContent(node: CanvasNodeState): string {
63
66
  return (node.data.fileContent as string) || '';
64
67
  case 'webpage':
65
68
  return (node.data.content as string) || '';
69
+ case 'html':
70
+ return (node.data.html as string) || (node.data.content as string) || '';
66
71
  case 'json-render':
67
72
  case 'graph':
68
73
  return JSON.stringify(node.data.spec ?? node.data.graphConfig ?? {}, null, 2);
@@ -37,6 +37,7 @@ function getNodeColors(): Record<CanvasNodeState['type'], string> {
37
37
  trace: t.purple,
38
38
  file: t.accent,
39
39
  image: t.ok,
40
+ html: t.warn,
40
41
  group: t.dim,
41
42
  };
42
43
  }
@@ -419,6 +419,7 @@ export function getNodeIcon(type: string): (p: IconProps) => JSX.Element {
419
419
  case 'ext-app': return IconNodeExtApp;
420
420
  case 'json-render': return IconNodeJsonRender;
421
421
  case 'graph': return IconNodeGraph;
422
+ case 'html': return IconNodeWebpage;
422
423
  default: return IconNodeMarkdown;
423
424
  }
424
425
  }
@@ -13,11 +13,6 @@ import type { CanvasNodeState } from '../types';
13
13
 
14
14
  type McpUiTheme = 'light' | 'dark';
15
15
 
16
- type IframeLoadTarget = Pick<
17
- HTMLIFrameElement,
18
- 'addEventListener' | 'removeEventListener' | 'contentDocument'
19
- >;
20
-
21
16
  type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
22
17
  type DisplayMode = 'inline' | 'fullscreen' | 'pip';
23
18
  const DEFAULT_EXT_APP_SANDBOX = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
@@ -43,21 +38,6 @@ async function postJson<T>(url: string, body: Record<string, unknown>): Promise<
43
38
  return json.result as T;
44
39
  }
45
40
 
46
- export function waitForExtAppFrameLoad(target: IframeLoadTarget): Promise<void> {
47
- const readyState = target.contentDocument?.readyState;
48
- if (readyState === 'interactive' || readyState === 'complete') {
49
- return Promise.resolve();
50
- }
51
-
52
- return new Promise<void>((resolve) => {
53
- const onLoad = () => {
54
- target.removeEventListener('load', onLoad);
55
- resolve();
56
- };
57
- target.addEventListener('load', onLoad, { once: true });
58
- });
59
- }
60
-
61
41
  export function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number): string {
62
42
  const html = typeof node.data.html === 'string' ? node.data.html : '';
63
43
  const serverName = typeof node.data.serverName === 'string' ? node.data.serverName : '';
@@ -131,7 +111,7 @@ export function shouldApplyExtAppSizeChange(height: unknown, isExpanded: boolean
131
111
  return typeof height === 'number' && Number.isFinite(height) && height > 0 && !isExpanded;
132
112
  }
133
113
 
134
- export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
114
+ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
135
115
  const iframeRef = useRef<HTMLIFrameElement>(null);
136
116
  const bridgeRef = useRef<AppBridge | null>(null);
137
117
  const transportRef = useRef<PostMessageTransport | null>(null);
@@ -164,10 +144,9 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
164
144
  const sessionError = node.data.sessionError as string | undefined;
165
145
  const maxHeight = node.size.height;
166
146
  const nodeId = node.id;
167
- const frameKey = `${node.id}:${retryKey}`;
168
- const bridgeInitKey = getExtAppBridgeInitKey(node, retryKey);
147
+ const frameKey = getExtAppBridgeInitKey(node, retryKey);
169
148
  const toMcpTheme = (theme: string): McpUiTheme => (theme === 'light' ? 'light' : 'dark');
170
- const isExpanded = expandedNodeId.value === nodeId;
149
+ const isExpanded = expanded || expandedNodeId.value === nodeId;
171
150
 
172
151
  latestToolInputRef.current = toolInput;
173
152
  latestToolResultRef.current = toolResult;
@@ -221,9 +200,9 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
221
200
  return sendPromise;
222
201
  };
223
202
 
224
- // Initialize bridge when iframe loads and HTML is available
203
+ // Initialize as soon as HTML is mounted; some apps send initialize before iframe load fires.
225
204
  useEffect(() => {
226
- if (!html) return; // Wait for HTML to arrive
205
+ if (!html) return;
227
206
  const iframe = iframeRef.current;
228
207
  if (!iframe) return;
229
208
  let disposed = false;
@@ -242,12 +221,8 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
242
221
  };
243
222
 
244
223
  const init = async () => {
245
- let contentWindow = iframe.contentWindow;
246
- if (!contentWindow) {
247
- await waitForExtAppFrameLoad(iframe);
248
- if (disposed) return;
249
- contentWindow = iframe.contentWindow;
250
- }
224
+ if (!html) return;
225
+ const contentWindow = iframe.contentWindow;
251
226
  if (!contentWindow) {
252
227
  throw new Error('Ext-app iframe window is unavailable');
253
228
  }
@@ -397,8 +372,8 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
397
372
  bridgeReadyRef.current = true;
398
373
  setStatus('ready');
399
374
  setError(null);
400
- scheduleHostContextUpdate();
401
- void sendExtAppBootstrapState(bridge, latestToolInputRef.current, undefined)
375
+ void Promise.resolve(bridge.sendHostContextChange(buildHostContext(isExpanded ? 'fullscreen' : 'inline')))
376
+ .then(() => sendExtAppBootstrapState(bridge, latestToolInputRef.current, undefined))
402
377
  .then(() => flushToolResult(bridge))
403
378
  .catch((err) => {
404
379
  const msg = err instanceof Error ? err.message : String(err);
@@ -481,7 +456,7 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
481
456
  transportRef.current = null;
482
457
  }
483
458
  };
484
- }, [bridgeInitKey]);
459
+ }, [frameKey]);
485
460
 
486
461
  // Forward tool result when it arrives after bridge is ready
487
462
  useEffect(() => {