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.
- package/CHANGELOG.md +131 -0
- package/Readme.md +35 -8
- package/dist/canvas/index.js +70 -70
- package/dist/types/client/nodes/ExtAppFrame.d.ts +13 -1
- package/dist/types/client/state/canvas-store.d.ts +2 -1
- package/dist/types/client/types.d.ts +3 -0
- package/dist/types/server/bundled-skills.d.ts +40 -0
- package/dist/types/server/diagram-presets.d.ts +13 -0
- package/dist/types/server/index.d.ts +6 -1
- package/dist/types/server/web-artifacts.d.ts +1 -0
- package/dist/types/shared/ext-app-tool-result.d.ts +12 -0
- package/package.json +2 -1
- package/skills/pmx-canvas/SKILL.md +26 -5
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +66 -0
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +10 -0
- package/skills/web-artifacts-builder/scripts/init-artifact.sh +1 -1
- package/src/cli/agent.ts +78 -7
- package/src/cli/index.ts +22 -2
- package/src/client/App.tsx +2 -1
- package/src/client/canvas/CanvasNode.tsx +3 -2
- package/src/client/canvas/ExpandedNodeOverlay.tsx +6 -1
- package/src/client/nodes/ExtAppFrame.tsx +183 -38
- package/src/client/state/canvas-store.ts +63 -1
- package/src/client/state/sse-bridge.ts +5 -0
- package/src/client/types.ts +12 -0
- package/src/mcp/server.ts +92 -6
- package/src/server/bundled-skills.ts +143 -0
- package/src/server/canvas-operations.ts +57 -8
- package/src/server/canvas-schema.ts +2 -1
- package/src/server/diagram-presets.ts +219 -4
- package/src/server/index.ts +22 -10
- package/src/server/server.ts +172 -45
- package/src/server/web-artifacts/scripts/bundle-artifact.sh +10 -0
- package/src/server/web-artifacts/scripts/init-artifact.sh +1 -1
- package/src/server/web-artifacts.ts +83 -3
- 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)' }}>
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
412
|
+
const bootstrapToolResult = latestToolResultRef.current;
|
|
413
|
+
void sendExtAppBootstrapState(bridge, latestToolInputRef.current, bootstrapToolResult)
|
|
327
414
|
.then(() => {
|
|
328
|
-
toolResultSentRef.current = Boolean(
|
|
329
|
-
|
|
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
|
-
{/*
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
|
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
|
}
|
package/src/client/types.ts
CHANGED
|
@@ -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[];
|