diff --git a/apps/app/src/components/secondary-panel/FilePreview.test.tsx b/apps/app/src/components/secondary-panel/FilePreview.test.tsx
index 0540b6917..40d23c1a7 100644
--- a/apps/app/src/components/secondary-panel/FilePreview.test.tsx
+++ b/apps/app/src/components/secondary-panel/FilePreview.test.tsx
@@ -740,6 +740,25 @@ describe("FilePreview", () => {
});
});
+ it("frames a pdf preview without a sandbox so Chromium's viewer loads", () => {
+ const path = "docs/handbook.pdf";
+ const url = "/api/v1/threads/thread-1/host-files/content?path=" + path;
+ render(
+ ,
+ );
+
+ const frame = screen.getByTitle(path);
+ expect(frame.getAttribute("src")).toBe(url);
+ // A sandboxed frame blocks the viewer's own scripts and the load fails
+ // with ERR_BLOCKED_BY_CLIENT, so the absence of the attribute is the
+ // behavior under test, not an oversight.
+ expect(frame.hasAttribute("sandbox")).toBe(false);
+ });
+
it("reloads an HTML iframe only when the fetched source changes", () => {
const path = "reports/preview.html";
const htmlPreviewUrl =
diff --git a/apps/app/src/components/secondary-panel/FilePreview.tsx b/apps/app/src/components/secondary-panel/FilePreview.tsx
index 179b1b4d6..7d3a85a89 100644
--- a/apps/app/src/components/secondary-panel/FilePreview.tsx
+++ b/apps/app/src/components/secondary-panel/FilePreview.tsx
@@ -52,6 +52,7 @@ export type FilePreviewState =
| { kind: "not-found" }
| { kind: "error"; message?: string }
| { kind: "image"; url: string }
+ | { kind: "pdf"; url: string }
| { kind: "video"; url: string }
| ({ kind: "iframe" } & IframeFilePreviewTarget)
| {
@@ -146,6 +147,11 @@ interface FilePreviewVideoProps {
title: string;
}
+interface FilePreviewPdfProps {
+ url: string;
+ title: string;
+}
+
interface FilePreviewMessageProps {
message: string;
role?: "alert";
@@ -467,8 +473,14 @@ export function FilePreview({
// The code view owns its own scroller too: pierre's virtualizer needs the
// scroll container to be the code viewport so it can render only the rows
// near it, which the outer panel scroller (shared with the header) cannot be.
+ // Chromium's PDF viewer sizes itself to its frame and scrolls internally, so
+ // it needs the panel height rather than the content-height column.
+ const usesPdfPreviewLayout = state.kind === "pdf";
const usesFullHeightLayout =
- usesIframeLayout || usesCsvPreviewLayout || usesCodeLayout;
+ usesIframeLayout ||
+ usesCsvPreviewLayout ||
+ usesCodeLayout ||
+ usesPdfPreviewLayout;
const usesContentHeightLayout = usesMarkdownPreviewLayout;
// Establish a `@container/page` scope so MarkdownPreview's `100cqw`-based
@@ -541,6 +553,9 @@ function FilePreviewBody({
if (state.kind === "image") {
return ;
}
+ if (state.kind === "pdf") {
+ return ;
+ }
if (state.kind === "video") {
return ;
}
@@ -1016,6 +1031,22 @@ function FilePreviewVideo({ url, title }: FilePreviewVideoProps) {
);
}
+// Chromium renders PDFs with its own viewer when a frame navigates to a
+// response typed `application/pdf`, so this is an iframe over the URL the
+// preview already carries — no PDF library, no new dependency. It takes no
+// `sandbox`, unlike the HTML preview: the viewer is a privileged internal
+// resource whose scripts a sandboxed frame blocks, and the load fails with
+// ERR_BLOCKED_BY_CLIENT. That is not the same exposure the HTML preview
+// guards against — Chromium hands these bytes to the viewer rather than
+// parsing them as a document, so the file never executes in this origin.
+function FilePreviewPdf({ url, title }: FilePreviewPdfProps) {
+ return (
+
+
+
+ );
+}
+
function IframeFilePreview({ sandbox, title, url }: IframeFilePreviewTarget) {
const [loadState, setLoadState] = useState("loading");
const [showLoadingIndicator, setShowLoadingIndicator] = useState(false);
diff --git a/apps/app/src/components/secondary-panel/ThreadStorageFilePreview.tsx b/apps/app/src/components/secondary-panel/ThreadStorageFilePreview.tsx
index 80b0abe76..a984d19e9 100644
--- a/apps/app/src/components/secondary-panel/ThreadStorageFilePreview.tsx
+++ b/apps/app/src/components/secondary-panel/ThreadStorageFilePreview.tsx
@@ -238,6 +238,21 @@ export function SecondaryPanelFilePreview({
);
}
+ if (filePreview.kind === "pdf") {
+ return (
+
+ );
+ }
+
if (filePreview.kind === "video") {
return (
` / `