← reports

#1985 · PDF file previews show "Preview not available for application/pdf"

Bug Priority: Medium Effort: Small (per issue) ui open on GitHub 2026-08-20 · base c7c66423d

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

Opening any real-world PDF in the secondary-panel file preview (workspace file tab, host file, project file, or thread storage) shows the dashed error card Preview not available for application/pdf. instead of the document. The server side is fine: the content routes return 200, content-type: application/pdf and the exact bytes. The gap is purely in the shared client classifier buildFilePreview() (now in packages/client-core/src/file-preview.ts after #1986 moved it out of apps/app): it recognises image/*, a list of text mime types, a UTF‑8 fallback and video/*, and anything else becomes kind: "unsupported", which SecondaryPanelFilePreview renders as that error message. A side effect of the UTF‑8 fallback is that an all‑ASCII PDF (no compressed streams) is not "unsupported" at all — it is shown as plain text of PDF markers. PR #1979 adds a pdf kind rendered via an un-sandboxed <iframe> and works in both Chromium and Electron 41 in my testing, but it is currently CONFLICTING with main and, once rebased onto the shared @bb/client-core module, breaks the @bb/mobile typecheck.

2. Claims vs findings

Claim from the issueStatusEvidence
Opening a PDF in the preview panel shows Preview not available for application/pdf.VerifiedLive repro on c7c66423d, screenshot 1985-bug.png; unit test file-preview-pdf-repro.test.ts fails with expected 'unsupported' not to be 'unsupported'.
Content routes already serve the bytes with the correct mime type.Verifiedcurl -sD - .../projects/proj_…/files/content?path=handbook.pdf200, content-type: application/pdf, content-length: 632, bytes identical to the file (cmp).
buildFilePreview checks image, known text, UTF‑8 fallback, then video; application/pdf matches none → unsupported.VerifiedCode at client-core/src/file-preview.ts#L232-L281 (moved from apps/app/src/lib/file-preview.ts by d678d65c4, logic unchanged). Nuance: an uncompressed all-ASCII PDF hits the UTF‑8 fallback and renders as text, not "unsupported" (second test in the repro file, passes on main).
Code path unchanged on main at b61ec88.Verified (with caveat)True at b61ec88. Between b61ec88 and c7c66423d, #1986 relocated the file to packages/client-core; behaviour identical, but the location matters for PR #1979 (see §7).
Images, video, CSV and markdown render.VerifiedREADME.md renders in the same panel (1985-before.png); image/video/CSV branches exist in the same function.
Path header and open-in-editor action render normally; only the body shows the error.VerifiedSee 1985-bug.png: header with path, refresh and open icons; body shows the dashed error card.
Not a duplicate; #1670 (HEIC) is a different format.Verifiedgh issue list --search pdf shows no other PDF preview issue; #1670 is about HEIC decoding.
Chromium renders a PDF with its built-in viewer when a frame loads an application/pdf response; fix is one new preview kind plus an iframe.VerifiedPR #1979 merged locally onto c7c66423d renders the PDF in headed Chromium (1985-pr1979-pdf.png) and in Electron 41.7.0 (1985-desktop-pr1979.png, …-apiurl.png).
Observed in packaged 0.39.0 desktop app on macOS 26.Unverified (platform)I reproduced on Linux in the web app and confirmed the Electron desktop shell shows the same classifier output; macOS not tested, but the code path is platform-neutral.

3. Environment

4. Minimal reproduction

4a. Unit-level (fails on main)

  1. Save file-preview-pdf-repro.test.ts to packages/client-core/test/ and run:
    cd packages/client-core && pnpm exec vitest run test/file-preview-pdf-repro.test.ts
    expected: both tests pass (a binary application/pdf gets a renderable kind)
    actual:
    
     RUN  v4.1.1 /home/sawyer/projects/bb/.claude/worktrees/wf_926b3193-f6c-6/packages/client-core
    
     ❯  @bb/client-core  test/file-preview-pdf-repro.test.ts (2 tests | 1 failed) 4ms
         × a real (binary) PDF served as application/pdf should get a renderable preview kind 3ms
    
    ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
    
     FAIL   @bb/client-core  test/file-preview-pdf-repro.test.ts > #1985 PDF file preview > a real (binary) PDF served as application/pdf should get a renderable preview kind
    AssertionError: expected 'unsupported' not to be 'unsupported' // Object.is equality
     ❯ test/file-preview-pdf-repro.test.ts:29:30
         27|     // On main this is "unsupported", which ThreadStorageFilePreview r…
         28|     // `Preview not available for application/pdf.`
         29|     expect(preview.kind).not.toBe("unsupported");
           |                              ^
         30|   });
         31|
    
    ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
    
    
     Test Files  1 failed (1)
          Tests  1 failed | 1 passed (2)
       Start at  14:12:21
       Duration  117ms (transform 18ms, setup 0ms, import 28ms, tests 4ms, environment 0ms)
    
    
    The first assertion fails because buildFilePreview returns kind: "unsupported". The second test passes and documents the less obvious half: an all-ASCII PDF is classified as text.
