pmx-canvas 0.1.12 → 0.1.14

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 (32) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/dist/canvas/index.js +42 -42
  3. package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -3
  4. package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
  5. package/dist/types/client/nodes/trace-model.d.ts +9 -0
  6. package/dist/types/client/state/canvas-store.d.ts +2 -0
  7. package/dist/types/mcp/canvas-access.d.ts +1 -0
  8. package/dist/types/server/canvas-operations.d.ts +8 -0
  9. package/dist/types/server/diagram-presets.d.ts +4 -0
  10. package/dist/types/server/index.d.ts +8 -0
  11. package/dist/types/server/mcp-app-runtime.d.ts +1 -0
  12. package/dist/types/server/web-artifacts.d.ts +1 -0
  13. package/package.json +1 -1
  14. package/skills/web-artifacts-builder/scripts/init-artifact.sh +9 -8
  15. package/src/cli/agent.ts +15 -1
  16. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -3
  17. package/src/client/nodes/ExtAppFrame.tsx +10 -35
  18. package/src/client/nodes/McpAppNode.tsx +2 -2
  19. package/src/client/nodes/TraceNode.tsx +2 -6
  20. package/src/client/nodes/trace-model.ts +19 -0
  21. package/src/client/state/canvas-store.ts +5 -2
  22. package/src/client/state/sse-bridge.ts +3 -1
  23. package/src/mcp/canvas-access.ts +28 -3
  24. package/src/mcp/server.ts +51 -9
  25. package/src/server/canvas-operations.ts +36 -0
  26. package/src/server/canvas-schema.ts +11 -0
  27. package/src/server/diagram-presets.ts +44 -46
  28. package/src/server/index.ts +31 -4
  29. package/src/server/mcp-app-runtime.ts +15 -5
  30. package/src/server/server.ts +96 -50
  31. package/src/server/web-artifacts/scripts/init-artifact.sh +9 -8
  32. package/src/server/web-artifacts.ts +14 -1
@@ -1,7 +1,6 @@
1
1
  import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
2
  import { AppBridge } from '@modelcontextprotocol/ext-apps/app-bridge';
3
3
  import type { CanvasNodeState } from '../types';
4
- type IframeLoadTarget = Pick<HTMLIFrameElement, 'addEventListener' | 'removeEventListener' | 'contentDocument'>;
5
4
  type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
6
5
  type DisplayMode = 'inline' | 'fullscreen' | 'pip';
7
6
  interface ExtAppHostDimensionsTarget {
@@ -9,7 +8,6 @@ interface ExtAppHostDimensionsTarget {
9
8
  clientHeight?: number;
10
9
  getBoundingClientRect(): Pick<DOMRectReadOnly, 'width' | 'height'>;
11
10
  }
12
- export declare function waitForExtAppFrameLoad(target: IframeLoadTarget): Promise<void>;
13
11
  export declare function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number): string;
