pmx-canvas 0.1.2 → 0.1.3

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.
@@ -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 && (
@@ -1,5 +1,5 @@
1
1
  import { batch, computed, signal } from '@preact/signals';
2
- import type { CanvasEdge, CanvasLayout, CanvasNodeState, ConnectionStatus, ViewportState } from '../types';
2
+ import { isExcalidrawNode, type CanvasEdge, type CanvasLayout, type CanvasNodeState, type ConnectionStatus, type ViewportState } from '../types';
3
3
  import { computeAutoArrange } from '../../shared/auto-arrange';
4
4
  import { pushCanvasUpdate, updateViewportFromClient } from './intent-bridge';
5
5
 
@@ -22,6 +22,11 @@ export const hasInitialServerLayout = signal<boolean>(false);
22
22
  // Only one node at a time can be in expanded/focus mode. When expanded, the
23
23
  // node renders as a full-viewport overlay for deep editing/reading.
24
24
  export const expandedNodeId = signal<string | null>(null);
25
+ export const pendingExpandedNodeCloseId = signal<string | null>(null);
26
+ let expandedCloseTimer: ReturnType<typeof setTimeout> | null = null;
27
+ let pendingCloseInitialCheckpointAt: unknown = undefined;
28
+ const EXCALIDRAW_CLOSE_POLL_MS = 100;
29
+ const EXCALIDRAW_CLOSE_MAX_WAIT_MS = 2500;
25
30
 
26
31
  // ── Pending edge connection (for context menu "Connect from") ─
27
32
  export const pendingConnection = signal<{ from: string } | null>(null);
@@ -192,6 +197,14 @@ export function updateNode(id: string, patch: Partial<CanvasNodeState>): void {
192
197
  }
193
198
  next.set(id, { ...existing, ...patch });
194
199
  nodes.value = next;
200
+ const updatedAt = (next.get(id)?.data.appCheckpoint as { updatedAt?: unknown } | undefined)?.updatedAt;
201
+ if (
202
+ pendingExpandedNodeCloseId.value === id &&
203
+ updatedAt !== undefined &&
204
+ updatedAt !== pendingCloseInitialCheckpointAt
205
+ ) {
206
+ finishExpandedNodeClose(id);
207
+ }
195
208
  }
196
209
 
