Skip to content

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

Merged
teamleaderleo merged 5 commits into
feat-cmux-nextfrom
inline-html-svg-fence-preview
Nov 1, 2026
Merged

teamleaderleo merged 5 commits into
feat-cmux-nextfrom
inline-html-svg-fence-preview

Conversation

@teamleaderleo

@teamleaderleo teamleaderleo commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

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 (feat(agent-chat): sandbox HTML artifacts before Run #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

teamleaderleo and others added 2 commits November 1, 2026 01:52
…e preview

A closed ```html or ```svg fence now draws as its document in an
iframe sandboxed with allow-scripts and no same-origin. Its srcdoc starts
with the artifact policy (no network); before Run only the frame's nonce'd
height reporter runs. Height follows the frame's own report, capped at
480 px. Code shows the source card (Copy kept). An open fence stays plain
code. Scripted documents wait for Run, then use the native artifact frame.
Meta refresh is made inert, link clicks are refused, and a frame that loads
twice falls back to the source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-92fa78a91e29868c5eaa
@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

cmux status (one comment, edited in place; each line is updated by the job that owns it)

Review 9936b66: HQ feed card · gallery before/after runs · checks

App of this head (app.zip, kept 1 day)

gh workflow run reload-build.yml -R manaflow-ai/cmux --ref feat-cmux-next -f tag=pr30673 -f ref=inline-html-svg-fence-preview -f platform=macos -f backend_mode=local

Gallery before/after for 9936b66: 16 states rendered, 0 images differ, 2776 more touched states not rendered. Images are in the run's artifact, not on this public PR; the independent review of this head reads them on its own, so the PR needs no hand-made screenshots of these states.

gh run download 41315594316 -R manaflow-ai/cmux -n gallery-advisory-30673-9936b66ff95d3c457a20e628264b6dad9bd516ae (then read gallery-review.json)

States
State Theme Status Diff Picked by
agent-pane.access-offer/turn-no-credits Apple System Colors Light unchanged 0% own conversation.css
agent-pane.access-offer/turn-no-credits Apple System Colors unchanged 0% own conversation.css
agent-pane.access-offer/turn-low-credits Apple System Colors Light unchanged 0% own conversation.css
agent-pane.access-offer/turn-low-credits Apple System Colors unchanged 0% own conversation.css
agent-pane.access-offer/turn-no-offer Apple System Colors Light unchanged 0% own conversation.css
agent-pane.access-offer/turn-no-offer Apple System Colors unchanged 0% own conversation.css
agent-pane.approval-edits/single Apple System Colors Light unchanged 0% own conversation.css
agent-pane.approval-edits/single Apple System Colors unchanged 0% own conversation.css
agent-pane.approval-edits/grouped Apple System Colors Light unchanged 0% own conversation.css
agent-pane.approval-edits/grouped Apple System Colors unchanged 0% own conversation.css
agent-pane.approval-edits/single-multiedit Apple System Colors Light unchanged 0% own conversation.css
agent-pane.approval-edits/single-multiedit Apple System Colors unchanged 0% own conversation.css
agent-pane.approval-edits/grouped-multiedit Apple System Colors Light unchanged 0% own conversation.css
agent-pane.approval-edits/grouped-multiedit Apple System Colors unchanged 0% own conversation.css
agent-pane.approval-edits/single-long Apple System Colors Light unchanged 0% own conversation.css
agent-pane.approval-edits/single-long Apple System Colors unchanged 0% own conversation.css
agent-pane.approval-edits/grouped-long Apple System Colors Light unchanged 0% own conversation.css
agent-pane.approval-edits/grouped-long Apple System Colors unchanged 0% own conversation.css
agent-pane.background-activity/duration-columns Apple System Colors Light unchanged 0% own conversation.css
agent-pane.background-activity/duration-columns Apple System Colors unchanged 0% own conversation.css
agent-pane.background-activity/pinned-live-tail-light Apple System Colors Light unchanged 0% own conversation.css
agent-pane.background-activity/pinned-live-tail-light Apple System Colors unchanged 0% own conversation.css
agent-pane.background-activity/pinned-live-tail-dark Apple System Colors Light unchanged 0% own conversation.css
agent-pane.background-activity/pinned-live-tail-dark Apple System Colors unchanged 0% own conversation.css
agent-pane.background-activity/after-latest-message-light Apple System Colors Light unchanged 0% own conversation.css
agent-pane.background-activity/after-latest-message-light Apple System Colors unchanged 0% own conversation.css
agent-pane.background-activity/after-latest-message-dark Apple System Colors Light unchanged 0% own conversation.css
agent-pane.background-activity/after-latest-message-dark Apple System Colors unchanged 0% own conversation.css
agent-pane.background-activity/compact-pill-light Apple System Colors Light unchanged 0% own conversation.css
agent-pane.background-activity/compact-pill-light Apple System Colors unchanged 0% own conversation.css
agent-pane.background-activity/compact-pill-dark Apple System Colors Light unchanged 0% own conversation.css
agent-pane.background-activity/compact-pill-dark Apple System Colors unchanged 0% own conversation.css

Pick other states with a Gallery: entry.id, entry.id/variant line in the PR body.

Passes: CI passes on 9936b66ff9.

CI passes on 9936b66ff9 (run 41315460378 attempt 1).

Written by scripts/ci/classify_failures.py (ci-failure-attribution.yml); signatures are its SIGNATURES table. A machine verdict is the runner's fault, not this PR's; yours means the failing file is one this PR changes, also red on main that main's latest full suite fails the same way, seen on other PRs that it failed on another pull request's run lately.

red: cmux-next checks (god files, concurrency, crash safety, l10n); NEW: red check: Script tests (pin-cmux-tui, embed-cef, ensure-cef store) (+1 more) is not red on the base e3e3507; do not merge until green; see lane-rules

Shipped in:

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Owned by an agent session · session

@github-actions github-actions Bot added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Nov 1, 2026

@teamleaderleo teamleaderleo left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SW-15 independent exact-head review — FIX [P2] at bfce631069df8b0bd2a86eb69fe9dc563cb1b942, cmux #30673 (@teamleaderleo). The sandbox/CSP/nonce approach is well bounded; an explicit Run click can nevertheless authorize a different chat's scripted HTML without another click.

InlinePreview.tsx:44-50 initializes each new InlinePreview with const [running,setRunning] = useState(() => ranRenders.has(code)), importing the module-global ranRenders from RenderCard.tsx:23-25. Clicking Run stores ranRenders.add(code) (InlinePreview.tsx:74-76). Markdown.tsx:984-987 creates preview components by code text only. After someone runs <button onclick="...">... in chat A, a subsequent new reply in chat B containing the same HTML bytes starts with running === true, enters FrameCard run={true}, mounts NativeFrame on a native pane and feeds the frame the script immediately. The current reply's Run button is bypassed. The set is shared across chats and tool render cards for the entire webview JS context; it does not include session ID, turn or code-fence identity. A malicious agent can deliberately repeat a previously approved snippet to trigger it without a fresh click. Sandbox isolation remains intact, but a busy-loop script can monopolize the shared WebKit process, and the PR specifically presents Run as a per-document consent gesture. This extends an earlier render-card cache to newly auto-rendered reply fences.

Minimal fix: Keep running as component-local state initialized to false (preserving source↔preview toggles within one document), and do not read/write the global render-card cache for inline fenced HTML. Or persist by a stable chat+turn+fence identity if preserving Run through virtualization is a requirement. Add regression: Run scripted HTML in one Markdown tree; unmount that tree/switch chat; render an identical scripted fence in a new tree; it must show Run and no iframe. A Code↔Preview toggle within the original component should retain its running state. Test both native and gallery conditions where feasible.

Source-only logic proof; no browser process or credential access. This is separate from the CSP policy and navigation guards and doesn't require changing them. I can provide the two-file light patch in a separate draft stacked on this owner branch, leaving the owner branch intact.

// The frame left its document (its frame counts its loads): the card shows the source.
const [left, setLeft] = useState(false);
// Run needs a frame that may run script: the native artifact frame, or the gallery's srcdoc.
const canRun = scripted && (artifactFrameURL() !== undefined || artifactGallery());

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Run consent leaks between chats. This initializer reads ranRenders.has(code) from a module-global set also used by render cards. After a click to Run a scripted fence in chat A, the first render of identical code in chat B initializes running=true and mounts the native scripted iframe without clicking Run there. Please scope Run to a document identity (at minimum component-local state) and add a cross-chat same-code remount regression.

type="button"
className="cv-codeblock__action cv-codeblock__action--text"
onClick={() => {
ranRenders.add(code);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ranRenders.add(code) makes user consent to run this document's script persistent for every future exact-text document in any chat. The sandbox limits network and parent access, but scripts can still monopolize the pane's WebKit process. Keep this state per fenced document (or key by chat+turn+fence) so subsequent replies require their own Run action.

Copy link
Copy Markdown
Collaborator Author

SW-15 follow-up to the exact-head Run-consent review: I implemented the narrow fix as isolated draft stacked PR #30701 against this feature's head bfce631069df8b0bd2a86eb69fe9dc563cb1b942.

InlinePreview now requires a Run click for each newly mounted scripted fence instead of reading/writing the shared global ranRenders cache by HTML bytes; Code↔Preview retains the running state in the same component. A jsdom regression executes a scripted document in chat A, switches to another Markdown tree and renders byte-identical HTML in chat B; B must show Run and no iframe. The sandbox/CSP, native rendering, and original render-tool behavior stay unchanged. Bun/browser tests are committed and await your CI lane. Owner branch untouched; please review/integrate the stacked draft when ready.

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review (codex, router-review): SHIP at bfce631

Reviewer: codex gpt-6-astra high, fast tier.

Follow-ups: 1. Each lands as an issue in this repo that links this receipt; the lane that opened the PR fixes it forward.

SHIP at bfce631

Visual verdict: no images; Leo tries the build

  1. [P2][follow-up] No after screenshots were provided for visual comparison (review/images/README.txt:1).

Follow-ups:

  • [P2][follow-up] No after screenshots were provided for visual comparison.

@cmux-bot cmux-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cmux-bot approves bfce631: an independent review passed this exact head (router-review receipt by teamleaderleo). A push needs a new review; a later BLOCK at this head dismisses this approval.

…pted documents stay code

- http-equiv and <link> in the document are made inert (meta refresh
  navigates script-free; dns-prefetch/preconnect reach the network past CSP);
  x-dns-prefetch-control off.
- The frame script refuses link clicks; a second load tears the frame
  down to the source.
- A document with script never previews inline: it stays the code card,
  and Open takes it to the side panel where Run already is (#26216).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-92fa78a91e29868c5eaa
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review (codex, router-review): SHIP at c6ba4ce

Reviewer: codex gpt-6-astra high, fast tier.

Follow-ups: 1. Each lands as an issue in this repo that links this receipt; the lane that opened the PR fixes it forward.

SHIP at c6ba4ce

Visual verdict: no images; Leo tries the build

No correctness, regression, security, or test-breaking findings.

Follow-ups:

  • [P2][follow-up] No after screenshots were supplied, so visual comparison remains unverified.

@cmux-bot cmux-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cmux-bot approves c6ba4ce: an independent review passed this exact head (router-review receipt by teamleaderleo). A push needs a new review; a later BLOCK at this head dismisses this approval.

@blacksmith-sh

blacksmith-sh Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Found 7 test failures on Blacksmith runners:

Failures

Test View Logs
/tmp/tmp1by7p9on/summary-fail.json (live):/summary-fail.json (live): View Logs
/tmp/tmpug65ynks/summary-missing.json (live):/summary-missing.json (live): View Logs
[new_workspaces] debug.new_workspace timed 0 of 50 new workspace rows/
[new_workspaces] debug.new_workspace timed 0 of 50 new workspace rows
View Logs
[new_workspaces] the window draws no frames/[new_workspaces] the window draws no frames View Logs
[tabs] debug.tab_switch timed 0 of 40 switches/
[tabs] debug.tab_switch timed 0 of 40 switches
View Logs
[tabs] the window draws no frames/[tabs] the window draws no frames View Logs
test_the_repository_highlights_validate (main.DigestTests.test_the_repository_highl
ights_validate)/
test_the_repository_highlights_validate (main.DigestTests.test_the_repository_highl
ights_validate)
View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

…ive DNS of agent-written hosts)

Review of design/artifacts (gpt-6.1-sol) BLOCK: CSP does not stop an
engine resolving <a href> hosts. Clicks on them are refused anyway, so
cross-origin href/xlink:href become # with the original in data-href;
fragments and inline data:/blob: images stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-92fa78a91e29868c5eaa

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 8 files

You’re at about 98% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name=".github/design-surfaces.json">

<violation number="1" location=".github/design-surfaces.json:158">
P3: The `artifacts` surface omits the files that wire the preview into the transcript: `conversation/Markdown.tsx` (fence routing) and `conversation/conversation.css` (the `.cv-inline-preview` styles). Add them so spec-check covers changes to the inline preview's integration and look.</violation>
</file>

<file name="webviews/test/inline-html-svg-preview-browser.test.ts">

<violation number="1" location="webviews/test/inline-html-svg-preview-browser.test.ts:109">
P3: The test title promises that fragments still scroll, but nothing asserts a scroll or fragment navigation. `inPrevented: false` only shows the default action was not cancelled. Either assert the result (for example, `location.hash === "#in"` in the frame) or drop "fragments still scroll" from the title.</violation>
</file>

<file name="webviews/src/agent-session/acpmux/conversation/InlinePreview.tsx">

<violation number="1" location="webviews/src/agent-session/acpmux/conversation/InlinePreview.tsx:51">
P3: This toggle changes its label and also sets `aria-pressed`. Keep one fixed accessible name and let `aria-pressed` carry the state, or drop `aria-pressed` and keep the label change.</violation>
</file>

<file name="webviews/test/inline-html-svg-preview.test.tsx">

<violation number="1" location="webviews/test/inline-html-svg-preview.test.tsx:171">
P3: This test dispatches two `load` events, so it would still pass if the frame tore down only after two extra loads. Assert `frame()` is non-null after the document's own load, then dispatch exactly one extra `load` and assert the fallback. That pins the `loads > 1` boundary in InlinePreview.tsx.</violation>
</file>

<file name="webviews/src/agent-session/acpmux/conversation/previewDocument.ts">

<violation number="1" location="webviews/src/agent-session/acpmux/conversation/previewDocument.ts:72">
P3: `inertDocument` rewrites every occurrence, including text nodes: `<p>http-equiv</p>` renders as `data-http-equiv`. Rename the meta attribute without mutating the whole document.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

"webviews/src/agent-session/acpmux/conversation/artifactSandbox.ts",
"webviews/src/agent-session/acpmux/conversation/artifactVersions.ts",
"webviews/src/agent-session/acpmux/conversation/RenderCard.tsx",
"webviews/src/agent-session/acpmux/conversation/renderCall.ts"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The artifacts surface omits the files that wire the preview into the transcript: conversation/Markdown.tsx (fence routing) and conversation/conversation.css (the .cv-inline-preview styles). Add them so spec-check covers changes to the inline preview's integration and look.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At .github/design-surfaces.json, line 158:

<comment>The `artifacts` surface omits the files that wire the preview into the transcript: `conversation/Markdown.tsx` (fence routing) and `conversation/conversation.css` (the `.cv-inline-preview` styles). Add them so spec-check covers changes to the inline preview's integration and look.</comment>

<file context>
@@ -142,6 +142,21 @@
+        "webviews/src/agent-session/acpmux/conversation/artifactSandbox.ts",
+        "webviews/src/agent-session/acpmux/conversation/artifactVersions.ts",
+        "webviews/src/agent-session/acpmux/conversation/RenderCard.tsx",
+        "webviews/src/agent-session/acpmux/conversation/renderCall.ts"
+      ]
     }
</file context>
Suggested change
"webviews/src/agent-session/acpmux/conversation/renderCall.ts"
"webviews/src/agent-session/acpmux/conversation/renderCall.ts",
"webviews/src/agent-session/acpmux/conversation/Markdown.tsx",
"webviews/src/agent-session/acpmux/conversation/conversation.css"

}

