pmx-canvas 0.4.4 → 0.4.6

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 (98) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/Readme.md +52 -4
  3. package/dist/canvas/global.css +115 -11
  4. package/dist/canvas/index.js +93 -72
  5. package/dist/canvas/mermaid-entry.js +3563 -0
  6. package/dist/canvas/surface-theme.css +2 -2
  7. package/dist/json-render/index.css +1 -1
  8. package/dist/types/cli/agent.d.ts +1 -0
  9. package/dist/types/cli/commands/skills.d.ts +1 -1
  10. package/dist/types/cli/commands/smoke.d.ts +1 -0
  11. package/dist/types/client/mermaid-entry.d.ts +11 -0
  12. package/dist/types/client/nodes/DiffNode.d.ts +20 -0
  13. package/dist/types/client/nodes/ExtAppFrame.d.ts +0 -2
  14. package/dist/types/client/nodes/MermaidNode.d.ts +12 -0
  15. package/dist/types/client/state/intent-store.d.ts +6 -0
  16. package/dist/types/client/types.d.ts +2 -2
  17. package/dist/types/server/ax-state-manager.d.ts +5 -0
  18. package/dist/types/server/ax-state.d.ts +9 -3
  19. package/dist/types/server/canvas-db.d.ts +3 -2
  20. package/dist/types/server/canvas-provenance.d.ts +1 -1
  21. package/dist/types/server/canvas-state.d.ts +22 -1
  22. package/dist/types/server/canvas-validation.d.ts +34 -1
  23. package/dist/types/server/html-surface.d.ts +8 -0
  24. package/dist/types/server/index.d.ts +5 -0
  25. package/dist/types/server/operations/ops/ax-shared.d.ts +4 -1
  26. package/dist/types/server/operations/ops/nodes.d.ts +1 -1
  27. package/dist/types/server/operations/registry.d.ts +9 -1
  28. package/dist/types/server/workboard.d.ts +46 -0
  29. package/dist/types/shared/ax-intent.d.ts +6 -0
  30. package/dist/types/shared/ax-surface-protocol.d.ts +7 -0
  31. package/docs/cli.md +12 -0
  32. package/docs/environment.md +1 -0
  33. package/docs/http-api.md +12 -0
  34. package/docs/mcp.md +1 -1
  35. package/docs/node-types.md +2 -0
  36. package/docs/screenshot.png +0 -0
  37. package/docs/screenshots/amp-orb-portal.png +0 -0
  38. package/docs/screenshots/claude-code-desktop.png +0 -0
  39. package/docs/screenshots/codex-app.png +0 -0
  40. package/docs/screenshots/github-copilot-app.png +0 -0
  41. package/package.json +4 -3
  42. package/skills/pmx-canvas/SKILL.md +71 -24
  43. package/skills/pmx-canvas-orchestration/SKILL.md +82 -0
  44. package/skills/pmx-canvas-testing/SKILL.md +3 -0
  45. package/src/cli/agent.ts +2 -0
  46. package/src/cli/commands/ax.ts +28 -3
  47. package/src/cli/commands/skills.ts +1 -1
  48. package/src/cli/commands/smoke.ts +212 -0
  49. package/src/cli/index.ts +15 -2
  50. package/src/cli/shared.ts +1 -0
  51. package/src/client/canvas/CanvasNode.tsx +12 -1
  52. package/src/client/canvas/CanvasViewport.tsx +6 -0
  53. package/src/client/canvas/ExpandedNodeOverlay.tsx +10 -0
  54. package/src/client/canvas/IntentLayer.tsx +8 -6
  55. package/src/client/canvas/Minimap.tsx +2 -0
  56. package/src/client/canvas/auto-fit.ts +2 -0
  57. package/src/client/icons.tsx +4 -0
  58. package/src/client/mermaid-entry.ts +76 -0
  59. package/src/client/nodes/DiffNode.tsx +114 -0
  60. package/src/client/nodes/ExtAppFrame.tsx +259 -84
  61. package/src/client/nodes/MermaidNode.tsx +81 -0
  62. package/src/client/state/intent-bridge.ts +12 -3
  63. package/src/client/state/intent-store.ts +47 -2
  64. package/src/client/state/node-factory.ts +2 -0
  65. package/src/client/state/sse-bridge.ts +28 -0
  66. package/src/client/theme/global.css +115 -11
  67. package/src/client/theme/surface-theme.css +2 -2
  68. package/src/client/types.ts +6 -0
  69. package/src/mcp/server.ts +22 -6
  70. package/src/server/ax-interaction.ts +6 -0
  71. package/src/server/ax-state-manager.ts +27 -6
  72. package/src/server/ax-state.ts +36 -3
  73. package/src/server/canvas-db.ts +83 -60
  74. package/src/server/canvas-operations.ts +39 -9
  75. package/src/server/canvas-provenance.ts +2 -0
  76. package/src/server/canvas-schema.ts +33 -0
  77. package/src/server/canvas-state.ts +78 -6
  78. package/src/server/canvas-validation.ts +79 -0
  79. package/src/server/html-surface.ts +11 -0
  80. package/src/server/index.ts +25 -6
  81. package/src/server/intent-registry.ts +2 -0
  82. package/src/server/operations/composites.ts +3 -2
  83. package/src/server/operations/http.ts +5 -1
  84. package/src/server/operations/ops/ax-shared.ts +20 -1
  85. package/src/server/operations/ops/ax-state.ts +4 -7
  86. package/src/server/operations/ops/ax-timeline.ts +26 -3
  87. package/src/server/operations/ops/ax-work.ts +27 -1
  88. package/src/server/operations/ops/batch.ts +11 -1
  89. package/src/server/operations/ops/json-render.ts +44 -0
  90. package/src/server/operations/ops/nodes.ts +24 -2
  91. package/src/server/operations/ops/viewport.ts +8 -1
  92. package/src/server/operations/registry.ts +122 -1
  93. package/src/server/server.ts +58 -3
  94. package/src/server/workboard.ts +159 -0
  95. package/src/shared/ax-intent.ts +6 -0
  96. package/src/shared/ax-surface-protocol.ts +8 -0
  97. package/src/shared/surface.ts +2 -0
  98. package/src/shared/themes.ts +1 -1
