pmx-canvas 0.3.4 → 0.4.1
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 +76 -0
- package/Readme.md +16 -3
- package/dist/canvas/index.js +80 -80
- package/dist/types/client/index.d.ts +1 -1
- package/dist/types/client/nodes/ExtAppFrame.d.ts +10 -0
- package/dist/types/client/nodes/iframe-document-url.d.ts +1 -0
- package/dist/types/client/nodes/use-surface-frame.d.ts +13 -0
- package/dist/types/client/state/canvas-store.d.ts +1 -0
- package/dist/types/client/state/iframe-mode.d.ts +25 -0
- package/dist/types/client/state/node-factory.d.ts +29 -0
- package/dist/types/client/uuid-polyfill.d.ts +0 -0
- package/dist/types/server/canvas-db.d.ts +0 -1
- package/dist/types/server/canvas-state.d.ts +14 -15
- package/dist/types/server/operations/composites.d.ts +6 -21
- package/dist/types/server/server.d.ts +1 -0
- package/docs/RELEASE.md +10 -4
- package/docs/cli.md +5 -0
- package/docs/environment.md +2 -2
- package/docs/http-api.md +47 -1
- package/docs/mcp.md +21 -30
- package/docs/plans/plan-009-tech-debt-backlog.md +22 -13
- package/docs/screenshot.png +0 -0
- package/docs/tech-debt-assessment-2026-06.md +5 -5
- package/package.json +7 -7
- package/skills/pmx-canvas/SKILL.md +36 -10
- package/skills/pmx-canvas/evals/evals.json +2 -2
- package/skills/pmx-canvas/references/full-reference.md +17 -15
- package/skills/pmx-canvas-testing/SKILL.md +3 -0
- package/src/cli/commands/history.ts +1 -1
- package/src/cli/commands/nodes.ts +31 -1
- package/src/cli/index.ts +8 -2
- package/src/cli/shared.ts +1 -0
- package/src/client/index.tsx +7 -0
- package/src/client/nodes/ExtAppFrame.tsx +50 -2
- package/src/client/nodes/HtmlNode.tsx +5 -1
- package/src/client/nodes/McpAppNode.tsx +5 -1
- package/src/client/nodes/iframe-document-url.ts +62 -9
- package/src/client/nodes/use-surface-frame.ts +53 -0
- package/src/client/state/canvas-store.ts +5 -0
- package/src/client/state/iframe-mode.ts +103 -0
- package/src/client/state/node-factory.ts +72 -0
- package/src/client/state/sse-bridge.ts +212 -86
- package/src/client/uuid-polyfill.ts +14 -0
- package/src/server/canvas-db.ts +0 -8
- package/src/server/canvas-schema.ts +2 -8
- package/src/server/canvas-state.ts +51 -151
- package/src/server/index.ts +3 -9
- package/src/server/operations/composites.ts +24 -21
- package/src/server/operations/ops/snapshots.ts +6 -7
- package/src/server/operations/registry.ts +4 -1
- package/src/server/server.ts +194 -75
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { useEffect, useMemo, useState } from 'preact/hooks';
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
2
|
+
import { workbenchConnectionEpoch } from '../state/canvas-store';
|
|
3
|
+
import { iframeMode } from '../state/iframe-mode';
|
|
4
|
+
import { surfaceContentHash } from './surface-url';
|
|
2
5
|
|
|
3
6
|
interface FrameDocumentCreateResponse {
|
|
4
7
|
ok: boolean;
|
|
@@ -37,13 +40,20 @@ export async function createIframeDocumentUrl(html: string, sandbox: string): Pr
|
|
|
37
40
|
export function useIframeDocument(
|
|
38
41
|
html: string,
|
|
39
42
|
sandbox: string,
|
|
40
|
-
): { attributes: { src?: string }; ready: boolean; key: string } {
|
|
43
|
+
): { attributes: { src?: string; srcdoc?: string }; ready: boolean; key: string } {
|
|
44
|
+
// Boot-wide embed probe (state/iframe-mode.ts): srcdoc mode means src-URL
|
|
45
|
+
// iframes are blocked here (nested-iframe hosts like Amp orb portals), so the
|
|
46
|
+
// frame-documents round-trip would produce a URL the browser refuses to load.
|
|
47
|
+
const mode = iframeMode.value;
|
|
41
48
|
const [src, setSrc] = useState<string | null>(null);
|
|
49
|
+
const [remintNonce, setRemintNonce] = useState(0);
|
|
50
|
+
const mintEpochRef = useRef(0);
|
|
42
51
|
|
|
43
52
|
useEffect(() => {
|
|
44
53
|
setSrc(null);
|
|
45
|
-
if (!html) return;
|
|
54
|
+
if (!html || mode !== 'src') return;
|
|
46
55
|
let cancelled = false;
|
|
56
|
+
mintEpochRef.current = workbenchConnectionEpoch.value;
|
|
47
57
|
void createIframeDocumentUrl(html, sandbox)
|
|
48
58
|
.then((url) => {
|
|
49
59
|
if (!cancelled) setSrc(url);
|
|
@@ -54,14 +64,57 @@ export function useIframeDocument(
|
|
|
54
64
|
return () => {
|
|
55
65
|
cancelled = true;
|
|
56
66
|
};
|
|
57
|
-
}, [html, sandbox]);
|
|
67
|
+
}, [html, sandbox, mode, remintNonce]);
|
|
58
68
|
|
|
59
|
-
|
|
60
|
-
|
|
69
|
+
// Finding S: frame documents live in server memory, so a daemon restart turns
|
|
70
|
+
// this mount's URL into a 404 while the panel keeps showing the dead frame.
|
|
71
|
+
// Every reconnect frame (SSE reconnect or poll snapshot reset) revalidates the
|
|
72
|
+
// minted URL and re-mints a fresh document when the server no longer has it —
|
|
73
|
+
// no full workbench reload required.
|
|
74
|
+
const epoch = workbenchConnectionEpoch.value;
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (mode !== 'src' || epoch === mintEpochRef.current) return;
|
|
77
|
+
if (!src) {
|
|
78
|
+
// A previous mint failed outright (e.g. the daemon flapped down between
|
|
79
|
+
// the HEAD 404 and the re-mint POST). A fresh connection epoch means the
|
|
80
|
+
// server is reachable again — retry the mint instead of stranding the
|
|
81
|
+
// tile until a manual reload.
|
|
82
|
+
setRemintNonce((n) => n + 1);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
let cancelled = false;
|
|
86
|
+
void fetch(src, { method: 'HEAD' })
|
|
87
|
+
.then((res) => {
|
|
88
|
+
if (cancelled) return;
|
|
89
|
+
if (res.ok) {
|
|
90
|
+
mintEpochRef.current = epoch;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
setRemintNonce((n) => n + 1);
|
|
94
|
+
})
|
|
95
|
+
.catch(() => {
|
|
96
|
+
// Transient network failure — the next reconnect epoch retries.
|
|
97
|
+
});
|
|
98
|
+
return () => {
|
|
99
|
+
cancelled = true;
|
|
100
|
+
};
|
|
101
|
+
}, [epoch, src, mode]);
|
|
102
|
+
|
|
103
|
+
return useMemo(() => {
|
|
104
|
+
if (mode === 'srcdoc' && html) {
|
|
105
|
+
// The document already lives client-side, so serve it inline. Consumers
|
|
106
|
+
// always render the iframe `sandbox` attribute, which supplies the
|
|
107
|
+
// sandboxing the frame-document CSP header would have.
|
|
108
|
+
return {
|
|
109
|
+
attributes: { srcdoc: html },
|
|
110
|
+
ready: true,
|
|
111
|
+
key: `srcdoc-${surfaceContentHash(html)}`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
61
115
|
attributes: src ? { src } : {},
|
|
62
116
|
ready: Boolean(src),
|
|
63
117
|
key: src ?? '',
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
);
|
|
118
|
+
};
|
|
119
|
+
}, [src, mode, html]);
|
|
67
120
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
2
|
+
import { iframeMode } from '../state/iframe-mode';
|
|
3
|
+
|
|
4
|
+
function isSameOriginUrl(url: string): boolean {
|
|
5
|
+
try {
|
|
6
|
+
return new URL(url, window.location.origin).origin === window.location.origin;
|
|
7
|
+
} catch {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Attributes for a surface iframe, honoring the boot-wide iframe mode
|
|
14
|
+
* (state/iframe-mode.ts): `src` by default, fetch() + `srcdoc` when src-URL
|
|
15
|
+
* iframes are blocked by the embedding context (nested-iframe hosts like Amp
|
|
16
|
+
* orb portals). Cross-origin URLs always stay `src` — they cannot be fetched
|
|
17
|
+
* from here. While the probe is pending, same-origin frames stay empty rather
|
|
18
|
+
* than loading a src that may show the broken placeholder; a failed srcdoc
|
|
19
|
+
* fetch falls back to `src` (no worse than before).
|
|
20
|
+
*/
|
|
21
|
+
export function useSurfaceFrame(url: string): { src?: string; srcdoc?: string } {
|
|
22
|
+
const mode = iframeMode.value;
|
|
23
|
+
const [doc, setDoc] = useState<{ url: string; html: string } | null>(null);
|
|
24
|
+
const sameOrigin = Boolean(url) && isSameOriginUrl(url);
|
|
25
|
+
const wantsSrcdoc = mode === 'srcdoc' && sameOrigin;
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!wantsSrcdoc) return;
|
|
29
|
+
let cancelled = false;
|
|
30
|
+
void fetch(url, { credentials: 'same-origin' })
|
|
31
|
+
.then((res) => (res.ok ? res.text() : Promise.reject(new Error(`HTTP ${res.status}`))))
|
|
32
|
+
.then((html) => {
|
|
33
|
+
if (!cancelled) setDoc({ url, html });
|
|
34
|
+
})
|
|
35
|
+
.catch((error) => {
|
|
36
|
+
console.error('[surface-frame] srcdoc fetch failed, falling back to src:', url, error);
|
|
37
|
+
// Empty html marks the src fallback below.
|
|
38
|
+
if (!cancelled) setDoc({ url, html: '' });
|
|
39
|
+
});
|
|
40
|
+
return () => {
|
|
41
|
+
cancelled = true;
|
|
42
|
+
};
|
|
43
|
+
}, [wantsSrcdoc, url]);
|
|
44
|
+
|
|
45
|
+
if (!url) return {};
|
|
46
|
+
if (!wantsSrcdoc) {
|
|
47
|
+
return mode === null && sameOrigin ? {} : { src: url };
|
|
48
|
+
}
|
|
49
|
+
// Keep the previous document visible while a newer URL is being fetched —
|
|
50
|
+
// same UX as src mode, where the old document stays until the new one loads.
|
|
51
|
+
if (doc) return doc.html ? { srcdoc: doc.html } : { src: doc.url };
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
@@ -22,6 +22,11 @@ export const edges = signal<Map<string, CanvasEdge>>(new Map());
|
|
|
22
22
|
export const annotations = signal<Map<string, CanvasAnnotation>>(new Map());
|
|
23
23
|
export const activeNodeId = signal<string | null>(null);
|
|
24
24
|
export const connectionStatus = signal<ConnectionStatus>('connecting');
|
|
25
|
+
// Bumped on every processed `connected` frame (SSE reconnects and poll snapshot
|
|
26
|
+
// resets alike). Consumers holding server-minted URLs revalidate on change —
|
|
27
|
+
// a server restart invalidates in-memory resources like frame documents
|
|
28
|
+
// (Finding S) while the browser keeps the stale URLs alive.
|
|
29
|
+
export const workbenchConnectionEpoch = signal<number>(0);
|
|
25
30
|
export const sessionId = signal<string>('');
|
|
26
31
|
export const traceEnabled = signal<boolean>(false);
|
|
27
32
|
export const canvasTheme = signal<string>('dark');
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nested-iframe embedding detection (Amp orb portals).
|
|
3
|
+
*
|
|
4
|
+
* When the canvas page itself runs inside an iframe (ampcode.com embeds the
|
|
5
|
+
* portal URL on *.onamp.dev), Chrome refuses to load child iframes from `src`
|
|
6
|
+
* URLs — even same-origin relative ones — leaving every iframe-backed node a
|
|
7
|
+
* gray "refused to connect" placeholder. Inline documents (`srcdoc`) still
|
|
8
|
+
* render. This module probes the real behavior once per boot: a hidden 1px
|
|
9
|
+
* iframe navigates to the tiny /api/canvas/iframe-probe document; if `load`
|
|
10
|
+
* fires the host is normal (`src` mode), otherwise same-origin surfaces fall
|
|
11
|
+
* back to fetch() + `srcdoc` (see nodes/use-surface-frame.ts and
|
|
12
|
+
* nodes/iframe-document-url.ts).
|
|
13
|
+
*
|
|
14
|
+
* `src` stays the default everywhere it works: URL-loaded surfaces keep real
|
|
15
|
+
* document URLs, and the frame-document transport exists precisely because
|
|
16
|
+
* attribute-sized srcdoc documents were the worse default (v0.1.24). srcdoc is
|
|
17
|
+
* the blocked-embed fallback, not the new normal.
|
|
18
|
+
*
|
|
19
|
+
* Force a mode with `?iframe-mode=srcdoc` (or `=src`) for debugging.
|
|
20
|
+
*/
|
|
21
|
+
import { signal } from '@preact/signals';
|
|
22
|
+
|
|
23
|
+
export type IframeMode = 'src' | 'srcdoc';
|
|
24
|
+
|
|
25
|
+
export const IFRAME_PROBE_PATH = '/api/canvas/iframe-probe';
|
|
26
|
+
const PROBE_TIMEOUT_MS = 3000;
|
|
27
|
+
|
|
28
|
+
/** Boot-wide iframe transport mode; null until the probe (or override) resolves. */
|
|
29
|
+
export const iframeMode = signal<IframeMode | null>(null);
|
|
30
|
+
|
|
31
|
+
export function forcedIframeMode(): IframeMode | null {
|
|
32
|
+
const value = new URLSearchParams(window.location.search).get('iframe-mode');
|
|
33
|
+
return value === 'srcdoc' || value === 'src' ? value : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Probe whether `src`-URL iframes load in this embedding context. Resolves true
|
|
38
|
+
* when the hidden probe iframe fires `load`; false on error or timeout. The
|
|
39
|
+
* blocked-portal case shows its placeholder without reliably firing either
|
|
40
|
+
* event, so the timeout is the real signal there — and a false negative is
|
|
41
|
+
* safe, because srcdoc rendering works in normal hosts too.
|
|
42
|
+
*/
|
|
43
|
+
export function probeSrcIframes(timeoutMs = PROBE_TIMEOUT_MS): Promise<boolean> {
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
const test = document.createElement('iframe');
|
|
46
|
+
test.setAttribute('data-pmx-iframe-probe', '');
|
|
47
|
+
test.style.cssText = 'position:fixed;width:1px;height:1px;left:-9999px;border:none';
|
|
48
|
+
test.setAttribute('sandbox', 'allow-scripts');
|
|
49
|
+
const done = (ok: boolean) => {
|
|
50
|
+
window.clearTimeout(timer);
|
|
51
|
+
test.remove();
|
|
52
|
+
resolve(ok);
|
|
53
|
+
};
|
|
54
|
+
const timer = window.setTimeout(() => done(false), timeoutMs);
|
|
55
|
+
test.addEventListener('load', () => done(true));
|
|
56
|
+
test.addEventListener('error', () => done(false));
|
|
57
|
+
test.src = IFRAME_PROBE_PATH;
|
|
58
|
+
(document.body ?? document.documentElement).appendChild(test);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let pending: Promise<IframeMode> | null = null;
|
|
63
|
+
|
|
64
|
+
function isEmbeddedDocument(): boolean {
|
|
65
|
+
try {
|
|
66
|
+
return window.self !== window.top;
|
|
67
|
+
} catch {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve the boot-wide iframe mode once; all surface hooks share the result.
|
|
74
|
+
* The blocked-src condition only exists when the canvas page itself runs
|
|
75
|
+
* inside an iframe, so top-level documents resolve `src` synchronously — the
|
|
76
|
+
* normal path pays zero probe latency before surfaces mount. Only embedded
|
|
77
|
+
* documents run the probe (`embedded` is overridable for tests).
|
|
78
|
+
*/
|
|
79
|
+
export function resolveIframeMode(opts: { embedded?: boolean } = {}): Promise<IframeMode> {
|
|
80
|
+
if (iframeMode.value) return Promise.resolve(iframeMode.value);
|
|
81
|
+
if (pending) return pending;
|
|
82
|
+
const forced = forcedIframeMode();
|
|
83
|
+
if (forced) {
|
|
84
|
+
iframeMode.value = forced;
|
|
85
|
+
return Promise.resolve(forced);
|
|
86
|
+
}
|
|
87
|
+
if (!(opts.embedded ?? isEmbeddedDocument())) {
|
|
88
|
+
iframeMode.value = 'src';
|
|
89
|
+
return Promise.resolve('src');
|
|
90
|
+
}
|
|
91
|
+
pending = probeSrcIframes().then((srcWorks) => {
|
|
92
|
+
const mode: IframeMode = srcWorks ? 'src' : 'srcdoc';
|
|
93
|
+
iframeMode.value = mode;
|
|
94
|
+
return mode;
|
|
95
|
+
});
|
|
96
|
+
return pending;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Test hook: clear the memoized probe so each test starts unresolved. */
|
|
100
|
+
export function resetIframeModeForTests(): void {
|
|
101
|
+
iframeMode.value = null;
|
|
102
|
+
pending = null;
|
|
103
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { CanvasNodeState } from '../types';
|
|
2
|
+
|
|
3
|
+
// ── Client node-creation defaults (plan-009 H5, client half) ──
|
|
4
|
+
// Single source of truth for the frame (position/size/zIndex/flags) a NEW
|
|
5
|
+
// client-created node gets when its creating event carries no explicit
|
|
6
|
+
// geometry. Extracted from sse-bridge, which kept one `makeNode` helper plus
|
|
7
|
+
// three inline node literals (ext-app open, prompt, response) duplicating
|
|
8
|
+
// these defaults.
|
|
9
|
+
//
|
|
10
|
+
// Divergences found during extraction — documented, NOT silently unified:
|
|
11
|
+
// - `canvas-store.ts` holds no node-creation defaults today. Human-created
|
|
12
|
+
// nodes go through `createNodeFromClient` (intent-bridge → POST
|
|
13
|
+
// /api/canvas/node) and get the SERVER's `defaultNodeSize`
|
|
14
|
+
// (src/server/operations/ops/nodes.ts), which differs from this table for
|
|
15
|
+
// most types (e.g. its fallback is 360x200). The server half of H5
|
|
16
|
+
// (`syncEventToCanvasState`, `ensureDefaultDockedNodes`) is deliberately
|
|
17
|
+
// deferred — do not "fix" that mismatch from this file.
|
|
18
|
+
// - The server seeds `status-main`/`context-main` docked AND collapsed
|
|
19
|
+
// (`ensureDefaultDockedNodes` in src/server/server.ts); the SSE bridge
|
|
20
|
+
// creates the same ids expanded, with `context-main` undocked. Client
|
|
21
|
+
// behavior is preserved as-is.
|
|
22
|
+
// - sse-bridge's prompt literal hardcoded `height: 400` beside a table entry
|
|
23
|
+
// of the same value; both now read the table (no rendering change).
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_POSITIONS: Record<CanvasNodeState['type'], { x: number; y: number; w: number; h: number }> &
|
|
26
|
+
Record<'prompt' | 'response', { x: number; y: number; w: number; h: number }> = {
|
|
27
|
+
status: { x: 40, y: 80, w: 300, h: 120 },
|
|
28
|
+
markdown: { x: 380, y: 80, w: 720, h: 600 },
|
|
29
|
+
context: { x: 1130, y: 80, w: 320, h: 400 },
|
|
30
|
+
'mcp-app': { x: 380, y: 720, w: 960, h: 600 },
|
|
31
|
+
webpage: { x: 380, y: 80, w: 520, h: 420 },
|
|
32
|
+
'json-render': { x: 380, y: 720, w: 840, h: 620 },
|
|
33
|
+
graph: { x: 380, y: 720, w: 760, h: 520 },
|
|
34
|
+
ledger: { x: 1130, y: 520, w: 320, h: 280 },
|
|
35
|
+
trace: { x: 40, y: 900, w: 200, h: 56 },
|
|
36
|
+
file: { x: 380, y: 80, w: 720, h: 600 },
|
|
37
|
+
image: { x: 380, y: 80, w: 720, h: 520 },
|
|
38
|
+
html: { x: 380, y: 80, w: 720, h: 640 },
|
|
39
|
+
group: { x: 220, y: 60, w: 840, h: 560 },
|
|
40
|
+
prompt: { x: 380, y: 1260, w: 520, h: 400 },
|
|
41
|
+
response: { x: 380, y: 1480, w: 720, h: 400 },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build a canvas node with the shared client defaults. `position`/`size`
|
|
46
|
+
* default to the type's DEFAULT_POSITIONS entry; callers that compute
|
|
47
|
+
* placement (auto-placement, event-supplied geometry) pass explicit values.
|
|
48
|
+
* Status nodes sit at zIndex 0 (background chrome), everything else at 1.
|
|
49
|
+
*/
|
|
50
|
+
export function makeNodeState(
|
|
51
|
+
id: string,
|
|
52
|
+
type: CanvasNodeState['type'],
|
|
53
|
+
data: Record<string, unknown>,
|
|
54
|
+
options: {
|
|
55
|
+
position?: { x: number; y: number };
|
|
56
|
+
size?: { width: number; height: number };
|
|
57
|
+
dockPosition?: 'left' | 'right' | null;
|
|
58
|
+
} = {},
|
|
59
|
+
): CanvasNodeState {
|
|
60
|
+
const defaults = DEFAULT_POSITIONS[type];
|
|
61
|
+
return {
|
|
62
|
+
id,
|
|
63
|
+
type,
|
|
64
|
+
position: options.position ?? { x: defaults.x, y: defaults.y },
|
|
65
|
+
size: options.size ?? { width: defaults.w, height: defaults.h },
|
|
66
|
+
zIndex: type === 'status' ? 0 : 1,
|
|
67
|
+
collapsed: false,
|
|
68
|
+
pinned: false,
|
|
69
|
+
dockPosition: options.dockPosition ?? null,
|
|
70
|
+
data,
|
|
71
|
+
};
|
|
72
|
+
}
|