pmx-canvas 0.3.0 → 0.3.2
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 +73 -0
- package/dist/canvas/index.js +65 -65
- package/dist/types/cli/agent.d.ts +9 -1
- package/dist/types/cli/daemon.d.ts +74 -0
- package/dist/types/cli/watch.d.ts +2 -2
- package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
- package/dist/types/client/canvas/CommandPalette.d.ts +1 -1
- package/dist/types/client/canvas/Minimap.d.ts +1 -1
- package/dist/types/client/nodes/ExtAppFrame.d.ts +9 -1
- package/dist/types/client/nodes/FileNode.d.ts +1 -1
- package/dist/types/client/nodes/ImageNode.d.ts +1 -1
- package/dist/types/client/nodes/InlineFormatBar.d.ts +1 -1
- package/dist/types/client/nodes/MarkdownNode.d.ts +1 -1
- package/dist/types/client/nodes/PromptNode.d.ts +1 -1
- package/dist/types/client/nodes/ResponseNode.d.ts +1 -1
- package/dist/types/server/canvas-schema.d.ts +1 -1
- package/dist/types/server/html-primitives.d.ts +1 -1
- package/dist/types/server/index.d.ts +4 -4
- package/dist/types/server/operations/index.d.ts +1 -1
- package/dist/types/server/operations/ops/ax-read.d.ts +2 -0
- package/dist/types/server/operations/ops/canvas-wire.d.ts +2 -0
- package/dist/types/server/operations/ops/ext-app.d.ts +2 -0
- package/dist/types/server/server.d.ts +8 -0
- package/docs/cli.md +10 -1
- package/docs/http-api.md +28 -0
- package/docs/plans/plan-009-tech-debt-backlog.md +5 -5
- package/docs/screenshot.png +0 -0
- package/docs/tech-debt-assessment-2026-07.md +2 -2
- package/package.json +5 -2
- package/skills/pmx-canvas/SKILL.md +15 -10
- package/skills/pmx-canvas/references/full-reference.md +17 -3
- package/src/cli/agent.ts +1951 -1571
- package/src/cli/daemon.ts +460 -0
- package/src/cli/index.ts +80 -323
- package/src/cli/watch.ts +2 -10
- package/src/client/App.tsx +48 -46
- package/src/client/canvas/AttentionHistory.tsx +11 -1
- package/src/client/canvas/CanvasNode.tsx +41 -29
- package/src/client/canvas/CanvasViewport.tsx +101 -66
- package/src/client/canvas/CommandPalette.tsx +61 -27
- package/src/client/canvas/ContextMenu.tsx +13 -20
- package/src/client/canvas/ContextPinBar.tsx +1 -5
- package/src/client/canvas/ContextPinHud.tsx +1 -6
- package/src/client/canvas/DockedNode.tsx +4 -4
- package/src/client/canvas/EdgeLayer.tsx +37 -36
- package/src/client/canvas/ExpandedNodeOverlay.tsx +69 -45
- package/src/client/canvas/FocusFieldLayer.tsx +20 -22
- package/src/client/canvas/IntentLayer.tsx +31 -14
- package/src/client/canvas/Minimap.tsx +11 -16
- package/src/client/canvas/SelectionBar.tsx +4 -11
- package/src/client/canvas/ShortcutOverlay.tsx +3 -1
- package/src/client/canvas/SnapshotPanel.tsx +77 -95
- package/src/client/canvas/auto-fit.ts +15 -14
- package/src/client/canvas/snap-guides.ts +12 -12
- package/src/client/canvas/use-node-resize.ts +1 -5
- package/src/client/canvas/use-pan-zoom.ts +25 -26
- package/src/client/ext-app/bridge.ts +3 -12
- package/src/client/icons.tsx +63 -20
- package/src/client/nodes/ContextNode.tsx +14 -25
- package/src/client/nodes/ExtAppFrame.tsx +194 -80
- package/src/client/nodes/FileNode.tsx +74 -62
- package/src/client/nodes/GroupNode.tsx +4 -6
- package/src/client/nodes/HtmlNode.tsx +76 -46
- package/src/client/nodes/ImageNode.tsx +18 -27
- package/src/client/nodes/InlineFormatBar.tsx +4 -21
- package/src/client/nodes/InlineMarkdownEditor.tsx +0 -1
- package/src/client/nodes/LedgerNode.tsx +10 -4
- package/src/client/nodes/MarkdownNode.tsx +3 -10
- package/src/client/nodes/McpAppNode.tsx +26 -22
- package/src/client/nodes/MdFormatBar.tsx +10 -7
- package/src/client/nodes/PromptNode.tsx +23 -51
- package/src/client/nodes/ResponseNode.tsx +3 -13
- package/src/client/nodes/StatusNode.tsx +5 -9
- package/src/client/nodes/StatusSummary.tsx +2 -8
- package/src/client/nodes/WebpageNode.tsx +20 -14
- package/src/client/nodes/iframe-document-url.ts +25 -16
- package/src/client/nodes/image-warnings.ts +1 -7
- package/src/client/nodes/md-format.ts +20 -5
- package/src/client/state/attention-bridge.ts +4 -9
- package/src/client/state/attention-store.ts +1 -7
- package/src/client/state/canvas-store.ts +52 -36
- package/src/client/state/intent-bridge.ts +176 -112
- package/src/client/state/intent-store.ts +4 -1
- package/src/client/state/sse-bridge.ts +53 -70
- package/src/json-render/catalog.ts +12 -16
- package/src/json-render/charts/components.tsx +16 -20
- package/src/json-render/charts/extra-components.tsx +8 -16
- package/src/json-render/charts/extra-definitions.ts +1 -2
- package/src/json-render/charts/tufte-components.tsx +37 -20
- package/src/json-render/charts/tufte-definitions.ts +8 -2
- package/src/json-render/renderer/index.tsx +42 -22
- package/src/json-render/schema.ts +6 -3
- package/src/json-render/server.ts +33 -39
- package/src/mcp/canvas-access.ts +35 -21
- package/src/mcp/server.ts +132 -70
- package/src/server/agent-context.ts +63 -36
- package/src/server/ax-context.ts +7 -5
- package/src/server/ax-interaction.ts +176 -43
- package/src/server/ax-state-manager.ts +182 -39
- package/src/server/ax-state.ts +142 -47
- package/src/server/canvas-db.ts +213 -95
- package/src/server/canvas-operations.ts +180 -123
- package/src/server/canvas-provenance.ts +1 -4
- package/src/server/canvas-schema.ts +454 -73
- package/src/server/canvas-serialization.ts +27 -35
- package/src/server/canvas-state.ts +150 -58
- package/src/server/chart-template.ts +4 -5
- package/src/server/code-graph.ts +19 -6
- package/src/server/diagram-presets.ts +28 -29
- package/src/server/ext-app-lookup.ts +3 -12
- package/src/server/html-node-summary.ts +19 -10
- package/src/server/html-primitives.ts +326 -97
- package/src/server/html-surface.ts +6 -9
- package/src/server/image-source.ts +6 -4
- package/src/server/index.ts +320 -217
- package/src/server/intent-registry.ts +2 -5
- package/src/server/mcp-app-candidate.ts +5 -10
- package/src/server/mcp-app-host.ts +14 -38
- package/src/server/mcp-app-runtime.ts +12 -20
- package/src/server/mutation-history.ts +15 -5
- package/src/server/operations/composites.ts +1 -3
- package/src/server/operations/http.ts +2 -3
- package/src/server/operations/index.ts +7 -1
- package/src/server/operations/invoker.ts +4 -3
- package/src/server/operations/mcp.ts +22 -30
- package/src/server/operations/ops/annotation.ts +122 -10
- package/src/server/operations/ops/app.ts +98 -73
- package/src/server/operations/ops/ax-await.ts +17 -10
- package/src/server/operations/ops/ax-read.ts +347 -0
- package/src/server/operations/ops/ax-shared.ts +2 -7
- package/src/server/operations/ops/ax-state.ts +32 -14
- package/src/server/operations/ops/ax-timeline.ts +32 -19
- package/src/server/operations/ops/ax-work.ts +54 -37
- package/src/server/operations/ops/batch.ts +41 -15
- package/src/server/operations/ops/canvas-wire.ts +91 -0
- package/src/server/operations/ops/edges.ts +37 -25
- package/src/server/operations/ops/ext-app.ts +346 -0
- package/src/server/operations/ops/groups.ts +49 -20
- package/src/server/operations/ops/intent.ts +18 -12
- package/src/server/operations/ops/json-render.ts +239 -98
- package/src/server/operations/ops/nodes.ts +298 -109
- package/src/server/operations/ops/query.ts +46 -28
- package/src/server/operations/ops/snapshots.ts +35 -26
- package/src/server/operations/ops/validate.ts +2 -1
- package/src/server/operations/ops/viewport.ts +60 -16
- package/src/server/operations/ops/webview.ts +44 -18
- package/src/server/operations/registry.ts +2 -3
- package/src/server/operations/types.ts +7 -5
- package/src/server/operations/webview-runner.ts +1 -3
- package/src/server/placement.ts +8 -18
- package/src/server/server.ts +122 -1028
- package/src/server/spatial-analysis.ts +39 -25
- package/src/server/trace-manager.ts +3 -8
- package/src/server/web-artifacts.ts +23 -27
- package/src/server/webpage-node.ts +5 -13
- package/src/shared/auto-arrange.ts +12 -5
- package/src/shared/content-height-reporter.ts +8 -6
- package/src/shared/ext-app-tool-result.ts +2 -6
- package/src/shared/placement.ts +1 -4
- package/src/shared/semantic-attention.ts +39 -37
- package/src/shared/surface.ts +8 -4
|
@@ -37,7 +37,7 @@ async function postJson<T>(url: string, body: Record<string, unknown>): Promise<
|
|
|
37
37
|
headers: { 'Content-Type': 'application/json' },
|
|
38
38
|
body: JSON.stringify(body),
|
|
39
39
|
});
|
|
40
|
-
const json = await response.json() as {
|
|
40
|
+
const json = (await response.json()) as {
|
|
41
41
|
ok: boolean;
|
|
42
42
|
result?: T;
|
|
43
43
|
error?: string;
|
|
@@ -59,22 +59,50 @@ export function isWebKitOnlyHost(userAgent: string): boolean {
|
|
|
59
59
|
return /AppleWebKit/.test(userAgent) && !/Chrome|Chromium|CriOS|Edg|Android/.test(userAgent);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
// Finding F (0.2.5):
|
|
63
|
-
// hydration BURST problem — a single ext-app repaints fine
|
|
64
|
-
// a live-created node, or expand+close), but several
|
|
65
|
-
// WebKit and all stay black.
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
62
|
+
// Finding F (0.2.5, reworked 0.3.1): BOOT-AWARE serialized WebKit remount queue.
|
|
63
|
+
// The black tile is a cold-hydration BURST problem — a single ext-app repaints fine
|
|
64
|
+
// into an idle panel (like a live-created node, or expand+close), but several
|
|
65
|
+
// compositing at once overwhelm WebKit and all stay black. The 0.2.5 fixed-stagger
|
|
66
|
+
// slots (450ms apart) were NOT boot-aware: each recovery remount reboots the app
|
|
67
|
+
// (~1-2s for Excalidraw), so N staggered remounts overlapped into a fresh burst and
|
|
68
|
+
// the per-node one-shot flag was spent — exactly the multi-app reload blackout the
|
|
69
|
+
// 0.3.1 report shows. This queue runs remounts strictly one at a time: each entry
|
|
70
|
+
// performs its remount, then waits for that app's GENUINE initialized handshake
|
|
71
|
+
// (or a bounded timeout for an app that never boots) plus a settle delay before the
|
|
72
|
+
// next entry fires — so every remount lands in an idle panel, which is the
|
|
73
|
+
// empirically always-successful recovery (what expand+close does manually).
|
|
74
|
+
// Settle covers the scene DRAW: the app's initialized handshake fires before the
|
|
75
|
+
// replayed tool result arrives and the scene is painted, so the queue waits for the
|
|
76
|
+
// settled signal (bootstrap chain incl. tool-result send complete) plus this pause.
|
|
77
|
+
export const WEBKIT_REMOUNT_SETTLE_MS = 1000;
|
|
78
|
+
const WEBKIT_BOOT_TIMEOUT_MS = 7000;
|
|
79
|
+
|
|
80
|
+
export interface WebkitRemountTask {
|
|
81
|
+
/** Perform the remount. Return false if the node no longer needs it (skips the boot wait). */
|
|
82
|
+
remount: () => boolean;
|
|
83
|
+
/** Resolves when the remounted app genuinely boots AND finishes its bootstrap replay, or after a bounded timeout. */
|
|
84
|
+
awaitBoot: () => Promise<void>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Bounded recovery trail readable via `webview evaluate` / devtools
|
|
88
|
+
// (window.__PMX_EXTAPP_LOG). The WebKit compositor dropout is otherwise
|
|
89
|
+
// unobservable from page JS — this is the only diagnostic surface.
|
|
90
|
+
export function extAppRecoveryLog(nodeId: string, event: string): void {
|
|
91
|
+
if (typeof window === 'undefined') return;
|
|
92
|
+
const host = window as unknown as { __PMX_EXTAPP_LOG?: Array<{ t: number; nodeId: string; event: string }> };
|
|
93
|
+
host.__PMX_EXTAPP_LOG ??= [];
|
|
94
|
+
if (host.__PMX_EXTAPP_LOG.length < 500) host.__PMX_EXTAPP_LOG.push({ t: Date.now(), nodeId, event });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let webkitRemountChain: Promise<void> = Promise.resolve();
|
|
98
|
+
export function enqueueWebkitRemount(task: WebkitRemountTask): void {
|
|
99
|
+
webkitRemountChain = webkitRemountChain
|
|
100
|
+
.then(async () => {
|
|
101
|
+
if (!task.remount()) return;
|
|
102
|
+
await task.awaitBoot();
|
|
103
|
+
await new Promise((resolve) => setTimeout(resolve, WEBKIT_REMOUNT_SETTLE_MS));
|
|
104
|
+
})
|
|
105
|
+
.catch(() => {});
|
|
78
106
|
}
|
|
79
107
|
|
|
80
108
|
export function shouldScheduleWebKitRepaint(status: ExtAppFrameStatus, hasReplayToolResult: boolean): boolean {
|
|
@@ -128,9 +156,7 @@ export async function sendExtAppBootstrapState(
|
|
|
128
156
|
}
|
|
129
157
|
|
|
130
158
|
export function resolveExtAppSandbox(value: unknown): string {
|
|
131
|
-
return typeof value === 'string' && value.trim().length > 0
|
|
132
|
-
? value.trim()
|
|
133
|
-
: DEFAULT_EXT_APP_SANDBOX;
|
|
159
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : DEFAULT_EXT_APP_SANDBOX;
|
|
134
160
|
}
|
|
135
161
|
|
|
136
162
|
export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): string {
|
|
@@ -231,7 +257,14 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
231
257
|
const bridgeReadyRef = useRef(false);
|
|
232
258
|
const themeUnsubRef = useRef<(() => void) | null>(null);
|
|
233
259
|
const webkitRepaintDoneRef = useRef(false);
|
|
234
|
-
const
|
|
260
|
+
const webkitRemountAttemptsRef = useRef(0);
|
|
261
|
+
// Genuine boot signal: set ONLY when the app completes the ui/initialize
|
|
262
|
+
// handshake (bridge.oninitialized) — NOT by the 1200ms bootstrap fallback,
|
|
263
|
+
// which flips status via notifications that resolve even into a dead iframe.
|
|
264
|
+
const appInitializedRef = useRef(false);
|
|
265
|
+
const bootWaitersRef = useRef<Array<() => void>>([]);
|
|
266
|
+
const remountQueuedRef = useRef(false);
|
|
267
|
+
const unmountedRef = useRef(false);
|
|
235
268
|
const [status, setStatus] = useState<ExtAppFrameStatus>('loading');
|
|
236
269
|
const [error, setError] = useState<string | null>(null);
|
|
237
270
|
const [retryKey, setRetryKey] = useState(0);
|
|
@@ -246,10 +279,12 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
246
279
|
const rawToolCallId = node.data.toolCallId;
|
|
247
280
|
const toolCallId: RequestId | undefined =
|
|
248
281
|
typeof rawToolCallId === 'string' || typeof rawToolCallId === 'number' ? rawToolCallId : undefined;
|
|
249
|
-
const resourceMeta = node.data.resourceMeta as
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
282
|
+
const resourceMeta = node.data.resourceMeta as
|
|
283
|
+
| {
|
|
284
|
+
csp?: Record<string, unknown>;
|
|
285
|
+
permissions?: Record<string, unknown>;
|
|
286
|
+
}
|
|
287
|
+
| undefined;
|
|
253
288
|
const sessionStatus = node.data.sessionStatus as string | undefined;
|
|
254
289
|
const sessionError = node.data.sessionError as string | undefined;
|
|
255
290
|
const maxHeight = node.size.height;
|
|
@@ -262,9 +297,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
262
297
|
const axCaps = node.data.axCapabilities as { enabled?: boolean } | undefined;
|
|
263
298
|
const axEnabled = axCaps?.enabled === true && typeof html === 'string' && html.length > 0;
|
|
264
299
|
const axToken = useMemo(() => `ax-${crypto.randomUUID()}`, []);
|
|
265
|
-
const axBridgeScript = axEnabled
|
|
266
|
-
? buildExtAppAxBridgeScript(axToken, nodeId)
|
|
267
|
-
: '';
|
|
300
|
+
const axBridgeScript = axEnabled ? buildExtAppAxBridgeScript(axToken, nodeId) : '';
|
|
268
301
|
const iframeDocument = useIframeDocument(injectExtAppAxBridgeScript(html ?? '', axBridgeScript), iframeSandbox);
|
|
269
302
|
|
|
270
303
|
useEffect(() => {
|
|
@@ -272,7 +305,10 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
272
305
|
function onAxMessage(event: MessageEvent) {
|
|
273
306
|
if (event.source !== iframeRef.current?.contentWindow) return;
|
|
274
307
|
const data = event.data as {
|
|
275
|
-
source?: string;
|
|
308
|
+
source?: string;
|
|
309
|
+
token?: string;
|
|
310
|
+
nodeId?: string;
|
|
311
|
+
correlationId?: string;
|
|
276
312
|
interaction?: { type?: unknown; payload?: unknown };
|
|
277
313
|
} | null;
|
|
278
314
|
if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== nodeId) return;
|
|
@@ -289,36 +325,71 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
289
325
|
}).then((res) => {
|
|
290
326
|
if (res.ok) showToast('context', 'AX interaction', interactionType, [nodeId]);
|
|
291
327
|
else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [nodeId]);
|
|
292
|
-
iframeRef.current?.contentWindow?.postMessage(
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
328
|
+
iframeRef.current?.contentWindow?.postMessage(
|
|
329
|
+
{
|
|
330
|
+
source: 'pmx-canvas-ax-ack',
|
|
331
|
+
token: axToken,
|
|
332
|
+
...(data.correlationId ? { correlationId: data.correlationId } : {}),
|
|
333
|
+
interaction: { type: interactionType },
|
|
334
|
+
result: res,
|
|
335
|
+
},
|
|
336
|
+
'*',
|
|
337
|
+
);
|
|
299
338
|
});
|
|
300
339
|
}
|
|
301
340
|
window.addEventListener('message', onAxMessage);
|
|
302
341
|
return () => window.removeEventListener('message', onAxMessage);
|
|
303
342
|
}, [axEnabled, axToken, nodeId]);
|
|
304
343
|
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
// (
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
344
|
+
// Enqueue one serialized remount attempt for this node (Finding F recovery).
|
|
345
|
+
// Attempts are capped so a persistently-failing app degrades to the manual
|
|
346
|
+
// fallback (expand+close / Retry) instead of remount-looping forever.
|
|
347
|
+
const WEBKIT_MAX_REMOUNT_ATTEMPTS = 3;
|
|
348
|
+
const scheduleWebkitRemount = (reason: string): void => {
|
|
349
|
+
if (webkitRemountAttemptsRef.current >= WEBKIT_MAX_REMOUNT_ATTEMPTS) {
|
|
350
|
+
extAppRecoveryLog(nodeId, `remount-cap-hit (${reason})`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (remountQueuedRef.current) return; // an attempt is already queued and has not run yet
|
|
354
|
+
webkitRemountAttemptsRef.current += 1;
|
|
355
|
+
remountQueuedRef.current = true;
|
|
356
|
+
extAppRecoveryLog(nodeId, `remount-queued #${webkitRemountAttemptsRef.current} (${reason})`);
|
|
357
|
+
enqueueWebkitRemount({
|
|
358
|
+
remount: () => {
|
|
359
|
+
remountQueuedRef.current = false;
|
|
360
|
+
if (unmountedRef.current || expandedNodeId.value === nodeId) {
|
|
361
|
+
extAppRecoveryLog(nodeId, 'remount-skipped');
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
extAppRecoveryLog(nodeId, `remount-run #${webkitRemountAttemptsRef.current}`);
|
|
365
|
+
setRetryKey((k) => k + 1);
|
|
366
|
+
return true;
|
|
367
|
+
},
|
|
368
|
+
awaitBoot: () =>
|
|
369
|
+
new Promise<void>((resolve) => {
|
|
370
|
+
const timer = window.setTimeout(finish, WEBKIT_BOOT_TIMEOUT_MS);
|
|
371
|
+
function finish() {
|
|
372
|
+
window.clearTimeout(timer);
|
|
373
|
+
resolve();
|
|
374
|
+
}
|
|
375
|
+
bootWaitersRef.current.push(finish);
|
|
376
|
+
}),
|
|
377
|
+
});
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// Finding F (0.2.4/0.2.5, reworked 0.3.1): in a WebKit host panel (e.g. the GitHub
|
|
381
|
+
// Copilot app's WKWebView, and Bun's headless WebKit WebView) the doubly-nested
|
|
382
|
+
// ext-app iframe (workbench iframe → mcp-app.html iframe) can come up as a black
|
|
383
|
+
// tile for nodes present at panel-load. The mcp-app shell loads blank, then the app
|
|
384
|
+
// boots over the bridge and draws its content AFTER load; under a cold-hydration
|
|
385
|
+
// burst WebKit does not composite that late draw, so the layer stays black (clean
|
|
386
|
+
// in Blink, and clean for a node created live into an already-idle panel). A
|
|
387
|
+
// parent-side transform/src nudge does NOT repair a black layer — only a full
|
|
388
|
+
// remount (new iframe element + bridge re-init, what expand+close does) does, and
|
|
389
|
+
// only when it lands in an idle moment. So: once the app has booted — `ready` for
|
|
390
|
+
// empty apps, `done` for restored apps that must replay saved tool output — under
|
|
391
|
+
// WebKit only, enqueue ONE recovery remount through the boot-aware queue above, so
|
|
392
|
+
// concurrent ext-apps remount strictly one at a time instead of re-bursting.
|
|
322
393
|
// Strict no-op in Blink/Gecko; the e2e engine is unaffected. Inline instance only.
|
|
323
394
|
useEffect(() => {
|
|
324
395
|
if (expanded || webkitRepaintDoneRef.current) return;
|
|
@@ -326,19 +397,36 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
326
397
|
if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
|
|
327
398
|
if (!isWebKitOnlyHost(navigator.userAgent)) return;
|
|
328
399
|
webkitRepaintDoneRef.current = true;
|
|
329
|
-
|
|
330
|
-
// cold-hydration burst becomes a sequence of single (always-successful) repaints.
|
|
331
|
-
// The timer is held in a ref and cleared only on UNMOUNT (separate effect), NOT in
|
|
332
|
-
// this effect's cleanup — otherwise a later status change (ready→done) would cancel
|
|
333
|
-
// the one-shot remount before it fires. The gate above runs once per node.
|
|
334
|
-
const delayMs = 250 + nextWebkitRepaintSlot() * 450;
|
|
335
|
-
webkitRepaintTimerRef.current = window.setTimeout(() => setRetryKey((k) => k + 1), delayMs);
|
|
400
|
+
scheduleWebkitRemount('post-boot-repaint');
|
|
336
401
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
337
402
|
}, [status, hasReplayToolResult]);
|
|
338
403
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
404
|
+
// Never-booted watchdog (0.3.1): an iframe whose scripts never ran in the burst
|
|
405
|
+
// shows no error — the bootstrap fallback flips status via notifications that
|
|
406
|
+
// resolve into a dead window, so the tile just stays black. If the CURRENT frame
|
|
407
|
+
// has not completed the genuine initialize handshake within the watchdog window,
|
|
408
|
+
// retry it through the same serialized queue (bounded by the shared attempt cap).
|
|
409
|
+
const WEBKIT_BOOT_WATCHDOG_MS = 6000;
|
|
410
|
+
useEffect(() => {
|
|
411
|
+
if (expanded) return;
|
|
412
|
+
if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
|
|
413
|
+
if (!isWebKitOnlyHost(navigator.userAgent)) return;
|
|
414
|
+
if (!iframeDocument.ready) return;
|
|
415
|
+
const timer = window.setTimeout(() => {
|
|
416
|
+
if (appInitializedRef.current || unmountedRef.current) return;
|
|
417
|
+
scheduleWebkitRemount('boot-watchdog');
|
|
418
|
+
}, WEBKIT_BOOT_WATCHDOG_MS);
|
|
419
|
+
return () => window.clearTimeout(timer);
|
|
420
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
421
|
+
}, [frameKey, iframeDocument.ready, expanded]);
|
|
422
|
+
|
|
423
|
+
useEffect(
|
|
424
|
+
() => () => {
|
|
425
|
+
unmountedRef.current = true;
|
|
426
|
+
for (const waiter of bootWaitersRef.current.splice(0)) waiter();
|
|
427
|
+
},
|
|
428
|
+
[],
|
|
429
|
+
);
|
|
342
430
|
|
|
343
431
|
const toMcpTheme = (theme: string): McpUiTheme => (theme === 'light' ? 'light' : 'dark');
|
|
344
432
|
const isExpanded = expanded || expandedNodeId.value === nodeId;
|
|
@@ -368,10 +456,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
368
456
|
if (lastSentToolResultRef.current === pendingToolResult) {
|
|
369
457
|
return null;
|
|
370
458
|
}
|
|
371
|
-
if (
|
|
372
|
-
lastSentToolResultRef.current &&
|
|
373
|
-
extAppToolResultsMatch(lastSentToolResultRef.current, pendingToolResult)
|
|
374
|
-
) {
|
|
459
|
+
if (lastSentToolResultRef.current && extAppToolResultsMatch(lastSentToolResultRef.current, pendingToolResult)) {
|
|
375
460
|
lastSentToolResultRef.current = pendingToolResult;
|
|
376
461
|
return null;
|
|
377
462
|
}
|
|
@@ -409,6 +494,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
409
494
|
lastSentToolResultRef.current = undefined;
|
|
410
495
|
toolResultSendingRef.current = null;
|
|
411
496
|
bridgeReadyRef.current = false;
|
|
497
|
+
// New frame = new boot attempt: the genuine-initialized signal belongs to the
|
|
498
|
+
// CURRENT iframe. The queue's awaitBoot waits on this frame's handshake.
|
|
499
|
+
appInitializedRef.current = false;
|
|
412
500
|
|
|
413
501
|
const clearFallbackTimer = (): void => {
|
|
414
502
|
if (!fallbackTimer) return;
|
|
@@ -423,7 +511,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
423
511
|
throw new Error('Ext-app iframe window is unavailable');
|
|
424
512
|
}
|
|
425
513
|
|
|
426
|
-
const buildHostContext = (
|
|
514
|
+
const buildHostContext = (
|
|
515
|
+
displayMode: DisplayMode = expandedNodeId.value === nodeId ? 'fullscreen' : 'inline',
|
|
516
|
+
) => ({
|
|
427
517
|
theme: toMcpTheme(canvasTheme.value),
|
|
428
518
|
platform: 'web' as const,
|
|
429
519
|
containerDimensions: resolveExtAppContainerDimensions(iframe, {
|
|
@@ -433,12 +523,14 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
433
523
|
displayMode,
|
|
434
524
|
locale: navigator.language,
|
|
435
525
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
436
|
-
...(toolDefinition
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
526
|
+
...(toolDefinition
|
|
527
|
+
? {
|
|
528
|
+
toolInfo: {
|
|
529
|
+
id: toolCallId,
|
|
530
|
+
tool: toolDefinition,
|
|
531
|
+
},
|
|
532
|
+
}
|
|
533
|
+
: {}),
|
|
442
534
|
});
|
|
443
535
|
|
|
444
536
|
const scheduleHostContextUpdate = () => {
|
|
@@ -601,12 +693,23 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
601
693
|
if (disposed) return;
|
|
602
694
|
clearFallbackTimer();
|
|
603
695
|
bridgeReadyRef.current = true;
|
|
696
|
+
appInitializedRef.current = true;
|
|
697
|
+
extAppRecoveryLog(nodeId, 'initialized');
|
|
604
698
|
setStatus('ready');
|
|
605
699
|
setError(null);
|
|
606
700
|
void Promise.resolve(bridge.sendHostContextChange(buildHostContext(isExpanded ? 'fullscreen' : 'inline')))
|
|
607
701
|
.then(() => sendExtAppBootstrapState(bridge, latestToolInputRef.current, undefined))
|
|
608
702
|
.then(() => flushToolResult(bridge))
|
|
609
|
-
.then(() =>
|
|
703
|
+
.then(() => {
|
|
704
|
+
// Settled: handshake + bootstrap replay delivered — the app draws its
|
|
705
|
+
// scene right after this. Release the remount queue (which then adds
|
|
706
|
+
// its own settle pause covering the draw) only from this genuine path;
|
|
707
|
+
// the bootstrap fallback never releases it (a dead iframe must run the
|
|
708
|
+
// queue's bounded timeout instead of green-lighting the next remount).
|
|
709
|
+
extAppRecoveryLog(nodeId, 'settled');
|
|
710
|
+
for (const waiter of bootWaitersRef.current.splice(0)) waiter();
|
|
711
|
+
nudgeHostContextAfterLayout();
|
|
712
|
+
})
|
|
610
713
|
.catch((err) => {
|
|
611
714
|
const msg = err instanceof Error ? err.message : String(err);
|
|
612
715
|
setError(`Bridge bootstrap failed: ${msg}`);
|
|
@@ -655,7 +758,10 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
655
758
|
// at fire time so the widget keeps its fullscreen/inline context accurate.
|
|
656
759
|
let firstFire = true;
|
|
657
760
|
themeUnsubRef.current = canvasTheme.subscribe((newTheme) => {
|
|
658
|
-
if (firstFire) {
|
|
761
|
+
if (firstFire) {
|
|
762
|
+
firstFire = false;
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
659
765
|
if (disposed) return;
|
|
660
766
|
bridge.setHostContext?.({
|
|
661
767
|
...buildHostContext(),
|
|
@@ -787,7 +893,17 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
787
893
|
}
|
|
788
894
|
|
|
789
895
|
return (
|
|
790
|
-
<div
|
|
896
|
+
<div
|
|
897
|
+
style={{
|
|
898
|
+
flex: 1,
|
|
899
|
+
width: '100%',
|
|
900
|
+
height: '100%',
|
|
901
|
+
minWidth: 0,
|
|
902
|
+
minHeight: 0,
|
|
903
|
+
display: 'flex',
|
|
904
|
+
flexDirection: 'column',
|
|
905
|
+
}}
|
|
906
|
+
>
|
|
791
907
|
{sessionStatus && sessionStatus !== 'ready' && (
|
|
792
908
|
<div
|
|
793
909
|
style={{
|
|
@@ -852,9 +968,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
852
968
|
</div>
|
|
853
969
|
)}
|
|
854
970
|
{status === 'loading' && (
|
|
855
|
-
<div style={{ padding: '8px', fontSize: '11px', color: 'var(--c-muted)' }}>
|
|
856
|
-
Connecting to ext-app viewer...
|
|
857
|
-
</div>
|
|
971
|
+
<div style={{ padding: '8px', fontSize: '11px', color: 'var(--c-muted)' }}>Connecting to ext-app viewer...</div>
|
|
858
972
|
)}
|
|
859
973
|
{/* Iframe stack: the widget renders a preview; when not expanded, a
|
|
860
974
|
transparent click-catcher sits on top so the first click always
|
|
@@ -8,20 +8,38 @@ import { runNodeAxInteraction } from './ax-node-actions';
|
|
|
8
8
|
function langFromPath(path: string): string {
|
|
9
9
|
const ext = path.split('.').pop()?.toLowerCase() ?? '';
|
|
10
10
|
const map: Record<string, string> = {
|
|
11
|
-
ts: 'TypeScript',
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
ts: 'TypeScript',
|
|
12
|
+
tsx: 'TSX',
|
|
13
|
+
js: 'JavaScript',
|
|
14
|
+
jsx: 'JSX',
|
|
15
|
+
py: 'Python',
|
|
16
|
+
rs: 'Rust',
|
|
17
|
+
go: 'Go',
|
|
18
|
+
rb: 'Ruby',
|
|
19
|
+
java: 'Java',
|
|
20
|
+
kt: 'Kotlin',
|
|
21
|
+
swift: 'Swift',
|
|
22
|
+
c: 'C',
|
|
23
|
+
cpp: 'C++',
|
|
24
|
+
h: 'C/C++',
|
|
25
|
+
css: 'CSS',
|
|
26
|
+
html: 'HTML',
|
|
27
|
+
json: 'JSON',
|
|
28
|
+
yaml: 'YAML',
|
|
29
|
+
yml: 'YAML',
|
|
30
|
+
md: 'Markdown',
|
|
31
|
+
toml: 'TOML',
|
|
32
|
+
sql: 'SQL',
|
|
33
|
+
sh: 'Shell',
|
|
34
|
+
bash: 'Shell',
|
|
35
|
+
xml: 'XML',
|
|
36
|
+
graphql: 'GraphQL',
|
|
37
|
+
proto: 'Protobuf',
|
|
17
38
|
};
|
|
18
39
|
return map[ext] ?? (ext.toUpperCase() || 'Text');
|
|
19
40
|
}
|
|
20
41
|
|
|
21
|
-
export function FileNode({
|
|
22
|
-
node,
|
|
23
|
-
expanded = false,
|
|
24
|
-
}: { node: CanvasNodeState; expanded?: boolean }) {
|
|
42
|
+
export function FileNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
|
|
25
43
|
const filePath = (node.data.path as string) || (node.data.content as string) || '';
|
|
26
44
|
const title = (node.data.title as string) || filePath.split('/').pop() || 'File';
|
|
27
45
|
const cachedContent = node.data.fileContent as string | undefined;
|
|
@@ -44,26 +62,30 @@ export function FileNode({
|
|
|
44
62
|
let cancelled = false;
|
|
45
63
|
setLoading(true);
|
|
46
64
|
setError(null);
|
|
47
|
-
fetchFile(filePath)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
65
|
+
fetchFile(filePath)
|
|
66
|
+
.then(({ content: fileText }) => {
|
|
67
|
+
if (cancelled) return;
|
|
68
|
+
if (!fileText && fileText !== '') {
|
|
69
|
+
setError('File not found');
|
|
70
|
+
setLoading(false);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
setContent(fileText);
|
|
51
74
|
setLoading(false);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
return () => { cancelled = true; };
|
|
75
|
+
// Cache content in node data so it survives re-renders
|
|
76
|
+
const lines = fileText.split('\n').length;
|
|
77
|
+
updateNodeData(node.id, { fileContent: fileText, lineCount: lines });
|
|
78
|
+
void updateNodeFromClient(node.id, { data: { fileContent: fileText, lineCount: lines } });
|
|
79
|
+
})
|
|
80
|
+
.catch(() => {
|
|
81
|
+
if (!cancelled) {
|
|
82
|
+
setError('Failed to load file');
|
|
83
|
+
setLoading(false);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
return () => {
|
|
87
|
+
cancelled = true;
|
|
88
|
+
};
|
|
67
89
|
}, [filePath, cachedContent]);
|
|
68
90
|
|
|
69
91
|
// Sync content when server pushes updates via SSE
|
|
@@ -80,27 +102,29 @@ export function FileNode({
|
|
|
80
102
|
// Clear cached content to force a fresh fetch
|
|
81
103
|
updateNodeData(node.id, { fileContent: undefined });
|
|
82
104
|
void updateNodeFromClient(node.id, { data: { fileContent: undefined } });
|
|
83
|
-
fetchFile(filePath)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
lineCount: lines,
|
|
91
|
-
updatedAt,
|
|
92
|
-
});
|
|
93
|
-
void updateNodeFromClient(node.id, {
|
|
94
|
-
data: {
|
|
105
|
+
fetchFile(filePath)
|
|
106
|
+
.then(({ content: fileText }) => {
|
|
107
|
+
setContent(fileText);
|
|
108
|
+
setLoading(false);
|
|
109
|
+
const lines = fileText.split('\n').length;
|
|
110
|
+
const updatedAt = new Date().toISOString();
|
|
111
|
+
updateNodeData(node.id, {
|
|
95
112
|
fileContent: fileText,
|
|
96
113
|
lineCount: lines,
|
|
97
114
|
updatedAt,
|
|
98
|
-
}
|
|
115
|
+
});
|
|
116
|
+
void updateNodeFromClient(node.id, {
|
|
117
|
+
data: {
|
|
118
|
+
fileContent: fileText,
|
|
119
|
+
lineCount: lines,
|
|
120
|
+
updatedAt,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
})
|
|
124
|
+
.catch(() => {
|
|
125
|
+
setError('Failed to reload');
|
|
126
|
+
setLoading(false);
|
|
99
127
|
});
|
|
100
|
-
}).catch(() => {
|
|
101
|
-
setError('Failed to reload');
|
|
102
|
-
setLoading(false);
|
|
103
|
-
});
|
|
104
128
|
}, [filePath, node.id]);
|
|
105
129
|
|
|
106
130
|
const lang = langFromPath(filePath);
|
|
@@ -108,11 +132,7 @@ export function FileNode({
|
|
|
108
132
|
const gutterWidth = `${String(lines.length).length + 1}ch`;
|
|
109
133
|
|
|
110
134
|
if (!filePath) {
|
|
111
|
-
return (
|
|
112
|
-
<div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>
|
|
113
|
-
No file path set
|
|
114
|
-
</div>
|
|
115
|
-
);
|
|
135
|
+
return <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>No file path set</div>;
|
|
116
136
|
}
|
|
117
137
|
|
|
118
138
|
return (
|
|
@@ -162,9 +182,7 @@ export function FileNode({
|
|
|
162
182
|
{filePath}
|
|
163
183
|
</span>
|
|
164
184
|
{lineCount !== undefined && (
|
|
165
|
-
<span style={{ color: 'var(--c-dim)', fontSize: '10px', flexShrink: 0 }}>
|
|
166
|
-
{lineCount} lines
|
|
167
|
-
</span>
|
|
185
|
+
<span style={{ color: 'var(--c-dim)', fontSize: '10px', flexShrink: 0 }}>{lineCount} lines</span>
|
|
168
186
|
)}
|
|
169
187
|
{updatedAt && (
|
|
170
188
|
<span style={{ color: 'var(--c-dim)', fontSize: '10px', flexShrink: 0 }}>
|
|
@@ -224,14 +242,8 @@ export function FileNode({
|
|
|
224
242
|
borderRadius: expanded ? '0 0 8px 8px' : undefined,
|
|
225
243
|
}}
|
|
226
244
|
>
|
|
227
|
-
{loading && (
|
|
228
|
-
|
|
229
|
-
Loading…
|
|
230
|
-
</div>
|
|
231
|
-
)}
|
|
232
|
-
{error && (
|
|
233
|
-
<div style={{ color: 'var(--c-danger)', padding: '12px' }}>{error}</div>
|
|
234
|
-
)}
|
|
245
|
+
{loading && <div style={{ color: 'var(--c-dim)', padding: '12px', fontStyle: 'italic' }}>Loading…</div>}
|
|
246
|
+
{error && <div style={{ color: 'var(--c-danger)', padding: '12px' }}>{error}</div>}
|
|
235
247
|
{!loading && !error && (
|
|
236
248
|
<pre
|
|
237
249
|
style={{
|
|
@@ -26,14 +26,12 @@ export function GroupNode({ node }: GroupNodeProps) {
|
|
|
26
26
|
return (
|
|
27
27
|
<div class="group-node-body">
|
|
28
28
|
<div class="group-summary">
|
|
29
|
-
<span class="group-child-count">
|
|
29
|
+
<span class="group-child-count">
|
|
30
|
+
{childCount} node{childCount !== 1 ? 's' : ''}
|
|
31
|
+
</span>
|
|
30
32
|
{typeSummary && <span class="group-type-summary">{typeSummary}</span>}
|
|
31
33
|
</div>
|
|
32
|
-
{childCount === 0 &&
|
|
33
|
-
<div class="group-empty-hint">
|
|
34
|
-
Drag nodes here or use the selection bar to group nodes
|
|
35
|
-
</div>
|
|
36
|
-
)}
|
|
34
|
+
{childCount === 0 && <div class="group-empty-hint">Drag nodes here or use the selection bar to group nodes</div>}
|
|
37
35
|
</div>
|
|
38
36
|
);
|
|
39
37
|
}
|