pmx-canvas 0.6.4 → 0.6.5

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 (78) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/Readme.md +7 -1
  3. package/dist/canvas/global.css +58 -4
  4. package/dist/canvas/index.js +92 -92
  5. package/dist/canvas/mermaid-entry.js +258 -258
  6. package/dist/types/cli/agent.d.ts +1 -0
  7. package/dist/types/cli/commands/record.d.ts +12 -0
  8. package/dist/types/client/canvas/Presentation.d.ts +13 -0
  9. package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
  10. package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
  11. package/dist/types/client/state/presentation.d.ts +3 -0
  12. package/dist/types/client/types.d.ts +1 -1
  13. package/dist/types/json-render/catalog.d.ts +2 -2
  14. package/dist/types/json-render/server.d.ts +2 -0
  15. package/dist/types/server/canvas-db.d.ts +2 -0
  16. package/dist/types/server/canvas-operations.d.ts +22 -0
  17. package/dist/types/server/canvas-schema.d.ts +1 -0
  18. package/dist/types/server/canvas-state.d.ts +6 -0
  19. package/dist/types/server/canvas-validation.d.ts +3 -0
  20. package/dist/types/server/html-surface.d.ts +1 -1
  21. package/dist/types/server/index.d.ts +4 -0
  22. package/dist/types/server/operations/ops/app.d.ts +2 -0
  23. package/dist/types/server/operations/ops/json-render.d.ts +2 -0
  24. package/dist/types/server/operations/ops/nodes.d.ts +3 -1
  25. package/dist/types/server/web-artifacts.d.ts +3 -0
  26. package/dist/types/shared/tour.d.ts +72 -0
  27. package/docs/cli.md +89 -0
  28. package/docs/http-api.md +79 -4
  29. package/docs/mcp.md +25 -1
  30. package/docs/node-types.md +80 -0
  31. package/package.json +1 -1
  32. package/skills/pmx-canvas/SKILL.md +10 -2
  33. package/skills/pmx-canvas/references/full-reference.md +1 -1
  34. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  35. package/src/cli/agent.ts +5 -0
  36. package/src/cli/commands/nodes.ts +10 -0
  37. package/src/cli/commands/record.ts +218 -0
  38. package/src/cli/index.ts +2 -0
  39. package/src/client/App.tsx +7 -1
  40. package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
  41. package/src/client/canvas/CanvasNode.tsx +8 -0
  42. package/src/client/canvas/IntentLayer.tsx +9 -2
  43. package/src/client/canvas/Presentation.tsx +108 -0
  44. package/src/client/canvas/TopBar.tsx +28 -2
  45. package/src/client/canvas/use-node-drag.ts +8 -3
  46. package/src/client/mermaid-entry.ts +39 -0
  47. package/src/client/nodes/HtmlNode.tsx +12 -13
  48. package/src/client/nodes/McpAppNode.tsx +105 -13
  49. package/src/client/nodes/MermaidNode.tsx +3 -2
  50. package/src/client/state/intent-store.ts +5 -1
  51. package/src/client/state/presentation.ts +7 -0
  52. package/src/client/state/session-store.ts +3 -0
  53. package/src/client/state/sse-bridge.ts +3 -0
  54. package/src/client/theme/global.css +58 -4
  55. package/src/json-render/server.ts +29 -0
  56. package/src/server/agent-presence.ts +15 -9
  57. package/src/server/canvas-db.ts +15 -0
  58. package/src/server/canvas-operations.ts +115 -22
  59. package/src/server/canvas-schema.ts +10 -0
  60. package/src/server/canvas-state.ts +39 -4
  61. package/src/server/canvas-validation.ts +9 -1
  62. package/src/server/demo-state.json +11 -7
  63. package/src/server/html-primitives.ts +199 -91
  64. package/src/server/html-surface.ts +8 -3
  65. package/src/server/index.ts +12 -0
  66. package/src/server/operations/composites.ts +3 -1
  67. package/src/server/operations/ops/app.ts +10 -0
  68. package/src/server/operations/ops/batch.ts +12 -3
  69. package/src/server/operations/ops/groups.ts +14 -7
  70. package/src/server/operations/ops/json-render.ts +37 -5
  71. package/src/server/operations/ops/nodes.ts +42 -16
  72. package/src/server/operations/ops/viewport.ts +32 -0
  73. package/src/server/operations/registry.ts +6 -0
  74. package/src/server/scope-fence.ts +1 -1
  75. package/src/server/server.ts +9 -6
  76. package/src/server/web-artifacts.ts +9 -2
  77. package/src/server/workboard.ts +3 -3
  78. package/src/shared/tour.ts +87 -0