197
210
  export function updateNodeData(id: string, dataPatch: Record<string, unknown>): void {
@@ -585,11 +598,60 @@ export function walkGraph(direction: 'up' | 'down' | 'left' | 'right'): void {
585
598
  export function expandNode(id: string): void {
586
599
  const node = nodes.value.get(id);
587
600
  if (!node) return;
601
+ if (expandedCloseTimer !== null) {
602
+ clearTimeout(expandedCloseTimer);
603
+ expandedCloseTimer = null;
604
+ }
605
+ pendingExpandedNodeCloseId.value = null;
606
+ pendingCloseInitialCheckpointAt = undefined;
588
607
  bringToFront(id);
589
608
  expandedNodeId.value = id;
590
609
  }
591
610
 
611
+ function finishExpandedNodeClose(nodeId: string): void {
612
+ if (expandedCloseTimer !== null) {
613
+ clearTimeout(expandedCloseTimer);
614
+ expandedCloseTimer = null;
615
+ }
616
+ if (expandedNodeId.value === nodeId) expandedNodeId.value = null;
617
+ if (pendingExpandedNodeCloseId.value === nodeId) pendingExpandedNodeCloseId.value = null;
618
+ pendingCloseInitialCheckpointAt = undefined;
619
+ }
620
+
592
621
  export function collapseExpandedNode(): void {
622
+ const nodeId = expandedNodeId.value;
623
+ const node = nodeId ? nodes.value.get(nodeId) : undefined;
624
+ if (nodeId && node && isExcalidrawNode(node)) {
625
+ const closingNodeId = nodeId;
626
+ const startedAt = Date.now();
627
+ pendingExpandedNodeCloseId.value = closingNodeId;
628
+ pendingCloseInitialCheckpointAt = (node.data.appCheckpoint as { updatedAt?: unknown } | undefined)?.updatedAt;
629
+ if (expandedCloseTimer !== null) clearTimeout(expandedCloseTimer);
630
+ const pollForSave = () => {
631
+ const latestNode = nodes.value.get(closingNodeId);
632
+ const latestCheckpointAt = (latestNode?.data.appCheckpoint as { updatedAt?: unknown } | undefined)?.updatedAt;
633
+ if (
634
+ latestCheckpointAt !== undefined &&
635
+ latestCheckpointAt !== pendingCloseInitialCheckpointAt
636
+ ) {
637
+ finishExpandedNodeClose(closingNodeId);
638
+ return;
639
+ }
640
+ if (Date.now() - startedAt >= EXCALIDRAW_CLOSE_MAX_WAIT_MS) {
641
+ finishExpandedNodeClose(closingNodeId);
642
+ return;
643
+ }
644
+ expandedCloseTimer = setTimeout(pollForSave, EXCALIDRAW_CLOSE_POLL_MS);
645
+ };
646
+ expandedCloseTimer = setTimeout(pollForSave, EXCALIDRAW_CLOSE_POLL_MS);
647
+ return;
648
+ }
649
+ if (expandedCloseTimer !== null) {
650
+ clearTimeout(expandedCloseTimer);
651
+ expandedCloseTimer = null;
652
+ }
653
+ pendingExpandedNodeCloseId.value = null;
654
+ pendingCloseInitialCheckpointAt = undefined;
593
655
  expandedNodeId.value = null;
594
656
  }
595
657
 
@@ -6,6 +6,7 @@ import {
6
6
  addEdge,
7
7
  addNode,
8
8
  applyServerCanvasLayout,
9
+ bringToFront,
9
10
  cancelViewportAnimation,
10
11
  canvasTheme,
11
12
  connectionStatus,
@@ -827,6 +828,10 @@ function reconnectDelayMs(attempt: number): number {
827
828
  function handleCanvasFocusNode(data: Record<string, unknown>): void {
828
829
  const nodeId = data.nodeId as string;
829
830
  if (nodeId && nodes.value.has(nodeId)) {
831
+ if (data.noPan === true) {
832
+ bringToFront(nodeId);
833
+ return;
834
+ }
830
835
  focusNode(nodeId);
831
836
  }
832
837
  }
@@ -74,6 +74,18 @@ export const EXPANDABLE_TYPES = new Set<CanvasNodeState['type']>([
74
74
  'image',
75
75
  ]);
76
76
 
77
+ export const EXCALIDRAW_SERVER_NAME = 'Excalidraw';
78
+ export const EXCALIDRAW_CREATE_VIEW_TOOL = 'create_view';
79
+
80
+ export function isExcalidrawNode(node: CanvasNodeState): boolean {
81
+ return (
82
+ node.type === 'mcp-app' &&
83
+ node.data.mode === 'ext-app' &&
84
+ node.data.serverName === EXCALIDRAW_SERVER_NAME &&
85
+ node.data.toolName === EXCALIDRAW_CREATE_VIEW_TOOL
86
+ );
87
+ }
88
+
77
89
  export interface CanvasLayout {
78
90
  viewport: ViewportState;
79
91
  nodes: CanvasNodeState[];
package/src/mcp/server.ts CHANGED
@@ -348,6 +348,7 @@ export async function startMcpServer(): Promise<void> {
348
348
  mainTsx: z.string().optional().describe('Optional contents for src/main.tsx'),
349
349
  indexHtml: z.string().optional().describe('Optional contents for index.html'),
350
350
  files: z.record(z.string(), z.string()).optional().describe('Optional map of additional project-relative file paths to file contents'),
351
+ deps: z.array(z.string()).optional().describe('Optional npm dependencies to install before bundling (e.g. ["recharts", "framer-motion@^11"]). Validated against npm-name format; flags and shell metacharacters are rejected.'),
351
352
  projectPath: z.string().optional().describe('Optional workspace-relative reusable project path. Defaults to .pmx-canvas/artifacts/.web-artifacts/<slug>'),
352
353
  outputPath: z.string().optional().describe('Optional workspace-relative HTML output path. Defaults to .pmx-canvas/artifacts/<slug>.html'),
353
354
  openInCanvas: z.boolean().optional().describe('Open the generated artifact in canvas after build (default true)'),
@@ -366,6 +367,7 @@ export async function startMcpServer(): Promise<void> {
366
367
  ...(typeof input.mainTsx === 'string' ? { mainTsx: input.mainTsx } : {}),
367
368
  ...(typeof input.indexHtml === 'string' ? { indexHtml: input.indexHtml } : {}),
368
369
  ...(input.files ? { files: input.files } : {}),
370
+ ...(Array.isArray(input.deps) ? { deps: input.deps } : {}),
369
371
  ...(typeof input.projectPath === 'string'
370
372
  ? { projectPath: safeWorkspacePath(input.projectPath) }
371
373
  : {}),
@@ -672,13 +674,34 @@ export async function startMcpServer(): Promise<void> {
672
674
  // ── canvas_focus_node ──────────────────────────────────────────
673
675
  server.tool(
674
676
  'canvas_focus_node',
675
- 'Pan the viewport to center on a specific node.',
676
- { id: z.string().describe('Node ID to focus on') },
677
- async ({ id }) => {
677
+ 'Bring a node into focus. By default the viewport pans so the node is centered. Pass noPan=true to raise/select the node without moving the human\'s camera (useful when reacting to background events without disrupting the human\'s current view).',
678
+ {
679
+ id: z.string().describe('Node ID to focus on'),
680
+ noPan: z
681
+ .boolean()
682
+ .optional()
683
+ .describe('If true, raise/select the node without panning the viewport. Default false.'),
684
+ },
685
+ async ({ id, noPan }) => {
678
686
  const c = await ensureCanvas();
679
- c.focusNode(id);
687
+ const result = c.focusNode(id, { ...(noPan === true ? { noPan: true } : {}) });
688
+ if (!result) {
689
+ return {
690
+ content: [
691
+ {
692
+ type: 'text',
693
+ text: JSON.stringify({ ok: false, error: `Node "${id}" not found.` }),
694
+ },
695
+ ],
696
+ };
697
+ }
680
698
  return {
681
- content: [{ type: 'text', text: JSON.stringify({ ok: true, focused: id }) }],
699
+ content: [
700
+ {
701
+ type: 'text',
702
+ text: JSON.stringify({ ok: true, focused: result.focused, panned: result.panned }),
703
+ },
704
+ ],
682
705
  };
683
706
  },
684
707
  );
@@ -36,6 +36,7 @@ import {
36
36
  getWebpageFetchErrorDetails,
37
37
  normalizeWebpageUrl,
38
38
  } from './webpage-node.js';
39
+ import { buildExcalidrawRestoreCheckpointToolInput, ensureExcalidrawCheckpointId, isExcalidrawCreateView } from './diagram-presets.js';
39
40
 
40
41
  export type CanvasArrangeMode = 'grid' | 'column' | 'flow';
41
42
  export type CanvasPinMode = 'set' | 'add' | 'remove';
@@ -82,6 +83,29 @@ function isRecord(value: unknown): value is Record<string, unknown> {
82
83
  return value !== null && typeof value === 'object' && !Array.isArray(value);
83
84
  }
84
85
 
86
+ function getStoredExcalidrawCheckpointId(node: CanvasNodeState): string | null {
87
+ const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
88
+ const checkpointId = appCheckpoint?.id;
89
+ return typeof checkpointId === 'string' && checkpointId.trim().length > 0 ? checkpointId.trim() : null;
90
+ }
91
+
92
+ function resolveExtAppRehydratedToolInput(
93
+ node: CanvasNodeState,
94
+ openedToolInput: Record<string, unknown>,
95
+ ): Record<string, unknown> {
96
+ if (!isExcalidrawCreateView(node.data.serverName, node.data.toolName)) return openedToolInput;
97
+ const checkpointId = getStoredExcalidrawCheckpointId(node);
98
+ if (!checkpointId) return openedToolInput;
99
+ const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
100
+ return {
101
+ ...openedToolInput,
102
+ elements: buildExcalidrawRestoreCheckpointToolInput(
103
+ checkpointId,
104
+ typeof appCheckpoint?.data === 'string' ? appCheckpoint.data : undefined,
105
+ ),
106
+ };
107
+ }
108
+
85
109
  function isExtAppNode(node: CanvasNodeState | undefined): node is CanvasNodeState {
86
110
  return node?.type === 'mcp-app' && node.data.mode === 'ext-app';
87
111
  }
@@ -280,13 +304,18 @@ export async function syncCanvasRuntimeBackends(
280
304
  ? { serverName: current.data.serverName.trim() }
281
305
  : {}),
282
306
  });
307
+ const toolInput = resolveExtAppRehydratedToolInput(current, opened.toolInput);
308
+ const storedCheckpointId = getStoredExcalidrawCheckpointId(current);
309
+ const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
310
+ ? ensureExcalidrawCheckpointId(opened.toolResult, nodeId, storedCheckpointId)
311
+ : opened.toolResult;
283
312
 
284
313
  canvasState.withSuppressedRecording(() => {
285
314
  setExtAppRuntimeState(nodeId, {
286
315
  appSessionId: opened.sessionId,
287
316
  html: opened.html,
288
- toolInput: opened.toolInput,
289
- toolResult: opened.toolResult,
317
+ toolInput,
318
+ toolResult,
290
319
  resourceUri: opened.resourceUri,
291
320
  toolDefinition: opened.tool,
292
321
  resourceMeta: opened.resourceMeta,
@@ -704,7 +733,7 @@ function collectArrangeExcludedNodeIds(nodes: CanvasNodeState[]): Set<string> {
704
733
  const excluded = new Set<string>();
705
734
  for (const node of nodes) {
706
735
  const parentGroup = typeof node.data.parentGroup === 'string' ? node.data.parentGroup : null;
707
- if (isArrangeLocked(node) || (parentGroup && excludedGroupIds.has(parentGroup))) {
736
+ if (parentGroup || isArrangeLocked(node)) {
708
737
  excluded.add(node.id);
709
738
  }
710
739
  }
@@ -747,12 +776,13 @@ export function arrangeCanvasNodes(layout: CanvasArrangeMode): { arranged: numbe
747
776
  return;
748
777
  }
749
778
 
750
- const cols = Math.max(1, Math.floor(1440 / (360 + gap)));
779
+ const maxNodeWidth = movableNodes.reduce((max, node) => Math.max(max, node.size.width), 360);
780
+ const cols = Math.max(1, Math.floor(1440 / (maxNodeWidth + gap)));
751
781
  let col = 0;
752
782
  let rowY = 80;
753
783
  let rowMaxHeight = 0;
754
784
  for (const node of movableNodes) {
755
- const x = 40 + col * (360 + gap);
785
+ const x = 40 + col * (maxNodeWidth + gap);
756
786
  canvasState.updateNode(node.id, { position: { x, y: rowY } });
757
787
  rowMaxHeight = Math.max(rowMaxHeight, node.size.height);
758
788
  col++;
@@ -283,7 +283,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
283
283
  required: true,
284
284
  description: 'Chart type. Aliases like "stack" and "combo" are normalized server-side.',
285
285
  },
286
- { name: 'data', type: 'Record<string, unknown>[]', required: true, description: 'Chart dataset.' },
286
+ { name: 'data', type: 'Record<string, unknown>[]', required: true, description: 'Chart dataset.', aliases: ['data-json'] },
287
287
  { name: 'title', type: 'string', required: false, description: 'Optional graph title.' },
288
288
  { name: 'xKey', type: 'string', required: false, description: 'X-axis/category key for line, bar, area, scatter, stacked-bar, and composed charts.' },
289
289
  { name: 'yKey', type: 'string', required: false, description: 'Y-axis value key for line, bar, area, and scatter charts. Also used as a fallback bar key for composed charts.' },
@@ -334,6 +334,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
334
334
  { name: 'outputPath', type: 'string', required: false, description: 'Optional output HTML path.' },
335
335
  { name: 'openInCanvas', type: 'boolean', required: false, description: 'Open the built artifact on the canvas (default true).' },
336
336
  { name: 'includeLogs', type: 'boolean', required: false, description: 'Include raw build stdout/stderr in the response (default false).' },
337
+ { name: 'deps', type: 'string[]', required: false, description: 'Optional npm dependencies to add before bundling, e.g. recharts.', aliases: ['deps'] },
337
338
  ],
338
339
  example: {
339
340
  title: 'Dashboard Artifact',