pmx-canvas 0.5.1 → 0.6.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 +50 -0
- package/Readme.md +10 -7
- package/dist/canvas/global.css +101 -7
- package/dist/canvas/index.js +171 -128
- package/dist/canvas/mermaid-entry.js +1303 -1303
- package/dist/json-render/index.js +101 -102
- package/dist/types/client/canvas/use-pan-zoom.d.ts +2 -1
- package/docs/adr-001-bun-only-runtime.md +1 -1
- package/docs/bun-webview-integration.md +2 -2
- package/docs/node-types.md +1 -1
- 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 +2 -2
- package/skills/pmx-canvas/SKILL.md +62 -36
- package/skills/pmx-canvas/evals/evals.json +1 -1
- package/skills/pmx-canvas/references/excalidraw-diagram-authoring.md +5 -0
- package/skills/pmx-canvas/references/full-reference.md +5 -4
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +126 -18
- package/skills/pmx-canvas-testing/SKILL.md +13 -4
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +18 -8
- package/src/client/canvas/CanvasNode.tsx +9 -12
- package/src/client/canvas/CanvasViewport.tsx +20 -33
- package/src/client/canvas/ContextMenu.tsx +13 -0
- package/src/client/canvas/EdgeLayer.tsx +2 -2
- package/src/client/canvas/SessionPanel.tsx +23 -4
- package/src/client/canvas/ShortcutOverlay.tsx +2 -2
- package/src/client/canvas/TopBar.tsx +85 -45
- package/src/client/canvas/use-pan-zoom.ts +23 -3
- package/src/client/nodes/ExtAppFrame.tsx +97 -28
- package/src/client/state/canvas-store.ts +17 -5
- package/src/client/state/sse-bridge.ts +3 -1
- package/src/client/theme/global.css +101 -7
- package/src/server/canvas-operations.ts +2 -2
- package/src/server/operations/mcp.ts +45 -31
- package/src/server/operations/ops/ax-presence.ts +3 -0
- package/src/server/operations/ops/viewport.ts +6 -1
- package/src/server/scope-fence.ts +1 -1
- package/src/server/server.ts +9 -9
- package/src/server/web-artifacts/scripts/bundle-artifact.sh +29 -8
|
@@ -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
|
}
|
|
@@ -22,7 +22,8 @@ interface PanZoomOptions {
|
|
|
22
22
|
* Hook that wires up pan/zoom interactions on a container element.
|
|
23
23
|
* - Wheel + Ctrl/Cmd: zoom centered on pointer
|
|
24
24
|
* - Wheel without modifier: pan
|
|
25
|
-
* -
|
|
25
|
+
* - Right or middle pointer drag: pan from anywhere
|
|
26
|
+
* - Pointer drag on background in pan mode: pan
|
|
26
27
|
* - Pinch (touch): zoom
|
|
27
28
|
*/
|
|
28
29
|
export function usePanZoom({ viewport, onViewportChange, onViewportCommit, disabled = false }: PanZoomOptions) {
|
|
@@ -103,18 +104,27 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit, disab
|
|
|
103
104
|
if (disabled) return;
|
|
104
105
|
const container = containerRef.current;
|
|
105
106
|
if (!container) return;
|
|
106
|
-
//
|
|
107
|
-
|
|
107
|
+
// Right- and middle-drag pan from anywhere, any tool. Node handlers
|
|
108
|
+
// deliberately leave non-left buttons alone so this bubbles here.
|
|
109
|
+
if (e.button === 1 || e.button === 2) {
|
|
108
110
|
e.preventDefault();
|
|
109
111
|
} else if (isPanModeActive()) {
|
|
110
112
|
// Pan tool / held Space: the world layer is pointer-inert (CSS), so
|
|
111
113
|
// the event target is the container even over nodes.
|
|
112
114
|
if (e.target !== container) return;
|
|
115
|
+
// A pan gesture must not double as the browser's drag-select: without
|
|
116
|
+
// preventDefault the sweep still extends a text selection through the
|
|
117
|
+
// pointer-inert world, and a later pointerdown inside that selection
|
|
118
|
+
// starts a native text drag the canvas mis-reads as a URL drop —
|
|
119
|
+
// panning from a node minted webpage nodes from card text (0.5.1 Amp
|
|
120
|
+
// finding D).
|
|
121
|
+
e.preventDefault();
|
|
113
122
|
} else {
|
|
114
123
|
// Select tool: background drag belongs to the lasso (CanvasViewport).
|
|
115
124
|
return;
|
|
116
125
|
}
|
|
117
126
|
isPanning.current = true;
|
|
127
|
+
document.documentElement.classList.add('is-canvas-panning');
|
|
118
128
|
lastPointer.current = { x: e.clientX, y: e.clientY };
|
|
119
129
|
container.setPointerCapture(e.pointerId);
|
|
120
130
|
},
|
|
@@ -140,6 +150,7 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit, disab
|
|
|
140
150
|
onViewportCommit(viewport.value);
|
|
141
151
|
}
|
|
142
152
|
isPanning.current = false;
|
|
153
|
+
document.documentElement.classList.remove('is-canvas-panning');
|
|
143
154
|
}, [onViewportCommit, viewport]);
|
|
144
155
|
|
|
145
156
|
// Touch pinch
|
|
@@ -200,6 +211,7 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit, disab
|
|
|
200
211
|
el.addEventListener('pointermove', handlePointerMove);
|
|
201
212
|
el.addEventListener('pointerup', handlePointerUp);
|
|
202
213
|
el.addEventListener('pointercancel', handlePointerUp);
|
|
214
|
+
el.addEventListener('lostpointercapture', handlePointerUp);
|
|
203
215
|
el.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
204
216
|
el.addEventListener('touchend', handleTouchEnd);
|
|
205
217
|
|
|
@@ -212,10 +224,18 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit, disab
|
|
|
212
224
|
el.removeEventListener('pointermove', handlePointerMove);
|
|
213
225
|
el.removeEventListener('pointerup', handlePointerUp);
|
|
214
226
|
el.removeEventListener('pointercancel', handlePointerUp);
|
|
227
|
+
el.removeEventListener('lostpointercapture', handlePointerUp);
|
|
215
228
|
el.removeEventListener('touchmove', handleTouchMove);
|
|
216
229
|
el.removeEventListener('touchend', handleTouchEnd);
|
|
217
230
|
};
|
|
218
231
|
}, [handleWheel, handlePointerDown, handlePointerMove, handlePointerUp, handleTouchMove, handleTouchEnd]);
|
|
219
232
|
|
|
233
|
+
useEffect(
|
|
234
|
+
() => () => {
|
|
235
|
+
document.documentElement.classList.remove('is-canvas-panning');
|
|
236
|
+
},
|
|
237
|
+
[],
|
|
238
|
+
);
|
|
239
|
+
|
|
220
240
|
return containerRef;
|
|
221
241
|
}
|
|
@@ -137,7 +137,7 @@ export function extAppRecoveryLog(nodeId: string, event: string): void {
|
|
|
137
137
|
// panel without devtools access — the Copilot WKWebView, exactly where the
|
|
138
138
|
// black tiles happen — still yields an actionable trace: agents read it back
|
|
139
139
|
// via GET /api/canvas/debug/ext-app-recovery (0.3.2 report Finding N).
|
|
140
|
-
|
|
140
|
+
const extAppReportQueue: Array<{ t: number; nodeId: string; event: string; visibility?: string }> = [];
|
|
141
141
|
let extAppReportTimer: ReturnType<typeof setTimeout> | null = null;
|
|
142
142
|
function queueExtAppRecoveryReport(entry: { t: number; nodeId: string; event: string }): void {
|
|
143
143
|
extAppReportQueue.push({
|
|
@@ -287,28 +287,72 @@ export function buildExtAppBootBeaconScript(frameToken: string, nodeId: string):
|
|
|
287
287
|
// rendering pipeline is actually running — a frame whose layer WebKit never
|
|
288
288
|
// composites stays silent, and that silence drives the parent's recovery
|
|
289
289
|
// ladder. One unsolicited tick at boot, then on-demand answers to probes.
|
|
290
|
-
// Content oracle (Finding N, 0.5.
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
|
|
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) {
|
|
296
299
|
try {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
+
}
|
|
301
343
|
}
|
|
302
344
|
var body = document.body;
|
|
303
|
-
|
|
345
|
+
if (body && body.innerText && body.innerText.trim().length > 0) return { content: true, detail: 'text' };
|
|
346
|
+
return { content: false, detail: 'empty' };
|
|
304
347
|
} catch (e) {
|
|
305
|
-
return false;
|
|
348
|
+
return { content: false, detail: 'probe-error' };
|
|
306
349
|
}
|
|
307
350
|
}
|
|
308
351
|
function sendPaintTick() {
|
|
309
352
|
requestAnimationFrame(function () {
|
|
310
353
|
requestAnimationFrame(function () {
|
|
311
|
-
|
|
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 }, '*');
|
|
312
356
|
});
|
|
313
357
|
});
|
|
314
358
|
}
|
|
@@ -388,8 +432,11 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
388
432
|
const themeUnsubRef = useRef<(() => void) | null>(null);
|
|
389
433
|
const webkitRemountAttemptsRef = useRef(0);
|
|
390
434
|
// Paint oracle (Finding N): resolvers waiting for the frame's next paint-tick,
|
|
391
|
-
// each handed the tick's content verdict.
|
|
392
|
-
const paintTickWaitersRef = useRef<Array<(content: boolean) => void>>([]);
|
|
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>('');
|
|
393
440
|
// Genuine boot signal: set ONLY when the app completes the ui/initialize
|
|
394
441
|
// handshake (bridge.oninitialized) — NOT by the 1200ms bootstrap fallback,
|
|
395
442
|
// which flips status via notifications that resolve even into a dead iframe.
|
|
@@ -471,8 +518,12 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
471
518
|
return;
|
|
472
519
|
appScriptsRanRef.current = true;
|
|
473
520
|
if (data.kind === 'paint-tick') {
|
|
474
|
-
const
|
|
475
|
-
|
|
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);
|
|
476
527
|
}
|
|
477
528
|
}
|
|
478
529
|
window.addEventListener('message', onBootBeacon);
|
|
@@ -590,13 +641,14 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
590
641
|
settled = true;
|
|
591
642
|
resolve('timeout');
|
|
592
643
|
}, PAINT_TICK_TIMEOUT_MS);
|
|
593
|
-
paintTickWaitersRef.current.push((
|
|
644
|
+
paintTickWaitersRef.current.push((tick) => {
|
|
594
645
|
if (settled) return;
|
|
595
646
|
settled = true;
|
|
596
647
|
window.clearTimeout(timer);
|
|
648
|
+
lastPaintDetailRef.current = tick.detail;
|
|
597
649
|
// A tick without content is NOT paint-ok: the rAF pipeline runs while
|
|
598
650
|
// the document is empty cream / never composited (Finding N reopen).
|
|
599
|
-
resolve(content ? 'content-verified' : 'tick-no-content');
|
|
651
|
+
resolve(tick.content ? 'content-verified' : 'tick-no-content');
|
|
600
652
|
});
|
|
601
653
|
contentWindow.postMessage({ source: EXT_APP_PAINT_PROBE_SOURCE, token: axToken }, '*');
|
|
602
654
|
});
|
|
@@ -672,10 +724,14 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
672
724
|
// A paint-ok collected while the document claims hidden proves only that the
|
|
673
725
|
// app's rAF pipeline is alive — NOT that anything composited (Finding N,
|
|
674
726
|
// 0.4.7). Say so in the trail instead of recording an unqualified success.
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
+
};
|
|
679
735
|
|
|
680
736
|
const runPaintLadder = async (): Promise<void> => {
|
|
681
737
|
// Runs for the EXPANDED instance too (Finding N reopen: expanded C was a
|
|
@@ -692,7 +748,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
692
748
|
}
|
|
693
749
|
extAppRecoveryLog(
|
|
694
750
|
nodeId,
|
|
695
|
-
verdict === 'tick-no-content'
|
|
751
|
+
verdict === 'tick-no-content'
|
|
752
|
+
? `paint-fail (tick without content: ${lastPaintDetailRef.current || 'unknown'})`
|
|
753
|
+
: 'paint-fail (no tick)',
|
|
696
754
|
);
|
|
697
755
|
await runSoftExpandCycle();
|
|
698
756
|
verdict = await probePaint();
|
|
@@ -777,7 +835,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
777
835
|
() => () => {
|
|
778
836
|
unmountedRef.current = true;
|
|
779
837
|
for (const waiter of bootWaitersRef.current.splice(0)) waiter();
|
|
780
|
-
for (const waiter of paintTickWaitersRef.current.splice(0)) waiter(false);
|
|
838
|
+
for (const waiter of paintTickWaitersRef.current.splice(0)) waiter({ content: false, detail: 'unmounted' });
|
|
781
839
|
},
|
|
782
840
|
[],
|
|
783
841
|
);
|
|
@@ -1096,7 +1154,12 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
1096
1154
|
};
|
|
1097
1155
|
if (typeof navigator !== 'undefined' && isWebKitOnlyHost(navigator.userAgent)) {
|
|
1098
1156
|
void awaitContentVerified(4_000).then((verified) => {
|
|
1099
|
-
extAppRecoveryLog(
|
|
1157
|
+
extAppRecoveryLog(
|
|
1158
|
+
nodeId,
|
|
1159
|
+
verified
|
|
1160
|
+
? 'settled (content-verified)'
|
|
1161
|
+
: `settled (content-timeout${lastPaintDetailRef.current ? `: ${lastPaintDetailRef.current}` : ''})`,
|
|
1162
|
+
);
|
|
1100
1163
|
releaseQueue();
|
|
1101
1164
|
});
|
|
1102
1165
|
} else {
|
|
@@ -1404,7 +1467,13 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
1404
1467
|
minHeight: 0,
|
|
1405
1468
|
border: 'none',
|
|
1406
1469
|
background: 'var(--c-panel)',
|
|
1407
|
-
|
|
1470
|
+
// Keep the expanded frame in Chromium's pointer hit-test tree from
|
|
1471
|
+
// its first paint. Toggling none → auto when boot completes can
|
|
1472
|
+
// leave the compositor routing the first click to the parent even
|
|
1473
|
+
// though DOM hit testing and Playwright both resolve the button.
|
|
1474
|
+
// The loading sibling is non-interactive; inline frames remain
|
|
1475
|
+
// guarded by the preview catcher below.
|
|
1476
|
+
pointerEvents: isExpanded ? 'auto' : 'none',
|
|
1408
1477
|
}}
|
|
1409
1478
|
title={`Ext App: ${toolName}`}
|
|
1410
1479
|
/>
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
type ViewportState,
|
|
10
10
|
} from '../types';
|
|
11
11
|
import { computeAutoArrange } from '../../shared/auto-arrange';
|
|
12
|
-
import { canvasAreaCenter, canvasFitInsets } 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 {
|
|
@@ -833,14 +833,26 @@ export function focusNode(id: string, options: { recordHistory?: boolean } = {})
|
|
|
833
833
|
const node = nodes.value.get(id);
|
|
834
834
|
if (!node) return;
|
|
835
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);
|
|
836
849
|
const cx = node.position.x + node.size.width / 2;
|
|
837
850
|
const cy = node.position.y + node.size.height / 2;
|
|
838
|
-
const centre = canvasAreaCenter();
|
|
839
851
|
animateViewport(
|
|
840
852
|
{
|
|
841
|
-
x:
|
|
842
|
-
y:
|
|
843
|
-
scale
|
|
853
|
+
x: insets.left + usableW / 2 - cx * scale,
|
|
854
|
+
y: insets.top + usableH / 2 - cy * scale,
|
|
855
|
+
scale,
|
|
844
856
|
},
|
|
845
857
|
300,
|
|
846
858
|
options,
|
|
@@ -922,7 +922,9 @@ function handleCanvasLayoutUpdate(data: Record<string, unknown>): void {
|
|
|
922
922
|
}
|
|
923
923
|
: undefined;
|
|
924
924
|
|
|
925
|
-
|
|
925
|
+
// Routine node/layout broadcasts do not replace the camera, so must not
|
|
926
|
+
// interrupt a focus animation either (view.focus also emits a layout).
|
|
927
|
+
if (shouldApplyViewport) cancelViewportAnimation();
|
|
926
928
|
applyServerCanvasLayout(
|
|
927
929
|
{
|
|
928
930
|
...(nextViewport ? { viewport: nextViewport } : {}),
|