pmx-canvas 0.1.1 → 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.
Files changed (36) hide show
  1. package/CHANGELOG.md +131 -0
  2. package/Readme.md +35 -8
  3. package/dist/canvas/index.js +70 -70
  4. package/dist/types/client/nodes/ExtAppFrame.d.ts +13 -1
  5. package/dist/types/client/state/canvas-store.d.ts +2 -1
  6. package/dist/types/client/types.d.ts +3 -0
  7. package/dist/types/server/bundled-skills.d.ts +40 -0
  8. package/dist/types/server/diagram-presets.d.ts +13 -0
  9. package/dist/types/server/index.d.ts +6 -1
  10. package/dist/types/server/web-artifacts.d.ts +1 -0
  11. package/dist/types/shared/ext-app-tool-result.d.ts +12 -0
  12. package/package.json +2 -1
  13. package/skills/pmx-canvas/SKILL.md +26 -5
  14. package/skills/pmx-canvas/references/installing-pmx-canvas.md +66 -0
  15. package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +10 -0
  16. package/skills/web-artifacts-builder/scripts/init-artifact.sh +1 -1
  17. package/src/cli/agent.ts +78 -7
  18. package/src/cli/index.ts +22 -2
  19. package/src/client/App.tsx +2 -1
  20. package/src/client/canvas/CanvasNode.tsx +3 -2
  21. package/src/client/canvas/ExpandedNodeOverlay.tsx +6 -1
  22. package/src/client/nodes/ExtAppFrame.tsx +183 -38
  23. package/src/client/state/canvas-store.ts +63 -1
  24. package/src/client/state/sse-bridge.ts +5 -0
  25. package/src/client/types.ts +12 -0
  26. package/src/mcp/server.ts +92 -6
  27. package/src/server/bundled-skills.ts +143 -0
  28. package/src/server/canvas-operations.ts +57 -8
  29. package/src/server/canvas-schema.ts +2 -1
  30. package/src/server/diagram-presets.ts +219 -4
  31. package/src/server/index.ts +22 -10
  32. package/src/server/server.ts +172 -45
  33. package/src/server/web-artifacts/scripts/bundle-artifact.sh +10 -0
  34. package/src/server/web-artifacts/scripts/init-artifact.sh +1 -1
  35. package/src/server/web-artifacts.ts +83 -3
  36. package/src/shared/ext-app-tool-result.ts +25 -0
@@ -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}
@@ -2,6 +2,7 @@ import type { CallToolResult, ListToolsResult, RequestId, Tool } from '@modelcon
2
2
  import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
3
3
  import { AppBridge, PostMessageTransport, buildAllowAttribute } from '@modelcontextprotocol/ext-apps/app-bridge';
4
4
  import { useEffect, useRef, useState } from 'preact/hooks';
5
+ import { extAppToolResultsMatch } from '../../shared/ext-app-tool-result.js';
5
6
  import {
6
7
  canvasTheme,
7
8
  collapseExpandedNode,
@@ -19,6 +20,11 @@ type IframeLoadTarget = Pick<
19
20
 
20
21
  type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
21
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
+ }
22
28
 