// Repro for get-bb/bb#1985: PDF previews fall through to `unsupported`.
// Run: cd packages/client-core && pnpm exec vitest run test/file-preview-pdf-repro.test.ts
import { describe, expect, it } from "vitest";
import { buildFilePreview } from "../src/file-preview.js";

// Header + a binary (deflate) stream with bytes that are not valid UTF-8,
// which is what every real-world PDF looks like.
const BINARY_PDF_BYTES = Uint8Array.from([
  ...new TextEncoder().encode("%PDF-1.7\n%"),
  0xe2, 0xe3, 0xcf, 0xd3, // the conventional binary-marker comment bytes
  ...new TextEncoder().encode(
    "\n1 0 obj\n<< /Length 8 /Filter /FlateDecode >>\nstream\n",
  ),
  0x78, 0x9c, 0x00, 0xff, 0xfe, 0x01, 0x00, 0x80,
  ...new TextEncoder().encode("\nendstream\nendobj\n%%EOF\n"),
]);

describe("#1985 PDF file preview", () => {
  it("a real (binary) PDF served as application/pdf should get a renderable preview kind", () => {
    const preview = buildFilePreview({
      contentBytes: BINARY_PDF_BYTES,
      mimeType: "application/pdf",
      name: "handbook.pdf",
      path: "docs/handbook.pdf",
      url: "/api/v1/threads/t1/host-files/content?path=docs/handbook.pdf",
    });
    // On main this is "unsupported", which ThreadStorageFilePreview renders as
    // `Preview not available for application/pdf.`
    expect(preview.kind).not.toBe("unsupported");
  });

  it("an all-ASCII PDF (no compressed streams) is NOT 'unsupported' but is mis-classified as text", () => {
    const preview = buildFilePreview({
      contentBytes: new TextEncoder().encode(
        "%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n%%EOF\n",
      ),
      mimeType: "application/pdf",
      name: "flat.pdf",
      path: "flat.pdf",
      url: "/files/flat.pdf",
    });
    // Documents current behavior: the UTF-8 fallback wins, so the panel shows
    // the PDF's markers as source text instead of a document.
    expect(preview.kind).toBe("text");
  });
});

4b. Live, in the app

  1. Start a dev instance: scripts/bb-dev-app current; note App/Server URLs.
  2. Make a scratch git repo with a PDF (any real PDF works; mine was generated with a small Python script, see appendix) and register it as a project:
    curl -s -X POST $BB_SERVER_URL/api/v1/projects -H 'content-type: application/json' \
      -d '{"name":"qa","source":{"type":"local_path","path":"/tmp/bb-1985-qa","hostId":"<host id from /api/v1/hosts>"}}'
  3. Confirm the server serves the PDF correctly (this is the part the issue says already works):
    $ curl -sD - -o /tmp/out.pdf "$BB_SERVER_URL/api/v1/projects/proj_cw26yy9qhd/files/content?path=handbook.pdf"
    HTTP/1.1 200 OK
    cache-control: private, no-cache
    content-length: 632
    content-type: application/pdf
    etag: "cdabf618…"
    x-bb-content-encoding: base64
    $ cmp /tmp/out.pdf /tmp/bb-1985-qa/handbook.pdf && echo BYTES_MATCH
    BYTES_MATCH
  4. Create a thread in that project (I used POST /api/v1/threads with a one-line prompt) and open it in a browser at http://localhost:14918/threads/<thread id>.
  5. Open the PDF in the panel from the CLI:
    pnpm bb:dev thread open <thread id> handbook.pdf --json
    # → {"file":{"source":"workspace","path":"handbook.pdf"}, "delivered": 2, …}
  6. Observe the panel.
    expected: the PDF renders (like images / markdown do)
    actual:   dashed card reading  "Preview not available for application/pdf."
README.md rendering in the preview panel
Before: a markdown file (README.md) opened via the same bb thread open path renders in the right-hand panel. The handbook.pdf tab is already open next to it.
PDF preview showing Preview not available for application/pdf
The bug on c7c66423d: handbook.pdf tab selected; the header shows the path with refresh/open-in-editor icons, the body shows only Preview not available for application/pdf.

Repro files: 1985/repro/ (test, PDF, doobie scripts, logs, rebased PR diff).

5. Root cause

The client decides how to render a file purely from buildFilePreview() in packages/client-core/src/file-preview.ts#L232-L281 (re-exported by apps/app/src/lib/file-preview.ts):