@@ -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
- window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: ${JSON.stringify(frameToken)}, nodeId: ${JSON.stringify(nodeId)} }, '*');
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): void => {
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
- // Finding F (0.2.4/0.2.5, reworked 0.3.1): in a WebKit host panel (e.g. the GitHub
443
- // Copilot app's WKWebView, and Bun's headless WebKit WebView) the doubly-nested
444
- // ext-app iframe (workbench iframe → mcp-app.html iframe) can come up as a black
445
- // tile for nodes present at panel-load. The mcp-app shell loads blank, then the app
446
- // boots over the bridge and draws its content AFTER load; under a cold-hydration
447
- // burst WebKit does not composite that late draw, so the layer stays black (clean
448
- // in Blink, and clean for a node created live into an already-idle panel). A
449
- // parent-side transform/src nudge does NOT repair a black layer — only a full
450
- // remount (new iframe element + bridge re-init, what expand+close does) does, and
451
- // only when it lands in an idle moment. So: once the app has booted — `ready` for
452
- // empty apps, `done` for restored apps that must replay saved tool output — under
453
- // WebKit only, enqueue ONE recovery remount through the boot-aware queue above, so
454
- // concurrent ext-apps remount strictly one at a time instead of re-bursting.
455
- // Strict no-op in Blink/Gecko; the e2e engine is unaffected. Inline instance only.
456
- useEffect(() => {
457
- if (expanded || webkitRepaintDoneRef.current) return;
458
- if (!shouldScheduleWebKitRepaint(status, hasReplayToolResult)) return;
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
- webkitRepaintDoneRef.current = true;
462
- scheduleWebkitRemount('post-boot-repaint');
463
- // eslint-disable-next-line react-hooks/exhaustive-deps
464
- }, [status, hasReplayToolResult]);
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 best-effort: WKWebView can keep compositing a stale (black)
669
- // texture for the doubly-nested app iframe even after a genuine settled
670
- // boot — the user-proven unstick is enlarge+close, i.e. a layout-size
671
- // change. Mimic the cheap half of that after the scene draw: bounce the
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
- const frame = iframeRef.current;
682
- if (disposed || !frame) return;
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
- scheduleWebkitRepaintNudge();
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
- scheduleWebkitRepaintNudge();
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
- <iframe
1121
- key={frameKey}
1122
- ref={iframeRef}
1123
- {...iframeDocument.attributes}
1124
- sandbox={iframeSandbox}
1125
- allow={buildAllowAttribute(resourceMeta?.permissions)}
1126
- // NB: do NOT add the `.mcp-app-frame` GPU-layer class (translateZ(0)) here —
1127
- // it creates a stacking context that breaks the AX emit→ack round-trip in the
1128
- // expanded ext-app overlay (#55 e2e); the post-boot WebKit repaint remount
1129
- // below recovers a single present-at-load ext-app without it (Finding F).
1130
- style={{
1131
- flex: 1,
1132
- width: '100%',
1133
- height: '100%',
1134
- minHeight: 0,
1135
- border: 'none',
1136
- background: 'var(--c-panel)',
1137
- pointerEvents: isExpanded && status !== 'loading' ? 'auto' : 'none',
1138
- }}
1139
- title={`Ext App: ${toolName}`}
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. During WebKit recovery remounts the iframe's own layer
1143
- can composite black (Finding N), but sibling DOM always paints — so
1144
- the multi-second recovery window reads as an intentional loading
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}...</div>
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
- {!isExpanded && (
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) => {
@@ -0,0 +1,81 @@
1
+ import { useEffect, useMemo, useRef } from 'preact/hooks';
2
+ import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
3
+ import { canvasTheme } from '../state/canvas-store';
4
+ import type { CanvasNodeState } from '../types';
5
+ import { nodeSurfaceUrl, surfaceContentHash } from './surface-url';
6
+ import { useIframeContentHeight } from './use-iframe-content-height';
7
+ import { useSurfaceFrame } from './use-surface-frame';
8
+
9
+ /**
10
+ * Mermaid diagram node — a display-only iframe surface. The server renders the
11
+ * surface document (escaped diagram source + /canvas/mermaid-entry.js) at
12
+ * /api/canvas/surface/:id; the iframe and "Open as site" share that one render
13
+ * path, exactly like html nodes. Minimal subset of HtmlNode: theme push and
14
+ * content-height growth, no AX bridge, no presentation mode.
15
+ */
16
+ export function MermaidNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
17
+ const iframeRef = useRef<HTMLIFrameElement>(null);
18
+ const theme = canvasTheme.value;
19
+ // Stable per-mount nonce that authorizes parent → iframe theme-update messages.
20
+ const themeToken = useMemo(() => `theme-${crypto.randomUUID()}`, []);
21
+ // Per-mount nonce for the content-height reporter (node grows to fit content).
22
+ const frameToken = useMemo(() => `frame-${crypto.randomUUID()}`, []);
23
+ const source = typeof node.data.content === 'string' ? node.data.content : '';
24
+ const v = useMemo(() => surfaceContentHash(source), [source]);
25
+
26
+ // `theme` is intentionally excluded from the deps: live theme changes are
27
+ // pushed via postMessage below (no reload), while `v` reloads the frame when
28
+ // the diagram source changes.
29
+ const surfaceSrc = useMemo(
30
+ () => (source ? nodeSurfaceUrl(node.id, { theme, themeToken, v, frameToken }) : ''),
31
+ [source, themeToken, v, node.id, frameToken],
32
+ );
33
+
34
+ // src vs fetch()+srcdoc, decided by the boot-wide embed probe (Amp portals).
35
+ const surfaceFrame = useSurfaceFrame(surfaceSrc);
36
+
37
+ // Grow the node to fit the surface's reported content height (grow-only, gated).
38
+ // Never in the expanded overlay — there the surface fills the large overlay frame.
39
+ useIframeContentHeight(node, iframeRef, expanded ? '' : frameToken);
40
+
41
+ const pushTheme = () => {
42
+ iframeRef.current?.contentWindow?.postMessage(
43
+ {
44
+ source: HTML_SURFACE_PUSH_SOURCE,
45
+ type: 'theme-update',
46
+ token: themeToken,
47
+ theme,
48
+ },
49
+ '*',
50
+ );
51
+ };
52
+
53
+ useEffect(pushTheme, [theme, themeToken]);
54
+
55
+ if (!source) {
56
+ return <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>No diagram source set</div>;
57
+ }
58
+
59
+ // SECURITY: sandbox stays `allow-scripts` ONLY — same tier as html surfaces
60
+ // (see HtmlNode for the full rationale); the surface route sends a matching
61
+ // CSP sandbox header.
62
+ return (
63
+ <iframe
64
+ ref={iframeRef}
65
+ class="html-node-frame"
66
+ title={typeof node.data.title === 'string' ? node.data.title : 'Mermaid diagram'}
67
+ sandbox="allow-scripts"
68
+ {...surfaceFrame}
69
+ onLoad={pushTheme}
70
+ style={{
71
+ width: '100%',
72
+ height: '100%',
73
+ minHeight: expanded ? '70vh' : '300px',
74
+ border: 'none',
75
+ background: 'var(--c-bg)',
76
+ borderRadius: '6px',
77
+ display: 'block',
78
+ }}
79
+ />
80
+ );
81
+ }
@@ -2,9 +2,18 @@ function logRequestError(action: string, error: unknown): void {
2
2
  console.error(`[intent-bridge] ${action} failed`, error);
3
3
  }
4
4
 
5
+ /**
6
+ * Every workbench-originated request carries this marker so the server never
7
+ * synthesizes an auto-ghost for it — a human dragging or editing in the
8
+ * browser is not agent activity (the ghost cursor shows AGENT moves).
9
+ */
10
+ function withWorkbenchMarker(init?: RequestInit): RequestInit {
11
+ return { ...init, headers: { ...(init?.headers as Record<string, string> | undefined), 'X-PMX-Workbench': '1' } };
12
+ }
13
+
5
14
  async function requestJson<T>(action: string, url: string, fallback: T, init?: RequestInit): Promise<T> {
6
15
  try {
7
- const res = await fetch(url, init);
16
+ const res = await fetch(url, withWorkbenchMarker(init));
8
17
  return (await res.json()) as T;
9
18
  } catch (error) {
10
19
  logRequestError(action, error);
@@ -14,7 +23,7 @@ async function requestJson<T>(action: string, url: string, fallback: T, init?: R
14
23
 
15
24
  async function requestOk(action: string, url: string, init?: RequestInit): Promise<{ ok: boolean }> {
16
25
  try {
17
- const res = await fetch(url, init);
26
+ const res = await fetch(url, withWorkbenchMarker(init));
18
27
  return { ok: res.ok };
19
28
  } catch (error) {
20
29
  logRequestError(action, error);
@@ -24,7 +33,7 @@ async function requestOk(action: string, url: string, init?: RequestInit): Promi
24
33
 
25
34
  async function requestBestEffort(action: string, url: string, init?: RequestInit): Promise<void> {
26
35
  try {
27
- await fetch(url, init);
36
+ await fetch(url, withWorkbenchMarker(init));
28
37
  } catch (error) {
29
38
  logRequestError(action, error);
30
39
  }