pmx-canvas 0.4.5 → 0.4.7
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 +118 -0
- package/Readme.md +76 -36
- package/dist/canvas/global.css +169 -3
- package/dist/canvas/index.js +112 -87
- package/dist/canvas/mermaid-entry.js +3563 -0
- package/dist/json-render/index.css +1 -1
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/smoke.d.ts +1 -0
- package/dist/types/cli/shared.d.ts +13 -1
- package/dist/types/client/canvas/CanvasNode.d.ts +11 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
- package/dist/types/client/mermaid-entry.d.ts +11 -0
- package/dist/types/client/nodes/AxStepControls.d.ts +4 -0
- package/dist/types/client/nodes/DiffNode.d.ts +20 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +0 -2
- package/dist/types/client/nodes/FileNode.d.ts +14 -0
- package/dist/types/client/nodes/MermaidNode.d.ts +12 -0
- package/dist/types/client/state/canvas-store.d.ts +10 -0
- package/dist/types/client/state/intent-bridge.d.ts +9 -0
- package/dist/types/client/types.d.ts +2 -2
- package/dist/types/server/ax-flow-loop.d.ts +5 -0
- package/dist/types/server/ax-interaction.d.ts +21 -2
- package/dist/types/server/canvas-operations.d.ts +5 -0
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-state.d.ts +18 -1
- package/dist/types/server/canvas-validation.d.ts +28 -1
- package/dist/types/server/file-content.d.ts +46 -0
- package/dist/types/server/html-primitives.d.ts +13 -1
- package/dist/types/server/html-surface.d.ts +8 -0
- package/dist/types/server/index.d.ts +5 -0
- package/dist/types/server/operations/ops/ax-work.d.ts +1 -0
- package/dist/types/server/operations/ops/nodes.d.ts +1 -1
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/web-artifacts.d.ts +14 -0
- package/dist/types/server/workboard.d.ts +46 -0
- package/dist/types/shared/ax-flow.d.ts +51 -0
- package/dist/types/shared/ax-surface-protocol.d.ts +7 -0
- package/dist/types/shared/node-sizes.d.ts +33 -0
- package/docs/cli.md +31 -2
- package/docs/environment.md +1 -0
- package/docs/http-api.md +29 -0
- package/docs/mcp.md +13 -1
- package/docs/node-types.md +47 -1
- package/docs/screenshot.png +0 -0
- package/package.json +5 -4
- package/skills/pmx-canvas/SKILL.md +123 -25
- package/skills/pmx-canvas/evals/evals.json +54 -3
- package/skills/pmx-canvas/references/full-reference.md +11 -6
- package/skills/pmx-canvas-testing/SKILL.md +3 -0
- package/src/cli/agent.ts +2 -0
- package/src/cli/commands/ax.ts +28 -3
- package/src/cli/commands/history.ts +1 -1
- package/src/cli/commands/nodes.ts +2 -2
- package/src/cli/commands/query.ts +1 -1
- package/src/cli/commands/smoke.ts +212 -0
- package/src/cli/commands/view.ts +1 -1
- package/src/cli/index.ts +7 -0
- package/src/cli/shared.ts +35 -5
- package/src/client/App.tsx +28 -15
- package/src/client/canvas/CanvasNode.tsx +54 -5
- package/src/client/canvas/CanvasViewport.tsx +6 -0
- package/src/client/canvas/EdgeLayer.tsx +44 -24
- package/src/client/canvas/ExpandedNodeOverlay.tsx +21 -0
- package/src/client/canvas/Minimap.tsx +2 -0
- package/src/client/canvas/auto-fit.ts +12 -1
- package/src/client/canvas/use-pan-zoom.ts +16 -1
- package/src/client/icons.tsx +4 -0
- package/src/client/mermaid-entry.ts +76 -0
- package/src/client/nodes/AxStepControls.tsx +226 -0
- package/src/client/nodes/DiffNode.tsx +114 -0
- package/src/client/nodes/ExtAppFrame.tsx +259 -84
- package/src/client/nodes/FileNode.tsx +249 -51
- package/src/client/nodes/MermaidNode.tsx +81 -0
- package/src/client/state/canvas-store.ts +19 -0
- package/src/client/state/intent-bridge.ts +32 -0
- package/src/client/state/node-factory.ts +2 -0
- package/src/client/state/sse-bridge.ts +32 -1
- package/src/client/theme/global.css +169 -3
- package/src/client/types.ts +6 -0
- package/src/mcp/server.ts +19 -1
- package/src/server/ax-flow-loop.ts +114 -0
- package/src/server/ax-interaction.ts +305 -4
- package/src/server/canvas-operations.ts +83 -26
- package/src/server/canvas-provenance.ts +2 -0
- package/src/server/canvas-schema.ts +33 -0
- package/src/server/canvas-state.ts +46 -10
- package/src/server/canvas-validation.ts +88 -0
- package/src/server/demo-state.json +4605 -523
- package/src/server/demo.ts +24 -0
- package/src/server/file-content.ts +152 -0
- package/src/server/file-watcher.ts +7 -10
- package/src/server/html-primitives.ts +636 -0
- package/src/server/html-surface.ts +11 -0
- package/src/server/index.ts +25 -6
- package/src/server/operations/composites.ts +3 -2
- package/src/server/operations/ops/ax-work.ts +5 -0
- package/src/server/operations/ops/batch.ts +8 -0
- package/src/server/operations/ops/json-render.ts +44 -0
- package/src/server/operations/ops/nodes.ts +24 -2
- package/src/server/operations/ops/viewport.ts +15 -2
- package/src/server/server.ts +154 -6
- package/src/server/web-artifacts.ts +109 -0
- package/src/server/workboard.ts +159 -0
- package/src/shared/ax-flow.ts +102 -0
- package/src/shared/ax-surface-protocol.ts +8 -0
- package/src/shared/node-sizes.ts +52 -0
- package/src/shared/surface.ts +2 -0
- package/dist/types/server/chart-template.d.ts +0 -29
- package/src/server/chart-template.ts +0 -448
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useMemo } from 'preact/hooks';
|
|
2
|
+
import type { CanvasNodeState } from '../types';
|
|
3
|
+
|
|
4
|
+
export interface ParsedDiffLine {
|
|
5
|
+
kind: 'add' | 'remove' | 'context' | 'hunk';
|
|
6
|
+
text: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ParsedDiffSection {
|
|
10
|
+
/** Display path derived from the +++/--- headers or the `diff --git` line; null for headerless diffs. */
|
|
11
|
+
path: string | null;
|
|
12
|
+
lines: ParsedDiffLine[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function stripDiffPathPrefix(path: string): string {
|
|
16
|
+
return path.replace(/^[ab]\//, '');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Derive a display path from a `--- old` / `+++ new` header pair. */
|
|
20
|
+
function headerPairPath(oldPath: string, newPath: string): string | null {
|
|
21
|
+
if (newPath && newPath !== '/dev/null') return stripDiffPathPrefix(newPath);
|
|
22
|
+
if (oldPath && oldPath !== '/dev/null') return stripDiffPathPrefix(oldPath);
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Split unified diff text into file sections, hunk headers, and classified
|
|
28
|
+
* lines. Recognizes `diff --git` boundaries and `---`/`+++` header pairs
|
|
29
|
+
* (never misclassifying them as remove/add); diffs without any file headers
|
|
30
|
+
* parse as a single anonymous section.
|
|
31
|
+
*/
|
|
32
|
+
export function parseUnifiedDiff(text: string): ParsedDiffSection[] {
|
|
33
|
+
if (!text.trim()) return [];
|
|
34
|
+
const rawLines = text.split('\n');
|
|
35
|
+
// Drop the trailing empty string produced by a terminating newline.
|
|
36
|
+
if (rawLines.length > 0 && rawLines[rawLines.length - 1] === '') rawLines.pop();
|
|
37
|
+
|
|
38
|
+
const sections: ParsedDiffSection[] = [];
|
|
39
|
+
let current: ParsedDiffSection | null = null;
|
|
40
|
+
let currentHasHunk = false;
|
|
41
|
+
|
|
42
|
+
const startSection = (path: string | null): ParsedDiffSection => {
|
|
43
|
+
const section: ParsedDiffSection = { path, lines: [] };
|
|
44
|
+
currentHasHunk = false;
|
|
45
|
+
sections.push(section);
|
|
46
|
+
return section;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
50
|
+
const line = rawLines[i] ?? '';
|
|
51
|
+
|
|
52
|
+
const gitHeader = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
|
|
53
|
+
if (gitHeader) {
|
|
54
|
+
current = startSection(gitHeader[2] ?? null);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const next = rawLines[i + 1] ?? '';
|
|
59
|
+
if (line.startsWith('--- ') && next.startsWith('+++ ')) {
|
|
60
|
+
const path = headerPairPath(line.slice(4).trim(), next.slice(4).trim());
|
|
61
|
+
if (current && !currentHasHunk) {
|
|
62
|
+
// Header pair inside a `diff --git` preamble refines that section.
|
|
63
|
+
if (!current.path) current.path = path;
|
|
64
|
+
} else {
|
|
65
|
+
current = startSection(path);
|
|
66
|
+
}
|
|
67
|
+
i++; // Consume the `+++` line too — neither header is an add/remove line.
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!current) current = startSection(null);
|
|
72
|
+
const section = current;
|
|
73
|
+
|
|
74
|
+
if (line.startsWith('@@')) {
|
|
75
|
+
currentHasHunk = true;
|
|
76
|
+
section.lines.push({ kind: 'hunk', text: line });
|
|
77
|
+
} else if (line.startsWith('+')) {
|
|
78
|
+
section.lines.push({ kind: 'add', text: line });
|
|
79
|
+
} else if (line.startsWith('-')) {
|
|
80
|
+
section.lines.push({ kind: 'remove', text: line });
|
|
81
|
+
} else {
|
|
82
|
+
// Includes `` and git meta lines (index, mode).
|
|
83
|
+
section.lines.push({ kind: 'context', text: line });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return sections;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function DiffNode({ node }: { node: CanvasNodeState }) {
|
|
91
|
+
const content = typeof node.data.content === 'string' ? node.data.content : '';
|
|
92
|
+
const sections = useMemo(() => parseUnifiedDiff(content), [content]);
|
|
93
|
+
|
|
94
|
+
if (sections.length === 0) {
|
|
95
|
+
return <div class="diff-node diff-node-empty">Empty diff</div>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div class="diff-node">
|
|
100
|
+
{sections.map((section, sectionIndex) => (
|
|
101
|
+
<div class="diff-file" key={`${section.path ?? 'anonymous'}-${sectionIndex}`}>
|
|
102
|
+
{section.path && <div class="diff-file-header">{section.path}</div>}
|
|
103
|
+
<pre class="diff-lines">
|
|
104
|
+
{section.lines.map((line, lineIndex) => (
|
|
105
|
+
<div key={lineIndex} class={line.kind === 'hunk' ? 'diff-hunk' : `diff-line-${line.kind}`}>
|
|
106
|
+
{line.text || ' '}
|
|
107
|
+
</div>
|
|
108
|
+
))}
|
|
109
|
+
</pre>
|
|
110
|
+
</div>
|
|
111
|
+
))}
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
AX_SURFACE_ACK_SOURCE,
|
|
7
7
|
AX_SURFACE_EMIT_SOURCE,
|
|
8
8
|
EXT_APP_BOOT_BEACON_SOURCE,
|
|
9
|
+
EXT_APP_PAINT_PROBE_SOURCE,
|
|
9
10
|
} from '../../shared/ax-surface-protocol.js';
|
|
10
11
|
import { extAppToolResultsMatch } from '../../shared/ext-app-tool-result.js';
|
|
11
12
|
import { DEFAULT_EXT_APP_SANDBOX } from '../../shared/surface.js';
|
|
@@ -137,10 +138,6 @@ export function enqueueWebkitRemount(task: WebkitRemountTask): void {
|
|
|
137
138
|
.catch(() => {});
|
|
138
139
|
}
|
|
139
140
|
|
|
140
|
-
export function shouldScheduleWebKitRepaint(status: ExtAppFrameStatus, hasReplayToolResult: boolean): boolean {
|
|
141
|
-
return hasReplayToolResult ? status === 'done' : status === 'ready' || status === 'done';
|
|
142
|
-
}
|
|
143
|
-
|
|
144
141
|
export function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number): string {
|
|
145
142
|
const html = typeof node.data.html === 'string' ? node.data.html : '';
|
|
146
143
|
const serverName = typeof node.data.serverName === 'string' ? node.data.serverName : '';
|
|
@@ -250,7 +247,27 @@ export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): stri
|
|
|
250
247
|
*/
|
|
251
248
|
export function buildExtAppBootBeaconScript(frameToken: string, nodeId: string): string {
|
|
252
249
|
return `<script data-pmx-canvas-boot-beacon>
|
|
253
|
-
|
|
250
|
+
(function () {
|
|
251
|
+
var TOKEN = ${JSON.stringify(frameToken)};
|
|
252
|
+
var NODE_ID = ${JSON.stringify(nodeId)};
|
|
253
|
+
window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: TOKEN, nodeId: NODE_ID }, '*');
|
|
254
|
+
// Paint-tick answers (Finding N): a double rAF only fires when this frame's
|
|
255
|
+
// rendering pipeline is actually running — a frame whose layer WebKit never
|
|
256
|
+
// composites stays silent, and that silence drives the parent's recovery
|
|
257
|
+
// ladder. One unsolicited tick at boot, then on-demand answers to probes.
|
|
258
|
+
function sendPaintTick() {
|
|
259
|
+
requestAnimationFrame(function () {
|
|
260
|
+
requestAnimationFrame(function () {
|
|
261
|
+
window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: TOKEN, nodeId: NODE_ID, kind: 'paint-tick' }, '*');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
window.addEventListener('message', function (event) {
|
|
266
|
+
var m = event.data;
|
|
267
|
+
if (m && m.source === '${EXT_APP_PAINT_PROBE_SOURCE}' && m.token === TOKEN) sendPaintTick();
|
|
268
|
+
});
|
|
269
|
+
sendPaintTick();
|
|
270
|
+
})();
|
|
254
271
|
</script>`;
|
|
255
272
|
}
|
|
256
273
|
|
|
@@ -319,8 +336,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
319
336
|
const toolResultSendingRef = useRef<Promise<void> | null>(null);
|
|
320
337
|
const bridgeReadyRef = useRef(false);
|
|
321
338
|
const themeUnsubRef = useRef<(() => void) | null>(null);
|
|
322
|
-
const webkitRepaintDoneRef = useRef(false);
|
|
323
339
|
const webkitRemountAttemptsRef = useRef(0);
|
|
340
|
+
// Paint oracle (Finding N): resolvers waiting for the frame's next paint-tick.
|
|
341
|
+
const paintTickWaitersRef = useRef<Array<() => void>>([]);
|
|
324
342
|
// Genuine boot signal: set ONLY when the app completes the ui/initialize
|
|
325
343
|
// handshake (bridge.oninitialized) — NOT by the 1200ms bootstrap fallback,
|
|
326
344
|
// which flips status via notifications that resolve even into a dead iframe.
|
|
@@ -338,6 +356,21 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
338
356
|
const [status, setStatus] = useState<ExtAppFrameStatus>('loading');
|
|
339
357
|
const [error, setError] = useState<string | null>(null);
|
|
340
358
|
const [retryKey, setRetryKey] = useState(0);
|
|
359
|
+
// WebKit paint ladder state: overlay holds until paint is confirmed; when the
|
|
360
|
+
// ladder exhausts its budget the tile shows an explicit Retry instead of
|
|
361
|
+
// silent black (Finding N).
|
|
362
|
+
const [paintPending, setPaintPending] = useState(false);
|
|
363
|
+
const [recoveryExhausted, setRecoveryExhausted] = useState(false);
|
|
364
|
+
// Serialized initial mount (Finding N): on WebKit, present-at-load ext-apps
|
|
365
|
+
// boot strictly one at a time through the shared queue — the cold-hydration
|
|
366
|
+
// BURST is what overwhelms the compositor. Everywhere else, mount instantly.
|
|
367
|
+
const [mountGranted, setMountGranted] = useState(
|
|
368
|
+
() =>
|
|
369
|
+
expanded ||
|
|
370
|
+
typeof navigator === 'undefined' ||
|
|
371
|
+
typeof window === 'undefined' ||
|
|
372
|
+
!isWebKitOnlyHost(navigator.userAgent),
|
|
373
|
+
);
|
|
341
374
|
|
|
342
375
|
const html = node.data.html as string | null;
|
|
343
376
|
const serverName = node.data.serverName as string | undefined;
|
|
@@ -360,7 +393,6 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
360
393
|
const maxHeight = node.size.height;
|
|
361
394
|
const nodeId = node.id;
|
|
362
395
|
const frameKey = getExtAppBridgeInitKey(node, retryKey);
|
|
363
|
-
const hasReplayToolResult = toolResult != null;
|
|
364
396
|
const iframeSandbox = resolveExtAppSandbox(null);
|
|
365
397
|
// Phase 6 — opt-in ext-app AX bridge. When the node sets data.axCapabilities.enabled,
|
|
366
398
|
// inject window.PMX_AX into the app HTML and accept emits below (server re-validates).
|
|
@@ -380,15 +412,45 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
380
412
|
useEffect(() => {
|
|
381
413
|
function onBootBeacon(event: MessageEvent) {
|
|
382
414
|
if (event.source !== iframeRef.current?.contentWindow) return;
|
|
383
|
-
const data = event.data as { source?: string; token?: string; nodeId?: string } | null;
|
|
415
|
+
const data = event.data as { source?: string; token?: string; nodeId?: string; kind?: string } | null;
|
|
384
416
|
if (!data || data.source !== EXT_APP_BOOT_BEACON_SOURCE || data.token !== axToken || data.nodeId !== nodeId)
|
|
385
417
|
return;
|
|
386
418
|
appScriptsRanRef.current = true;
|
|
419
|
+
if (data.kind === 'paint-tick') {
|
|
420
|
+
for (const waiter of paintTickWaitersRef.current.splice(0)) waiter();
|
|
421
|
+
}
|
|
387
422
|
}
|
|
388
423
|
window.addEventListener('message', onBootBeacon);
|
|
389
424
|
return () => window.removeEventListener('message', onBootBeacon);
|
|
390
425
|
}, [axToken, nodeId]);
|
|
391
426
|
|
|
427
|
+
// Acquire a serialized mount slot (Finding N). Reuses the boot-aware queue:
|
|
428
|
+
// the next present-at-load app's iframe is not even created until this one
|
|
429
|
+
// has settled (or timed out), so every WebKit boot lands in an idle panel —
|
|
430
|
+
// the empirically always-successful condition. The expanded overlay instance
|
|
431
|
+
// and non-WebKit hosts mount immediately (mountGranted starts true).
|
|
432
|
+
useEffect(() => {
|
|
433
|
+
if (mountGranted) return;
|
|
434
|
+
enqueueWebkitRemount({
|
|
435
|
+
remount: () => {
|
|
436
|
+
if (unmountedRef.current) return false;
|
|
437
|
+
extAppRecoveryLog(nodeId, 'mount-slot');
|
|
438
|
+
setMountGranted(true);
|
|
439
|
+
return true;
|
|
440
|
+
},
|
|
441
|
+
awaitBoot: () =>
|
|
442
|
+
new Promise<void>((resolve) => {
|
|
443
|
+
const timer = window.setTimeout(finish, WEBKIT_BOOT_TIMEOUT_MS);
|
|
444
|
+
function finish() {
|
|
445
|
+
window.clearTimeout(timer);
|
|
446
|
+
resolve();
|
|
447
|
+
}
|
|
448
|
+
bootWaitersRef.current.push(finish);
|
|
449
|
+
}),
|
|
450
|
+
});
|
|
451
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
452
|
+
}, []);
|
|
453
|
+
|
|
392
454
|
// Ext-app AX emits — the shared sandboxed-surface trust boundary (M2).
|
|
393
455
|
useAxSurfaceBridge({ enabled: axEnabled, token: axToken, nodeId, sourceSurface: 'mcp-app', iframeRef });
|
|
394
456
|
|
|
@@ -396,12 +458,12 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
396
458
|
// Attempts are capped so a persistently-failing app degrades to the manual
|
|
397
459
|
// fallback (expand+close / Retry) instead of remount-looping forever.
|
|
398
460
|
const WEBKIT_MAX_REMOUNT_ATTEMPTS = 3;
|
|
399
|
-
const scheduleWebkitRemount = (reason: string):
|
|
461
|
+
const scheduleWebkitRemount = (reason: string): boolean => {
|
|
400
462
|
if (webkitRemountAttemptsRef.current >= WEBKIT_MAX_REMOUNT_ATTEMPTS) {
|
|
401
463
|
extAppRecoveryLog(nodeId, `remount-cap-hit (${reason})`);
|
|
402
|
-
return;
|
|
464
|
+
return false;
|
|
403
465
|
}
|
|
404
|
-
if (remountQueuedRef.current) return; // an attempt is already queued and has not run yet
|
|
466
|
+
if (remountQueuedRef.current) return true; // an attempt is already queued and has not run yet
|
|
405
467
|
webkitRemountAttemptsRef.current += 1;
|
|
406
468
|
remountQueuedRef.current = true;
|
|
407
469
|
extAppRecoveryLog(nodeId, `remount-queued #${webkitRemountAttemptsRef.current} (${reason})`);
|
|
@@ -410,6 +472,12 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
410
472
|
remountQueuedRef.current = false;
|
|
411
473
|
if (unmountedRef.current || expandedNodeId.value === nodeId) {
|
|
412
474
|
extAppRecoveryLog(nodeId, 'remount-skipped');
|
|
475
|
+
// The ladder left paintPending set on the promise that this remount
|
|
476
|
+
// would re-enter it via the new frame's settle path. A skip means
|
|
477
|
+
// that never happens — release the overlay here, or the tile spins
|
|
478
|
+
// "Finishing paint…" forever with no Retry (the expanded overlay
|
|
479
|
+
// mounts its own frame, and closing it re-runs the inline ladder).
|
|
480
|
+
setPaintPending(false);
|
|
413
481
|
return false;
|
|
414
482
|
}
|
|
415
483
|
// Stamp visibility at RUN time, not schedule time: the serialized queue
|
|
@@ -437,31 +505,93 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
437
505
|
bootWaitersRef.current.push(finish);
|
|
438
506
|
}),
|
|
439
507
|
});
|
|
508
|
+
return true;
|
|
440
509
|
};
|
|
441
510
|
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
511
|
+
// ── Paint oracle + recovery ladder (Finding N, 0.4.5 report) ────────────
|
|
512
|
+
// The old stack keyed recovery off "settled" (bridge ready + bootstrap
|
|
513
|
+
// replayed) and blind-remounted every booted app once. But Finding N is a
|
|
514
|
+
// COMPOSITOR failure after all those signals look healthy — the DOM
|
|
515
|
+
// recovers, the painted layer stays black, and the blind remount both
|
|
516
|
+
// missed real failures and rebooted healthy apps. The oracle: ask the app
|
|
517
|
+
// document for a double-rAF paint-tick. A frame whose rendering pipeline is
|
|
518
|
+
// live answers within a frame or two; silence is the paint-fail signal.
|
|
519
|
+
// Ladder on silence: soft-expand cycle (the automatic analogue of the
|
|
520
|
+
// user-proven enlarge+close — a real container size change forces WebKit to
|
|
521
|
+
// allocate a fresh layer texture, discarding the stale black one) → probe
|
|
522
|
+
// again → serialized remount (bounded by the shared attempt cap) → explicit
|
|
523
|
+
// Retry affordance. Never silent eternal black. WebKit inline frames only.
|
|
524
|
+
const PAINT_TICK_TIMEOUT_MS = 1500;
|
|
525
|
+
const probePaint = (): Promise<boolean> =>
|
|
526
|
+
new Promise((resolve) => {
|
|
527
|
+
const contentWindow = iframeRef.current?.contentWindow;
|
|
528
|
+
if (!contentWindow || unmountedRef.current) {
|
|
529
|
+
resolve(false);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
let settled = false;
|
|
533
|
+
const timer = window.setTimeout(() => {
|
|
534
|
+
if (settled) return;
|
|
535
|
+
settled = true;
|
|
536
|
+
resolve(false);
|
|
537
|
+
}, PAINT_TICK_TIMEOUT_MS);
|
|
538
|
+
paintTickWaitersRef.current.push(() => {
|
|
539
|
+
if (settled) return;
|
|
540
|
+
settled = true;
|
|
541
|
+
window.clearTimeout(timer);
|
|
542
|
+
resolve(true);
|
|
543
|
+
});
|
|
544
|
+
contentWindow.postMessage({ source: EXT_APP_PAINT_PROBE_SOURCE, token: axToken }, '*');
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
// Force a real layer reallocation: grow the iframe's box meaningfully for two
|
|
548
|
+
// frames, then restore. Invisible under the paint-pending overlay.
|
|
549
|
+
const runSoftExpandCycle = (): Promise<void> =>
|
|
550
|
+
new Promise((resolve) => {
|
|
551
|
+
const frame = iframeRef.current;
|
|
552
|
+
if (!frame) {
|
|
553
|
+
resolve();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const previousWidth = frame.style.width;
|
|
557
|
+
const previousHeight = frame.style.height;
|
|
558
|
+
frame.style.width = '140%';
|
|
559
|
+
frame.style.height = '140%';
|
|
560
|
+
requestAnimationFrame(() => {
|
|
561
|
+
requestAnimationFrame(() => {
|
|
562
|
+
frame.style.width = previousWidth;
|
|
563
|
+
frame.style.height = previousHeight;
|
|
564
|
+
extAppRecoveryLog(nodeId, 'soft-expand-cycle');
|
|
565
|
+
resolve();
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
const runPaintLadder = async (): Promise<void> => {
|
|
571
|
+
if (expanded) return;
|
|
459
572
|
if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
|
|
460
573
|
if (!isWebKitOnlyHost(navigator.userAgent)) return;
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
574
|
+
setPaintPending(true);
|
|
575
|
+
if (await probePaint()) {
|
|
576
|
+
extAppRecoveryLog(nodeId, 'paint-ok');
|
|
577
|
+
setPaintPending(false);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
extAppRecoveryLog(nodeId, 'paint-fail');
|
|
581
|
+
await runSoftExpandCycle();
|
|
582
|
+
if (await probePaint()) {
|
|
583
|
+
extAppRecoveryLog(nodeId, 'paint-ok (after soft-expand)');
|
|
584
|
+
setPaintPending(false);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (unmountedRef.current) return;
|
|
588
|
+
// The remounted frame re-enters this ladder from its own settle path.
|
|
589
|
+
if (!scheduleWebkitRemount('paint-fail')) {
|
|
590
|
+
extAppRecoveryLog(nodeId, 'recovery-exhausted');
|
|
591
|
+
setPaintPending(false);
|
|
592
|
+
setRecoveryExhausted(true);
|
|
593
|
+
}
|
|
594
|
+
};
|
|
465
595
|
|
|
466
596
|
// Finding N (0.3.2 report): the Copilot panel can load the canvas while the
|
|
467
597
|
// WKWebView document is HIDDEN (panel backgrounded/occluded). WebKit suppresses
|
|
@@ -482,6 +612,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
482
612
|
// Fresh visibility = fresh recovery budget: the hidden-run attempts were
|
|
483
613
|
// spent without a chance of compositing.
|
|
484
614
|
webkitRemountAttemptsRef.current = 0;
|
|
615
|
+
setRecoveryExhausted(false);
|
|
485
616
|
extAppRecoveryLog(nodeId, 'visible-again-rearm');
|
|
486
617
|
scheduleWebkitRemount('visible-after-hidden-boot');
|
|
487
618
|
}
|
|
@@ -504,19 +635,20 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
504
635
|
if (expanded) return;
|
|
505
636
|
if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
|
|
506
637
|
if (!isWebKitOnlyHost(navigator.userAgent)) return;
|
|
507
|
-
if (!iframeDocument.ready) return;
|
|
638
|
+
if (!iframeDocument.ready || !mountGranted) return;
|
|
508
639
|
const timer = window.setTimeout(() => {
|
|
509
640
|
if (appInitializedRef.current || appScriptsRanRef.current || unmountedRef.current) return;
|
|
510
641
|
scheduleWebkitRemount('boot-watchdog');
|
|
511
642
|
}, WEBKIT_BOOT_WATCHDOG_MS);
|
|
512
643
|
return () => window.clearTimeout(timer);
|
|
513
644
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
514
|
-
}, [frameKey, iframeDocument.ready, expanded]);
|
|
645
|
+
}, [frameKey, iframeDocument.ready, expanded, mountGranted]);
|
|
515
646
|
|
|
516
647
|
useEffect(
|
|
517
648
|
() => () => {
|
|
518
649
|
unmountedRef.current = true;
|
|
519
650
|
for (const waiter of bootWaitersRef.current.splice(0)) waiter();
|
|
651
|
+
for (const waiter of paintTickWaitersRef.current.splice(0)) waiter();
|
|
520
652
|
},
|
|
521
653
|
[],
|
|
522
654
|
);
|
|
@@ -584,7 +716,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
584
716
|
|
|
585
717
|
// Initialize as soon as HTML is mounted; some apps send initialize before iframe load fires.
|
|
586
718
|
useEffect(() => {
|
|
587
|
-
if (!html || !iframeDocument.ready) return;
|
|
719
|
+
if (!html || !iframeDocument.ready || !mountGranted) return;
|
|
588
720
|
const iframe = iframeRef.current;
|
|
589
721
|
if (!iframe) return;
|
|
590
722
|
let disposed = false;
|
|
@@ -665,29 +797,16 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
665
797
|
});
|
|
666
798
|
};
|
|
667
799
|
|
|
668
|
-
// Finding N
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
|
|
672
|
-
// iframe's height by 1px for two frames so WebKit must re-rasterize the
|
|
673
|
-
// layer. No reload, no app state loss; a healthy frame repaints
|
|
674
|
-
// identically. Scheduled past the remount queue's settle pause so it
|
|
675
|
-
// lands after the app has actually drawn.
|
|
676
|
-
const scheduleWebkitRepaintNudge = () => {
|
|
800
|
+
// Finding N: run the paint oracle + recovery ladder after the app has had
|
|
801
|
+
// its post-settle draw window. Scheduled past the remount queue's settle
|
|
802
|
+
// pause so the probe measures the frame AFTER the scene draw.
|
|
803
|
+
const schedulePaintLadder = () => {
|
|
677
804
|
if (!isWebKitOnlyHost(navigator.userAgent)) return;
|
|
678
805
|
if (repaintNudgeTimer) clearTimeout(repaintNudgeTimer);
|
|
679
806
|
repaintNudgeTimer = setTimeout(() => {
|
|
680
807
|
repaintNudgeTimer = null;
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
const previous = frame.style.height;
|
|
684
|
-
frame.style.height = 'calc(100% - 1px)';
|
|
685
|
-
requestAnimationFrame(() => {
|
|
686
|
-
requestAnimationFrame(() => {
|
|
687
|
-
frame.style.height = previous;
|
|
688
|
-
extAppRecoveryLog(nodeId, 'repaint-nudge');
|
|
689
|
-
});
|
|
690
|
-
});
|
|
808
|
+
if (disposed) return;
|
|
809
|
+
void runPaintLadder();
|
|
691
810
|
}, WEBKIT_REMOUNT_SETTLE_MS + 200);
|
|
692
811
|
};
|
|
693
812
|
|
|
@@ -842,7 +961,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
842
961
|
extAppRecoveryLog(nodeId, 'settled');
|
|
843
962
|
for (const waiter of bootWaitersRef.current.splice(0)) waiter();
|
|
844
963
|
nudgeHostContextAfterLayout();
|
|
845
|
-
|
|
964
|
+
schedulePaintLadder();
|
|
846
965
|
})
|
|
847
966
|
.catch((err) => {
|
|
848
967
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -869,7 +988,15 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
869
988
|
setStatus(bootstrapToolResult ? 'done' : 'ready');
|
|
870
989
|
setError(null);
|
|
871
990
|
nudgeHostContextAfterLayout();
|
|
872
|
-
|
|
991
|
+
schedulePaintLadder();
|
|
992
|
+
// Release the serialized queue's boot wait — but ONLY when the boot
|
|
993
|
+
// beacon proved this frame's scripts ran. A live non-SDK app (never
|
|
994
|
+
// sends initialized) would otherwise hold every later mount slot
|
|
995
|
+
// for the full boot timeout; a dead window still runs it.
|
|
996
|
+
if (appScriptsRanRef.current) {
|
|
997
|
+
extAppRecoveryLog(nodeId, 'settled (fallback)');
|
|
998
|
+
for (const waiter of bootWaitersRef.current.splice(0)) waiter();
|
|
999
|
+
}
|
|
873
1000
|
})
|
|
874
1001
|
.catch((err) => {
|
|
875
1002
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -947,7 +1074,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
947
1074
|
transportRef.current = null;
|
|
948
1075
|
}
|
|
949
1076
|
};
|
|
950
|
-
}, [frameKey, iframeDocument.key]);
|
|
1077
|
+
}, [frameKey, iframeDocument.key, mountGranted]);
|
|
951
1078
|
|
|
952
1079
|
// Forward tool result when it arrives after bridge is ready
|
|
953
1080
|
useEffect(() => {
|
|
@@ -1117,34 +1244,79 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
1117
1244
|
a second time to actually enter edit mode. Routing all inline clicks
|
|
1118
1245
|
to "expand" makes the flow "open → edit" instead of "edit → expand → edit". */}
|
|
1119
1246
|
<div style={{ flex: 1, position: 'relative', display: 'flex', minHeight: 0, height: '100%' }}>
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1247
|
+
{mountGranted && (
|
|
1248
|
+
<iframe
|
|
1249
|
+
key={frameKey}
|
|
1250
|
+
ref={iframeRef}
|
|
1251
|
+
{...iframeDocument.attributes}
|
|
1252
|
+
sandbox={iframeSandbox}
|
|
1253
|
+
allow={buildAllowAttribute(resourceMeta?.permissions)}
|
|
1254
|
+
// NB: do NOT add the `.mcp-app-frame` GPU-layer class (translateZ(0)) here —
|
|
1255
|
+
// it creates a stacking context that breaks the AX emit→ack round-trip in the
|
|
1256
|
+
// expanded ext-app overlay (#55 e2e).
|
|
1257
|
+
style={{
|
|
1258
|
+
flex: 1,
|
|
1259
|
+
width: '100%',
|
|
1260
|
+
height: '100%',
|
|
1261
|
+
minHeight: 0,
|
|
1262
|
+
border: 'none',
|
|
1263
|
+
background: 'var(--c-panel)',
|
|
1264
|
+
pointerEvents: isExpanded && status !== 'loading' ? 'auto' : 'none',
|
|
1265
|
+
}}
|
|
1266
|
+
title={`Ext App: ${toolName}`}
|
|
1267
|
+
/>
|
|
1268
|
+
)}
|
|
1269
|
+
{/* Recovery exhausted (Finding N): the paint ladder ran out of budget.
|
|
1270
|
+
Never leave a silent black tile — offer an explicit Retry with a
|
|
1271
|
+
fresh recovery budget (a manual retry mirrors the user-proven
|
|
1272
|
+
expand+close in an idle panel). */}
|
|
1273
|
+
{recoveryExhausted && !error && (
|
|
1274
|
+
<div
|
|
1275
|
+
style={{
|
|
1276
|
+
position: 'absolute',
|
|
1277
|
+
inset: 0,
|
|
1278
|
+
display: 'flex',
|
|
1279
|
+
flexDirection: 'column',
|
|
1280
|
+
alignItems: 'center',
|
|
1281
|
+
justifyContent: 'center',
|
|
1282
|
+
gap: '10px',
|
|
1283
|
+
background: 'var(--c-panel)',
|
|
1284
|
+
color: 'var(--c-warn)',
|
|
1285
|
+
fontSize: '12px',
|
|
1286
|
+
}}
|
|
1287
|
+
>
|
|
1288
|
+
<div>App surface failed to paint.</div>
|
|
1289
|
+
<button
|
|
1290
|
+
type="button"
|
|
1291
|
+
onClick={(e) => {
|
|
1292
|
+
e.stopPropagation();
|
|
1293
|
+
extAppRecoveryLog(nodeId, 'manual-retry');
|
|
1294
|
+
webkitRemountAttemptsRef.current = 0;
|
|
1295
|
+
setRecoveryExhausted(false);
|
|
1296
|
+
setStatus('loading');
|
|
1297
|
+
setRetryKey((k) => k + 1);
|
|
1298
|
+
}}
|
|
1299
|
+
style={{
|
|
1300
|
+
background: 'var(--c-surface-hover)',
|
|
1301
|
+
border: '1px solid var(--c-warn-15)',
|
|
1302
|
+
borderRadius: '4px',
|
|
1303
|
+
color: 'var(--c-warn)',
|
|
1304
|
+
cursor: 'pointer',
|
|
1305
|
+
fontSize: '11px',
|
|
1306
|
+
padding: '3px 10px',
|
|
1307
|
+
}}
|
|
1308
|
+
>
|
|
1309
|
+
Retry
|
|
1310
|
+
</button>
|
|
1311
|
+
</div>
|
|
1312
|
+
)}
|
|
1141
1313
|
{/* Connecting overlay: an opaque sibling ABOVE the iframe while this
|
|
1142
|
-
frame boots
|
|
1143
|
-
|
|
1144
|
-
the
|
|
1314
|
+
frame boots — and, on WebKit, until the paint oracle confirms the
|
|
1315
|
+
frame actually composited (Finding N). Sibling DOM always paints,
|
|
1316
|
+
so the boot + recovery window reads as an intentional loading
|
|
1145
1317
|
state instead of a broken black tile. pointer-events pass through
|
|
1146
1318
|
so the expand click-catcher keeps working. */}
|
|
1147
|
-
{status === 'loading' && !error && (
|
|
1319
|
+
{(status === 'loading' || paintPending) && !error && !recoveryExhausted && (
|
|
1148
1320
|
<div
|
|
1149
1321
|
style={{
|
|
1150
1322
|
position: 'absolute',
|
|
@@ -1170,11 +1342,14 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
1170
1342
|
animation: 'spin 1s linear infinite',
|
|
1171
1343
|
}}
|
|
1172
1344
|
/>
|
|
1173
|
-
<div>Connecting to {toolName}
|
|
1345
|
+
<div>{status === 'loading' ? `Connecting to ${toolName}...` : 'Finishing paint...'}</div>
|
|
1174
1346
|
<style>{'@keyframes spin { to { transform: rotate(360deg); } }'}</style>
|
|
1175
1347
|
</div>
|
|
1176
1348
|
)}
|
|
1177
|
-
{
|
|
1349
|
+
{/* Suppressed while the recovery card is up: the catcher is rendered
|
|
1350
|
+
last and would paint over the centred Retry button, swallowing the
|
|
1351
|
+
click that is the ladder's only actionable terminal rung. */}
|
|
1352
|
+
{!isExpanded && !(recoveryExhausted && !error) && (
|
|
1178
1353
|
<button
|
|
1179
1354
|
type="button"
|
|
1180
1355
|
onClick={(e) => {
|