if (args.mimeType.startsWith("image/")) return { kind: "image", ...base };
if (isKnownTextMimeType(args.mimeType)) { … return { kind: "text", … } }
const fallbackTextContent = decodeUtf8Text(args.contentBytes);
if (fallbackTextContent !== null) return { kind: "text", …, content: fallbackTextContent };
if (args.mimeType.startsWith("video/")) return { kind: "video", ...base };
return { kind: "unsupported", ...base };

Every real PDF contains compressed/binary streams (and usually the %âãÏÓ binary-marker comment), so decodeUtf8Text returns null and the function falls through to unsupported. SecondaryPanelFilePreview then maps unsupported to an error state at ThreadStorageFilePreview.tsx#L255-L269:

state={{ kind: "error", message: `Preview not available for ${filePreview.mimeType}.` }}

The mime type itself comes from the host daemon's extension lookup (mimeTypes.lookup(path) in file-read.ts#L259) and is passed through unchanged by createDaemonFileContentResponse (daemon-file-response.ts#L48-L63) with x-content-type-options: nosniff and no Content-Disposition, so the server really is ready to be framed. The same classifier feeds all four app surfaces (host files, project files, thread storage via loadFilePreview in apps/app/src/lib/api.ts; workspace files via buildEnvironmentFilePreview in environment-queries.ts) and, since #1986, the mobile app too.

Deeper/secondary issue: the ordering of the UTF‑8 fallback before any "known binary document" check means an uncompressed PDF is silently shown as source text (%PDF-1.4 1 0 obj << /Type /Catalog …) rather than flagged; any fix must classify by mime type before the fallback, as #1979 does.

6. Proposed fix (first principles)

7. PR review

PR #1979 — "Render PDF file previews instead of 'Preview not available'" (jsilets, head e93c47262, base b61ec88)

What it changes: adds a pdf kind to buildFilePreview (checked before the UTF‑8 fallback), a FilePreviewPdf iframe without sandbox, routing in SecondaryPanelFilePreview, pdf in the workspace data:-URL rewrite, a Ladle story, and three tests. No wire changes (correct — no protocol bump needed).

Does it address the root cause? Yes — it is the right layer (client classifier + app renderer), and the mime-before-fallback ordering also fixes the all-ASCII-PDF-as-text edge case. I merged it locally onto c7c66423d (resolving conflicts by moving the classifier hunk into packages/client-core/src/file-preview.ts; diff in pr-1979-rebased-onto-c7c66423d.diff) and it renders the PDF in headed Chromium and in the Electron 41 desktop shell, for both the data: URL (workspace) path and a framed /api/v1/…/files/content URL.

PR 1979 rendering the PDF in Chromium
PR #1979 rebased onto c7c66423d, headed Chromium: the same handbook.pdf tab now shows Chromium's PDF viewer with toolbar, thumbnail rail and the page text "Hello from bb issue 1985".
PR 1979 rendering the PDF in the Electron desktop shell
Same, in the Electron 41.7.0 desktop dev shell (sandbox: true, contextIsolation: true, no plugins flag): the built-in viewer loads from a data:application/pdf iframe.
Framed API URL in Electron
Electron, red-bordered test frame I injected with src="/api/v1/projects/…/files/content?path=handbook.pdf" (the host/project/thread-storage shape, no data: URL): the viewer loads too.
Sandboxed frame stays blank
Electron, same frame with sandbox="allow-scripts allow-same-origin": blank. Confirms the PR's claim that the viewer cannot run in a sandboxed frame.

Findings:

SeverityWhereFinding
Blockingwhole PRGitHub reports mergeable: CONFLICTING (merge-state.json). apps/app/src/lib/file-preview.ts and its test were moved to packages/client-core by #1986 (d678d65c4); the PR's classifier hunk and file-preview.test.ts hunk must be re-targeted to packages/client-core/src/file-preview.ts / packages/client-core/test/file-preview.test.ts, and PdfFilePreview added to the apps/app/src/lib/file-preview.ts re-export list.
Blocking (after rebase)apps/mobile/src/data/files/file-preview-model.ts:81,101; apps/mobile/src/screens/files/FilePreviewView.tsxOnce FilePreview gains kind: "pdf" in the shared module, pnpm exec turbo run typecheck --filter=@bb/mobile fails:
@bb/mobile:typecheck: src/data/files/file-preview-model.ts(81,36): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.
The exhaustive switch (preview.kind) needs a pdf case (map to unsupported or render in a WebView). The PR description claims "All four preview surfaces render PDFs" — mobile is a fifth consumer it did not know about at its base.
Mediumapps/app/src/hooks/queries/environment-queries.ts ~L481PDFs now pay the base64 data: URL cost like images, but the daemon caps images at 10 MB and PDFs at 25 MB (NON_IMAGE_FILE_SIZE_LIMIT_BYTES), so a large workspace PDF becomes a ~33 MB string held in the React Query cache and the iframe src attribute. No test covers the new pdf branch of buildEnvironmentFilePreview (existing tests at environment-queries.test.tsx:194 only cover image/text).
LowFilePreview.tsx FilePreviewPdfNo loading/error handling, unlike IframeFilePreview: a 404/expired preview lease or a 413 renders the server's JSON error body inside the frame instead of the panel's error card. Also, browsers with "download PDFs instead of opening" enabled will trigger a download on every tab open. Acceptable for a first cut but worth a comment.
Lowun-sandboxed iframeSecurity reasoning in the PR is sound: the mime type is extension-derived on the daemon, the server sets nosniff, and Chromium hands application/pdf to the out-of-process viewer, so renaming HTML to .pdf does not execute in the app origin. I could not make a sandboxed variant work (screenshot above), so dropping sandbox is necessary. Note that the viewer's link clicks and the Electron preload error (sandboxed_renderer.bundle.js script failed to run logged when a same-origin PDF frame loads) were not discussed.
NitFilePreview.stories.tsx L322-327Unrelated reformat of the lint.ts story (contents: … .split("\n") wrapped) that also conflicts with main; drop it on rebase.