@@ -1,4 +1,6 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
+ import { Presentation } from './canvas/Presentation';
3
+ import { presenting } from './state/presentation';
2
4
  import { AttentionHistory } from './canvas/AttentionHistory';
3
5
  import { AttentionToast } from './canvas/AttentionToast';
4
6
  import { registerCanvasArea, canvasArea } from './canvas/canvas-area';
@@ -324,7 +326,11 @@ export function App() {
324
326
  const area = canvasArea();
325
327
 
326
328
  return (
327
- <div class="app-shell" data-session-active={sessionIsActive ? 'true' : 'false'}>
329
+ <div
330
+ class={`app-shell${presenting.value ? ' is-presenting' : ''}`}
331
+ data-session-active={sessionIsActive ? 'true' : 'false'}
332
+ >
333
+ <Presentation />
328
334
  <ToolRail
329
335
  minimapVisible={minimapVisible}
330
336
  onToggleMinimap={handleToggleMinimap}
@@ -45,7 +45,7 @@ export function AgentPresenceLayer() {
45
45
  {cursors.map(({ presence, position }) => (
46
46
  <div
47
47
  key={presence.sessionId}
48
- class={`agent-cursor phase-${presence.phase}${presence.attached ? '' : ' is-external'}${presence.parentAgentId ? ' is-worker' : ''}`}
48
+ class={`agent-cursor phase-${presence.phase}${presence.cursor ? ' has-explicit-cursor' : ''}${presence.attached ? '' : ' is-external'}${presence.parentAgentId ? ' is-worker' : ''}`}
49
49
  data-session-id={presence.sessionId}
50
50
  style={{
51
51
  transform: `translate(${position.x}px, ${position.y}px)`,
@@ -187,6 +187,9 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
187
187
  viewport,
188
188
  onMove: handleMove,
189
189
  onDragEnd: handleDragEnd,
190
+ onClick: () => {
191
+ selectedNodeIds.value = new Set([node.id]);
192
+ },
190
193
  });
191
194
 
192
195
  // ── Resize ────────────────────────────────────────────
@@ -236,6 +239,11 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
236
239
  (e: PointerEvent) => {
237
240
  if (e.button !== 0) return;
238
241
  if (renaming) return;
242
+ // Title controls act on the node without selecting or dragging it.
243
+ if (e.target instanceof Element && e.target.closest('button, input')) {
244
+ e.stopPropagation();
245
+ return;
246
+ }
239
247
  if (canvasTool.value === 'connect') {
240
248
  startEdgeDrag(e);
241
249
  return;
@@ -1,6 +1,6 @@
1
1
  import { useEffect } from 'preact/hooks';
2
2
  import { nodes } from '../state/canvas-store';
3
- import { hoveredIntentId, intents, type ClientIntent } from '../state/intent-store';
3
+ import { hoveredIntentId, intents, removeIntent, type ClientIntent } from '../state/intent-store';
4
4
  import { vetoGhostIntent } from '../state/intent-bridge';
5
5
  import { getNodeIcon } from '../icons';
6
6
  import { TYPE_LABELS } from '../types';
@@ -241,7 +241,14 @@ export function IntentLayer() {
241
241
  if (list.length === 0) return null;
242
242
 
243
243
  return (
244
- <div class="intent-layer">
244
+ <div
245
+ class="intent-layer"
246
+ onAnimationEnd={(event) => {
247
+ if (event.animationName !== 'intent-settle' && event.animationName !== 'intent-dissolve') return;
248
+ const id = (event.target as HTMLElement).dataset.intentId;
249
+ if (id && intents.value.get(id)?.phase !== 'forming') removeIntent(id);
250
+ }}
251
+ >
245
252
  <svg
246
253
  class="intent-line-layer"
247
254
  style={{
@@ -0,0 +1,108 @@
1
+ import { useEffect, useState } from 'preact/hooks';
2
+ import { cameraSchema, interpolateCamera, resolveStop, type Camera, type Tour } from '../../shared/tour.js';
3
+ import { canvasArea } from './canvas-area';
4
+ import { cancelViewportAnimation, hasInitialServerLayout, nodes, viewport } from '../state/canvas-store';
5
+ import { presenting, recordingCamera } from '../state/presentation';
6
+ import { requestJson } from '../state/intent-bridge';
7
+
8
+ declare global {
9
+ interface Window {
10
+ pmxCapture?: {
11
+ ready: () => boolean;
12
+ area: typeof canvasArea;
13
+ camera: () => Camera;
14
+ frame: (camera: Camera) => Promise<Camera>;
15
+ };
16
+ }
17
+ }
18
+
19
+ export function Presentation() {
20
+ const [tour, setTour] = useState<Tour>({ stops: [] });
21
+ const [index, setIndex] = useState(0);
22
+ const [error, setError] = useState('');
23
+ const active = presenting.value;
24
+ const ready = hasInitialServerLayout.value;
25
+ useEffect(() => {
26
+ const params = new URLSearchParams(location.search);
27
+ presenting.value = params.get('present') === '1';
28
+ recordingCamera.value = params.get('capture') === '1';
29
+ window.pmxCapture = {
30
+ ready: () => hasInitialServerLayout.value,
31
+ area: canvasArea,
32
+ camera: () => ({ ...viewport.value }),
33
+ frame: async (camera) => {
34
+ recordingCamera.value = true;
35
+ cancelViewportAnimation();
36
+ viewport.value = cameraSchema.parse(camera);
37
+ // Two animation frames: Preact has committed and the browser has had
38
+ // a paint opportunity before the screenshot command is acknowledged.
39
+ await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
40
+ return { ...viewport.value };
41
+ },
42
+ };
43
+ return () => {
44
+ delete window.pmxCapture;
45
+ presenting.value = false;
46
+ recordingCamera.value = false;
47
+ };
48
+ }, []);
49
+ useEffect(() => {
50
+ if (!active || !ready) return;
51
+ let cancelled = false;
52
+ void requestJson('read tour', '/api/canvas/tour', { tour: { stops: [] } as Tour }).then((result) => {
53
+ if (!cancelled) {
54
+ setTour(result.tour);
55
+ setIndex(0);
56
+ }
57
+ });
58
+ return () => {
59
+ cancelled = true;
60
+ };
61
+ }, [active, ready]);
62
+ useEffect(() => {
63
+ if (!active || recordingCamera.value) return;
64
+ const stop = tour.stops[index];
65
+ if (!stop) return;
66
+ const area = canvasArea();
67
+ let target: Camera;
68
+ try {
69
+ target = resolveStop(stop, [...nodes.value.values()], area.width, area.height);
70
+ setError('');
71
+ } catch (error) {
72
+ setError(error instanceof Error ? error.message : String(error));
73
+ return;
74
+ }
75
+ cancelViewportAnimation();
76
+ const from = { ...viewport.value };
77
+ const start = performance.now();
78
+ const duration = (stop.duration ?? 1) * 1000;
79
+ let frame = 0;
80
+ const tick = (now: number) => {
81
+ if (recordingCamera.value) return;
82
+ const t = duration === 0 ? 1 : Math.min(1, (now - start) / duration);
83
+ viewport.value = interpolateCamera(from, target, t, area.width, area.height, stop.easing, stop.pullback);
84
+ if (t < 1) frame = requestAnimationFrame(tick);
85
+ };
86
+ frame = requestAnimationFrame(tick);
87
+ return () => cancelAnimationFrame(frame);
88
+ }, [active, index, tour]);
89
+ useEffect(() => {
90
+ if (!active) return;
91
+ const key = (event: KeyboardEvent) => {
92
+ event.stopImmediatePropagation();
93
+ if (['Escape', 'ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', ' '].includes(event.key))
94
+ event.preventDefault();
95
+ if (event.key === 'Escape') presenting.value = false;
96
+ if (['ArrowRight', 'ArrowDown', ' '].includes(event.key))
97
+ setIndex((i) => Math.max(0, Math.min(tour.stops.length - 1, i + 1)));
98
+ if (['ArrowLeft', 'ArrowUp'].includes(event.key)) setIndex((i) => Math.max(0, i - 1));
99
+ };
100
+ document.addEventListener('keydown', key, true);
101
+ return () => document.removeEventListener('keydown', key, true);
102
+ }, [active, tour]);
103
+ return active && error ? (
104
+ <div class="presentation-error" role="alert">
105
+ {error}
106
+ </div>
107
+ ) : null;
108
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ComponentChildren } from 'preact';
2
+ import { presenting } from '../state/presentation';
2
3
  import { useEffect, useState } from 'preact/hooks';
3
4
  import { IconFitAll, IconZoomIn, IconZoomOut } from '../icons';
4
5
  import {
@@ -42,8 +43,19 @@ function AgentChip() {
42
43
  // bar and over the session panel, and the total was invisible): the most
43
44
  // active few wear their own chips and the rest fold into a census chip
44
45
  // that always names the total.
45
- const sessions = attachedSessions.value;
46
- if (sessions.length === 0) return null;
46
+ const allSessions = attachedSessions.value;
47
+ if (allSessions.length === 0) return null;
48
+ // Attached workers are represented by their orchestrator's roll-up, not a
49
+ // second top-level chip of their own. Orphans remain visible.
50
+ const sessions = allSessions.filter(
51
+ (session) =>
52
+ !session.parentAgentId ||
53
+ !allSessions.some(
54
+ (candidate) =>
55
+ candidate.sessionId !== session.sessionId &&
56
+ (candidate.sessionId === session.parentAgentId || candidate.source === session.parentAgentId),
57
+ ),
58
+ );
47
59
  const ranked = [...sessions].sort((a, b) => (AGENT_PHASE_RANK[a.phase] ?? 4) - (AGENT_PHASE_RANK[b.phase] ?? 4));
48
60
  const visible = ranked.slice(0, AGENT_CHIP_LIMIT);
49
61
  const overflow = ranked.slice(AGENT_CHIP_LIMIT);
@@ -318,6 +330,20 @@ export function TopBar() {
318
330
 
319
331
  <div class="top-bar-sep" />
320
332
 
333
+ <BarHint
334
+ label="Present board"
335
+ body="Arrow keys or Space step through stops; Esc exits. Without a saved tour, groups are read top-to-bottom, left-to-right."
336
+ >
337
+ <button
338
+ type="button"
339
+ class="present-button"
340
+ onClick={() => {
341
+ presenting.value = true;
342
+ }}
343
+ >
344
+ Present
345
+ </button>
346
+ </BarHint>
321
347
  <BarHint label="Zoom out" shortcut={modChord('\u2212')}>
322
348
  <button type="button" class="top-bar-btn" onClick={() => zoomByFactor(1 / 1.25)} aria-label="Zoom out">
323
349
  <IconZoomOut />
@@ -7,6 +7,7 @@ interface NodeDragOptions {
7
7
  viewport: Signal<ViewportState>;
8
8
  onMove: (id: string, x: number, y: number) => void;
9
9
  onDragEnd: () => void;
10
+ onClick?: () => void;
10
11
  }
11
12
 
12
13
  /**
@@ -14,7 +15,7 @@ interface NodeDragOptions {
14
15
  * Converts screen-space pointer delta to canvas-space position delta
15
16
  * (accounting for current viewport scale).
16
17
  */
17
- export function useNodeDrag({ nodeId, viewport, onMove, onDragEnd }: NodeDragOptions) {
18
+ export function useNodeDrag({ nodeId, viewport, onMove, onDragEnd, onClick }: NodeDragOptions) {
18
19
  const isDragging = useRef(false);
19
20
  const startPointer = useRef({ x: 0, y: 0 });
20
21
  const startPosition = useRef({ x: 0, y: 0 });
@@ -30,6 +31,7 @@ export function useNodeDrag({ nodeId, viewport, onMove, onDragEnd }: NodeDragOpt
30
31
  startPosition.current = { x: currentX, y: currentY };
31
32
  let pendingPointer: { x: number; y: number } | null = null;
32
33
  let frameId: number | null = null;
34
+ let moved = false;
33
35
 
34
36
  const flushMove = () => {
35
37
  frameId = null;
@@ -44,12 +46,14 @@ export function useNodeDrag({ nodeId, viewport, onMove, onDragEnd }: NodeDragOpt
44
46
 
45
47
  const onPointerMove = (ev: PointerEvent) => {
46
48
  if (!isDragging.current) return;
49
+ if (!moved && Math.hypot(ev.clientX - e.clientX, ev.clientY - e.clientY) < 4) return;
50
+ moved = true;
47
51
  pendingPointer = { x: ev.clientX, y: ev.clientY };
48
52
  if (frameId !== null) return;
49
53
  frameId = window.requestAnimationFrame(flushMove);
50
54
  };
51
55
 
52
- const finishDrag = () => {
56
+ const finishDrag = (ev: PointerEvent) => {
53
57
  if (frameId !== null) {
54
58
  window.cancelAnimationFrame(frameId);
55
59
  flushMove();
@@ -60,13 +64,14 @@ export function useNodeDrag({ nodeId, viewport, onMove, onDragEnd }: NodeDragOpt
60
64
  document.removeEventListener('pointerup', finishDrag);
61
65
  document.removeEventListener('pointercancel', finishDrag);
62
66
  onDragEnd();
67
+ if (!moved && ev.type === 'pointerup') onClick?.();
63
68
  };
64
69
 
65
70
  document.addEventListener('pointermove', onPointerMove);
66
71
  document.addEventListener('pointerup', finishDrag);
67
72
  document.addEventListener('pointercancel', finishDrag);
68
73
  },
69
- [nodeId, viewport, onMove, onDragEnd],
74
+ [nodeId, viewport, onMove, onDragEnd, onClick],
70
75
  );
71
76
 
72
77
  return handlePointerDown;
@@ -29,6 +29,41 @@ function showError(container: HTMLElement, message: string): void {
29
29
 
30
30
  let renderSeq = 0;
31
31
 
32
+ function fitDiagram(container: HTMLElement): void {
33
+ const svg = container.querySelector('svg');
34
+ const box = svg?.viewBox.baseVal;
35
+ if (!svg || !box?.width || !box.height) return;
36
+ const source = document.querySelector<HTMLElement>('.mermaid-source');
37
+ // Only canvas frames carry a nonce. A standalone site may itself be inside
38
+ // a host browser pane's iframe; it must still offer natural-size viewing.
39
+ const token = source?.dataset.frameToken;
40
+ const embedded = !!token && window.parent !== window;
41
+ const contain = embedded && source?.dataset.fit !== 'none';
42
+ const width = document.documentElement.clientWidth;
43
+ const height = document.documentElement.clientHeight;
44
+ const scale = contain ? Math.min(1, width / box.width, height / box.height) : 1;
45
+ svg.style.maxWidth = 'none';
46
+ svg.style.width = `${box.width * scale}px`;
47
+ svg.style.height = `${box.height * scale}px`;
48
+ svg.style.display = 'block';
49
+ svg.style.margin = contain ? '0 auto' : '0';
50
+ svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
51
+
52
+ // Report the unconstrained, width-fitted height, not the visually contained
53
+ // height. Auto-grow remains useful; strictSize/userResized keep their frame.
54
+ if (embedded && token) {
55
+ window.parent.postMessage(
56
+ {
57
+ source: 'pmx-canvas-frame',
58
+ type: 'content-height',
59
+ token,
60
+ height: box.height * (contain ? Math.min(1, width / box.width) : 1),
61
+ },
62
+ '*',
63
+ );
64
+ }
65
+ }
66
+
32
67
  async function renderDiagram(source: string, container: HTMLElement): Promise<void> {
33
68
  const scheme = documentScheme();
34
69
  mermaid.initialize({
@@ -40,6 +75,7 @@ async function renderDiagram(source: string, container: HTMLElement): Promise<vo
40
75
  try {
41
76
  const { svg } = await mermaid.render(`pmx-mermaid-${renderSeq}`, source);
42
77
  container.innerHTML = svg;
78
+ fitDiagram(container);
43
79
  } catch (error) {
44
80
  // Never leave a blank/black frame: show the parse/render error instead.
45
81
  showError(container, error instanceof Error ? error.message : String(error));
@@ -51,7 +87,10 @@ function boot(): void {
51
87
  const source = sourceEl?.textContent ?? '';
52
88
  const container = document.createElement('div');
53
89
  container.className = 'mermaid-diagram';
90
+ document.body.style.margin = '0';
91
+ document.body.style.padding = '0';
54
92
  document.body.appendChild(container);
93
+ window.addEventListener('resize', () => fitDiagram(container));
55
94
  if (!source.trim()) {
56
95
  showError(container, 'Empty mermaid diagram');
57
96
  return;
@@ -3,6 +3,7 @@ import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
3
3
  import { axSurfaceState, canvasTheme } from '../state/canvas-store';
4
4
  import type { CanvasNodeState } from '../types';
5
5
  import { nodeSurfaceUrl, surfaceContentHash } from './surface-url';
6
+ import { RefreshingViewerFrame } from './McpAppNode';
6
7
  import { useAxSurfaceBridge } from './use-ax-surface-bridge';
7
8
  import { useIframeContentHeight } from './use-iframe-content-height';
8
9
  import { useSurfaceFrame } from './use-surface-frame';
@@ -148,23 +149,21 @@ export function HtmlNode({
148
149
  // standalone tab. The whole html-node tier assumes arbitrary author code runs
149
150
  // inside this exact sandbox.
150
151
  return (
151
- <iframe
152
- ref={iframeRef}
153
- class={presentation ? 'html-node-frame html-node-frame-presentation' : 'html-node-frame'}
152
+ <RefreshingViewerFrame
153
+ source={surfaceFrame}
154
+ iframeRef={iframeRef}
155
+ className={
156
+ presentation
157
+ ? 'html-node-frame html-node-frame-presentation'
158
+ : expanded
159
+ ? 'html-node-frame html-node-frame-expanded'
160
+ : 'html-node-frame'
161
+ }
154
162
  title={typeof node.data.title === 'string' ? node.data.title : 'HTML node'}
155
163
  sandbox="allow-scripts"
156
- {...surfaceFrame}
164
+ allow=""
157
165
  tabIndex={autoFocus ? 0 : undefined}
158
166
  onLoad={handleFrameLoad}
159
- style={{
160
- width: '100%',
161
- height: '100%',
162
- minHeight: presentation ? 0 : expanded ? '70vh' : '300px',
163
- border: 'none',
164
- background: 'var(--c-bg)',
165
- borderRadius: presentation ? 0 : '6px',
166
- display: 'block',
167
- }}
168
167
  />
169
168
  );
170
169
  }
@@ -1,4 +1,5 @@
1
- import { useEffect, useMemo, useRef } from 'preact/hooks';
1
+ import type { RefObject } from 'preact';
2
+ import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
2
3
  import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
3
4
  import { canvasThemeScheme } from '../../shared/themes.js';
4
5
  import type { CanvasNodeState } from '../types';
@@ -54,6 +55,106 @@ export function isSameOriginFrameDocumentUrl(url: string, origin = window.locati
54
55
  }
55
56
  }
56
57
 
58
+ type ViewerFrameSource = { src?: string; srcdoc?: string };
59
+
60
+ /**
61
+ * Keep the last painted viewer in front while its replacement navigates. Viewer
62
+ * specs are immutable documents, so updates normally change `?v=` and reload the
63
+ * iframe; putting the new document in a hidden sibling avoids exposing Chromium's
64
+ * white navigation paint. Two animation frames after load gives the new document
65
+ * a composited paint before the old one is removed.
66
+ */
67
+ export function RefreshingViewerFrame({
68
+ source,
69
+ iframeRef,
70
+ onLoad,
71
+ title,
72
+ className = 'mcp-app-frame',
73
+ sandbox = 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox',
74
+ allow = 'clipboard-read; clipboard-write',
75
+ tabIndex,
76
+ }: {
77
+ source: ViewerFrameSource;
78
+ iframeRef: RefObject<HTMLIFrameElement>;
79
+ onLoad: () => void;
80
+ title: string;
81
+ className?: string;
82
+ sandbox?: string;
83
+ allow?: string;
84
+ tabIndex?: number;
85
+ }) {
86
+ const sourceKey = source.src ?? source.srcdoc ?? '';
87
+ const [painted, setPainted] = useState({ key: sourceKey, source });
88
+ const [pending, setPending] = useState<{ key: string; source: ViewerFrameSource } | null>(null);
89
+ const promotionRef = useRef(0);
90
+
91
+ useEffect(() => {
92
+ promotionRef.current += 1;
93
+ if (!sourceKey || sourceKey === painted.key) {
94
+ setPending(null);
95
+ return;
96
+ }
97
+ if (!painted.key) {
98
+ setPainted({ key: sourceKey, source });
99
+ return;
100
+ }
101
+ setPending({ key: sourceKey, source });
102
+ }, [sourceKey, painted.key]);
103
+
104
+ useEffect(
105
+ () => () => {
106
+ promotionRef.current += 1;
107
+ },
108
+ [],
109
+ );
110
+
111
+ const frame = (entry: { key: string; source: ViewerFrameSource }, isPending: boolean) => (
112
+ <iframe
113
+ key={entry.key}
114
+ ref={isPending ? undefined : iframeRef}
115
+ {...entry.source}
116
+ class={className}
117
+ sandbox={sandbox}
118
+ allow={allow}
119
+ tabIndex={tabIndex}
120
+ loading={iframeMode.value === 'srcdoc' ? undefined : 'lazy'}
121
+ onLoad={(event) => {
122
+ if (!isPending) {
123
+ onLoad();
124
+ return;
125
+ }
126
+ const token = ++promotionRef.current;
127
+ const loadedFrame = event.currentTarget;
128
+ requestAnimationFrame(() =>
129
+ requestAnimationFrame(() => {
130
+ if (promotionRef.current !== token) return;
131
+ iframeRef.current = loadedFrame;
132
+ setPainted(entry);
133
+ setPending(null);
134
+ onLoad();
135
+ }),
136
+ );
137
+ }}
138
+ style={{
139
+ position: 'absolute',
140
+ inset: 0,
141
+ width: '100%',
142
+ height: '100%',
143
+ visibility: isPending ? 'hidden' : 'visible',
144
+ background: 'var(--c-panel-soft)',
145
+ }}
146
+ title={title}
147
+ />
148
+ );
149
+
150
+ return (
151
+ <div class="mcp-app-frame-stack">
152
+ {frame(painted, false)}
153
+ {pending && frame(pending, true)}
154
+ </div>
155
+ );
156
+ }
157
+
57
158
  export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
58
159
  if (node.data.mode === 'ext-app') {
59
160
  return <ExtAppFrame node={node} expanded={expanded} />;
@@ -180,19 +281,10 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
180
281
  {/* Plain iframe-backed viewers stay on an opaque origin. Hosted ext-apps use
181
282
  the explicit postMessage bridge instead, which is the only path that needs
182
283
  app/host RPC and broader capabilities. */}
183
- <iframe
184
- ref={iframeRef}
185
- {...surfaceFrame}
186
- class="mcp-app-frame"
187
- sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
188
- allow="clipboard-read; clipboard-write"
189
- // Lazy loading only helps src mode (defers the HTTP request for
190
- // off-screen nodes). In srcdoc mode the content is already inline —
191
- // lazy would just delay PAINTING until the node scrolls near the
192
- // viewport, which reads as "blank until opened" (Amp portal report).
193
- loading={iframeMode.value === 'srcdoc' ? undefined : 'lazy'}
284
+ <RefreshingViewerFrame
285
+ source={surfaceFrame}
286
+ iframeRef={iframeRef}
194
287
  onLoad={pushAxState}
195
- style={{ flex: 1, minHeight: 0, width: '100%' }}
196
288
  title={`MCP App: ${sourceServer}`}
197
289
  />
198
290
  </div>
@@ -21,7 +21,8 @@ export function MermaidNode({ node, expanded = false }: { node: CanvasNodeState;
21
21
  // Per-mount nonce for the content-height reporter (node grows to fit content).
22
22
  const frameToken = useMemo(() => `frame-${crypto.randomUUID()}`, []);
23
23
  const source = typeof node.data.content === 'string' ? node.data.content : '';
24
- const v = useMemo(() => surfaceContentHash(source), [source]);
24
+ const fit = node.data.fit === 'none' ? 'none' : 'contain';
25
+ const v = useMemo(() => surfaceContentHash(`${fit}:${source}`), [source, fit]);
25
26
 
26
27
  // `theme` is intentionally excluded from the deps: live theme changes are
27
28
  // pushed via postMessage below (no reload), while `v` reloads the frame when
@@ -70,7 +71,7 @@ export function MermaidNode({ node, expanded = false }: { node: CanvasNodeState;
70
71
  style={{
71
72
  width: '100%',
72
73
  height: '100%',
73
- minHeight: expanded ? '70vh' : '300px',
74
+ minHeight: expanded ? '70vh' : '0',
74
75
  border: 'none',
75
76
  background: 'var(--c-bg)',
76
77
  borderRadius: '6px',
@@ -83,9 +83,13 @@ function setPhase(id: string, phase: IntentPhase, ms: number, settledNodeId?: st
83
83
  next.set(id, { ...current, phase, ...(settledNodeId ? { settledNodeId } : {}) });
84
84
  writeIntents(next);
85
85
  clearExitTimer(id);
86
+ // The layer removes ghosts on animationend, after the browser actually paints
87
+ // the morph. A wall-clock deadline equal to the CSS duration can delete a
88
+ // ghost before its delayed first paint or transition completes on a busy tab.
89
+ // Retain a backstop for hidden tabs or disabled animations.
86
90
  exitTimers.set(
87
91
  id,
88
- setTimeout(() => removeIntent(id), ms),
92
+ setTimeout(() => removeIntent(id), ms + 1500),
89
93
  );
90
94
  }
91
95
 
@@ -0,0 +1,7 @@
1
+ import { signal } from '@preact/signals';
2
+
3
+ export const presenting = signal(false);
4
+ export const recordingCamera = signal(false);
5
+ export function ownsCamera(): boolean {
6
+ return presenting.value || recordingCamera.value;
7
+ }
@@ -386,6 +386,9 @@ export function applySessionReceipt(data: Record<string, unknown>): void {
386
386
  const counts = data.counts as Partial<SessionReceipt['counts']> | undefined;
387
387
  const snapshot = data.snapshot as SessionReceipt['snapshot'] | undefined;
388
388
  if (typeof data.label !== 'string' || typeof data.endedAt !== 'string') return;
389
+ // Keep history/timeline intact, but only interrupt for a changed top-level
390
+ // session. Quiet endings must not replace an existing useful receipt.
391
+ if (data.unchanged === true || typeof data.parentAgentId === 'string') return;
389
392
  sessionReceipt.value = {
390
393
  label: data.label,
391
394
  endedAt: data.endedAt,
@@ -1,4 +1,5 @@
1
1
  import { findOpenCanvasPosition } from '../utils/placement.js';
2
+ import { ownsCamera } from './presentation';
2
3
  import { normalizeExtAppToolResult } from '../utils/ext-app-tool-result.js';
3
4
  import type { CanvasAnnotation, CanvasEdge, CanvasNodeState } from '../types';
4
5
  import {
@@ -945,6 +946,7 @@ function reconnectDelayMs(attempt: number): number {
945
946
  }
946
947
 
947
948
  function handleCanvasFocusNode(data: Record<string, unknown>): void {
949
+ if (ownsCamera()) return;
948
950
  const nodeId = data.nodeId as string;
949
951
  if (nodeId && nodes.value.has(nodeId)) {
950
952
  if (data.noPan === true) {
@@ -956,6 +958,7 @@ function handleCanvasFocusNode(data: Record<string, unknown>): void {
956
958
  }
957
959
 
958
960
  function handleCanvasViewportUpdate(data: Record<string, unknown>): void {
961
+ if (ownsCamera()) return;
959
962
  const viewport = data.viewport as Record<string, unknown> | undefined;
960
963
  if (!viewport) return;
961
964
  const x = typeof viewport.x === 'number' ? viewport.x : 0;