feat(web): render HTML and PDF files in the file viewer#9143

merged2
t3code/open-outside-workspace-files
t3code/html-preview-in-panel
openedSep 2, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 3d ago

Stacked on #9140.

On web there was no way to see an HTML file as a page: the integrated browser is desktop-only, so the file panel always showed markup. Mobile already renders browser files in a WebView; this brings web (and the desktop panel) in line.

How

FilePreviewPanel gains a WorkspaceBrowserPreview that renders HTML and PDF from the signed asset URL in an <iframe>:

  • HTML defaults to the rendered page with a source toggle in the panel header, using the same control as the rendered-Markdown toggle (persisted separately, and a line reveal still forces source). PDF is always rendered and skips the text read, like images do.
  • HTML frames use sandbox="allow-scripts allow-forms allow-popups allow-modals" (no allow-same-origin), so the page gets an opaque origin and cannot reach the app's storage or bearer session. #9140 now also sends the same policy as a Content-Security-Policy: sandbox response header, so the frame attribute is defence in depth rather than the only barrier. PDFs load unsandboxed because the built-in viewer needs it and a PDF runs no script.
  • Files inside the workspace use the directory-scoped workspace-file URL so sibling CSS/images load; host files outside it use the exact-file media-file URL from #9140.
  • Workspace mutations bump the frame's workspace-revision query, matching the image preview.

Demo

A /tmp HTML chip opening as a rendered page, toggling to source and back, then a workspace report.html from the explorer that pulls in its sibling report.css and badge.svg:

https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/c36b5d260d5fa6ec/html-preview-in-panel.webm

Verification

  • Web typecheck and lint on the changed file; flow exercised end to end in the recording above (headless Chromium against vp run dev).
  • Not verified: PDF rendering. Headless Chromium has no PDF viewer, so the PDF branch needs a look in a real browser.

Written by Claude Fable 5 in Claude Code.

🤖 Generated with Claude Code


Note

Medium Risk HTML previews run user-controlled scripts inside a sandboxed iframe (no app session access), but popups/modals are allowed; PDFs use an unsandboxed iframe by design.

Overview The file viewer can show HTML and PDF as rendered pages on web and desktop, not only raw markup—closing the gap where the integrated browser was desktop-only.

FilePreviewPanel adds WorkspaceBrowserPreview, which loads signed asset URLs in an iframe and appends workspace-revision on workspace mutations (same pattern as images). Workspace HTML uses workspace-file URLs so sibling assets resolve; host paths outside the workspace use media-file. HTML defaults to rendered view with a persisted header toggle (shared UX with rendered Markdown); line-reveal links still force source. PDFs are always rendered and skip the text file query, truncation banner, and workspace text refresh—like other binary previews.

HTML iframes use sandbox="allow-scripts allow-forms allow-popups allow-modals" (no same-origin); PDFs load without sandbox for the built-in viewer. User docs in composer.md describe the toggle, isolation, and desktop “open in browser” behavior.

Reviewed by Cursor Bugbot for commit 4f990b7ec27919888b1d159f46aaba7f7a8fc23e. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add HTML and PDF rendering to web file viewer

  • Renders HTML and PDF files in-place using WorkspaceBrowserPreview in FilePreviewPanel.tsx.
  • HTML files load in a sandboxed iframe and can toggle between source and rendered page using a persisted local-storage preference. PDFs load in an unsandboxed iframe and always show the rendered page without a source toggle.
  • Line links force HTML and Markdown files into source mode until the reveal is handled. Truncation warnings are hidden for browser-rendered content.
  • Risk: WorkspaceBrowserPreview renders PDFs in an unsandboxed iframe, exposing the viewer to potential untrusted PDF execution.

Macroscope summarized 4f990b7.

Synced from GitHub

Thread transfer impact

âś… Thread transfer remains within every enforced ceiling.

ℹ️ No successful main baseline artifact is available yet. This run establishes the initial measurement.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire—13.4 KiB—15.1 KiB✅
CodexThread snapshot wire—6.9 KiB—7.3 KiB✅
CodexLive turn WebSocket wire—6.5 KiB—7.8 KiB✅
CodexLive turn WebSocket decoded—57.0 KiB—66.4 KiB✅
CodexLive turn messages—10—21✅
ClaudeTotal thread wire—13.5 KiB—15.1 KiB✅
ClaudeThread snapshot wire—6.9 KiB—7.3 KiB✅
ClaudeLive turn WebSocket wire—6.6 KiB—7.8 KiB✅
ClaudeLive turn WebSocket decoded—57.8 KiB—66.4 KiB✅
ClaudeLive turn messages—10—21✅

Baseline: unavailable · PR result: 4f990b7 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 109.4 KiB
  • Claude decoded thread snapshot: 110.1 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