23
29
  async function postJson<T>(url: string, body: Record<string, unknown>): Promise<T> {
24
30
  const response = await fetch(url, {
@@ -96,6 +102,33 @@ export async function sendExtAppBootstrapState(
96
102
  }
97
103
  }
98
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
+
99
132
  export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
100
133
  const iframeRef = useRef<HTMLIFrameElement>(null);
101
134
  const bridgeRef = useRef<AppBridge | null>(null);
@@ -103,6 +136,7 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
103
136
  const latestToolInputRef = useRef<Record<string, unknown>>({});
104
137
  const latestToolResultRef = useRef<CallToolResult | undefined>(undefined);
105
138
  const toolResultSentRef = useRef(false);
139
+ const lastSentToolResultRef = useRef<CallToolResult | undefined>(undefined);
106
140
  const toolResultSendingRef = useRef<Promise<void> | null>(null);
107
141
  const bridgeReadyRef = useRef(false);
108
142
  const themeUnsubRef = useRef<(() => void) | null>(null);
@@ -120,7 +154,10 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
120
154
  const rawToolCallId = node.data.toolCallId;
121
155
  const toolCallId: RequestId | undefined =
122
156
  typeof rawToolCallId === 'string' || typeof rawToolCallId === 'number' ? rawToolCallId : undefined;
123
- 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;
124
161
  const sessionStatus = node.data.sessionStatus as string | undefined;
125
162
  const sessionError = node.data.sessionError as string | undefined;
126
163
  const maxHeight = node.size.height;
@@ -140,13 +177,33 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
140
177
 
141
178
  const flushToolResult = (bridge: AppBridge | null): Promise<void> | null => {
142
179
  const pendingToolResult = latestToolResultRef.current;
143
- if (!bridge || !bridgeReadyRef.current || !pendingToolResult || toolResultSentRef.current) {
180
+ if (!bridge || !bridgeReadyRef.current || !pendingToolResult) {
181
+ return null;
182
+ }
183
+ // Skip when the content is unchanged. Updates from callServerTool
184
+ // (e.g. Excalidraw saving edits) produce a new reference via SSE and
185
+ // must be forwarded to keep other clients in sync — but SSE layout
186
+ // updates *also* mint new references when nothing in the tool result
187
+ // has actually changed (e.g. after the widget's own updateModelContext
188
+ // call), which would echo the result back and cause the widget to
189
+ // re-render mid-interaction (see: Counter fixture click instability).
190
+ // Deep-equality via structural compare handles both cases: new content
191
+ // is forwarded, unchanged content is suppressed.
192
+ if (lastSentToolResultRef.current === pendingToolResult) {
193
+ return null;
194
+ }
195
+ if (
196
+ lastSentToolResultRef.current &&
197
+ extAppToolResultsMatch(lastSentToolResultRef.current, pendingToolResult)
198
+ ) {
199
+ lastSentToolResultRef.current = pendingToolResult;
144
200
  return null;
145
201
  }
146
202
  if (toolResultSendingRef.current) return toolResultSendingRef.current;
147
203
  const sendPromise = bridge
148
204
  .sendToolResult(pendingToolResult)
149
205
  .then(() => {
206
+ lastSentToolResultRef.current = pendingToolResult;
150
207
  toolResultSentRef.current = true;
151
208
  setStatus('done');
152
209
  })
@@ -169,7 +226,10 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
169
226
  if (!iframe) return;
170
227
  let disposed = false;
171
228
  let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
229
+ let hostContextResizeObserver: ResizeObserver | null = null;
230
+ let hostContextRaf: number | null = null;
172
231
  toolResultSentRef.current = false;
232
+ lastSentToolResultRef.current = undefined;
173
233
  toolResultSendingRef.current = null;
174
234
  bridgeReadyRef.current = false;
175
235
 
@@ -190,6 +250,33 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
190
250
  throw new Error('Ext-app iframe window is unavailable');
191
251
  }
192
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
+
193
280
  const bridge = new AppBridge(
194
281
  null,
195
282
  { name: 'PMX Canvas', version: '1.0.0' },
@@ -201,26 +288,15 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
201
288
  updateModelContext: { text: {}, structuredContent: {} },
202
289
  },
203
290
  {
204
- hostContext: {
205
- theme: toMcpTheme(canvasTheme.value),
206
- platform: 'web',
207
- containerDimensions: { maxHeight },
208
- displayMode: isExpanded ? 'fullscreen' : 'inline',
209
- locale: navigator.language,
210
- timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
211
- ...(toolDefinition ? {
212
- toolInfo: {
213
- id: toolCallId,
214
- tool: toolDefinition,
215
- },
216
- } : {}),
217
- },
291
+ hostContext: buildHostContext(isExpanded ? 'fullscreen' : 'inline'),
218
292
  },
219
293
  );
220
294
 
221
295
  // Register handlers BEFORE connect
222
296
  bridge.onsizechange = async ({ height }) => {
223
- if (height && iframe) iframe.style.height = `${height}px`;
297
+ if (shouldApplyExtAppSizeChange(height, expandedNodeId.value === nodeId)) {
298
+ iframe.style.height = `${height}px`;
299
+ }
224
300
  return {};
225
301
  };
226
302
 
@@ -229,6 +305,15 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
229
305
  return {};
230
306
  };
231
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
+
232
317
  // Handle native fullscreen requests from the widget (e.g. Excalidraw expand button)
233
318
  bridge.onrequestdisplaymode = async ({ mode }) => {
234
319
  const { nextMode, shouldExpand, shouldCollapse } = resolveExtAppDisplayModeRequest(mode, isExpanded);
@@ -310,6 +395,7 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
310
395
  bridgeReadyRef.current = true;
311
396
  setStatus('ready');
312
397
  setError(null);
398
+ scheduleHostContextUpdate();
313
399
  void sendExtAppBootstrapState(bridge, latestToolInputRef.current, undefined)
314
400
  .then(() => flushToolResult(bridge))
315
401
  .catch((err) => {
@@ -323,10 +409,14 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
323
409
  // handshake timing differs across SDK versions.
324
410
  fallbackTimer = setTimeout(() => {
325
411
  if (disposed || bridgeReadyRef.current) return;
326
- void sendExtAppBootstrapState(bridge, latestToolInputRef.current, latestToolResultRef.current)
412
+ const bootstrapToolResult = latestToolResultRef.current;
413
+ void sendExtAppBootstrapState(bridge, latestToolInputRef.current, bootstrapToolResult)
327
414
  .then(() => {
328
- toolResultSentRef.current = Boolean(latestToolResultRef.current);
329
- setStatus(latestToolResultRef.current ? 'done' : 'ready');
415
+ toolResultSentRef.current = Boolean(bootstrapToolResult);
416
+ if (bootstrapToolResult) {
417
+ lastSentToolResultRef.current = bootstrapToolResult;
418
+ }
419
+ setStatus(bootstrapToolResult ? 'done' : 'ready');
330
420
  setError(null);
331
421
  })
332
422
  .catch((err) => {
@@ -343,19 +433,19 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
343
433
  }
344
434
  bridgeRef.current = bridge;
345
435
  transportRef.current = transport;
436
+ hostContextResizeObserver = new ResizeObserver(scheduleHostContextUpdate);
437
+ hostContextResizeObserver.observe(iframe);
438
+ if (iframe.parentElement) hostContextResizeObserver.observe(iframe.parentElement);
346
439
 
347
- // Propagate theme changes to ext-app iframe
440
+ // Propagate theme changes to ext-app iframe. Read current expanded state
441
+ // at fire time so the widget keeps its fullscreen/inline context accurate.
348
442
  let firstFire = true;
349
443
  themeUnsubRef.current = canvasTheme.subscribe((newTheme) => {
350
444
  if (firstFire) { firstFire = false; return; }
351
445
  if (disposed) return;
352
446
  bridge.setHostContext?.({
447
+ ...buildHostContext(),
353
448
  theme: toMcpTheme(newTheme),
354
- platform: 'web',
355
- containerDimensions: { maxHeight },
356
- displayMode: 'inline',
357
- locale: navigator.language,
358
- timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
359
449
  });
360
450
  });
361
451
 
@@ -371,6 +461,12 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
371
461
  return () => {
372
462
  disposed = true;
373
463
  clearFallbackTimer();
464
+ hostContextResizeObserver?.disconnect();
465
+ hostContextResizeObserver = null;
466
+ if (hostContextRaf !== null) {
467
+ cancelAnimationFrame(hostContextRaf);
468
+ hostContextRaf = null;
469
+ }
374
470
  bridgeReadyRef.current = false;
375
471
  toolResultSendingRef.current = null;
376
472
  themeUnsubRef.current?.();
@@ -392,6 +488,27 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
392
488
  }
393
489
  }, [toolResult, status]);
394
490
 
491
+ // Keep the widget's displayMode in sync when the host expands or collapses
492
+ // the node. Without this, a widget that opened in inline mode would never
493
+ // learn that it is now fullscreen (and vice versa), so features gated on
494
+ // fullscreen (like Excalidraw's edit mode) would not activate on the same
495
+ // click that triggered the expansion.
496
+ useEffect(() => {
497
+ const bridge = bridgeRef.current;
498
+ if (!bridge || !bridgeReadyRef.current) return;
499
+ bridge.setHostContext?.({
500
+ theme: toMcpTheme(canvasTheme.value),
501
+ platform: 'web',
502
+ containerDimensions: resolveExtAppContainerDimensions(iframeRef.current, {
503
+ width: node.size.width,
504
+ height: maxHeight,
505
+ }),
506
+ displayMode: isExpanded ? 'fullscreen' : 'inline',
507
+ locale: navigator.language,
508
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
509
+ });
510
+ }, [isExpanded, maxHeight]);
511
+
395
512
  // Loading state — HTML not yet fetched
396
513
  if (!html) {
397
514
  return (
@@ -424,7 +541,7 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
424
541
  }
425
542
 
426
543
  return (
427
- <div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
544
+ <div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
428
545
  {sessionStatus && sessionStatus !== 'ready' && (
429
546
  <div
430
547
  style={{
@@ -493,17 +610,45 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
493
610
  Connecting to ext-app viewer...
494
611
  </div>
495
612
  )}
496
- {/* allow-scripts only (no allow-same-origin) srcdoc gets opaque origin,
497
- cannot access host cookies/storage/DOM. Communication via postMessage only. */}
498
- <iframe
499
- key={frameKey}
500
- ref={iframeRef}
501
- srcdoc={html}
502
- sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
503
- allow={buildAllowAttribute(resourceMeta?.permissions)}
504
- style={{ flex: 1, border: 'none', background: 'var(--c-panel)' }}
505
- title={`Ext App: ${toolName}`}
506
- />
613
+ {/* Iframe stack: the widget renders a preview; when not expanded, a
614
+ transparent click-catcher sits on top so the first click always
615
+ expands the node. Without this, widgets like Excalidraw show their
616
+ own "Edit" button inline, which triggers a fullscreen request and
617
+ remounts the iframe in the overlay — forcing the user to click Edit
618
+ a second time to actually enter edit mode. Routing all inline clicks
619
+ to "expand" makes the flow "open → edit" instead of "edit → expand → edit". */}
620
+ <div style={{ flex: 1, position: 'relative', display: 'flex', minHeight: 0, height: '100%' }}>
621
+ <iframe
622
+ key={frameKey}
623
+ ref={iframeRef}
624
+ srcdoc={html}
625
+ sandbox={resolveExtAppSandbox(null)}
626
+ allow={buildAllowAttribute(resourceMeta?.permissions)}
627
+ style={{ flex: 1, width: '100%', height: '100%', minHeight: 0, border: 'none', background: 'var(--c-panel)' }}
628
+ title={`Ext App: ${toolName}`}
629
+ />
630
+ {!isExpanded && (
631
+ <button
632
+ type="button"
633
+ onClick={(e) => {
634
+ e.stopPropagation();
635
+ expandNode(nodeId);
636
+ }}
637
+ class="ext-app-preview-catcher"
638
+ title="Click to open"
639
+ style={{
640
+ position: 'absolute',
641
+ inset: 0,
642
+ background: 'transparent',
643
+ border: 'none',
644
+ padding: 0,
645
+ margin: 0,
646
+ cursor: 'zoom-in',
647
+ }}
648
+ aria-label="Open full view to edit"
649
+ />
650
+ )}
651
+ </div>
507
652
  </div>
508
653
  );
509
654
  }
@@ -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[];