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.
Files changed (41) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/Readme.md +10 -7
  3. package/dist/canvas/global.css +101 -7
  4. package/dist/canvas/index.js +171 -128
  5. package/dist/canvas/mermaid-entry.js +1303 -1303
  6. package/dist/json-render/index.js +101 -102
  7. package/dist/types/client/canvas/use-pan-zoom.d.ts +2 -1
  8. package/docs/adr-001-bun-only-runtime.md +1 -1
  9. package/docs/bun-webview-integration.md +2 -2
  10. package/docs/node-types.md +1 -1
  11. package/docs/product-context-vision-2026-09.md +207 -0
  12. package/docs/product-review-2026-09.md +174 -0
  13. package/docs/product-vision-2026-09.md +200 -0
  14. package/docs/screenshot.png +0 -0
  15. package/package.json +2 -2
  16. package/skills/pmx-canvas/SKILL.md +62 -36
  17. package/skills/pmx-canvas/evals/evals.json +1 -1
  18. package/skills/pmx-canvas/references/excalidraw-diagram-authoring.md +5 -0
  19. package/skills/pmx-canvas/references/full-reference.md +5 -4
  20. package/skills/pmx-canvas/references/installing-pmx-canvas.md +126 -18
  21. package/skills/pmx-canvas-testing/SKILL.md +13 -4
  22. package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +18 -8
  23. package/src/client/canvas/CanvasNode.tsx +9 -12
  24. package/src/client/canvas/CanvasViewport.tsx +20 -33
  25. package/src/client/canvas/ContextMenu.tsx +13 -0
  26. package/src/client/canvas/EdgeLayer.tsx +2 -2
  27. package/src/client/canvas/SessionPanel.tsx +23 -4
  28. package/src/client/canvas/ShortcutOverlay.tsx +2 -2
  29. package/src/client/canvas/TopBar.tsx +85 -45
  30. package/src/client/canvas/use-pan-zoom.ts +23 -3
  31. package/src/client/nodes/ExtAppFrame.tsx +97 -28
  32. package/src/client/state/canvas-store.ts +17 -5
  33. package/src/client/state/sse-bridge.ts +3 -1
  34. package/src/client/theme/global.css +101 -7
  35. package/src/server/canvas-operations.ts +2 -2
  36. package/src/server/operations/mcp.ts +45 -31
  37. package/src/server/operations/ops/ax-presence.ts +3 -0
  38. package/src/server/operations/ops/viewport.ts +6 -1
  39. package/src/server/scope-fence.ts +1 -1
  40. package/src/server/server.ts +9 -9
  41. 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
- {sessions.map((session) => {
42
- // Fleet roll-up: workers declaring this session as their parent count
43
- // into its chip instead of growing the bar one chip per worker.
44
- const workers = agentPresences.value.filter(
45
- (presence) =>
46
- presence.parentAgentId != null &&
47
- (presence.parentAgentId === session.sessionId || presence.parentAgentId === session.source),
48
- ).length;
49
- return (
50
- <BarHint
51
- key={session.sessionId}
52
- label={`Agent session — ${session.label}`}
53
- tapToOpen
54
- 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."
55
- >
56
- <span
57
- class={`agent-chip phase-${session.phase}`}
58
- data-phase={session.phase}
59
- style={{ '--identity-color': `hsl(${agentIdentityHue(session.sessionId)} 65% 62%)` }}
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 class="agent-chip-dot" aria-hidden="true" />
62
- <span class="agent-chip-label">{agentPhaseLabel(session)}</span>
63
- <span class="agent-chip-who hud-collapsible-text">{session.label}</span>
64
- {workers > 0 && (
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
- </button>
83
- </span>
84
- </BarHint>
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
- * - Pointer drag on background: pan
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
- // Middle-drag pans from anywhere, any tool — standard canvas UX.
107
- if (e.button === 1) {
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
- let extAppReportQueue: Array<{ t: number; nodeId: string; event: string; visibility?: string }> = [];
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.0 reopen): a double rAF fires even while
291
- // WKWebKit composites nothing — the tick alone was a false green. The tick
292
- // now carries a verdict: does this document actually CONTAIN rendered
293
- // content (a laid-out canvas/svg, or real text)? The parent only accepts
294
- // paint-ok when it does.
295
- function contentReady() {
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
- var els = document.querySelectorAll('canvas, svg');
298
- for (var i = 0; i < els.length; i++) {
299
- var r = els[i].getBoundingClientRect();
300
- if (r.width > 8 && r.height > 8) return true;
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
- return !!(body && body.innerText && body.innerText.trim().length > 0);
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
- window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: TOKEN, nodeId: NODE_ID, kind: 'paint-tick', content: contentReady() }, '*');
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 content = (data as { content?: unknown }).content === true;
475
- for (const waiter of paintTickWaitersRef.current.splice(0)) waiter(content);
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((content) => {
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
- const paintOkLabel = (suffix = ''): string =>
676
- typeof document !== 'undefined' && document.visibilityState === 'hidden'
677
- ? `paint-ok (content-verified)${suffix} (host hidden)`
678
- : `paint-ok (content-verified)${suffix}`;
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' ? 'paint-fail (tick without content)' : 'paint-fail (no tick)',
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(nodeId, verified ? 'settled (content-verified)' : 'settled (content-timeout)');
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
- pointerEvents: isExpanded && status !== 'loading' ? 'auto' : 'none',
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: centre.x - cx * v.scale,
842
- y: centre.y - cy * v.scale,
843
- scale: v.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
- cancelViewportAnimation();
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 } : {}),