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(
+      <SecondaryPanelFilePreview
+        activePath={path}
+        filePreview={{ kind: "pdf", mimeType: "application/pdf", path, url }}
+        isLoading={false}
+      />,
+    );
+
+    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 <FilePreviewImage url={state.url} alt={path} />;
   }
+  if (state.kind === "pdf") {
+    return <FilePreviewPdf url={state.url} title={path} />;
+  }
   if (state.kind === "video") {
     return <FilePreviewVideo url={state.url} title={path} />;
   }
@@ -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 (
+    <div className="min-h-0 flex-1 overflow-hidden pt-4">
+      <iframe src={url} title={title} className="h-full w-full border-0" />
+    </div>
+  );
+}
+
 function IframeFilePreview({ sandbox, title, url }: IframeFilePreviewTarget) {
   const [loadState, setLoadState] = useState<IframeLoadState>("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 (
+      <FilePreviewSurface
+        path={activePath}
+        copyPath={copyPath}
+        onSelectionAddToChat={onSelectionAddToChat}
+        onOpenInEditor={onOpenInEditor}
+        onRefresh={onRefresh}
+        isRefreshing={isRefreshing}
+        statusLabel={statusLabel}
+        state={{ kind: "pdf", url: filePreview.url }}
+      />
+    );
+  }
+
   if (filePreview.kind === "video") {
     return (
       <FilePreviewSurface
diff --git a/apps/app/src/hooks/queries/environment-queries.ts b/apps/app/src/hooks/queries/environment-queries.ts
index 0d21cf636..4c7e36764 100644
--- a/apps/app/src/hooks/queries/environment-queries.ts
+++ b/apps/app/src/hooks/queries/environment-queries.ts
@@ -451,9 +451,10 @@ function buildEnvironmentFilePreviewQuery(
 }
 
 /**
- * Build the preview for a `/diff/file` read. Only image and video previews
- * need a browser-loadable `url` (the `<img>` / `<video>` src), so only those
- * pay for a base64 `data:` URL. Text previews carry their content inline and
+ * Build the preview for a `/diff/file` read. Only image, PDF, and video
+ * previews need a browser-loadable `url` (the `<img>` / `<iframe>` / `<video>`
+ * src), so only those pay for a base64 `data:` URL. Text previews carry their
+ * content inline and
  * point `url` at the JSON route the bytes came from; building a `data:` URL
  * for them would re-encode the whole file to base64 (a second copy of a
  * multi-megabyte source file on a phone) and then bloat every cache key that
@@ -480,7 +481,11 @@ export function buildEnvironmentFilePreview({
     path,
     url: contentUrl,
   });
-  if (preview.kind !== "image" && preview.kind !== "video") {
+  if (
+    preview.kind !== "image" &&
+    preview.kind !== "pdf" &&
+    preview.kind !== "video"
+  ) {
     return preview;
   }
   const base64Content =
diff --git a/apps/app/src/lib/file-preview.ts b/apps/app/src/lib/file-preview.ts
index 02f373b8c..063061e4c 100644
--- a/apps/app/src/lib/file-preview.ts
+++ b/apps/app/src/lib/file-preview.ts
@@ -13,6 +13,7 @@ export {
 export type {
   FilePreviewTarget,
   ImageFilePreview,
+  PdfFilePreview,
   VideoFilePreview,
   TextFilePreview,
   UnsupportedFilePreview,
diff --git a/packages/client-core/src/file-preview.ts b/packages/client-core/src/file-preview.ts
index fd26fdd6f..dc66f5d84 100644
--- a/packages/client-core/src/file-preview.ts
+++ b/packages/client-core/src/file-preview.ts
@@ -23,6 +23,7 @@ const MARKDOWN_MIME_TYPES = new Set(["text/markdown", "text/x-markdown"]);
 const CSV_FILE_EXTENSIONS = [".csv"];
 const CSV_MIME_TYPES = new Set(["application/csv", "text/csv"]);
 const HTML_FILE_EXTENSION = ".html";
+const PDF_MIME_TYPE = "application/pdf";
 const NULL_CHARACTER = "\u0000";
 
 export interface FilePreviewTarget {
@@ -32,7 +33,7 @@ export interface FilePreviewTarget {
 }
 
 interface FilePreviewBase extends FilePreviewTarget {
-  kind: "image" | "text" | "unsupported" | "video";
+  kind: "image" | "pdf" | "text" | "unsupported" | "video";
   mimeType: string;
 }
 
@@ -40,6 +41,10 @@ export interface ImageFilePreview extends FilePreviewBase {
   kind: "image";
 }
 
+export interface PdfFilePreview extends FilePreviewBase {
+  kind: "pdf";
+}
+
 export interface VideoFilePreview extends FilePreviewBase {
   kind: "video";
 }
@@ -55,6 +60,7 @@ export interface UnsupportedFilePreview extends FilePreviewBase {
 
 export type FilePreview =
   | ImageFilePreview
+  | PdfFilePreview
   | VideoFilePreview
   | TextFilePreview
   | UnsupportedFilePreview;
@@ -248,6 +254,15 @@ export function buildFilePreview(args: BuildFilePreviewArgs): FilePreview {
     };
   }
 
+  // Before the UTF-8 fallback: a PDF with no compressed streams is all
+  // printable ASCII, and the fallback would render its markers as source.
+  if (args.mimeType === PDF_MIME_TYPE) {
+    return {
+      kind: "pdf",
+      ...base,
+    };
+  }
+
   if (isKnownTextMimeType(args.mimeType)) {
     const textContent = decodeDeclaredTextContent(args.contentBytes);
     if (textContent === null) {
diff --git a/packages/client-core/test/file-preview.test.ts b/packages/client-core/test/file-preview.test.ts
index e001df841..16c53c0c4 100644
--- a/packages/client-core/test/file-preview.test.ts
+++ b/packages/client-core/test/file-preview.test.ts
@@ -58,6 +58,40 @@ describe("file-preview", () => {
     });
   });
 
+  it("builds pdf previews for the pdf mime type", () => {
+    const preview = buildFilePreview({
+      contentBytes: Uint8Array.from([37, 80, 68, 70, 45, 49, 46, 52]),
+      mimeType: "application/pdf",
+      name: "resume.pdf",
+      path: "resume.pdf",
+      url: "/files/resume.pdf",
+    });
+
+    expect(preview).toEqual({
+      kind: "pdf",
+      mimeType: "application/pdf",
+      name: "resume.pdf",
+      path: "resume.pdf",
+      url: "/files/resume.pdf",
+    });
+  });
+
+  // A PDF with no compressed streams is all printable ASCII, so the UTF-8
+  // fallback would happily decode it and render the file's markers as source.
+  it("keeps an all-ASCII pdf a pdf rather than falling back to text", () => {
+    const preview = buildFilePreview({
+      contentBytes: new TextEncoder().encode(
+        "%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF\n",
+      ),
+      mimeType: "application/pdf",
+      name: "flat.pdf",
+      path: "flat.pdf",
+      url: "/files/flat.pdf",
+    });
+
+    expect(preview.kind).toBe("pdf");
+  });
+
   it("builds video previews for video mime types", () => {
     const preview = buildFilePreview({
       contentBytes: Uint8Array.from([0, 0, 0, 24]),