apps/web/src/components/files/FilePreviewPanel.tsxlines 258–258Outdated · 9549690Open thread
……@@ -196,6 +197,69 @@ function WorkspaceImagePreview(props: {
255+ src={src}
256+ title={props.title}
257+ className={className}
258+ sandbox="allow-scripts allow-forms allow-popups allow-modals"
MmacroscopeappBoton lines 258–258Sep 2, 2026

đź”´ Critical files/FilePreviewPanel.tsx:258

Untrusted workspace HTML can execute JavaScript and exfiltrate its location.href, which contains the bearer-signed workspace-file URL; anyone receiving that URL can fetch the selected file and permitted sibling assets until expiry. The opaque sandbox origin does not hide the frame URL, so remove allow-scripts or otherwise avoid exposing the bearer credential to executable page content.

suggestion
1 sandbox="allow-forms allow-popups allow-modals"
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/web/src/components/files/FilePreviewPanel.tsx around line 258:
2
3Untrusted workspace HTML can execute JavaScript and exfiltrate its `location.href`, which contains the bearer-signed `workspace-file` URL; anyone receiving that URL can fetch the selected file and permitted sibling assets until expiry. The opaque sandbox origin does not hide the frame URL, so remove `allow-scripts` or otherwise avoid exposing the bearer credential to executable page content.

Considered and accepted. The desktop integrated browser and the mobile WebView already load this exact signed URL with scripts enabled, so this is not a new exposure, and docs/internals/environment-auth.md already treats signed asset URLs as bearer capabilities. What the page can exfiltrate is a one-hour, single-file (or, inside the workspace, preview-extension-only sibling) read of content the author already controls, to an attacker who can also reach the environment. Removing allow-scripts would break most agent-generated reports (charts, tabs), which is the point of the feature. The frame has an opaque origin, so it cannot reach the app's session or storage. Dismissing.

apps/web/src/components/files/FilePreviewPanel.tsxline 200Outdated · 4028a2dOpen thread
……@@ -196,6 +197,69 @@ function WorkspaceImagePreview(props: {
196197 );
197198}
198199
200+const isPdfPreviewFile = (path: string): boolean => /\.pdf$/i.test(path);
CcursorBoton line 200Sep 2, 2026

PDF classification misses paths containing ? or #

Low Severity

isPdfPreviewFile tests the raw path with /\.pdf$/i without stripping ? or # characters, while isBrowserPreviewFile (used on the same input one line later) splits on [?#] before matching. If a path contains a literal # or ? after .pdf, isPdfPreviewFile returns false but isBrowserPreviewFile returns true, so the file falls into the isHtml branch. This means a PDF would get a sandboxed iframe (breaking the built-in PDF viewer), an enabled text query for content it can't display as text, and a source-toggle that doesn't apply. The shared hasPreviewExtension in filePreview.ts also strips these characters, making the local function the odd one out.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 4028a2d6a2cafcbc0839a8ab7c111b44141bf5bd. Configure here.

Added 2 commits

  • feat(web): render HTML and PDF files in the file viewer779ad56
  • fix(web): classify PDFs like other browser previews4f990b7
……@@ -196,6 +197,69 @@ function WorkspaceImagePreview(props: {
196197 );
197198}
198199
200+const isPdfPreviewFile = (path: string): boolean => /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? "");
MmacroscopeappBoton lines 200–200Sep 2, 2026

đźź  High files/FilePreviewPanel.tsx:200

A selected PDF path with a query or fragment, such as report.pdf#page=2, is sent through the sandboxed iframe branch instead of the built-in PDF viewer, so it fails to render in the file viewer. isPdfPreviewFile tests the raw path and therefore misses the .pdf extension before the ?/# suffix; strip those suffixes before testing the extension.

âś… Resolved in 4f990b7ec27919888b1d159f46aaba7f7a8fc23e

CcursorBotreviewed · 2 inline threadsSep 2, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix is ON, but a cloud agent failed to start.

Reviewed by Cursor Bugbot for commit 4f990b7ec27919888b1d159f46aaba7f7a8fc23e. Configure here.

……@@ -196,6 +197,69 @@ function WorkspaceImagePreview(props: {
227+ const revisionSuffix =
228+ props.workspaceMutationId === null
229+ ? ""
230+ : `${assetUrl._tag === "Success" && assetUrl.url.includes("?") ? "&" : "?"}workspace-revision=${encodeURIComponent(props.workspaceMutationId)}`;
CcursorBoton line 230Sep 2, 2026

Host previews skip exact-file remint

Medium Severity

Host HTML and PDF previews mint a media-file URL, which is bound to device and inode, but only append workspace-revision on change. Video previews remint that exact-file capability. After an outside-workspace file is replaced, the iframe can keep a dead or cached URL instead of a new capability.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 4f990b7ec27919888b1d159f46aaba7f7a8fc23e. Configure here.

Documented media-file semantics (environment-auth.md: replacing a file atomically requires a freshly signed URL). Nothing watches files outside the workspace, so there is no mutation signal to remint on; WorkspaceVideoPreview has the same limitation for host paths since its refresh also keys off workspaceMutationId. Closing and reopening the tab remints. A refresh affordance for host files is a reasonable follow-up but out of scope here. Dismissing.

……@@ -196,6 +197,69 @@ function WorkspaceImagePreview(props: {
258+ sandbox="allow-scripts allow-forms allow-popups allow-modals"
259+ />
260+ );
261+}
CcursorBoton line 261Sep 2, 2026

Rendered HTML ignores in-panel edits

Medium Severity

Toggling an HTML file from source back to the rendered page loads the signed asset URL in an iframe, not the in-panel contents. Asset responses are cached for an hour, and the frame only cache-busts when workspaceMutationId changes, so a source edit can still show the old page after the toggle.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit 4f990b7ec27919888b1d159f46aaba7f7a8fc23e. Configure here.

Same mechanism the image preview relies on: an in-panel save goes through projects.writeFile, the workspace watcher bumps workspaceMutationId, and the frame is keyed on the URL plus that revision, so it remounts with a fresh query string and misses the cache. There is no window where the toggle can show a stale page without the watcher having also missed the write. Dismissing.

Synced from GitHub

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — Adds a new default-on production path for rendering HTML and PDF files through signed asset URLs, including executable HTML frames and a line-level sandbox lint suppression. The unresolved security and preview-freshness concerns make the runtime impact significant enough for human review.

Not approved because:

  • 1 blocking correctness issue found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.