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.
- package/CHANGELOG.md +19 -0
- package/Readme.md +7 -1
- package/dist/canvas/global.css +58 -4
- package/dist/canvas/index.js +92 -92
- package/dist/canvas/mermaid-entry.js +258 -258
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/record.d.ts +12 -0
- package/dist/types/client/canvas/Presentation.d.ts +13 -0
- package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
- package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
- package/dist/types/client/state/presentation.d.ts +3 -0
- package/dist/types/client/types.d.ts +1 -1
- package/dist/types/json-render/catalog.d.ts +2 -2
- package/dist/types/json-render/server.d.ts +2 -0
- package/dist/types/server/canvas-db.d.ts +2 -0
- package/dist/types/server/canvas-operations.d.ts +22 -0
- package/dist/types/server/canvas-schema.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +6 -0
- package/dist/types/server/canvas-validation.d.ts +3 -0
- package/dist/types/server/html-surface.d.ts +1 -1
- package/dist/types/server/index.d.ts +4 -0
- package/dist/types/server/operations/ops/app.d.ts +2 -0
- package/dist/types/server/operations/ops/json-render.d.ts +2 -0
- package/dist/types/server/operations/ops/nodes.d.ts +3 -1
- package/dist/types/server/web-artifacts.d.ts +3 -0
- package/dist/types/shared/tour.d.ts +72 -0
- package/docs/cli.md +89 -0
- package/docs/http-api.md +79 -4
- package/docs/mcp.md +25 -1
- package/docs/node-types.md +80 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +10 -2
- package/skills/pmx-canvas/references/full-reference.md +1 -1
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
- package/src/cli/agent.ts +5 -0
- package/src/cli/commands/nodes.ts +10 -0
- package/src/cli/commands/record.ts +218 -0
- package/src/cli/index.ts +2 -0
- package/src/client/App.tsx +7 -1
- package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
- package/src/client/canvas/CanvasNode.tsx +8 -0
- package/src/client/canvas/IntentLayer.tsx +9 -2
- package/src/client/canvas/Presentation.tsx +108 -0
- package/src/client/canvas/TopBar.tsx +28 -2
- package/src/client/canvas/use-node-drag.ts +8 -3
- package/src/client/mermaid-entry.ts +39 -0
- package/src/client/nodes/HtmlNode.tsx +12 -13
- package/src/client/nodes/McpAppNode.tsx +105 -13
- package/src/client/nodes/MermaidNode.tsx +3 -2
- package/src/client/state/intent-store.ts +5 -1
- package/src/client/state/presentation.ts +7 -0
- package/src/client/state/session-store.ts +3 -0
- package/src/client/state/sse-bridge.ts +3 -0
- package/src/client/theme/global.css +58 -4
- package/src/json-render/server.ts +29 -0
- package/src/server/agent-presence.ts +15 -9
- package/src/server/canvas-db.ts +15 -0
- package/src/server/canvas-operations.ts +115 -22
- package/src/server/canvas-schema.ts +10 -0
- package/src/server/canvas-state.ts +39 -4
- package/src/server/canvas-validation.ts +9 -1
- package/src/server/demo-state.json +11 -7
- package/src/server/html-primitives.ts +199 -91
- package/src/server/html-surface.ts +8 -3
- package/src/server/index.ts +12 -0
- package/src/server/operations/composites.ts +3 -1
- package/src/server/operations/ops/app.ts +10 -0
- package/src/server/operations/ops/batch.ts +12 -3
- package/src/server/operations/ops/groups.ts +14 -7
- package/src/server/operations/ops/json-render.ts +37 -5
- package/src/server/operations/ops/nodes.ts +42 -16
- package/src/server/operations/ops/viewport.ts +32 -0
- package/src/server/operations/registry.ts +6 -0
- package/src/server/scope-fence.ts +1 -1
- package/src/server/server.ts +9 -6
- package/src/server/web-artifacts.ts +9 -2
- package/src/server/workboard.ts +3 -3
- package/src/shared/tour.ts +87 -0
package/src/client/App.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
46
|
-
if (
|
|
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
|
-
<
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
<
|
|
184
|
-
|
|
185
|
-
{
|
|
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
|
|
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' : '
|
|
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
|
|
|
@@ -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;
|