Agent chat: a reply's html/svg fence draws as a sandboxed inline preview

Merged
TE
5d ago
+900-0

A reply's closed ```html or ```svg fence now draws inline as the document it holds (a chart, a table, a styled section) instead of as source, in a sandboxed frame. Code swaps in the source card (Copy stays), Open preview still opens the side panel. Nothing renders while the fence is still streaming.

Leo, 2026-11-01, after watching Google's "Generate, edit, and preview files in Gemini" clip:

"can you watch this video i was thinking you know it'd be interesting if we could support that kinda visualization rendering in cmux-next too tho i guess it's mostly just the harness/prompt stuff"

"people don't seriously like antigravity/gemini related stuff tho for a variety of reasons when doing "real" work tho but idk maybe there's some semblance of opportunity here?"

Spec: leo-workspace design/artifacts.md (new surface; this PR adds its .github/design-surfaces.json entry).

Security model

  • <iframe sandbox="allow-scripts">, never allow-same-origin, so the document is an opaque origin with no access to parent.document, the pane's storage, its bridge or acpmux. No top navigation, popups, forms or modals.
  • Where it draws. In the app the preview loads in the native artifact frame (cmux-agent://render/artifact, the same host the side panel's Run uses): its own CSP (the artifact policy: default-src 'none'; connect-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none', images and fonts only data:/blob:), no network, its own height report, and the host's navigation delegate. The parent posts it the inert, script-free document. A srcdoc frame would take the pane's CSP, which runs no inline script, so srcdoc is used only in the gallery, behind the same policy with a per-frame nonce script-src for the client's frame script. Any other page shows the source.
  • The document's own scripts, on* handlers and javascript: URLs never run inline.
  • A document that carries script (artifactNeedsRun) never previews inline. It stays the code card, and Open takes it to the side panel, where Run already lives (#26216) in the native artifact frame. Nothing auto-runs.
  • No network outside the policy: <link> elements are made inert (dns-prefetch and preconnect hints reach the network past connect-src), x-dns-prefetch-control is off, and every href/xlink:href that leaves the document becomes # (matched with a non-consuming boundary, so adjacent attributes are caught too) (original in data-href), so no engine can speculatively resolve an agent-written host. Fragments and inline data:/blob: images keep their value.
  • The frame stays on its document. http-equiv attributes in the document are made inert, because <meta http-equiv="refresh"> is the one way script-free markup can navigate its own frame, and neither CSP nor the sandbox stops it. The frame's own script refuses link clicks (a #fragment scrolls in place, since in srcdoc it would resolve against the parent page). A frame that loads a second time anyway is torn down and the card shows the source.
  • Height: the native frame posts cmux-render-size and the gallery frame cmux-preview-size. The parent only takes it from that frame's own contentWindow, clamps it to 48 px minimum, and caps the card at 480 px (the document scrolls past that; Expand raises the cap to 4000 px).

Perf

  • No per-token work: an open fence stays plain code (block.open), and the frame mounts once the fence closes. It's keyed by the document text, so an unchanged block keeps its frame across streamed snapshots.
  • The frame mounts only once the card is near the viewport (useNearViewport), so a long chat doesn't start every frame at once.
  • The height reporter is one ResizeObserver inside the frame. It has no timer and is cleaned up with the frame.

Tests

  • webviews/test/inline-html-svg-preview.test.tsx (jsdom): in the pane the frame is the native artifact frame, its sandbox attribute is exactly allow-scripts without allow-same-origin, and on ready it gets the inert document. The dev server shows the source. The gallery srcdoc starts with the CSP (nonce script-src, no unsafe-inline, connect-src 'none'). Height follows only the frame's own report and is capped, and Expand lifts the cap. A refresh meta and resource-hint links are inert, and outbound links point to #. A second load falls back to the source. An unclosed fence renders no iframe. Code and Preview toggle back and forth with Copy kept. A scripted document stays the code card with no inline Run.
  • webviews/test/inline-html-svg-preview-browser.test.ts (real Chromium and WebKit): a passive frame has no live refresh, its link out points to #, and a fragment still scrolls. The document's script never runs, but the height report does. Even with inline scripts allowed, a script reading parent.document throws, fetch is refused, and no request reaches the probe host.
  • Gallery: agent-pane.inline-preview has an SVG bar chart, an HTML table plus line chart, the Code view, a streaming (unclosed) fence, and a scripted document that stays code.

Out of scope / next

  • Mermaid (and Vega) through the same inline card instead of DiagramBlock.
  • Edit-a-selection (select a region, follow-up turn replaces that section) and Export to PDF from the side panel, both specified in the design note.

Builds on #19473 (side panel), #26216 / #26286 (passive before Run, artifact host) and #21760 (render cards). It doesn't touch CodeBlock.tsx, HighlightedCode.tsx or StreamingCode.tsx, which another open change is reworking.

Fleet runs of both new test files plus artifact-panel*.test.tsx pass (29/29). Lint, types, design-tokens, fixed-waits and browser-lanes checks are clean.

Agent session: agent session