14
12
  export declare function resolveExtAppDisplayModeRequest(requestedMode: DisplayMode, isExpanded: boolean): {
15
13
  nextMode: DisplayMode;
@@ -26,7 +24,8 @@ export declare function resolveExtAppContainerDimensions(target: ExtAppHostDimen
26
24
  height: number;
27
25
  };
28
26
  export declare function shouldApplyExtAppSizeChange(height: unknown, isExpanded: boolean): height is number;
29
- export declare function ExtAppFrame({ node }: {
27
+ export declare function ExtAppFrame({ node, expanded }: {
30
28
  node: CanvasNodeState;
29
+ expanded?: boolean;
31
30
  }): import("preact/jsx-runtime").JSX.Element;
32
31
  export {};
@@ -1,4 +1,5 @@
1
1
  import type { CanvasNodeState } from '../types';
2
- export declare function McpAppNode({ node }: {
2
+ export declare function McpAppNode({ node, expanded }: {
3
3
  node: CanvasNodeState;
4
+ expanded?: boolean;
4
5
  }): import("preact/src").JSX.Element;
@@ -0,0 +1,9 @@
1
+ export interface TraceDisplayModel {
2
+ toolName: string;
3
+ category: string;
4
+ status: string;
5
+ duration: string;
6
+ resultSummary: string;
7
+ error: string;
8
+ }
9
+ export declare function buildTraceDisplayModel(data: Record<string, unknown>): TraceDisplayModel;
@@ -54,6 +54,8 @@ export declare function replaceViewport(next: ViewportState): void;
54
54
  export declare function commitViewport(next: ViewportState): void;
55
55
  export declare function applyServerCanvasLayout(layout: Pick<CanvasLayout, 'nodes' | 'edges'> & {
56
56
  viewport?: ViewportState;
57
+ }, options?: {
58
+ applyViewport?: boolean;
57
59
  }): void;
58
60
  /**
59
61
  * Smoothly animate the viewport to a target state.
@@ -83,5 +83,6 @@ export interface CanvasAccess {
83
83
  resizeAutomationWebView(width: number, height: number): Promise<AutomationWebViewStatus>;
84
84
  screenshotAutomationWebView(options?: AutomationScreenshotOptions): Promise<Uint8Array>;
85
85
  }
86
+ export declare function refreshCanvasAccess(access: CanvasAccess): Promise<CanvasAccess>;
86
87
  export declare function createCanvasAccess(): Promise<CanvasAccess>;
87
88
  export {};
@@ -40,6 +40,12 @@ interface CanvasAddNodeInput {
40
40
  title?: string;
41
41
  content?: string;
42
42
  data?: Record<string, unknown>;
43
+ toolName?: string;
44
+ category?: string;
45
+ status?: string;
46
+ duration?: string;
47
+ resultSummary?: string;
48
+ error?: string;
43
49
  x?: number;
44
50
  y?: number;
45
51
  width?: number;
@@ -106,6 +112,8 @@ export declare function validateCanvasNodePatch(patch: {
106
112
  height: number;
107
113
  };
108
114
  }): string | null;
115
+ export declare function mergeTraceNodeDataFields(base: Record<string, unknown>, input: Record<string, unknown>): Record<string, unknown>;
116
+ export declare function hasTraceNodeDataFields(input: Record<string, unknown>): boolean;
109
117
  export declare function scheduleCodeGraphRecompute(onComplete?: () => void): void;
110
118
  export declare function addCanvasNode(input: CanvasAddNodeInput): {
111
119
  id: string;
@@ -9,11 +9,13 @@ export declare const DEFAULT_EXCALIDRAW_ELEMENTS: ReadonlyArray<Record<string, u
9
9
  export declare const EXCALIDRAW_MCP_TRANSPORT: ExternalMcpTransportConfig;
10
10
  export interface DiagramPresetOpenInput {
11
11
  elements: unknown;
12
+ nodeId?: string;
12
13
  title?: string;
13
14
  x?: number;
14
15
  y?: number;
15
16
  width?: number;
16
17
  height?: number;
18
+ timeoutMs?: number;
17
19
  }
18
20
  export interface ExcalidrawOpenMcpAppInput {
19
21
  transport: ExternalMcpTransportConfig;
@@ -22,11 +24,13 @@ export interface ExcalidrawOpenMcpAppInput {
22
24
  toolArguments: {
23
25
  elements: string;
24
26
  };
27
+ nodeId?: string;
25
28
  title?: string;
26
29
  x?: number;
27
30
  y?: number;
28
31
  width?: number;
29
32
  height?: number;
33
+ timeoutMs?: number;
30
34
  }
31
35
  export declare function inferExcalidrawCameraUpdate(elements: Array<Record<string, unknown>>): Record<string, unknown> | null;
32
36
  export declare function normalizeExcalidrawElements(elements: unknown): string;
@@ -23,6 +23,12 @@ export declare class PmxCanvas extends EventEmitter {
23
23
  type: CanvasNodeState['type'];
24
24
  title?: string;
25
25
  content?: string;
26
+ toolName?: string;
27
+ category?: string;
28
+ status?: string;
29
+ duration?: string;
30
+ resultSummary?: string;
31
+ error?: string;
26
32
  x?: number;
27
33
  y?: number;
28
34
  width?: number;
@@ -198,12 +204,14 @@ export declare class PmxCanvas extends EventEmitter {
198
204
  transport: ExternalMcpTransportConfig;
199
205
  toolName: string;
200
206
  toolArguments?: Record<string, unknown>;
207
+ nodeId?: string;
201
208
  serverName?: string;
202
209
  title?: string;
203
210
  x?: number;
204
211
  y?: number;
205
212
  width?: number;
206
213
  height?: number;
214
+ timeoutMs?: number;
207
215
  }): Promise<{
208
216
  ok: true;
209
217
  id?: string;
@@ -18,6 +18,7 @@ export interface OpenMcpAppInput {
18
18
  toolName: string;
19
19
  toolArguments?: Record<string, unknown>;
20
20
  serverName?: string;
21
+ timeoutMs?: number;
21
22
  }
22
23
  export interface OpenMcpAppResult {
23
24
  sessionId: string;
@@ -38,6 +38,7 @@ export interface WebArtifactCanvasBuildResult extends WebArtifactBuildOutput {
38
38
  openedInCanvas: boolean;
39
39
  nodeId?: string;
40
40
  url?: string;
41
+ completedAt: string;
41
42
  }
42
43
  export declare function resolveWorkspacePath(pathLike: string, cwd?: string): string;
43
44
  export declare function resolveWebArtifactScriptPath(kind: 'init' | 'bundle'): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
5
5
  "type": "module",
6
6
  "main": "./src/server/index.ts",
@@ -115,12 +115,13 @@ else
115
115
  echo "✅ Using Vite $VITE_VERSION (Node 18 compatible)"
116
116
  fi
117
117
 
118
- # Detect OS and set sed syntax
119
- if [[ "$OSTYPE" == "darwin"* ]]; then
120
- SED_INPLACE="sed -i ''"
121
- else
122
- SED_INPLACE="sed -i"
123
- fi
118
+ function sed_in_place() {
119
+ if [[ "$OSTYPE" == "darwin"* ]]; then
120
+ sed -i '' "$@"
121
+ else
122
+ sed -i "$@"
123
+ fi
124
+ }
124
125
 
125
126
  declare -a PNPM_CMD
126
127
  configure_pnpm
@@ -158,8 +159,8 @@ fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
158
159
  "
159
160
 
160
161
  echo "🧹 Cleaning up Vite template..."
161
- $SED_INPLACE '/<link rel="icon".*/d' index.html
162
- $SED_INPLACE 's/<title>.*<\/title>/<title>'"$PROJECT_NAME"'<\/title>/' index.html
162
+ sed_in_place '/<link rel="icon".*/d' index.html
163
+ sed_in_place 's/<title>.*<\/title>/<title>'"$PROJECT_NAME"'<\/title>/' index.html
163
164
 
164
165
  echo "📦 Installing base dependencies..."
165
166
  run_pnpm_quiet install
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;
@@ -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);
1085
+ if (value !== undefined) body[field] = value;
1086
+ }
1087
+ }
1081
1088
 
1082
1089
  // Support --stdin for piping content
1083
1090
  if (flags.stdin) {
@@ -1565,7 +1572,9 @@ cmd('external-app add', 'Create a hosted external app node', [
1565
1572
  title: typeof flags.title === 'string' ? flags.title : 'Excalidraw Diagram',
1566
1573
  elements: DEFAULT_EXCALIDRAW_ELEMENTS,
1567
1574
  };
1568
- const elementsJson = getStringFlag(flags, 'elements-json');
1575
+ const nodeId = getStringFlag(flags, 'node-id', 'nodeId', 'id');
1576
+ if (nodeId) body.nodeId = nodeId;
1577
+ const elementsJson = getStringFlag(flags, 'elements-json', 'elements');
1569
1578
  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
1579
  const elementsFile = getStringFlag(flags, 'elements-file', 'initial-file');
1571
1580
  if (elementsFile) body.elements = parseJsonValue(readFileSync(elementsFile, 'utf-8'), 'Excalidraw elements file', 'Use --elements-file ./scene.excalidraw');
@@ -1575,6 +1584,8 @@ cmd('external-app add', 'Create a hosted external app node', [
1575
1584
  width: 'Use a positive number, e.g. --width 960',
1576
1585
  height: 'Use a positive number, e.g. --height 720',
1577
1586
  });
1587
+ const timeoutMs = optionalPositiveFiniteFlag(flags, 'timeout-ms', 'Use a positive number, e.g. --timeout-ms 120000');
1588
+ if (timeoutMs !== undefined) body.timeoutMs = timeoutMs;
1578
1589
 
1579
1590
  const result = await api('POST', '/api/canvas/diagram', body);
1580
1591
  output(result && typeof result === 'object' && !Array.isArray(result) && 'nodeId' in result && !('id' in result)
@@ -2303,9 +2314,12 @@ function showCommandHelp(name: string): void {
2303
2314
  console.log('\nOptions:');
2304
2315
  console.log(' --kind excalidraw External app kind to create');
2305
2316
  console.log(' --title <title> Node title');
2317
+ console.log(' --node-id <id> Existing Excalidraw app node to update in place');
2318
+ console.log(' --elements <json> Optional Excalidraw elements array JSON');
2306
2319
  console.log(' --elements-json <json> Optional Excalidraw elements array JSON');
2307
2320
  console.log(' --elements-file <path> Optional file containing Excalidraw elements JSON');
2308
2321
  console.log(' --initial-file <path> Alias for --elements-file');
2322
+ console.log(' --timeout-ms <number> Optional downstream MCP timeout for cold starts');
2309
2323
  }
2310
2324
  console.log('');
2311
2325
  }
@@ -26,13 +26,13 @@ function renderContent(node: CanvasNodeState, expanded: boolean) {
26
26
  case 'markdown':
27
27
  return <MarkdownNode node={node} expanded={expanded} />;
28
28
  case 'mcp-app':
29
- return <McpAppNode node={node} />;
29
+ return <McpAppNode node={node} expanded={expanded} />;
30
30
  case 'webpage':
31
31
  return <WebpageNode node={node} expanded={expanded} />;
32
32
  case 'json-render':
33
- return <McpAppNode node={node} />;
33
+ return <McpAppNode node={node} expanded={expanded} />;
34
34
  case 'graph':
35
- return <McpAppNode node={node} />;
35
+ return <McpAppNode node={node} expanded={expanded} />;
36
36
  case 'prompt':
37
37
  return <PromptNode node={node} />;
38
38
  case 'response':
@@ -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(() => {
@@ -13,9 +13,9 @@ function withTheme(url: string): string {
13
13
  }
14
14
  }
15
15
 
16
- export function McpAppNode({ node }: { node: CanvasNodeState }) {
16
+ export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
17
17
  if (node.data.mode === 'ext-app') {
18
- return <ExtAppFrame node={node} />;
18
+ return <ExtAppFrame node={node} expanded={expanded} />;
19
19
  }
20
20
 
21
21
  const url = withTheme((node.data.url as string) || '');
@@ -1,4 +1,5 @@
1
1
  import type { CanvasNodeState } from '../types';
2
+ import { buildTraceDisplayModel } from './trace-model';
2
3
 
3
4
  const CATEGORY_COLORS: Record<string, string> = {
4
5
  mcp: 'var(--c-accent)',
@@ -20,12 +21,7 @@ const STATUS_COLORS: Record<string, string> = {
20
21
  };
21
22
 
22
23
  export function TraceNode({ node }: { node: CanvasNodeState }) {
23
- const toolName = (node.data.toolName as string) || 'unknown';
24
- const category = (node.data.category as string) || 'other';
25
- const status = (node.data.status as string) || 'running';
26
- const duration = (node.data.duration as string) || '';
27
- const resultSummary = (node.data.resultSummary as string) || '';
28
- const error = (node.data.error as string) || '';
24
+ const { toolName, category, status, duration, resultSummary, error } = buildTraceDisplayModel(node.data);
29
25
 
30
26
  const catColor = CATEGORY_COLORS[category] ?? CATEGORY_COLORS.other;
31
27
  const statusIcon = STATUS_ICONS[status] ?? '◌';
@@ -0,0 +1,19 @@
1
+ export interface TraceDisplayModel {
2
+ toolName: string;
3
+ category: string;
4
+ status: string;
5
+ duration: string;
6
+ resultSummary: string;
7
+ error: string;
8
+ }
9
+
10
+ export function buildTraceDisplayModel(data: Record<string, unknown>): TraceDisplayModel {
11
+ return {
12
+ toolName: (data.toolName as string) || (data.title as string) || 'unknown',
13
+ category: (data.category as string) || 'other',
14
+ status: (data.status as string) || 'running',
15
+ duration: (data.duration as string) || '',
16
+ resultSummary: (data.resultSummary as string) || (data.content as string) || '',
17
+ error: (data.error as string) || '',
18
+ };
19
+ }
@@ -313,7 +313,10 @@ export function commitViewport(next: ViewportState): void {
313
313
  void updateViewportFromClient(next);
314
314
  }
315
315
 
316
- export function applyServerCanvasLayout(layout: Pick<CanvasLayout, 'nodes' | 'edges'> & { viewport?: ViewportState }): void {
316
+ export function applyServerCanvasLayout(
317
+ layout: Pick<CanvasLayout, 'nodes' | 'edges'> & { viewport?: ViewportState },
318
+ options: { applyViewport?: boolean } = {},
319
+ ): void {
317
320
  const nextNodes = new Map<string, CanvasNodeState>();
318
321
  let nextMaxZ = 1;
319
322
  for (const node of layout.nodes) {
@@ -337,7 +340,7 @@ export function applyServerCanvasLayout(layout: Pick<CanvasLayout, 'nodes' | 'ed
337
340
  const nextContextPinnedNodeIds = filterNodeIdSet(contextPinnedNodeIds.value, nextNodes);
338
341
 
339
342
  batch(() => {
340
- if (layout.viewport) {
343
+ if (options.applyViewport === true && layout.viewport) {
341
344
  viewport.value = layout.viewport;
342
345
  }
343
346
  maxZ = nextMaxZ;
@@ -529,6 +529,7 @@ function handleExtAppOpen(data: Record<string, unknown>): void {
529
529
  if (typeof data.toolCallId !== 'string' || !data.toolCallId) return;
530
530
  ensureExtAppNode({
531
531
  toolCallId: data.toolCallId,
532
+ ...(typeof data.nodeId === 'string' && data.nodeId.length > 0 ? { nodeId: data.nodeId } : {}),
532
533
  title: data.title,
533
534
  html: data.html,
534
535
  toolInput: data.toolInput,
@@ -803,6 +804,7 @@ function handleCanvasLayoutUpdate(data: Record<string, unknown>): void {
803
804
  }
804
805
  | undefined;
805
806
  if (!layout?.nodes) return;
807
+ const shouldApplyViewport = !hasInitialServerLayout.value;
806
808
  hasInitialServerLayout.value = true;
807
809
 
808
810
  const serverNodes = layout.nodes
@@ -824,7 +826,7 @@ function handleCanvasLayoutUpdate(data: Record<string, unknown>): void {
824
826
  ...(nextViewport ? { viewport: nextViewport } : {}),
825
827
  nodes: serverNodes,
826
828
  edges: serverEdges,
827
- });
829
+ }, { applyViewport: shouldApplyViewport });
828
830
 
829
831
  syncAttentionFromSse({ event: 'canvas-layout-update', data });
830
832
  }
@@ -135,7 +135,11 @@ export interface CanvasAccess {
135
135
  class LocalCanvasAccess implements CanvasAccess {
136
136
  readonly remoteBaseUrl = null;
137
137
 
138
- constructor(private readonly canvas: PmxCanvas) {}
138
+ constructor(
139
+ private readonly canvas: PmxCanvas,
140
+ readonly workspaceRoot: string,
141
+ readonly targetPort: number,
142
+ ) {}
139
143
 
140
144
  get port(): number {
141
145
  return this.canvas.port;
@@ -335,6 +339,9 @@ class RemoteCanvasAccess implements CanvasAccess {
335
339
  const error = parsed && typeof parsed === 'object' && 'error' in parsed
336
340
  ? String((parsed as { error?: unknown }).error)
337
341
  : `HTTP ${response.status}`;
342
+ if (path === '/api/canvas/batch' && parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
343
+ return parsed as T;
344
+ }
338
345
  throw new Error(error);
339
346
  }
340
347
  return parsed as T;
@@ -617,6 +624,10 @@ function candidateBaseUrls(port: number): string[] {
617
624
  return urls;
618
625
  }
619
626
 
627
+ function localBaseUrls(port: number): string[] {
628
+ return [`http://127.0.0.1:${port}`, `http://localhost:${port}`];
629
+ }
630
+
620
631
  async function readHealth(baseUrl: string): Promise<HealthResponse | null> {
621
632
  try {
622
633
  const response = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(400) });
@@ -627,9 +638,15 @@ async function readHealth(baseUrl: string): Promise<HealthResponse | null> {
627
638
  }
628
639
  }
629
640
 
630
- async function findExistingCanvasServer(workspaceRoot: string, port: number): Promise<string | null> {
641
+ async function findExistingCanvasServer(
642
+ workspaceRoot: string,
643
+ port: number,
644
+ options: { excludeBaseUrls?: string[] } = {},
645
+ ): Promise<string | null> {
631
646
  const canonicalWorkspaceRoot = canonicalWorkspacePath(workspaceRoot);
647
+ const excluded = new Set((options.excludeBaseUrls ?? []).map((baseUrl) => baseUrl.replace(/\/$/, '')));
632
648
  for (const baseUrl of candidateBaseUrls(port)) {
649
+ if (excluded.has(baseUrl)) continue;
633
650
  const health = await readHealth(baseUrl);
634
651
  if (health?.ok !== true) continue;
635
652
  const healthWorkspace = typeof health.workspace === 'string' ? canonicalWorkspacePath(health.workspace) : '';
@@ -639,6 +656,14 @@ async function findExistingCanvasServer(workspaceRoot: string, port: number): Pr
639
656
  return null;
640
657
  }
641
658
 
659
+ export async function refreshCanvasAccess(access: CanvasAccess): Promise<CanvasAccess> {
660
+ if (!(access instanceof LocalCanvasAccess)) return access;
661
+ const remoteBaseUrl = await findExistingCanvasServer(access.workspaceRoot, access.targetPort, {
662
+ excludeBaseUrls: localBaseUrls(access.port),
663
+ });
664
+ return remoteBaseUrl ? new RemoteCanvasAccess(remoteBaseUrl) : access;
665
+ }
666
+
642
667
  export async function createCanvasAccess(): Promise<CanvasAccess> {
643
668
  const workspaceRoot = resolve(process.cwd());
644
669
  const port = targetPort();
@@ -647,5 +672,5 @@ export async function createCanvasAccess(): Promise<CanvasAccess> {
647
672
 
648
673
  const canvas = createCanvas({ port });
649
674
  await canvas.start({ open: true });
650
- return new LocalCanvasAccess(canvas);
675
+ return new LocalCanvasAccess(canvas, workspaceRoot, port);
651
676
  }