pmx-canvas 0.5.0 → 0.6.1
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 +45 -0
- package/Readme.md +9 -6
- package/dist/canvas/global.css +83 -6
- package/dist/canvas/index.js +177 -116
- package/dist/canvas/mermaid-entry.js +1303 -1303
- package/dist/json-render/index.js +101 -102
- package/dist/types/client/canvas/canvas-area.d.ts +14 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
- package/dist/types/server/canvas-operations.d.ts +10 -0
- package/docs/RELEASE.md +14 -0
- package/docs/adr-001-bun-only-runtime.md +1 -1
- package/docs/bun-webview-integration.md +2 -2
- package/docs/product-context-vision-2026-09.md +207 -0
- package/docs/product-review-2026-09.md +174 -0
- package/docs/product-vision-2026-09.md +200 -0
- package/docs/screenshot.png +0 -0
- package/package.json +5 -5
- package/skills/pmx-canvas/SKILL.md +61 -30
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +126 -18
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +12 -1
- package/src/cli/commands/pump.ts +13 -2
- package/src/cli/index.ts +1 -0
- package/src/client/canvas/CanvasViewport.tsx +11 -0
- package/src/client/canvas/TopBar.tsx +85 -45
- package/src/client/canvas/canvas-area.ts +33 -0
- package/src/client/canvas/use-pan-zoom.ts +7 -0
- package/src/client/nodes/ExtAppFrame.tsx +152 -23
- package/src/client/state/canvas-store.ts +29 -11
- package/src/client/theme/global.css +83 -6
- package/src/server/canvas-operations.ts +41 -6
- package/src/server/index.ts +4 -2
- package/src/server/operations/mcp.ts +45 -31
- package/src/server/operations/ops/ax-presence.ts +3 -0
- package/src/server/operations/ops/nodes.ts +12 -0
- package/src/server/operations/ops/snapshots.ts +6 -19
- package/src/server/scope-fence.ts +1 -1
package/src/cli/commands/pump.ts
CHANGED
|
@@ -16,7 +16,15 @@
|
|
|
16
16
|
* takes the message from stdin instead. See `renderExecTemplate`.
|
|
17
17
|
*/
|
|
18
18
|
import { spawn } from 'node:child_process';
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
cmd,
|
|
21
|
+
getBaseUrl,
|
|
22
|
+
getStringFlag,
|
|
23
|
+
invokeOperation,
|
|
24
|
+
parseFlags,
|
|
25
|
+
requireFlag,
|
|
26
|
+
showCommandHelp,
|
|
27
|
+
} from '../shared.js';
|
|
20
28
|
|
|
21
29
|
interface PendingSteer {
|
|
22
30
|
id: string;
|
|
@@ -137,7 +145,10 @@ cmd(
|
|
|
137
145
|
`pmx-canvas pump --consumer testbot --exec 'cat' --once`,
|
|
138
146
|
],
|
|
139
147
|
async (args) => {
|
|
140
|
-
const { flags } = parseFlags(args, { boolFlags: ['once'] });
|
|
148
|
+
const { flags } = parseFlags(args, { boolFlags: ['once', 'help', 'h'] });
|
|
149
|
+
// Help before required-flag validation — `pump --help` died on
|
|
150
|
+
// "Missing required flag: --consumer" (0.5.0 Codex finding W).
|
|
151
|
+
if (flags.help || flags.h) return showCommandHelp('pump');
|
|
141
152
|
const consumer = requireFlag(
|
|
142
153
|
flags,
|
|
143
154
|
'consumer',
|
package/src/cli/index.ts
CHANGED
|
@@ -314,6 +314,7 @@ Agent CLI (works against running server):
|
|
|
314
314
|
code-graph File dependencies
|
|
315
315
|
spatial Spatial analysis
|
|
316
316
|
watch Semantic watch stream
|
|
317
|
+
pump Reactive steering loop for CLI agents (Amp, Codex CLI)
|
|
317
318
|
smoke Check that a running canvas works here
|
|
318
319
|
skills sync Sync installed copies of the bundled skills
|
|
319
320
|
copilot install-extension Install the GitHub Copilot app extension
|
|
@@ -704,6 +704,16 @@ export function CanvasViewport({
|
|
|
704
704
|
[annotationMode, containerRef, onCanvasContextMenu],
|
|
705
705
|
);
|
|
706
706
|
|
|
707
|
+
// Internal native drags (selected node text, links inside rendered
|
|
708
|
+
// markdown) re-enter this same container as drops and mint webpage nodes
|
|
709
|
+
// from card text (0.5.1 Amp finding D). Kill them at the source — external
|
|
710
|
+
// drags never fire a local dragstart, so file/URL import is untouched.
|
|
711
|
+
// Editable elements keep their native drag (text drag inside an editor).
|
|
712
|
+
const handleDragStart = useCallback((e: DragEvent) => {
|
|
713
|
+
if (isEditableElement(e.target instanceof Element ? e.target : null)) return;
|
|
714
|
+
e.preventDefault();
|
|
715
|
+
}, []);
|
|
716
|
+
|
|
707
717
|
// ── Drag-and-drop files from filesystem ──
|
|
708
718
|
const handleDragEnter = useCallback((e: DragEvent) => {
|
|
709
719
|
e.preventDefault();
|
|
@@ -824,6 +834,7 @@ export function CanvasViewport({
|
|
|
824
834
|
onPointerUp={handlePointerUp}
|
|
825
835
|
onContextMenu={handleContextMenu}
|
|
826
836
|
onDblClick={handleDblClick}
|
|
837
|
+
onDragStart={handleDragStart}
|
|
827
838
|
onDragEnter={handleDragEnter}
|
|
828
839
|
onDragOver={handleDragOver}
|
|
829
840
|
onDragLeave={handleDragLeave}
|
|
@@ -31,59 +31,99 @@ import { formatCountdown, gateRemainingMs } from '../../shared/approval-gates.js
|
|
|
31
31
|
* session is attached — the quiet board shows nothing here until phase 5
|
|
32
32
|
* lands the "Start agent session" affordance.
|
|
33
33
|
*/
|
|
34
|
+
const AGENT_CHIP_LIMIT = 3;
|
|
35
|
+
const AGENT_PHASE_RANK: Record<string, number> = { 'waiting-approval': 0, thinking: 1, tooling: 2, idle: 3 };
|
|
36
|
+
|
|
34
37
|
function AgentChip() {
|
|
35
38
|
// Every attached session gets its chip — with several agents on the board,
|
|
36
|
-
// showing only the first hid the rest from the top bar entirely.
|
|
39
|
+
// showing only the first hid the rest from the top bar entirely. But one
|
|
40
|
+
// chip PER agent floods the bar on a many-agent board (user feedback,
|
|
41
|
+
// 0.5.1 cycle: 16 unshrinkable chips shoved the zoom cluster out of the
|
|
42
|
+
// bar and over the session panel, and the total was invisible): the most
|
|
43
|
+
// active few wear their own chips and the rest fold into a census chip
|
|
44
|
+
// that always names the total.
|
|
37
45
|
const sessions = attachedSessions.value;
|
|
38
46
|
if (sessions.length === 0) return null;
|
|
47
|
+
const ranked = [...sessions].sort((a, b) => (AGENT_PHASE_RANK[a.phase] ?? 4) - (AGENT_PHASE_RANK[b.phase] ?? 4));
|
|
48
|
+
const visible = ranked.slice(0, AGENT_CHIP_LIMIT);
|
|
49
|
+
const overflow = ranked.slice(AGENT_CHIP_LIMIT);
|
|
50
|
+
const phaseCounts = sessions.reduce<Record<string, number>>((acc, s) => {
|
|
51
|
+
acc[s.phase] = (acc[s.phase] ?? 0) + 1;
|
|
52
|
+
return acc;
|
|
53
|
+
}, {});
|
|
54
|
+
const breakdown = [
|
|
55
|
+
phaseCounts['waiting-approval'] && `${phaseCounts['waiting-approval']} waiting on approval`,
|
|
56
|
+
phaseCounts.thinking && `${phaseCounts.thinking} thinking`,
|
|
57
|
+
phaseCounts.tooling && `${phaseCounts.tooling} running tools`,
|
|
58
|
+
phaseCounts.idle && `${phaseCounts.idle} idle`,
|
|
59
|
+
]
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.join(', ');
|
|
39
62
|
return (
|
|
40
63
|
<>
|
|
41
|
-
{
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
{/* The chips live in their own clipping flex wrapper: each chip keeps
|
|
65
|
+
its natural readable width and the WRAPPER absorbs the squeeze,
|
|
66
|
+
clipping at its edge — no per-child shrink cascade, and chip
|
|
67
|
+
content can never paint over the census, meter, or zoom cluster. */}
|
|
68
|
+
<span class="agent-chips">
|
|
69
|
+
{visible.map((session) => {
|
|
70
|
+
// Fleet roll-up: workers declaring this session as their parent count
|
|
71
|
+
// into its chip instead of growing the bar one chip per worker.
|
|
72
|
+
const workers = agentPresences.value.filter(
|
|
73
|
+
(presence) =>
|
|
74
|
+
presence.parentAgentId != null &&
|
|
75
|
+
(presence.parentAgentId === session.sessionId || presence.parentAgentId === session.source),
|
|
76
|
+
).length;
|
|
77
|
+
return (
|
|
78
|
+
<BarHint
|
|
79
|
+
key={session.sessionId}
|
|
80
|
+
label={`Agent session — ${session.label}`}
|
|
81
|
+
tapToOpen
|
|
82
|
+
body="What this attached agent is doing right now: idle, thinking, running a tool, or waiting on your approval. Steer it from the composer below."
|
|
60
83
|
>
|
|
61
|
-
<span
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
<span class="agent-chip-workers">
|
|
66
|
-
+{workers} worker{workers === 1 ? '' : 's'}
|
|
67
|
-
</span>
|
|
68
|
-
)}
|
|
69
|
-
<button
|
|
70
|
-
type="button"
|
|
71
|
-
class="agent-chip-end"
|
|
72
|
-
aria-label={`End ${session.label} session`}
|
|
73
|
-
title={`End ${session.label}'s session`}
|
|
74
|
-
onClick={(e) => {
|
|
75
|
-
// The chip sits inside a tap-to-open hint — ending a session
|
|
76
|
-
// must not also open it.
|
|
77
|
-
e.stopPropagation();
|
|
78
|
-
void endSession({ source: session.source, agentId: session.agentId });
|
|
79
|
-
}}
|
|
84
|
+
<span
|
|
85
|
+
class={`agent-chip phase-${session.phase}`}
|
|
86
|
+
data-phase={session.phase}
|
|
87
|
+
style={{ '--identity-color': `hsl(${agentIdentityHue(session.sessionId)} 65% 62%)` }}
|
|
80
88
|
>
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
89
|
+
<span class="agent-chip-dot" aria-hidden="true" />
|
|
90
|
+
<span class="agent-chip-label">{agentPhaseLabel(session)}</span>
|
|
91
|
+
<span class="agent-chip-who hud-collapsible-text">{session.label}</span>
|
|
92
|
+
{workers > 0 && (
|
|
93
|
+
<span class="agent-chip-workers">
|
|
94
|
+
+{workers} worker{workers === 1 ? '' : 's'}
|
|
95
|
+
</span>
|
|
96
|
+
)}
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
class="agent-chip-end"
|
|
100
|
+
aria-label={`End ${session.label} session`}
|
|
101
|
+
title={`End ${session.label}'s session`}
|
|
102
|
+
onClick={(e) => {
|
|
103
|
+
// The chip sits inside a tap-to-open hint — ending a session
|
|
104
|
+
// must not also open it.
|
|
105
|
+
e.stopPropagation();
|
|
106
|
+
void endSession({ source: session.source, agentId: session.agentId });
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
×
|
|
110
|
+
</button>
|
|
111
|
+
</span>
|
|
112
|
+
</BarHint>
|
|
113
|
+
);
|
|
114
|
+
})}
|
|
115
|
+
</span>
|
|
116
|
+
{overflow.length > 0 && (
|
|
117
|
+
<BarHint
|
|
118
|
+
label={`${sessions.length} agent sessions attached`}
|
|
119
|
+
tapToOpen
|
|
120
|
+
body={`${sessions.length} agents on this board — ${breakdown}. The most active wear their own chips; the rest are here, and idle agents' cursors leave the board until they work again.`}
|
|
121
|
+
>
|
|
122
|
+
<span class="agent-chip agent-chip-more" data-testid="agent-chip-more">
|
|
123
|
+
+{overflow.length} · {sessions.length} agents
|
|
124
|
+
</span>
|
|
125
|
+
</BarHint>
|
|
126
|
+
)}
|
|
87
127
|
</>
|
|
88
128
|
);
|
|
89
129
|
}
|
|
@@ -39,3 +39,36 @@ export function canvasAreaCenter(): { x: number; y: number } {
|
|
|
39
39
|
const rect = canvasArea();
|
|
40
40
|
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
export interface CanvasFitInsets {
|
|
44
|
+
top: number;
|
|
45
|
+
right: number;
|
|
46
|
+
bottom: number;
|
|
47
|
+
left: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Screen-space insets Fit must reserve for the floating chrome that overlays
|
|
52
|
+
* the canvas region: the bottom command bar (whose pinned-chip row grows it)
|
|
53
|
+
* and the bottom-right minimap. Measured from the live elements — fit's old
|
|
54
|
+
* world-space padding shrank with the fit zoom and parked nodes underneath
|
|
55
|
+
* both (0.5.0 Amp report, finding A; architecture rule 9's trap).
|
|
56
|
+
*/
|
|
57
|
+
export function canvasFitInsets(): CanvasFitInsets {
|
|
58
|
+
let bottom = 16;
|
|
59
|
+
try {
|
|
60
|
+
const area = canvasArea();
|
|
61
|
+
const areaBottom = area.top + area.height;
|
|
62
|
+
for (const selector of ['.command-bar', '.minimap']) {
|
|
63
|
+
const el = document.querySelector(selector);
|
|
64
|
+
if (!(el instanceof HTMLElement) || !el.isConnected) continue;
|
|
65
|
+
const rect = el.getBoundingClientRect();
|
|
66
|
+
if (rect.height <= 0) continue;
|
|
67
|
+
const overlap = areaBottom - rect.top;
|
|
68
|
+
if (overlap > 0) bottom = Math.max(bottom, overlap + 12);
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// Chromeless embeds and unit DOMs: the defaults below are the contract.
|
|
72
|
+
}
|
|
73
|
+
return { top: 16, right: 16, bottom, left: 16 };
|
|
74
|
+
}
|
|
@@ -110,6 +110,13 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit, disab
|
|
|
110
110
|
// Pan tool / held Space: the world layer is pointer-inert (CSS), so
|
|
111
111
|
// the event target is the container even over nodes.
|
|
112
112
|
if (e.target !== container) return;
|
|
113
|
+
// A pan gesture must not double as the browser's drag-select: without
|
|
114
|
+
// preventDefault the sweep still extends a text selection through the
|
|
115
|
+
// pointer-inert world, and a later pointerdown inside that selection
|
|
116
|
+
// starts a native text drag the canvas mis-reads as a URL drop —
|
|
117
|
+
// panning from a node minted webpage nodes from card text (0.5.1 Amp
|
|
118
|
+
// finding D).
|
|
119
|
+
e.preventDefault();
|
|
113
120
|
} else {
|
|
114
121
|
// Select tool: background drag belongs to the lasso (CanvasViewport).
|
|
115
122
|
return;
|
|
@@ -30,6 +30,8 @@ type McpUiTheme = 'light' | 'dark';
|
|
|
30
30
|
type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
|
|
31
31
|
type DisplayMode = 'inline' | 'fullscreen' | 'pip';
|
|
32
32
|
type ExtAppFrameStatus = 'loading' | 'ready' | 'done';
|
|
33
|
+
/** Paint probe outcome: only a content-carrying tick counts as painted. */
|
|
34
|
+
export type PaintProbeVerdict = 'content-verified' | 'tick-no-content' | 'timeout';
|
|
33
35
|
|
|
34
36
|
interface ExtAppHostDimensionsTarget {
|
|
35
37
|
clientWidth?: number;
|
|
@@ -135,7 +137,7 @@ export function extAppRecoveryLog(nodeId: string, event: string): void {
|
|
|
135
137
|
// panel without devtools access — the Copilot WKWebView, exactly where the
|
|
136
138
|
// black tiles happen — still yields an actionable trace: agents read it back
|
|
137
139
|
// via GET /api/canvas/debug/ext-app-recovery (0.3.2 report Finding N).
|
|
138
|
-
|
|
140
|
+
const extAppReportQueue: Array<{ t: number; nodeId: string; event: string; visibility?: string }> = [];
|
|
139
141
|
let extAppReportTimer: ReturnType<typeof setTimeout> | null = null;
|
|
140
142
|
function queueExtAppRecoveryReport(entry: { t: number; nodeId: string; event: string }): void {
|
|
141
143
|
extAppReportQueue.push({
|
|
@@ -285,10 +287,72 @@ export function buildExtAppBootBeaconScript(frameToken: string, nodeId: string):
|
|
|
285
287
|
// rendering pipeline is actually running — a frame whose layer WebKit never
|
|
286
288
|
// composites stays silent, and that silence drives the parent's recovery
|
|
287
289
|
// ladder. One unsolicited tick at boot, then on-demand answers to probes.
|
|
290
|
+
// Content oracle v2 (Finding N, 0.5.1 reopen): a LAID-OUT canvas passed the
|
|
291
|
+
// 0.5.1 oracle even when nothing was ever drawn into it — Excalidraw mounts
|
|
292
|
+
// its canvas element the moment React renders, so blank cream tiles read
|
|
293
|
+
// paint-ok (content-verified), the ladder concluded success, and Retry
|
|
294
|
+
// never engaged (Copilot 0.5.1 report: A/C/E cream with a green trail).
|
|
295
|
+
// The oracle now samples the canvas's PIXELS: a readable main drawing
|
|
296
|
+
// surface with uniform pixels is not content, whatever shell chrome or
|
|
297
|
+
// boilerplate text the document laid out around it.
|
|
298
|
+
function canvasInk(el) {
|
|
299
|
+
try {
|
|
300
|
+
if (!el.width || !el.height) return 'blank';
|
|
301
|
+
var sw = Math.min(96, el.width);
|
|
302
|
+
var sh = Math.min(64, el.height);
|
|
303
|
+
var probe = document.createElement('canvas');
|
|
304
|
+
probe.width = sw;
|
|
305
|
+
probe.height = sh;
|
|
306
|
+
var ctx = probe.getContext('2d', { willReadFrequently: true });
|
|
307
|
+
if (!ctx) return 'unknown';
|
|
308
|
+
ctx.drawImage(el, 0, 0, sw, sh);
|
|
309
|
+
var d = ctx.getImageData(0, 0, sw, sh).data;
|
|
310
|
+
var r = d[0], g = d[1], b = d[2], a = d[3];
|
|
311
|
+
for (var i = 4; i < d.length; i += 4) {
|
|
312
|
+
if (Math.abs(d[i] - r) > 3 || Math.abs(d[i + 1] - g) > 3 || Math.abs(d[i + 2] - b) > 3 || Math.abs(d[i + 3] - a) > 3) return 'ink';
|
|
313
|
+
}
|
|
314
|
+
return 'blank';
|
|
315
|
+
} catch (e) {
|
|
316
|
+
return 'unknown'; // tainted or WebGL-unreadable: pixels cannot be judged
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
function contentVerdict() {
|
|
320
|
+
try {
|
|
321
|
+
var canvases = document.querySelectorAll('canvas');
|
|
322
|
+
var blankMain = false;
|
|
323
|
+
var unreadable = false;
|
|
324
|
+
for (var i = 0; i < canvases.length; i++) {
|
|
325
|
+
var r = canvases[i].getBoundingClientRect();
|
|
326
|
+
if (r.width <= 8 || r.height <= 8) continue;
|
|
327
|
+
var ink = canvasInk(canvases[i]);
|
|
328
|
+
if (ink === 'ink') return { content: true, detail: 'ink' };
|
|
329
|
+
if (ink === 'unknown') unreadable = true;
|
|
330
|
+
else if (r.width >= 160 && r.height >= 120) blankMain = true;
|
|
331
|
+
}
|
|
332
|
+
// A readable, never-painted main surface vetoes everything below:
|
|
333
|
+
// toolbar icons and shell text around a blank drawing surface is
|
|
334
|
+
// exactly the false green the human reports as an empty cream tile.
|
|
335
|
+
if (blankMain) return { content: false, detail: 'blank-canvas' };
|
|
336
|
+
if (unreadable) return { content: true, detail: 'canvas-unreadable' };
|
|
337
|
+
var svgs = document.querySelectorAll('svg');
|
|
338
|
+
for (var j = 0; j < svgs.length; j++) {
|
|
339
|
+
var sr = svgs[j].getBoundingClientRect();
|
|
340
|
+
if (sr.width > 8 && sr.height > 8 && svgs[j].querySelector('path, rect, circle, ellipse, line, polyline, polygon, text, image, use')) {
|
|
341
|
+
return { content: true, detail: 'svg' };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
var body = document.body;
|
|
345
|
+
if (body && body.innerText && body.innerText.trim().length > 0) return { content: true, detail: 'text' };
|
|
346
|
+
return { content: false, detail: 'empty' };
|
|
347
|
+
} catch (e) {
|
|
348
|
+
return { content: false, detail: 'probe-error' };
|
|
349
|
+
}
|
|
350
|
+
}
|
|
288
351
|
function sendPaintTick() {
|
|
289
352
|
requestAnimationFrame(function () {
|
|
290
353
|
requestAnimationFrame(function () {
|
|
291
|
-
|
|
354
|
+
var v = contentVerdict();
|
|
355
|
+
window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: TOKEN, nodeId: NODE_ID, kind: 'paint-tick', content: v.content, detail: v.detail }, '*');
|
|
292
356
|
});
|
|
293
357
|
});
|
|
294
358
|
}
|
|
@@ -367,8 +431,12 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
367
431
|
const bridgeReadyRef = useRef(false);
|
|
368
432
|
const themeUnsubRef = useRef<(() => void) | null>(null);
|
|
369
433
|
const webkitRemountAttemptsRef = useRef(0);
|
|
370
|
-
// Paint oracle (Finding N): resolvers waiting for the frame's next paint-tick
|
|
371
|
-
|
|
434
|
+
// Paint oracle (Finding N): resolvers waiting for the frame's next paint-tick,
|
|
435
|
+
// each handed the tick's content verdict + the oracle's reason for it.
|
|
436
|
+
const paintTickWaitersRef = useRef<Array<(tick: { content: boolean; detail: string }) => void>>([]);
|
|
437
|
+
// Last tick's oracle detail (ink / blank-canvas / svg / text / …) for the
|
|
438
|
+
// recovery trail — the label is the diagnostic the field reports read.
|
|
439
|
+
const lastPaintDetailRef = useRef<string>('');
|
|
372
440
|
// Genuine boot signal: set ONLY when the app completes the ui/initialize
|
|
373
441
|
// handshake (bridge.oninitialized) — NOT by the 1200ms bootstrap fallback,
|
|
374
442
|
// which flips status via notifications that resolve even into a dead iframe.
|
|
@@ -450,7 +518,12 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
450
518
|
return;
|
|
451
519
|
appScriptsRanRef.current = true;
|
|
452
520
|
if (data.kind === 'paint-tick') {
|
|
453
|
-
|
|
521
|
+
const tickData = data as { content?: unknown; detail?: unknown };
|
|
522
|
+
const tick = {
|
|
523
|
+
content: tickData.content === true,
|
|
524
|
+
detail: typeof tickData.detail === 'string' ? tickData.detail : '',
|
|
525
|
+
};
|
|
526
|
+
for (const waiter of paintTickWaitersRef.current.splice(0)) waiter(tick);
|
|
454
527
|
}
|
|
455
528
|
}
|
|
456
529
|
window.addEventListener('message', onBootBeacon);
|
|
@@ -503,7 +576,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
503
576
|
enqueueWebkitRemount({
|
|
504
577
|
remount: () => {
|
|
505
578
|
remountQueuedRef.current = false;
|
|
506
|
-
if (unmountedRef.current || expandedNodeId.value === nodeId) {
|
|
579
|
+
if (unmountedRef.current || (!expanded && expandedNodeId.value === nodeId)) {
|
|
507
580
|
extAppRecoveryLog(nodeId, 'remount-skipped');
|
|
508
581
|
// The ladder left paintPending set on the promise that this remount
|
|
509
582
|
// would re-enter it via the new frame's settle path. A skip means
|
|
@@ -555,28 +628,43 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
555
628
|
// again → serialized remount (bounded by the shared attempt cap) → explicit
|
|
556
629
|
// Retry affordance. Never silent eternal black. WebKit inline frames only.
|
|
557
630
|
const PAINT_TICK_TIMEOUT_MS = 1500;
|
|
558
|
-
const probePaint = (): Promise<
|
|
631
|
+
const probePaint = (): Promise<PaintProbeVerdict> =>
|
|
559
632
|
new Promise((resolve) => {
|
|
560
633
|
const contentWindow = iframeRef.current?.contentWindow;
|
|
561
634
|
if (!contentWindow || unmountedRef.current) {
|
|
562
|
-
resolve(
|
|
635
|
+
resolve('timeout');
|
|
563
636
|
return;
|
|
564
637
|
}
|
|
565
638
|
let settled = false;
|
|
566
639
|
const timer = window.setTimeout(() => {
|
|
567
640
|
if (settled) return;
|
|
568
641
|
settled = true;
|
|
569
|
-
resolve(
|
|
642
|
+
resolve('timeout');
|
|
570
643
|
}, PAINT_TICK_TIMEOUT_MS);
|
|
571
|
-
paintTickWaitersRef.current.push(() => {
|
|
644
|
+
paintTickWaitersRef.current.push((tick) => {
|
|
572
645
|
if (settled) return;
|
|
573
646
|
settled = true;
|
|
574
647
|
window.clearTimeout(timer);
|
|
575
|
-
|
|
648
|
+
lastPaintDetailRef.current = tick.detail;
|
|
649
|
+
// A tick without content is NOT paint-ok: the rAF pipeline runs while
|
|
650
|
+
// the document is empty cream / never composited (Finding N reopen).
|
|
651
|
+
resolve(tick.content ? 'content-verified' : 'tick-no-content');
|
|
576
652
|
});
|
|
577
653
|
contentWindow.postMessage({ source: EXT_APP_PAINT_PROBE_SOURCE, token: axToken }, '*');
|
|
578
654
|
});
|
|
579
655
|
|
|
656
|
+
// Bounded wait for the frame's content oracle to turn green — the mount
|
|
657
|
+
// queue's release gate (guidance: never green-light the NEXT app while the
|
|
658
|
+
// previous document is still contentless; D-then-E was the repro).
|
|
659
|
+
const awaitContentVerified = async (maxMs: number): Promise<boolean> => {
|
|
660
|
+
const deadline = Date.now() + maxMs;
|
|
661
|
+
while (Date.now() < deadline && !unmountedRef.current) {
|
|
662
|
+
if ((await probePaint()) === 'content-verified') return true;
|
|
663
|
+
await new Promise((resolve) => window.setTimeout(resolve, 400));
|
|
664
|
+
}
|
|
665
|
+
return false;
|
|
666
|
+
};
|
|
667
|
+
|
|
580
668
|
// Force a real layer reallocation: grow the iframe's box meaningfully for two
|
|
581
669
|
// frames, then restore. Invisible under the paint-pending overlay.
|
|
582
670
|
const runSoftExpandCycle = (): Promise<void> =>
|
|
@@ -636,31 +724,52 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
636
724
|
// A paint-ok collected while the document claims hidden proves only that the
|
|
637
725
|
// app's rAF pipeline is alive — NOT that anything composited (Finding N,
|
|
638
726
|
// 0.4.7). Say so in the trail instead of recording an unqualified success.
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
727
|
+
// The [detail] bracket names WHAT the oracle verified (ink / svg / text) so
|
|
728
|
+
// field reports can distinguish pixel-verified greens from weaker ones.
|
|
729
|
+
const paintOkLabel = (suffix = ''): string => {
|
|
730
|
+
const detail = lastPaintDetailRef.current ? ` [${lastPaintDetailRef.current}]` : '';
|
|
731
|
+
return typeof document !== 'undefined' && document.visibilityState === 'hidden'
|
|
732
|
+
? `paint-ok (content-verified)${detail}${suffix} (host hidden)`
|
|
733
|
+
: `paint-ok (content-verified)${detail}${suffix}`;
|
|
734
|
+
};
|
|
643
735
|
|
|
644
736
|
const runPaintLadder = async (): Promise<void> => {
|
|
645
|
-
|
|
737
|
+
// Runs for the EXPANDED instance too (Finding N reopen: expanded C was a
|
|
738
|
+
// black frame with no recovery — the ladder used to early-return here).
|
|
646
739
|
if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
|
|
647
740
|
if (!isWebKitOnlyHost(navigator.userAgent)) return;
|
|
648
741
|
setPaintPending(true);
|
|
649
|
-
|
|
742
|
+
let verdict = await probePaint();
|
|
743
|
+
if (verdict === 'content-verified') {
|
|
650
744
|
extAppRecoveryLog(nodeId, paintOkLabel());
|
|
651
745
|
setPaintPending(false);
|
|
652
746
|
maybeAssumeVisibleRearm();
|
|
653
747
|
return;
|
|
654
748
|
}
|
|
655
|
-
extAppRecoveryLog(
|
|
749
|
+
extAppRecoveryLog(
|
|
750
|
+
nodeId,
|
|
751
|
+
verdict === 'tick-no-content'
|
|
752
|
+
? `paint-fail (tick without content: ${lastPaintDetailRef.current || 'unknown'})`
|
|
753
|
+
: 'paint-fail (no tick)',
|
|
754
|
+
);
|
|
656
755
|
await runSoftExpandCycle();
|
|
657
|
-
|
|
756
|
+
verdict = await probePaint();
|
|
757
|
+
if (verdict === 'content-verified') {
|
|
658
758
|
extAppRecoveryLog(nodeId, paintOkLabel(' (after soft-expand)'));
|
|
659
759
|
setPaintPending(false);
|
|
660
760
|
maybeAssumeVisibleRearm();
|
|
661
761
|
return;
|
|
662
762
|
}
|
|
663
763
|
if (unmountedRef.current) return;
|
|
764
|
+
// A contentless-but-alive app may still be DRAWING its scene: give the
|
|
765
|
+
// oracle a bounded settle before burning a remount attempt on it.
|
|
766
|
+
if (verdict === 'tick-no-content' && (await awaitContentVerified(3_000))) {
|
|
767
|
+
extAppRecoveryLog(nodeId, paintOkLabel(' (after content wait)'));
|
|
768
|
+
setPaintPending(false);
|
|
769
|
+
maybeAssumeVisibleRearm();
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (unmountedRef.current) return;
|
|
664
773
|
// The remounted frame re-enters this ladder from its own settle path.
|
|
665
774
|
if (!scheduleWebkitRemount('paint-fail')) {
|
|
666
775
|
extAppRecoveryLog(nodeId, 'recovery-exhausted');
|
|
@@ -709,7 +818,8 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
709
818
|
// serialized queue (bounded by the shared attempt cap).
|
|
710
819
|
const WEBKIT_BOOT_WATCHDOG_MS = 6000;
|
|
711
820
|
useEffect(() => {
|
|
712
|
-
|
|
821
|
+
// Covers the expanded instance too — its fresh iframe was black with no
|
|
822
|
+
// recovery at all (Finding N reopen, expanded C).
|
|
713
823
|
if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
|
|
714
824
|
if (!isWebKitOnlyHost(navigator.userAgent)) return;
|
|
715
825
|
if (!iframeDocument.ready || !mountGranted) return;
|
|
@@ -725,7 +835,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
725
835
|
() => () => {
|
|
726
836
|
unmountedRef.current = true;
|
|
727
837
|
for (const waiter of bootWaitersRef.current.splice(0)) waiter();
|
|
728
|
-
for (const waiter of paintTickWaitersRef.current.splice(0)) waiter();
|
|
838
|
+
for (const waiter of paintTickWaitersRef.current.splice(0)) waiter({ content: false, detail: 'unmounted' });
|
|
729
839
|
},
|
|
730
840
|
[],
|
|
731
841
|
);
|
|
@@ -1035,8 +1145,27 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
1035
1145
|
// its own settle pause covering the draw) only from this genuine path;
|
|
1036
1146
|
// the bootstrap fallback never releases it (a dead iframe must run the
|
|
1037
1147
|
// queue's bounded timeout instead of green-lighting the next remount).
|
|
1038
|
-
|
|
1039
|
-
|
|
1148
|
+
// Release the mount queue only once this document's CONTENT
|
|
1149
|
+
// oracle is green (bounded): a settled-but-empty app must not
|
|
1150
|
+
// green-light the next mount slot (D-then-E repro, Finding N
|
|
1151
|
+
// reopen). WebKit only — elsewhere the oracle race doesn't exist.
|
|
1152
|
+
const releaseQueue = () => {
|
|
1153
|
+
for (const waiter of bootWaitersRef.current.splice(0)) waiter();
|
|
1154
|
+
};
|
|
1155
|
+
if (typeof navigator !== 'undefined' && isWebKitOnlyHost(navigator.userAgent)) {
|
|
1156
|
+
void awaitContentVerified(4_000).then((verified) => {
|
|
1157
|
+
extAppRecoveryLog(
|
|
1158
|
+
nodeId,
|
|
1159
|
+
verified
|
|
1160
|
+
? 'settled (content-verified)'
|
|
1161
|
+
: `settled (content-timeout${lastPaintDetailRef.current ? `: ${lastPaintDetailRef.current}` : ''})`,
|
|
1162
|
+
);
|
|
1163
|
+
releaseQueue();
|
|
1164
|
+
});
|
|
1165
|
+
} else {
|
|
1166
|
+
extAppRecoveryLog(nodeId, 'settled');
|
|
1167
|
+
releaseQueue();
|
|
1168
|
+
}
|
|
1040
1169
|
nudgeHostContextAfterLayout();
|
|
1041
1170
|
schedulePaintLadder();
|
|
1042
1171
|
})
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
type ViewportState,
|
|
10
10
|
} from '../types';
|
|
11
11
|
import { computeAutoArrange } from '../../shared/auto-arrange';
|
|
12
|
-
import { canvasAreaCenter } from '../canvas/canvas-area';
|
|
12
|
+
import { canvasArea, canvasAreaCenter, canvasFitInsets } from '../canvas/canvas-area';
|
|
13
13
|
import { pushCanvasUpdate, requestBestEffort, requestOk, updateViewportFromClient } from './intent-bridge';
|
|
14
14
|
|
|
15
15
|
function logCanvasStoreError(action: string, error: unknown): void {
|
|
@@ -808,16 +808,22 @@ export function fitAll(containerW: number, containerH: number): void {
|
|
|
808
808
|
maxY = Math.max(maxY, n.position.y + n.size.height);
|
|
809
809
|
}
|
|
810
810
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
811
|
+
// Screen-space fit (architecture rule 9): the margins that must clear the
|
|
812
|
+
// floating chrome are SCREEN constants. The old world-space PAD shrank with
|
|
813
|
+
// the fit zoom, parking fitted nodes under the command bar and minimap
|
|
814
|
+
// (0.5.0 Amp report, finding A).
|
|
815
|
+
const insets = canvasFitInsets();
|
|
816
|
+
const usableW = Math.max(80, containerW - insets.left - insets.right);
|
|
817
|
+
const usableH = Math.max(80, containerH - insets.top - insets.bottom);
|
|
818
|
+
const worldW = Math.max(1, maxX - minX);
|
|
819
|
+
const worldH = Math.max(1, maxY - minY);
|
|
820
|
+
const scale = Math.min(1, Math.min(usableW / worldW, usableH / worldH));
|
|
815
821
|
const cx = (minX + maxX) / 2;
|
|
816
822
|
const cy = (minY + maxY) / 2;
|
|
817
823
|
|
|
818
824
|
animateViewport({
|
|
819
|
-
x:
|
|
820
|
-
y:
|
|
825
|
+
x: insets.left + usableW / 2 - cx * scale,
|
|
826
|
+
y: insets.top + usableH / 2 - cy * scale,
|
|
821
827
|
scale,
|
|
822
828
|
});
|
|
823
829
|
}
|
|
@@ -827,14 +833,26 @@ export function focusNode(id: string, options: { recordHistory?: boolean } = {})
|
|
|
827
833
|
const node = nodes.value.get(id);
|
|
828
834
|
if (!node) return;
|
|
829
835
|
const v = viewport.value;
|
|
836
|
+
// Same screen-space frame as fitAll: viewport x/y are CONTAINER-relative
|
|
837
|
+
// (screen = world*scale + viewport inside the canvas region), so the centre
|
|
838
|
+
// is built from the region's dimensions + chrome insets — the old
|
|
839
|
+
// window-coordinate canvasAreaCenter() over-shifted focus by the region
|
|
840
|
+
// origin, and centring without insets parked tall nodes (the 960×600
|
|
841
|
+
// mcp-app open) under the command bar (0.5.1 Amp finding C).
|
|
842
|
+
const area = canvasArea();
|
|
843
|
+
const insets = canvasFitInsets();
|
|
844
|
+
const usableW = Math.max(80, area.width - insets.left - insets.right);
|
|
845
|
+
const usableH = Math.max(80, area.height - insets.top - insets.bottom);
|
|
846
|
+
// Shrink (never grow) so the whole node — resize corner included — fits the
|
|
847
|
+
// usable rect clear of the floating chrome.
|
|
848
|
+
const scale = Math.min(v.scale, usableW / node.size.width, usableH / node.size.height);
|
|
830
849
|
const cx = node.position.x + node.size.width / 2;
|
|
831
850
|
const cy = node.position.y + node.size.height / 2;
|
|
832
|
-
const centre = canvasAreaCenter();
|
|
833
851
|
animateViewport(
|
|
834
852
|
{
|
|
835
|
-
x:
|
|
836
|
-
y:
|
|
837
|
-
scale
|
|
853
|
+
x: insets.left + usableW / 2 - cx * scale,
|
|
854
|
+
y: insets.top + usableH / 2 - cy * scale,
|
|
855
|
+
scale,
|
|
838
856
|
},
|
|
839
857
|
300,
|
|
840
858
|
options,
|