Tests I ran on the locally rebased PR: packages/client-core file-preview.test.ts (12 pass, including the two new PDF tests) and my repro test (first assertion now passes; the "all-ASCII is text" documentation test flips to pdf, as intended); apps/app FilePreview.test.tsx + src/hooks/queries (122 pass); turbo typecheck for @bb/client-core and @bb/app pass, @bb/mobile fails (above).

Verdict: REQUEST CHANGES — correct approach and verified working in Chromium and Electron, but it must be rebased onto the @bb/client-core move, handle the pdf kind in apps/mobile, and ideally add a test for the workspace data: URL branch.

8. Related issues

9. Appendix

Commands run

gh issue view 1985 --comments; gh pr view 1979; gh pr diff 1979
pnpm install --frozen-lockfile --prefer-offline; pnpm exec turbo run build
cd packages/client-core && pnpm exec vitest run test/file-preview-pdf-repro.test.ts      # fails on main (see §4a)
scripts/bb-dev-app current                                                            # App :14918 Server :22918 daemon :30918
python3 …  # generate /tmp/bb-1985-qa/handbook.pdf (FlateDecode stream) ; git init + commit
curl -s -X POST $BB_SERVER_URL/api/v1/projects … path=/tmp/bb-1985-qa                # proj_cw26yy9qhd
curl -sD - "$BB_SERVER_URL/api/v1/projects/proj_cw26yy9qhd/files/content?path=handbook.pdf"
curl -s -X POST $BB_SERVER_URL/api/v1/threads … "Reply only with ok." (codex)           # thr_pu2wtuaw77
doobie --headless run doobie-open.js ; bb thread open thr_pu2wtuaw77 handbook.pdf ; doobie run doobie-shot.js
git fetch origin pull/1979/head:pr-1979 ; git checkout -b review-1979 c7c66423d ; git merge --no-commit pr-1979
#   conflicts: FilePreview.stories.tsx, apps/app/src/lib/file-preview.ts -> took main, applied classifier hunk to packages/client-core
pnpm exec turbo run typecheck --filter=@bb/client-core --filter=@bb/app --filter=@bb/mobile   # mobile fails TS2366
Xvfb :150 ; doobie chrome --profile bb1985 --port 49222 ; doobie --connect 49222 run doobie-pr.js
cd apps/desktop && BB_DESKTOP_ELECTRON_ARGS="--remote-debugging-port=49223 --no-sandbox" pnpm run dev
doobie --connect 49223 run doobie-desktop.js / doobie-desktop-apiurl.js / doobie-desktop-sandbox.js
git reset --hard c7c66423d ; cleanup (pnpm dev:stop, kill Xvfb/Chrome/Electron, rm data dir)

PDF generator (Python)

import zlib
content = b"BT /F1 24 Tf 40 400 Td (Hello from bb issue 1985) Tj ET"; comp = zlib.compress(content)
objs = [b"<< /Type /Catalog /Pages 2 0 R >>", b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
        b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 500 500] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
        b"<< /Length %d /Filter /FlateDecode >>\nstream\n" % len(comp) + comp + b"\nendstream",
        b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"]
out = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n"; offsets = []
for i, o in enumerate(objs, 1): offsets.append(len(out)); out += b"%d 0 obj\n" % i + o + b"\nendobj\n"
xref = len(out); out += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs)+1)
for off in offsets: out += b"%010d 00000 n \n" % off
out += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objs)+1, xref)
open("/tmp/bb-1985-qa/handbook.pdf","wb").write(out)

Artifacts