Agent chat: a reply's html/svg fence draws as a sandboxed inline preview
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">, neverallow-same-origin, so the document is an opaque origin with no access toparent.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 onlydata:/blob:), no network, its own height report, and the host's navigation delegate. The parent posts it the inert, script-free document. Asrcdocframe would take the pane's CSP, which runs no inline script, sosrcdocis used only in the gallery, behind the same policy with a per-frame noncescript-srcfor the client's frame script. Any other page shows the source. - The document's own scripts,
on*handlers andjavascript: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-prefetchandpreconnecthints reach the network pastconnect-src),x-dns-prefetch-controlis off, and everyhref/xlink:hrefthat leaves the document becomes#(matched with a non-consuming boundary, so adjacent attributes are caught too) (original indata-href), so no engine can speculatively resolve an agent-written host. Fragments and inlinedata:/blob:images keep their value. - The frame stays on its document.
http-equivattributes 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#fragmentscrolls in place, since insrcdocit 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-sizeand the gallery framecmux-preview-size. The parent only takes it from that frame's owncontentWindow, 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
ResizeObserverinside 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 exactlyallow-scriptswithoutallow-same-origin, and on ready it gets the inert document. The dev server shows the source. The gallery srcdoc starts with the CSP (noncescript-src, nounsafe-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 readingparent.documentthrows,fetchis refused, and no request reaches the probe host.- Gallery:
agent-pane.inline-previewhas 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