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.
@@ -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 real SYSTEM browser via the server's OS
25
- * launcher for hosts (e.g. Codex) whose embedded browser makes a normal
26
- * `_blank` tab feel in-place. Falls back to a normal new-tab open when the server
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 openNodeInSystemBrowser(node: CanvasNodeState): Promise<void>;
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.33",
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` | Undelivered steering an MCP client can claim, act on, and mark delivered (adapterless delivery) |
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 can be claimed by adapterless MCP clients via
865
- `canvas://ax-pending-steering` / `canvas_claim_ax_delivery` and acknowledged
866
- with `canvas_mark_ax_delivery` (loop-safe — a consumer never receives steering
867
- it originated).
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, openNodeInSystemBrowser } from '../nodes/surface-url';
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(() => persistLayout(), []);
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). Covers html, web-artifact/ext-app/url mcp-apps,
282
- json-render/graph, and webpage nodes. */}
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 (new tab)"
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, openNodeInSystemBrowser } from '../nodes/surface-url';
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 a new tab"
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
- function isExtAppNode(node: CanvasNodeState): boolean {
7
- return node.type === 'mcp-app' && node.data.mode === 'ext-app';
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
- function hasExplicitStructuredFrame(node: CanvasNodeState): boolean {
11
- return node.type === 'graph' || node.type === 'json-render';
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
- function isPresentationHtmlNode(node: CanvasNodeState): boolean {
15
- return node.type === 'html' && node.data.presentation === true;
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 !node.collapsed && !node.dockPosition && node.data.strictSize !== true && node.type !== 'group' && !isExtAppNode(node) && !hasExplicitStructuredFrame(node) && !isPresentationHtmlNode(node);
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(url: string, expanded: boolean, specVersion?: number, axToken?: string, axNodeId?: string): string {
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 bridge (the json-render
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((node.data.url as string) || '', expanded, specVersion, axToken || undefined, isAxViewer ? node.id : undefined);
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 real SYSTEM browser via the server's OS
53
- * launcher for hosts (e.g. Codex) whose embedded browser makes a normal
54
- * `_blank` tab feel in-place. Falls back to a normal new-tab open when the server
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 openNodeInSystemBrowser(node: CanvasNodeState): Promise<void> {
58
- const res = await openNodeInSystemBrowserRequest(node.id);
59
- if (!res.opened) openNodeAsSite(node);
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: typeof explicitHeight === 'number' ? Math.min(explicitHeight, autoHeight) : autoHeight,
170
+ height,
161
171
  width: autoWidth,
162
172
  };
163
173
  }