pmx-canvas 0.1.33 → 0.1.35
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 +67 -0
- package/dist/canvas/index.js +61 -61
- package/dist/json-render/index.js +112 -112
- package/dist/types/client/canvas/auto-fit.d.ts +14 -0
- package/dist/types/client/nodes/surface-url.d.ts +6 -7
- package/dist/types/client/nodes/use-iframe-content-height.d.ts +16 -0
- package/dist/types/client/state/intent-bridge.d.ts +1 -1
- package/dist/types/json-render/server.d.ts +6 -0
- package/dist/types/server/html-surface.d.ts +9 -0
- package/dist/types/shared/content-height-reporter.d.ts +20 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +17 -5
- package/src/client/canvas/CanvasNode.tsx +15 -21
- package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -14
- package/src/client/canvas/auto-fit.ts +61 -7
- package/src/client/nodes/HtmlNode.tsx +9 -2
- package/src/client/nodes/McpAppNode.tsx +33 -4
- package/src/client/nodes/surface-url.ts +10 -12
- package/src/client/nodes/use-iframe-content-height.ts +53 -0
- package/src/client/state/intent-bridge.ts +2 -2
- package/src/json-render/charts/components.tsx +11 -1
- package/src/json-render/server.ts +13 -1
- package/src/mcp/server.ts +58 -6
- package/src/server/html-surface.ts +18 -1
- package/src/server/server.ts +70 -8
- package/src/shared/content-height-reporter.ts +35 -0
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import type { CanvasNodeState } from '../types';
|
|
2
2
|
export declare const AUTO_FIT_TITLEBAR_HEIGHT = 37;
|
|
3
3
|
export declare const AUTO_FIT_MAX_HEIGHT = 600;
|
|
4
|
+
export declare const AUTO_FIT_MAX_HEIGHT_IFRAME = 1400;
|
|
5
|
+
export declare const AUTO_FIT_BODY_PADDING = 24;
|
|
6
|
+
/** DOM-content nodes (markdown/status/file/…) whose body scrollHeight is directly
|
|
7
|
+
* measurable — the one-shot ResizeObserver auto-fit in CanvasNode handles these. */
|
|
4
8
|
export declare function shouldAutoFitNode(node: CanvasNodeState): boolean;
|
|
5
9
|
export declare function computeAutoFitHeight(node: CanvasNodeState, contentHeight: number): number | null;
|
|
10
|
+
/** Iframe surfaces that should GROW to fit their reported content height. */
|
|
11
|
+
export declare function shouldContentFitIframeNode(node: CanvasNodeState): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Grow-only target height from a surface-reported content height. Returns null
|
|
14
|
+
* when the node is exempt, the report is non-positive, or the node already fits
|
|
15
|
+
* (so it never shrinks — monotonic growth can't oscillate). Adds the titlebar +
|
|
16
|
+
* node-body padding so the content fully clears (no residual inner scrollbar),
|
|
17
|
+
* capped at the iframe ceiling.
|
|
18
|
+
*/
|
|
19
|
+
export declare function computeContentGrowHeight(node: CanvasNodeState, contentHeight: number): number | null;
|
|
@@ -13,17 +13,16 @@ export interface SurfaceUrlOptions {
|
|
|
13
13
|
v?: string;
|
|
14
14
|
/** Nonce authorizing iframe → parent AX emits (html bridge). */
|
|
15
15
|
axToken?: string;
|
|
16
|
+
/** Nonce for the content-height reporter (node grows to fit content). */
|
|
17
|
+
frameToken?: string;
|
|
16
18
|
}
|
|
17
19
|
/** Build the stable per-node surface URL (/api/canvas/surface/:id) the iframe and "Open as site" both use. */
|
|
18
20
|
export declare function nodeSurfaceUrl(nodeId: string, opts?: SurfaceUrlOptions): string;
|
|
19
21
|
/** Whether a node can be opened as a standalone site (shared with the server). */
|
|
20
22
|
export declare function canOpenAsSite(node: CanvasNodeState): boolean;
|
|
21
|
-
/** Open the node's surface in a new browser tab. */
|
|
22
|
-
export declare function openNodeAsSite(node: CanvasNodeState): void;
|
|
23
23
|
/**
|
|
24
|
-
* Open the node's surface in the user's
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* can't launch (headless / PMX_CANVAS_DISABLE_BROWSER_OPEN).
|
|
24
|
+
* Open the node's standalone surface in the user's system browser. Falls back to
|
|
25
|
+
* `window.open` when the server cannot launch a browser, preserving in-browser tests
|
|
26
|
+
* and headless/disabled-browser environments.
|
|
28
27
|
*/
|
|
29
|
-
export declare function
|
|
28
|
+
export declare function openNodeAsSite(node: CanvasNodeState): Promise<void>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CanvasNodeState } from '../types';
|
|
2
|
+
/**
|
|
3
|
+
* Grow an iframe-surface node to fit the content height its surface reports over
|
|
4
|
+
* the nonce-validated `content-height` postMessage bridge. Grow-only and gated
|
|
5
|
+
* (see computeContentGrowHeight / shouldContentFitIframeNode), so it never clips,
|
|
6
|
+
* never shrinks, never fights a manual resize / strictSize / docked node, and —
|
|
7
|
+
* because growth is monotonic with a dead-band — cannot oscillate. This is the
|
|
8
|
+
* fix for iframe nodes whose body scrollHeight the parent can't measure.
|
|
9
|
+
*
|
|
10
|
+
* The latest node is read through a ref so the effect stays mounted across the
|
|
11
|
+
* grow (its deps are only id + token). Putting node.size in the deps would re-run
|
|
12
|
+
* the effect on each grow and its cleanup would cancel the pending persist.
|
|
13
|
+
*/
|
|
14
|
+
export declare function useIframeContentHeight(node: CanvasNodeState, iframeRef: {
|
|
15
|
+
current: HTMLIFrameElement | null;
|
|
16
|
+
}, frameToken: string): void;
|
|
@@ -129,7 +129,7 @@ export interface AxInteractionResponse {
|
|
|
129
129
|
/** Fetch the compact AX state snapshot pushed into AX-enabled surfaces. */
|
|
130
130
|
export declare function fetchAxSurfaceState(): Promise<unknown>;
|
|
131
131
|
/** Ask the server to open a node's surface in the system browser. */
|
|
132
|
-
export declare function openNodeInSystemBrowserRequest(nodeId: string): Promise<{
|
|
132
|
+
export declare function openNodeInSystemBrowserRequest(nodeId: string, url?: string): Promise<{
|
|
133
133
|
ok: boolean;
|
|
134
134
|
opened: boolean;
|
|
135
135
|
}>;
|
|
@@ -93,4 +93,10 @@ export declare function buildJsonRenderViewerHtml(options: {
|
|
|
93
93
|
nodeId?: string;
|
|
94
94
|
axToken?: string;
|
|
95
95
|
axState?: unknown;
|
|
96
|
+
/** Nonce for the content-height reporter so the node can grow to fit the chart. */
|
|
97
|
+
frameToken?: string;
|
|
98
|
+
/** When true, charts render at their natural (intrinsic) height instead of
|
|
99
|
+
* filling the viewport down — so the reported scrollHeight is stable and the
|
|
100
|
+
* node grows to it. Off for strictSize / user-resized nodes (they fill-down). */
|
|
101
|
+
fitContent?: boolean;
|
|
96
102
|
}): Promise<string>;
|
|
@@ -38,6 +38,13 @@ export declare function buildAxBridge(axToken: string, nodeId: string): string;
|
|
|
38
38
|
* the existing AX-enabled gate.
|
|
39
39
|
*/
|
|
40
40
|
export declare function buildAxStateBridge(axToken: string, snapshotJson: string): string;
|
|
41
|
+
/**
|
|
42
|
+
* Reports the surface's natural content height to the parent canvas so the node
|
|
43
|
+
* can GROW to fit it (the fix for iframe nodes the parent can't measure — graph,
|
|
44
|
+
* json-render, html, web-artifact). Thin wrapper over the shared reporter so this
|
|
45
|
+
* and the json-render injection site stay byte-identical (no drift).
|
|
46
|
+
*/
|
|
47
|
+
export declare function buildContentHeightReporter(frameToken: string): string;
|
|
41
48
|
export interface HtmlSurfaceOptions {
|
|
42
49
|
theme: SurfaceTheme;
|
|
43
50
|
/**
|
|
@@ -61,6 +68,8 @@ export interface HtmlSurfaceOptions {
|
|
|
61
68
|
* axBridge is enabled). Kept live via parent → iframe `ax-update` messages.
|
|
62
69
|
*/
|
|
63
70
|
axState?: unknown;
|
|
71
|
+
/** Nonce for the content-height reporter (lets the node grow to fit content). */
|
|
72
|
+
contentHeightToken?: string;
|
|
64
73
|
}
|
|
65
74
|
/**
|
|
66
75
|
* Wrap author HTML into a complete, themed standalone document. Accepts either a
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-height reporter — injected into iframe-backed canvas surfaces so the
|
|
3
|
+
* parent canvas can grow the node to fit its content (the #48 graph-clipping fix).
|
|
4
|
+
*
|
|
5
|
+
* The surface posts its natural `document` scrollHeight to `window.parent` over a
|
|
6
|
+
* nonce-validated channel; the parent (use-iframe-content-height) grows the node
|
|
7
|
+
* grow-only to fit. Debounced (~100ms) + dead-banded (>4px) so a stray re-measure
|
|
8
|
+
* can't spam, and grow-only growth on the parent side cannot oscillate.
|
|
9
|
+
*
|
|
10
|
+
* Shared by both injection sites — src/server/html-surface.ts (html / web-artifact
|
|
11
|
+
* surfaces) and src/json-render/server.ts (the json-render/graph viewer) — so the
|
|
12
|
+
* two stay byte-identical. This module is framework-agnostic and imports nothing
|
|
13
|
+
* from src/server, preserving the json-render package's decoupling.
|
|
14
|
+
*/
|
|
15
|
+
/** Sanitize a nonce for safe interpolation into an inline script literal. */
|
|
16
|
+
export declare function sanitizeFrameToken(token: string): string;
|
|
17
|
+
/** Inline JS (no `<script>` wrapper) that reports content height to the parent. */
|
|
18
|
+
export declare function contentHeightReporterSource(frameToken: string): string;
|
|
19
|
+
/** `<script>`-wrapped reporter for injection into an HTML `<head>` / document. */
|
|
20
|
+
export declare function contentHeightReporterTag(frameToken: string): string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmx-canvas",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.35",
|
|
4
4
|
"description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/server/index.ts",
|
|
@@ -833,7 +833,7 @@ what the human has set up and what they're focusing on.
|
|
|
833
833
|
| `canvas://ax-context` | Agent-ready AX context: pinned context + current focus |
|
|
834
834
|
| `canvas://ax-work` | Canvas-bound AX work: work items, approval gates, review annotations, elicitations, mode requests, and tool/prompt policy |
|
|
835
835
|
| `canvas://ax-timeline` | Bounded AX timeline: recent agent events, evidence, and steering messages |
|
|
836
|
-
| `canvas://ax-pending-steering` |
|
|
836
|
+
| `canvas://ax-pending-steering` | Adapterless delivery: `pending` steering to claim + mark delivered, and `pendingActivity` (open work items / pending approvals / elicitations / mode requests awaiting the agent) |
|
|
837
837
|
| `canvas://ax-delivery` | Steering delivery state (delivered flag) for diagnostics |
|
|
838
838
|
| `canvas://skills` | Index of bundled agent skills shipped with the install. Each skill is also addressable as `canvas://skills/<name>` (e.g. `canvas://skills/web-artifacts-builder`) and returns the full SKILL.md. Read this resource first to discover companion workflows the canvas is built to support. |
|
|
839
839
|
|
|
@@ -861,10 +861,22 @@ elicitation, or mode request. One envelope, many transports:
|
|
|
861
861
|
**`mcp-app`** nodes get the same `window.PMX_AX.emit` injected
|
|
862
862
|
(`sourceSurface: 'mcp-app'`). The server re-validates capabilities regardless
|
|
863
863
|
of transport — bridges are convenience, not a trust boundary.
|
|
864
|
-
- **Delivery:** steering
|
|
865
|
-
`
|
|
866
|
-
|
|
867
|
-
|
|
864
|
+
- **Delivery (adapterless):** `canvas://ax-pending-steering` /
|
|
865
|
+
`canvas_claim_ax_delivery` return two things, both loop-safe (a consumer never
|
|
866
|
+
receives items it originated):
|
|
867
|
+
- `pending` — undelivered **steering** (directives). Act, then acknowledge with
|
|
868
|
+
`canvas_mark_ax_delivery`.
|
|
869
|
+
- `pendingActivity` — open canvas-bound items **awaiting the agent** (open work
|
|
870
|
+
items, pending approval gates / elicitations / mode requests), usually created
|
|
871
|
+
by the human in the browser. These are **state, not steering**: don't
|
|
872
|
+
`canvas_mark_ax_delivery` them — resolve each via its own tool
|
|
873
|
+
(`canvas_resolve_approval` / `canvas_respond_elicitation` /
|
|
874
|
+
`canvas_resolve_mode` / `canvas_update_work_item`).
|
|
875
|
+
- **Contract:** every AX mutation fires `ax-state-changed`, so MCP clients that
|
|
876
|
+
**subscribe** to resources are pushed `canvas://ax-work` / `canvas://ax-context`
|
|
877
|
+
live. Clients that **poll** instead should poll `canvas_claim_ax_delivery` —
|
|
878
|
+
`pendingActivity` is how non-steering browser changes reach them. Only steering
|
|
879
|
+
flows through the claim/ack queue.
|
|
868
880
|
- **Elicitation / mode:** request structured human input
|
|
869
881
|
(`canvas_request_elicitation` → `canvas_respond_elicitation`) or a workflow
|
|
870
882
|
mode transition (`canvas_request_mode` → `canvas_resolve_mode`); both are
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
viewport,
|
|
26
26
|
} from '../state/canvas-store';
|
|
27
27
|
import { removeNodeFromClient, updateNodeFromClient } from '../state/intent-bridge';
|
|
28
|
-
import { canOpenAsSite, openNodeAsSite
|
|
28
|
+
import { canOpenAsSite, openNodeAsSite } from '../nodes/surface-url';
|
|
29
29
|
import { getNodeIcon } from '../icons';
|
|
30
30
|
import { EXPANDABLE_TYPES, TYPE_LABELS } from '../types';
|
|
31
31
|
import type { CanvasNodeState } from '../types';
|
|
@@ -86,7 +86,16 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
86
86
|
updateNode(id, { size: { width, height } });
|
|
87
87
|
}, []);
|
|
88
88
|
|
|
89
|
-
const handleResizeEnd = useCallback(() =>
|
|
89
|
+
const handleResizeEnd = useCallback(() => {
|
|
90
|
+
// A manual resize is explicit user intent — stop auto/content-fit from
|
|
91
|
+
// overriding it (see isAutoSizeExempt in auto-fit.ts). Persist the flag to the
|
|
92
|
+
// server (mirrors the rename path below) so it survives layout reconciles,
|
|
93
|
+
// undo/redo, and snapshots — a client-only flag is wiped by the next
|
|
94
|
+
// canvas-layout-update broadcast. persistLayout() persists the new size.
|
|
95
|
+
updateNodeData(node.id, { userResized: true });
|
|
96
|
+
void updateNodeFromClient(node.id, { data: { userResized: true } });
|
|
97
|
+
persistLayout();
|
|
98
|
+
}, [node.id]);
|
|
90
99
|
|
|
91
100
|
const startResize = useNodeResize({
|
|
92
101
|
nodeId: node.id,
|
|
@@ -278,35 +287,20 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
278
287
|
</button>
|
|
279
288
|
{/* Open as site — full-page standalone view of this node's surface,
|
|
280
289
|
served from /api/canvas/surface/:id (same document as the canvas
|
|
281
|
-
iframe).
|
|
282
|
-
|
|
290
|
+
iframe). Opens via the system browser so embedded hosts do not trap
|
|
291
|
+
it in their own webview. */}
|
|
283
292
|
{canOpenAsSite(node) && (
|
|
284
293
|
<button
|
|
285
294
|
type="button"
|
|
286
295
|
onClick={(e) => {
|
|
287
296
|
e.stopPropagation();
|
|
288
|
-
openNodeAsSite(node);
|
|
297
|
+
void openNodeAsSite(node);
|
|
289
298
|
}}
|
|
290
|
-
title="Open as site
|
|
299
|
+
title="Open as site"
|
|
291
300
|
>
|
|
292
301
|
↗
|
|
293
302
|
</button>
|
|
294
303
|
)}
|
|
295
|
-
{/* Open in the real system browser (for hosts whose embedded browser
|
|
296
|
-
makes a normal new tab feel in-place, e.g. Codex). Falls back to a
|
|
297
|
-
new tab when the server can't launch the OS browser. */}
|
|
298
|
-
{canOpenAsSite(node) && (
|
|
299
|
-
<button
|
|
300
|
-
type="button"
|
|
301
|
-
onClick={(e) => {
|
|
302
|
-
e.stopPropagation();
|
|
303
|
-
void openNodeInSystemBrowser(node);
|
|
304
|
-
}}
|
|
305
|
-
title="Open in system browser"
|
|
306
|
-
>
|
|
307
|
-
⤤
|
|
308
|
-
</button>
|
|
309
|
-
)}
|
|
310
304
|
{/* Expand — opens node as full-viewport overlay for focused work */}
|
|
311
305
|
{EXPANDABLE_TYPES.has(node.type) && (
|
|
312
306
|
<button
|
|
@@ -8,7 +8,7 @@ import { StatusNode } from '../nodes/StatusNode';
|
|
|
8
8
|
import { ImageNode } from '../nodes/ImageNode';
|
|
9
9
|
import { WebpageNode } from '../nodes/WebpageNode';
|
|
10
10
|
import { HtmlNode, shouldShowPresentationControls } from '../nodes/HtmlNode';
|
|
11
|
-
import { canOpenAsSite, openNodeAsSite
|
|
11
|
+
import { canOpenAsSite, openNodeAsSite } from '../nodes/surface-url';
|
|
12
12
|
import { PromptNode } from '../nodes/PromptNode';
|
|
13
13
|
import { ResponseNode } from '../nodes/ResponseNode';
|
|
14
14
|
import { TraceNode } from '../nodes/TraceNode';
|
|
@@ -308,24 +308,13 @@ export function ExpandedNodeOverlay() {
|
|
|
308
308
|
<button
|
|
309
309
|
type="button"
|
|
310
310
|
class="expanded-action-btn"
|
|
311
|
-
onClick={() => openNodeAsSite(node)}
|
|
312
|
-
title="Open as a full-page site in
|
|
311
|
+
onClick={() => void openNodeAsSite(node)}
|
|
312
|
+
title="Open as a full-page site in the system browser"
|
|
313
313
|
>
|
|
314
314
|
Open as site
|
|
315
315
|
</button>
|
|
316
316
|
)}
|
|
317
317
|
|
|
318
|
-
{canOpenAsSite(node) && (
|
|
319
|
-
<button
|
|
320
|
-
type="button"
|
|
321
|
-
class="expanded-action-btn"
|
|
322
|
-
onClick={() => void openNodeInSystemBrowser(node)}
|
|
323
|
-
title="Open in the system browser (e.g. Chrome) — useful when the host browser opens tabs in-place"
|
|
324
|
-
>
|
|
325
|
-
Open in system browser
|
|
326
|
-
</button>
|
|
327
|
-
)}
|
|
328
|
-
|
|
329
318
|
{canPresent && (
|
|
330
319
|
<button
|
|
331
320
|
type="button"
|
|
@@ -2,24 +2,78 @@ import type { CanvasNodeState } from '../types';
|
|
|
2
2
|
|
|
3
3
|
export const AUTO_FIT_TITLEBAR_HEIGHT = 37;
|
|
4
4
|
export const AUTO_FIT_MAX_HEIGHT = 600;
|
|
5
|
+
// Iframe surfaces (charts/dashboards/rich html) can legitimately need more room
|
|
6
|
+
// than a text node, so they grow to a higher ceiling before scrolling.
|
|
7
|
+
export const AUTO_FIT_MAX_HEIGHT_IFRAME = 1400;
|
|
8
|
+
// `.node-body` adds 12px padding top+bottom around an iframe surface (global.css).
|
|
9
|
+
// The bridge reports the iframe's OWN document scrollHeight, so the grow target
|
|
10
|
+
// must add titlebar + this body padding or the node settles ~24px short and the
|
|
11
|
+
// surface shows a residual inner scrollbar. (The DOM auto-fit above doesn't need
|
|
12
|
+
// this: body.scrollHeight already includes the body's padding.)
|
|
13
|
+
export const AUTO_FIT_BODY_PADDING = 24;
|
|
5
14
|
|
|
6
|
-
|
|
7
|
-
|
|
15
|
+
/** Node types the DOM auto-fit can't measure: iframe-backed surfaces (html/
|
|
16
|
+
* json-render/graph/mcp-app), where the body's scrollHeight equals the iframe
|
|
17
|
+
* height (circular), and webpage (its card uses a bounded flex/overflow layout,
|
|
18
|
+
* so auto-fit was already a no-op). Iframe surfaces are sized by the content-
|
|
19
|
+
* height bridge (use-iframe-content-height) instead; webpage intentionally
|
|
20
|
+
* scrolls. Excluding them from the DOM path is behaviour-neutral. */
|
|
21
|
+
function isIframeNode(node: CanvasNodeState): boolean {
|
|
22
|
+
return node.type === 'html'
|
|
23
|
+
|| node.type === 'json-render'
|
|
24
|
+
|| node.type === 'graph'
|
|
25
|
+
|| node.type === 'mcp-app'
|
|
26
|
+
|| node.type === 'webpage';
|
|
8
27
|
}
|
|
9
28
|
|
|
10
|
-
|
|
11
|
-
|
|
29
|
+
/** Authored iframe surfaces whose content has a bounded natural height — they may
|
|
30
|
+
* grow to fit it. Excludes presentation decks, hosted ext-apps, and URL/webpage
|
|
31
|
+
* viewers (unbounded/scrolling content that must not drive node height). */
|
|
32
|
+
function isContentFitSurface(node: CanvasNodeState): boolean {
|
|
33
|
+
if (node.type === 'html') return node.data.presentation !== true;
|
|
34
|
+
if (node.type === 'json-render' || node.type === 'graph') return true;
|
|
35
|
+
if (node.type === 'mcp-app') return node.data.viewerType === 'web-artifact';
|
|
36
|
+
return false;
|
|
12
37
|
}
|
|
13
38
|
|
|
14
|
-
|
|
15
|
-
|
|
39
|
+
/** Shared exemptions: never auto-size a node the user/agent has fixed or a node
|
|
40
|
+
* whose height is controlled elsewhere. */
|
|
41
|
+
function isAutoSizeExempt(node: CanvasNodeState): boolean {
|
|
42
|
+
return node.collapsed === true
|
|
43
|
+
|| node.dockPosition != null
|
|
44
|
+
|| node.data.strictSize === true
|
|
45
|
+
|| node.data.userResized === true
|
|
46
|
+
|| node.type === 'group';
|
|
16
47
|
}
|
|
17
48
|
|
|
49
|
+
/** DOM-content nodes (markdown/status/file/…) whose body scrollHeight is directly
|
|
50
|
+
* measurable — the one-shot ResizeObserver auto-fit in CanvasNode handles these. */
|
|
18
51
|
export function shouldAutoFitNode(node: CanvasNodeState): boolean {
|
|
19
|
-
return !
|
|
52
|
+
return !isAutoSizeExempt(node) && !isIframeNode(node);
|
|
20
53
|
}
|
|
21
54
|
|
|
22
55
|
export function computeAutoFitHeight(node: CanvasNodeState, contentHeight: number): number | null {
|
|
23
56
|
if (!shouldAutoFitNode(node) || contentHeight <= 0) return null;
|
|
24
57
|
return Math.min(contentHeight + AUTO_FIT_TITLEBAR_HEIGHT, AUTO_FIT_MAX_HEIGHT);
|
|
25
58
|
}
|
|
59
|
+
|
|
60
|
+
/** Iframe surfaces that should GROW to fit their reported content height. */
|
|
61
|
+
export function shouldContentFitIframeNode(node: CanvasNodeState): boolean {
|
|
62
|
+
return isContentFitSurface(node) && !isAutoSizeExempt(node);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Grow-only target height from a surface-reported content height. Returns null
|
|
67
|
+
* when the node is exempt, the report is non-positive, or the node already fits
|
|
68
|
+
* (so it never shrinks — monotonic growth can't oscillate). Adds the titlebar +
|
|
69
|
+
* node-body padding so the content fully clears (no residual inner scrollbar),
|
|
70
|
+
* capped at the iframe ceiling.
|
|
71
|
+
*/
|
|
72
|
+
export function computeContentGrowHeight(node: CanvasNodeState, contentHeight: number): number | null {
|
|
73
|
+
if (!shouldContentFitIframeNode(node) || contentHeight <= 0) return null;
|
|
74
|
+
const want = Math.min(
|
|
75
|
+
contentHeight + AUTO_FIT_TITLEBAR_HEIGHT + AUTO_FIT_BODY_PADDING,
|
|
76
|
+
AUTO_FIT_MAX_HEIGHT_IFRAME,
|
|
77
|
+
);
|
|
78
|
+
return want > node.size.height + 8 ? want : null;
|
|
79
|
+
}
|
|
@@ -4,6 +4,7 @@ import { submitAxInteractionFromClient } from '../state/intent-bridge';
|
|
|
4
4
|
import { showToast } from '../state/attention-bridge';
|
|
5
5
|
import type { CanvasNodeState } from '../types';
|
|
6
6
|
import { nodeSurfaceUrl, surfaceContentHash } from './surface-url';
|
|
7
|
+
import { useIframeContentHeight } from './use-iframe-content-height';
|
|
7
8
|
|
|
8
9
|
export function shouldShowPresentationControls(node: CanvasNodeState): boolean {
|
|
9
10
|
return node.type === 'html' && node.data.presentation === true;
|
|
@@ -22,6 +23,8 @@ export function HtmlNode({
|
|
|
22
23
|
const themeToken = useMemo(() => `theme-${crypto.randomUUID()}`, []);
|
|
23
24
|
// Per-mount nonce authorizing iframe → parent AX emits (Phase 3 HTML bridge).
|
|
24
25
|
const axToken = useMemo(() => `ax-${crypto.randomUUID()}`, []);
|
|
26
|
+
// Per-mount nonce for the content-height reporter (node grows to fit content).
|
|
27
|
+
const frameToken = useMemo(() => `frame-${crypto.randomUUID()}`, []);
|
|
25
28
|
const html = typeof node.data.html === 'string'
|
|
26
29
|
? node.data.html
|
|
27
30
|
: typeof node.data.content === 'string'
|
|
@@ -36,11 +39,15 @@ export function HtmlNode({
|
|
|
36
39
|
// itself changes.
|
|
37
40
|
const surfaceSrc = useMemo(
|
|
38
41
|
() => (html
|
|
39
|
-
? nodeSurfaceUrl(node.id, { theme, themeToken, present: presentation, presentToken: presentationExitToken, v, axToken })
|
|
42
|
+
? nodeSurfaceUrl(node.id, { theme, themeToken, present: presentation, presentToken: presentationExitToken, v, axToken, frameToken })
|
|
40
43
|
: ''),
|
|
41
|
-
[html, presentation, presentationExitToken, themeToken, v, node.id, axToken],
|
|
44
|
+
[html, presentation, presentationExitToken, themeToken, v, node.id, axToken, frameToken],
|
|
42
45
|
);
|
|
43
46
|
|
|
47
|
+
// Grow the node to fit the surface's reported content height (grow-only, gated).
|
|
48
|
+
// Never in the expanded overlay — there the surface fills the large overlay frame.
|
|
49
|
+
useIframeContentHeight(node, iframeRef, expanded ? '' : frameToken);
|
|
50
|
+
|
|
44
51
|
// Phase 3 HTML bridge: receive window.PMX_AX.emit(...) messages from the
|
|
45
52
|
// sandboxed iframe, validate the nonce + node id, and submit the interaction
|
|
46
53
|
// through the capability-gated endpoint (the server re-validates capabilities).
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef } from 'preact/hooks';
|
|
2
2
|
import type { CanvasNodeState } from '../types';
|
|
3
3
|
import { axSurfaceState, canvasTheme } from '../state/canvas-store';
|
|
4
|
+
import { shouldContentFitIframeNode } from '../canvas/auto-fit';
|
|
4
5
|
import { submitAxInteractionFromClient } from '../state/intent-bridge';
|
|
5
6
|
import { showToast } from '../state/attention-bridge';
|
|
6
7
|
import { ExtAppFrame } from './ExtAppFrame';
|
|
8
|
+
import { useIframeContentHeight } from './use-iframe-content-height';
|
|
7
9
|
|
|
8
|
-
function withViewerParams(
|
|
10
|
+
function withViewerParams(
|
|
11
|
+
url: string,
|
|
12
|
+
expanded: boolean,
|
|
13
|
+
specVersion?: number,
|
|
14
|
+
axToken?: string,
|
|
15
|
+
axNodeId?: string,
|
|
16
|
+
frameToken?: string,
|
|
17
|
+
fitContent?: boolean,
|
|
18
|
+
): string {
|
|
9
19
|
if (!url) return url;
|
|
10
20
|
try {
|
|
11
21
|
const resolved = new URL(url, window.location.origin);
|
|
@@ -16,9 +26,12 @@ function withViewerParams(url: string, expanded: boolean, specVersion?: number,
|
|
|
16
26
|
if (typeof specVersion === 'number') resolved.searchParams.set('v', String(specVersion));
|
|
17
27
|
// AX bridge nonce for json-render/graph + web-artifact viewer nodes.
|
|
18
28
|
if (axToken) resolved.searchParams.set('axToken', axToken);
|
|
19
|
-
// The /artifact route needs the node id to inject the AX
|
|
20
|
-
// view route already gets nodeId from its own query param).
|
|
29
|
+
// The /artifact route needs the node id to inject the AX/content bridges (the
|
|
30
|
+
// json-render view route already gets nodeId from its own query param).
|
|
21
31
|
if (axNodeId) resolved.searchParams.set('axNodeId', axNodeId);
|
|
32
|
+
// Content-fit: report natural height (charts render intrinsic) so the node grows.
|
|
33
|
+
if (frameToken) resolved.searchParams.set('frameToken', frameToken);
|
|
34
|
+
if (fitContent) resolved.searchParams.set('fit', 'content');
|
|
22
35
|
return resolved.toString();
|
|
23
36
|
} catch {
|
|
24
37
|
return url;
|
|
@@ -59,6 +72,14 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
|
|
|
59
72
|
const isAxViewer = (isJsonViewer || isWebArtifact) && axOn;
|
|
60
73
|
const axSurface: 'json-render' | 'mcp-app' = isWebArtifact ? 'mcp-app' : 'json-render';
|
|
61
74
|
const axToken = useMemo(() => (isAxViewer ? `ax-${crypto.randomUUID()}` : ''), [isAxViewer]);
|
|
75
|
+
// Content-fit: grow the node to the viewer's natural height (charts render
|
|
76
|
+
// intrinsic via fit=content). Gated by shouldContentFitIframeNode (json-render /
|
|
77
|
+
// graph / web-artifact, unless strictSize / user-resized / docked / collapsed).
|
|
78
|
+
// NEVER in the expanded overlay — there the chart must stretch to fill the large
|
|
79
|
+
// overlay frame (fill-down), not sit at its intrinsic in-canvas height.
|
|
80
|
+
const contentFit = shouldContentFitIframeNode(node) && !expanded;
|
|
81
|
+
const frameToken = useMemo(() => (contentFit ? `frame-${crypto.randomUUID()}` : ''), [contentFit]);
|
|
82
|
+
useIframeContentHeight(node, iframeRef, frameToken);
|
|
62
83
|
|
|
63
84
|
// Receive AX emits forwarded by the json-render viewer; validate (bound to this
|
|
64
85
|
// node's iframe + nonce + node id) and submit through the capability-gated
|
|
@@ -105,7 +126,15 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
|
|
|
105
126
|
useEffect(pushAxState, [isAxViewer, axToken, axStateValue]);
|
|
106
127
|
|
|
107
128
|
const specVersion = typeof node.data.specVersion === 'number' ? node.data.specVersion : undefined;
|
|
108
|
-
const url = withViewerParams(
|
|
129
|
+
const url = withViewerParams(
|
|
130
|
+
(node.data.url as string) || '',
|
|
131
|
+
expanded,
|
|
132
|
+
specVersion,
|
|
133
|
+
axToken || undefined,
|
|
134
|
+
isAxViewer ? node.id : undefined,
|
|
135
|
+
frameToken || undefined,
|
|
136
|
+
contentFit,
|
|
137
|
+
);
|
|
109
138
|
const sourceServer = (node.data.sourceServer as string) || '';
|
|
110
139
|
const hostMode = (node.data.hostMode as string) || 'hosted';
|
|
111
140
|
const fallbackReason = node.data.fallbackReason as string | undefined;
|
|
@@ -24,6 +24,8 @@ export interface SurfaceUrlOptions {
|
|
|
24
24
|
v?: string;
|
|
25
25
|
/** Nonce authorizing iframe → parent AX emits (html bridge). */
|
|
26
26
|
axToken?: string;
|
|
27
|
+
/** Nonce for the content-height reporter (node grows to fit content). */
|
|
28
|
+
frameToken?: string;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/** Build the stable per-node surface URL (/api/canvas/surface/:id) the iframe and "Open as site" both use. */
|
|
@@ -35,6 +37,7 @@ export function nodeSurfaceUrl(nodeId: string, opts: SurfaceUrlOptions = {}): st
|
|
|
35
37
|
if (opts.presentToken) params.set('presentToken', opts.presentToken);
|
|
36
38
|
if (opts.v) params.set('v', opts.v);
|
|
37
39
|
if (opts.axToken) params.set('axToken', opts.axToken);
|
|
40
|
+
if (opts.frameToken) params.set('frameToken', opts.frameToken);
|
|
38
41
|
return `/api/canvas/surface/${encodeURIComponent(nodeId)}?${params.toString()}`;
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -43,18 +46,13 @@ export function canOpenAsSite(node: CanvasNodeState): boolean {
|
|
|
43
46
|
return canOpenNodeAsSurface(node.type, node.data as Record<string, unknown>);
|
|
44
47
|
}
|
|
45
48
|
|
|
46
|
-
/** Open the node's surface in a new browser tab. */
|
|
47
|
-
export function openNodeAsSite(node: CanvasNodeState): void {
|
|
48
|
-
window.open(nodeSurfaceUrl(node.id), '_blank', 'noopener');
|
|
49
|
-
}
|
|
50
|
-
|
|
51
49
|
/**
|
|
52
|
-
* Open the node's surface in the user's
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* can't launch (headless / PMX_CANVAS_DISABLE_BROWSER_OPEN).
|
|
50
|
+
* Open the node's standalone surface in the user's system browser. Falls back to
|
|
51
|
+
* `window.open` when the server cannot launch a browser, preserving in-browser tests
|
|
52
|
+
* and headless/disabled-browser environments.
|
|
56
53
|
*/
|
|
57
|
-
export async function
|
|
58
|
-
const
|
|
59
|
-
|
|
54
|
+
export async function openNodeAsSite(node: CanvasNodeState): Promise<void> {
|
|
55
|
+
const url = nodeSurfaceUrl(node.id);
|
|
56
|
+
const res = await openNodeInSystemBrowserRequest(node.id, url);
|
|
57
|
+
if (!res.opened) window.open(url, '_blank', 'noopener');
|
|
60
58
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'preact/hooks';
|
|
2
|
+
import { persistLayout, resizeNode } from '../state/canvas-store';
|
|
3
|
+
import { computeContentGrowHeight } from '../canvas/auto-fit';
|
|
4
|
+
import type { CanvasNodeState } from '../types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Grow an iframe-surface node to fit the content height its surface reports over
|
|
8
|
+
* the nonce-validated `content-height` postMessage bridge. Grow-only and gated
|
|
9
|
+
* (see computeContentGrowHeight / shouldContentFitIframeNode), so it never clips,
|
|
10
|
+
* never shrinks, never fights a manual resize / strictSize / docked node, and —
|
|
11
|
+
* because growth is monotonic with a dead-band — cannot oscillate. This is the
|
|
12
|
+
* fix for iframe nodes whose body scrollHeight the parent can't measure.
|
|
13
|
+
*
|
|
14
|
+
* The latest node is read through a ref so the effect stays mounted across the
|
|
15
|
+
* grow (its deps are only id + token). Putting node.size in the deps would re-run
|
|
16
|
+
* the effect on each grow and its cleanup would cancel the pending persist.
|
|
17
|
+
*/
|
|
18
|
+
export function useIframeContentHeight(
|
|
19
|
+
node: CanvasNodeState,
|
|
20
|
+
iframeRef: { current: HTMLIFrameElement | null },
|
|
21
|
+
frameToken: string,
|
|
22
|
+
): void {
|
|
23
|
+
const nodeRef = useRef(node);
|
|
24
|
+
nodeRef.current = node;
|
|
25
|
+
const persistTimer = useRef<number | null>(null);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!frameToken) return undefined;
|
|
29
|
+
function onMessage(event: MessageEvent) {
|
|
30
|
+
if (event.source !== iframeRef.current?.contentWindow) return;
|
|
31
|
+
const d = event.data as { source?: string; type?: string; token?: string; height?: unknown } | null;
|
|
32
|
+
if (!d || d.source !== 'pmx-canvas-frame' || d.type !== 'content-height' || d.token !== frameToken) return;
|
|
33
|
+
const current = nodeRef.current;
|
|
34
|
+
const reported = typeof d.height === 'number' ? d.height : 0;
|
|
35
|
+
const target = computeContentGrowHeight(current, reported);
|
|
36
|
+
if (target === null) return;
|
|
37
|
+
resizeNode(current.id, { width: current.size.width, height: target });
|
|
38
|
+
if (persistTimer.current !== null) window.clearTimeout(persistTimer.current);
|
|
39
|
+
persistTimer.current = window.setTimeout(() => {
|
|
40
|
+
persistLayout({ recordHistory: false });
|
|
41
|
+
persistTimer.current = null;
|
|
42
|
+
}, 300);
|
|
43
|
+
}
|
|
44
|
+
window.addEventListener('message', onMessage);
|
|
45
|
+
return () => {
|
|
46
|
+
window.removeEventListener('message', onMessage);
|
|
47
|
+
if (persistTimer.current !== null) {
|
|
48
|
+
window.clearTimeout(persistTimer.current);
|
|
49
|
+
persistTimer.current = null;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}, [node.id, frameToken]);
|
|
53
|
+
}
|
|
@@ -261,7 +261,7 @@ export async function fetchAxSurfaceState(): Promise<unknown> {
|
|
|
261
261
|
}
|
|
262
262
|
|
|
263
263
|
/** Ask the server to open a node's surface in the system browser. */
|
|
264
|
-
export async function openNodeInSystemBrowserRequest(nodeId: string): Promise<{ ok: boolean; opened: boolean }> {
|
|
264
|
+
export async function openNodeInSystemBrowserRequest(nodeId: string, url?: string): Promise<{ ok: boolean; opened: boolean }> {
|
|
265
265
|
return requestJson<{ ok: boolean; opened: boolean }>(
|
|
266
266
|
'openNodeInSystemBrowserRequest',
|
|
267
267
|
'/api/canvas/open-external',
|
|
@@ -269,7 +269,7 @@ export async function openNodeInSystemBrowserRequest(nodeId: string): Promise<{
|
|
|
269
269
|
{
|
|
270
270
|
method: 'POST',
|
|
271
271
|
headers: { 'Content-Type': 'application/json' },
|
|
272
|
-
body: JSON.stringify({ nodeId }),
|
|
272
|
+
body: JSON.stringify({ nodeId, url }),
|
|
273
273
|
},
|
|
274
274
|
);
|
|
275
275
|
}
|
|
@@ -155,9 +155,19 @@ export function useChartFrameHeight(explicitHeight: number | null | undefined, f
|
|
|
155
155
|
};
|
|
156
156
|
}, [explicitHeight]);
|
|
157
157
|
|
|
158
|
+
// Content-fit mode (node grows to fit, set by the viewer when fit=content): the
|
|
159
|
+
// chart takes its INTRINSIC height — explicit, or the fallback — independent of
|
|
160
|
+
// the node/viewport height. That makes the document's scrollHeight stable so the
|
|
161
|
+
// node can grow to it once and converge (no fill-down feedback loop). When NOT in
|
|
162
|
+
// content-fit (strictSize / user-resized nodes), it fills the frame down as before.
|
|
163
|
+
const fitContent = typeof window !== 'undefined'
|
|
164
|
+
&& (window as { __PMX_CANVAS_FIT_CONTENT__?: boolean }).__PMX_CANVAS_FIT_CONTENT__ === true;
|
|
165
|
+
const height = fitContent
|
|
166
|
+
? (typeof explicitHeight === 'number' ? explicitHeight : fallbackHeight)
|
|
167
|
+
: (typeof explicitHeight === 'number' ? Math.min(explicitHeight, autoHeight) : autoHeight);
|
|
158
168
|
return {
|
|
159
169
|
frameRef,
|
|
160
|
-
height
|
|
170
|
+
height,
|
|
161
171
|
width: autoWidth,
|
|
162
172
|
};
|
|
163
173
|
}
|