for (const engine of ["chromium", "webkit"]) {
test(`${engine}: a passive frame stays on its document: no refresh, no link out, fragments still scroll`, async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The test title promises that fragments still scroll, but nothing asserts a scroll or fragment navigation. inPrevented: false only shows the default action was not cancelled. Either assert the result (for example, location.hash === "#in" in the frame) or drop "fragments still scroll" from the title.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At webviews/test/inline-html-svg-preview-browser.test.ts, line 109:

<comment>The test title promises that fragments still scroll, but nothing asserts a scroll or fragment navigation. `inPrevented: false` only shows the default action was not cancelled. Either assert the result (for example, `location.hash === "#in"` in the frame) or drop "fragments still scroll" from the title.</comment>

<file context>
@@ -0,0 +1,152 @@
+  }
+
+  for (const engine of ["chromium", "webkit"]) {
+    test(`${engine}: a passive frame stays on its document: no refresh, no link out, fragments still scroll`, async () => {
+      const { page, frame, requests } = await passiveFrame(engine);
+      const result = await frame.evaluate(() => {
</file context>

<button
type="button"
className="cv-codeblock__action cv-codeblock__action--text"
aria-pressed={showCode}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This toggle changes its label and also sets aria-pressed. Keep one fixed accessible name and let aria-pressed carry the state, or drop aria-pressed and keep the label change.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At webviews/src/agent-session/acpmux/conversation/InlinePreview.tsx, line 51:

<comment>This toggle changes its label and also sets `aria-pressed`. Keep one fixed accessible name and let `aria-pressed` carry the state, or drop `aria-pressed` and keep the label change.</comment>

<file context>
@@ -0,0 +1,177 @@
+    <button
+      type="button"
+      className="cv-codeblock__action cv-codeblock__action--text"
+      aria-pressed={showCode}
+      onClick={() => setShowCode((value) => !value)}
+    >
</file context>

await show("```svg\n" + SVG.replace("red", "blue") + "\n```\n");
const iframe = frame()!;
// Its first load is its own document (jsdom may or may not have fired it); two more are past it.
for (const _ of [1, 2])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This test dispatches two load events, so it would still pass if the frame tore down only after two extra loads. Assert frame() is non-null after the document's own load, then dispatch exactly one extra load and assert the fallback. That pins the loads > 1 boundary in InlinePreview.tsx.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At webviews/test/inline-html-svg-preview.test.tsx, line 171:

<comment>This test dispatches two `load` events, so it would still pass if the frame tore down only after two extra loads. Assert `frame()` is non-null after the document's own load, then dispatch exactly one extra `load` and assert the fallback. That pins the `loads > 1` boundary in InlinePreview.tsx.</comment>

<file context>
@@ -0,0 +1,205 @@
+  await show("```svg\n" + SVG.replace("red", "blue") + "\n```\n");
+  const iframe = frame()!;
+  // Its first load is its own document (jsdom may or may not have fired it); two more are past it.
+  for (const _ of [1, 2])
+    await act(() => {
+      iframe.dispatchEvent(new dom.window.Event("load"));
</file context>

/// as written).
export function inertDocument(html: string): string {
return html
.replace(/http-equiv/gi, "data-http-equiv")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: inertDocument rewrites every occurrence, including text nodes: <p>http-equiv</p> renders as data-http-equiv. Rename the meta attribute without mutating the whole document.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At webviews/src/agent-session/acpmux/conversation/previewDocument.ts, line 72:

<comment>`inertDocument` rewrites every occurrence, including text nodes: `<p>http-equiv</p>` renders as `data-http-equiv`. Rename the meta attribute without mutating the whole document.</comment>

<file context>
@@ -0,0 +1,112 @@
+/// as written).
+export function inertDocument(html: string): string {
+  return html
+    .replace(/http-equiv/gi, "data-http-equiv")
+    .replace(/<link(?=[\s/>])/gi, "<cmux-inert-link")
+    .replace(LINK_ATTRIBUTE, (whole, before: string, name: string, equals: string, value: string) => {
</file context>

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review (codex gpt-6.1-sol xhigh, router-review): BLOCK at 74ebf5a

BLOCK at 74ebf5a
Visual verdict: no images; Leo tries the build

  1. [P1] [blocking] The shipped CSP blocks the height reporter. previewDocument.ts:100 inserts an inline script, but srcdoc inherits the pane’s CSP. Both native hosts forbid inline scripts (build-agent-pane-web.sh:100, AgentPageProvider.swift:19); the child’s nonce cannot relax that policy. Consequently, previews remain 48 px high, never offer Expand, and lack the link guard. Load the trusted reporter through a mechanism permitted by the shipped policy, and test with that policy on the parent.

  2. [P1] [blocking] Adjacent attributes bypass outbound-link sanitization. previewDocument.ts:57 consumes the attribute boundary. Executing the actual sanitizer leaves <a xlink:href="#x"href="https://probe.invalid/"> unchanged: the first match consumes the quote needed to match the following href. Browsers accept these adjacent attributes, leaving a live external URL and reopening the speculative-DNS surface this sanitization protects. Use structural attribute parsing or a non-consuming boundary, and cover this input.

  3. [P2] [follow-up] Fragment links do not reliably stay inside srcdoc. previewDocument.ts:53 allows #fragment navigation, but relative URLs in srcdoc resolve against the embedding page. Without explicit local scrolling, these links attempt navigation instead; the native delegate cancels it. The browser test checks only defaultPrevented, never scrolling or document retention.

  4. [P2] [follow-up] Visual verification is missing. InlinePreview.gallery.ts:84 supplies fixtures, but no after images were available to assess their layout.

Source parsing and pure-function probes ran; Bun and browser suites were unavailable here.

Follow-ups:

… link rewrite boundary; fragments scroll in place

Review (gpt-6.1-sol) BLOCK at 74ebf5a:
- a srcdoc frame takes the pane's CSP, which runs no inline script, so
  the nonce'd reporter never ran in the app. In the app the preview now
  uses the native artifact frame (own policy, no network, height report,
  host navigation delegate) with the inert, script-free document; srcdoc
  stays for the gallery; other pages show the source.
- the href rewrite consumed the boundary quote, so adjacent attributes
  slipped past; it is a lookbehind now, with that input covered.
- in srcdoc a #fragment resolves against the parent page; the frame
  script scrolls to it in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-92fa78a91e29868c5eaa
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review (codex, router-review): SHIP at 9936b66

Reviewer: codex gpt-6-astra high, fast tier.

Follow-ups: 1. Each lands as an issue in this repo that links this receipt; the lane that opened the PR fixes it forward.

SHIP at 9936b66

Visual verdict: no images; Leo tries the build

No correctness findings.

Follow-ups: [P2][follow-up] No after screenshots were available for visual comparison.

@cmux-bot cmux-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cmux-bot approves 9936b66: an independent review passed this exact head (router-review receipt by teamleaderleo). A push needs a new review; a later BLOCK at this head dismisses this approval.

@teamleaderleo
teamleaderleo merged commit 7fa67fe into feat-cmux-next Nov 1, 2026
196 of 206 checks passed
@teamleaderleo
teamleaderleo deleted the inline-html-svg-fence-preview branch November 1, 2026 16:17
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review (independent Codex gpt-6.1-sol, high, via the review router; full diff from merge base 9f5e2e1 to this head): SHIP at 9936b66.

[P2] [follow-up] webviews/src/agent-session/acpmux/conversation/previewDocument.ts:90 - Native previews leave #fragment links unchanged but lack the gallery’s fragment handler; the native artifact navigation delegate rejects navigation from the artifact frame, so in-document anchors cannot scroll - add fragment handling to the native bootstrap or allow same-document fragments in the host delegate, with coverage for both native endpoints.

This branch had an error being deployed

1 failed deployment
ci-routing-agent — 9936b66f Deployed Nov 1, 2026 by teamleaderleo via plan the macOS fallback build #96489
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant