Skip to content

feat(staged): accept PDFs as image attachments by rendering the first page to PNG - #975

Merged
matt2e merged 10 commits into
mainfrom
pdf-support
Oct 15, 2026
Merged

matt2e merged 10 commits into
mainfrom
pdf-support

Conversation

@matt2e

@matt2e matt2e commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Summary

PDFs dropped onto a branch card, pasted, or picked in the chat composer used to fall through to the text-file path, so you'd get a UTF-8 error toast or the raw path pasted into the textarea. They're now accepted as image attachments: the backend renders page one to a PNG and stores that in place of the PDF.

Backend

  • store_sniffed_image detects %PDF- and renders the first page in-process with CoreGraphics (new note_media::pdf, using objc2-core-graphics/objc2-core-foundation, both already in the lock).
    • Uses the CropBox (the area viewers show), with the MediaBox as fallback. Honours /Rotate and draws on an opaque white background.
    • Scale is min(2048 / long_edge_pt, 4.0). If the PNG comes out over the 10 MB image cap, it re-renders once at half scale.
    • The PDF bytes are not kept, and <stem>.pdf is stored as <stem>.png.
  • PDF input gets its own 50 MiB cap (MAX_PDF_INPUT_SIZE), shared by the in-memory, path, and base64 routes. Other images keep the 10 MB cap, and an oversized non-PDF is refused before it's buffered. Size errors now all come from one size_error helper.
  • create_image and create_image_from_data run on spawn_blocking, and so does the web dispatcher's create_image_from_data arm.
  • In web mode, /api/invoke/{command} now has a body limit that fits a base64 50 MiB PDF. Before, axum's default 2 MiB limit returned 413 for those bodies before they reached the handler.
  • On non-macOS targets a stub returns "PDF attachments require macOS", and the CoreGraphics tests only build on macOS.
  • .pdf references in notes are still refused as inline note media.

Frontend

  • isImageAttachmentFile (images plus .pdf) is now used by the native drop handlers. Paste accepts application/pdf, and both pickers use a shared ATTACHMENT_ACCEPT.
  • Local base64 previews are only used when the browser's MIME type matches the stored one. Otherwise the composer fetches the rendered PNG.
  • The picker labels say "image or PDF". When createImage fails on a branch card, the toast now names the file, e.g. Could not attach "report.pdf" — ….

Docs

  • docs/inline-note-media.md now covers PDF attachments.

Testing

  • cargo test, cargo clippy -D warnings, cargo fmt --check
  • vitest, pnpm run check, prettier
  • New tests:
    • CropBox rendering and the fallback when the CropBox is degenerate
    • Page two is not rendered, checked in a way that would fail if it were
    • A padded PDF over 10 MiB is accepted, a PDF over 50 MiB and a PNG over 10 MiB are refused, on both routes
    • read_local_attachment edge cases
    • The web invoke body limit
  • Rendered a real 3150×1800 pt fixture: the output is a 2048×1170 PNG of about 206 KB.

🤖 Generated with Claude Code

matt2e and others added 5 commits October 14, 2026 16:00
…achments

A PDF dropped into Staged never reached the image store: the frontend
extension allowlist routed it down the text-file path (a UTF-8 error
toast on the branch card, or the literal path inserted into the chat
textarea), paste and the file picker filtered on `image/*`, and the
backend sniffer had no PDF branch.

Backend: `store_sniffed_image`, the single choke point for chat and
timeline attachments, now detects `%PDF-` and rasterizes page one with
CoreGraphics in-process (new `note_media::pdf`, via the already-locked
`objc2-core-graphics`/`objc2-core-foundation` crates) before the usual
PNG sniff, renaming `<stem>.pdf` to `<stem>.png`. The page is drawn on
an opaque white background at `min(2048 / long_edge_pt, 4.0)` px/pt, so
the long edge never exceeds 2048 px and tiny pages are not blown up past
4x; `/Rotate` is honoured; if the PNG exceeds MAX_IMAGE_SIZE it is
re-rendered once at half scale before failing with the existing size
message. Malformed input is rejected by `CGPDFDocument` immediately
(no subprocess, no hang). The renderer reports the page count for a
later one-attachment-per-page follow-up; the PDF bytes are not kept.
`create_image` and `create_image_from_data` are now async and run on
`spawn_blocking` so rendering stays off the main thread; the web
dispatcher keeps calling the synchronous impl. Non-macOS targets compile
a stub returning "PDF attachments require macOS" and the CoreGraphics
tests are cfg-gated, so the ubuntu CI build and clippy stay green.
Note ingest (`validate_media`) still rejects `.pdf`.

Frontend: `isImageAttachmentFile` (images + `.pdf`) drives the three
native drop handlers and `isMaybeTextFile`; paste accepts
`application/pdf` alongside `image/*`; both pickers list it via a shared
ATTACHMENT_ACCEPT; local base64 previews are used only when the browser
type matches the stored MIME, otherwise the existing `getImageData`
effect fetches the rendered PNG; branch-card `createImage` failures now
toast like the text path instead of failing silently.

Verified: a 3150x1800 pt one-page fixture renders to a 2048x1170 opaque
PNG (~206 KB) that matches the source; `cargo test` (1061 passed),
`cargo clippy -D warnings`, `cargo fmt --check`, vitest (1317 passed,
--maxWorkers=3), `pnpm run check` (0 errors/warnings), prettier.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…ff the runtime thread

Follow-ups from review of 2760cdd (PDF attachments as PNG images).

`render_first_page` chose the MediaBox and only fell back to the CropBox
when the MediaBox was unusable. Preview, Quick Look and most viewers
display the CropBox, so a print-ready PDF with bleed and crop marks
rendered a different image from what the user sees when they open the
file. The order is now CropBox first — CoreGraphics already clips it to
the MediaBox and returns the MediaBox when none is set — with the
MediaBox as the fallback for a degenerate CropBox. New test: a letter
MediaBox with a 300x400 pt CropBox renders at 1200x1600 (4x cap) with
only the in-crop content, and an empty CropBox still falls back to the
full page. `tiny_pdf_with` gained a `crop_box` argument for it.

The test-only `PAGE_TWO` constant moved inside the macOS test module so
the Linux `cargo test` step no longer warns that it is never used.

The web dispatcher's `create_image_from_data` arm now runs the impl
under `tauri::async_runtime::spawn_blocking` like the other heavy arms,
so PDF rasterization no longer blocks the async runtime in web mode;
the `?`/`??` error shape is unchanged with a "task failed" join error.

Verified: the 3150x1800 pt onboarding flow chart fixture (CropBox equal
to MediaBox) still renders to 2048x1170, 205,763 bytes; `cargo test`
(1062 passed), `cargo clippy -D warnings`, `cargo fmt --check`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…the attach UI

Follow-ups from review of 2bdff35 (PDF attachments as PNG images).

Only the rendered PNG of a PDF's first page is stored, but the 10 MB
MAX_IMAGE_SIZE was applied to the PDF bytes themselves, so scans and
print-ready files (routinely 10–50 MB) were refused with "media exceeds
10 MB limit" even though their first page rasterizes to a few hundred
KB. `%PDF-` payloads now get MAX_PDF_INPUT_SIZE (50 MiB), chosen by a
shared `attachment_limit(header)` on every route: `store_sniffed_image`
for in-memory bytes, the new `read_local_attachment` for the path route
(it reads the 5-byte signature first and refuses an oversized non-PDF
from its metadata, so a 10 MB+ image is never buffered towards 50 MiB),
and `create_image_from_data_impl`, which decodes the first two base64
quanta to pick the cap before decoding the payload. An oversized PDF
now fails with "PDF exceeds 50 MB limit"; images keep "media exceeds
10 MB limit" (the base64 route previously said "Image too large", and
now matches the message the same bytes get elsewhere). The rendered PNG
still has to satisfy MAX_IMAGE_SIZE via the existing half-scale retry.
`read_local` is split into `open_regular` + `read_capped` so both
readers share the FIFO-safe open and the grew-past-cap check, and the
"exceeds N MB limit" strings come from one `size_error` helper. The
frontend read paths (SessionChatPane, ImageAttachment) impose no cap of
their own, so nothing changes there.

Tests: a well-formed PDF padded past 10 MiB with comment lines
(`padded_pdf`, xref offsets skip the padding so it still renders
instantly) is accepted on both routes and stored as a PNG under 10 MB;
a >50 MiB PDF is refused with the new message on both routes (sparse
file on the path route, a 67 MB base64 string on the data route); a
>10 MiB PNG is still refused with the old message on both; and
`read_local_attachment` is covered directly for the cap decision,
sub-signature files, directories, and missing files.

UI copy: the picker buttons now say "Attach image or PDF" / "Add image
or PDF" / "Attach images or PDFs", and docs/inline-note-media.md states
that a dropped, pasted, or picked PDF (up to 50 MiB) is stored as a PNG
of its first page and is still refused as inline note media.

BranchCard's createImage failure toast uses `attachBasename`, like the
composer paths, so it reads `Could not attach "report.pdf" — …` rather
than dropping the extension that explains the failure.

The pdf.rs "page two was not rendered" assertion sampled a pixel that
is white on either page. PAGE_TWO now draws a rule at y=400 pt that
PAGE_ONE lacks; the test asserts that row is white in the rendered
first page and dark when PAGE_TWO's content is rendered as page one, so
the check is self-validating.

Verified: `cargo test` (1064 passed), `cargo clippy -D warnings`,
`cargo fmt --check`, vitest (1317 passed, --maxWorkers=3), `pnpm run
check` (0 errors/warnings), prettier on the changed Svelte files (the
docs file's only prettier complaint is a pre-existing one on line 18).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Follow-ups from review of 7251de3 (PDF attachment input cap).

In web mode `create_image_from_data`'s MAX_PDF_INPUT_SIZE check never
ran: `invoke_command` takes `Json<Value>` and the router had no body
limit layer, so axum 0.8's default 2 MiB cap rejected any
`/api/invoke/create_image_from_data` body larger than that with a 413
before dispatch. The desktop path route accepted the same PDF, so the
"up to 50 MiB" promise in docs/inline-note-media.md was false for the
browser (and images over ~1.5 MB raw had been failing there all along).

`/api/invoke/{command}` — only that route, via `MethodRouter::layer`,
not the whole app — now carries `DefaultBodyLimit::max(
INVOKE_BODY_LIMIT)`, where the constant is the base64 size of a
MAX_PDF_INPUT_SIZE payload (`div_ceil(3) * 4`) plus a 64 KiB margin for
the JSON envelope, derived from the note_media::files constant so the
two cannot drift. The route construction moved into `invoke_router`,
generic over the handler like `media_router_for_store`, because
`WebAppState` holds a Tauri `AppHandle` and cannot be built in a unit
test; `app_router` calls it with `invoke_command`. The new test mounts
a stand-in with the same `Path`/`Json<Value>` extractors and checks
that a body holding the base64 of a cap-sized PDF (well past 2 MiB)
reaches the handler with its payload intact, while one byte over
INVOKE_BODY_LIMIT gets 413. The docs sentence is now true for both
desktop and web, so it is unchanged.

Out of scope, noted for later: axum's 413 body is plain text
("Failed to buffer the request body: length limit exceeded"), not the
`{ "error": … }` JSON `invoke_command` emits, and `transport.ts` falls
through its JSON.parse and surfaces that text verbatim. The test pins
that shape so a future change to the transport knows what it is
handling.

`read_image_file` in tool_results.rs produced "Image too large (max 10
MB)" for an oversized tool-result image; it now goes through the shared
`size_error("Image", MAX_IMAGE_SIZE)` and reads "Image exceeds 10 MB
limit", matching the attachment routes. Only its own test's expected
substring changed; nothing in the frontend matched the old string.

Verified: `cargo test` (1065 passed), `cargo clippy -D warnings`,
`cargo fmt --check`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
… body-limit test

Follow-up from review of c625fb4 (web-mode invoke body limit).

The over-limit half of `invoke_route_admits_a_cap_sized_pdf_body_and_
refuses_larger` asserted `text.contains("length limit exceeded")`, which
is axum's internal `LengthLimitError` wording, so a routine axum bump
could fail the test with no behaviour change on our side. The property
`transport.ts` actually depends on is that the body is not the
`{ "error": … }` JSON `invoke_command` emits, and the following
`serde_json::from_str(text).is_err()` already captures that. The
substring match is gone; the 413 status and non-JSON assertions remain,
with the body echoed in the failure message so a future JSON 413 is
still easy to diagnose. The comment above was already about the
plain-text/non-JSON shape, so it is unchanged.

Verified: `cargo test --lib web_server` (12 passed), `cargo fmt --check`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-14T09:15:45.593142Z 1e7a92f PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1e7a92f2e3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

async function handleFileDrop(paths: string[]) {
if (!activeProjectId) return;
const imagePaths = paths.filter((p) => isImageFile(p));
const imagePaths = paths.filter((p) => isImageAttachmentFile(p));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Surface failures for PDF drops in the new-session modal

When a .pdf dropped onto the new-session modal is malformed, encrypted, over 50 MiB, or otherwise rejected by createImage, this new classification removes it from textPaths, while the catch below only writes to the console. The user therefore sees neither an attachment nor an error, whereas the same failure is surfaced by the branch-card and session-chat drop handlers; report the rejection through the modal's attachment error UI or a toast.

Useful? React with 👍 / 👎.

* File-picker `accept` list for the image store. A PDF is accepted alongside
* images because the backend stores page one of it as a rendered PNG.
*/
export const ATTACHMENT_ACCEPT = 'image/png,image/jpeg,image/gif,image/webp,application/pdf';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid main-thread encoding for 50 MiB PDF attachments

Advertising PDFs here makes the newly allowed 50 MiB payload size reachable through the picker and paste flows, but both addImageFile implementations synchronously build thousands of strings, join them, and call btoa on the UI thread before invoking the backend. A PDF near the supported limit consequently creates large simultaneous binary/string/base64 allocations and freezes the WebView despite PDF rendering itself being moved to spawn_blocking; encode off-thread/use a native or binary upload path, or apply a substantially lower limit to these base64-based flows.

Useful? React with 👍 / 👎.

matt2e and others added 5 commits October 14, 2026 20:53
…ils and prompt pages

Reworks the PDF attachment feature (2760cdd..1e7a92f) after feedback
that the stored PNG of page one loses quality when zoomed and drops the
other pages. A dropped, pasted, or picked PDF is now stored byte-for-byte
as `<stem>.pdf` with `mime_type = application/pdf`, opened in the
webview's native PDF viewer (vector zoom, every page), and rasterized
only where a bitmap is unavoidable: the thumbnail slots and the agent
prompt.

Storage: `sniff_format` gains a `%PDF-` branch and `mime_for_extension`
a `pdf` entry, so every `mime_for_extension(sniffed).unwrap()` has an
entry. `store_sniffed_image` no longer renders; on macOS it asks
CoreGraphics to open the document (`pdf::check`) so `%PDF-` garbage is
still refused up front, and it writes through a new `write_media`
(split out of `store_media_file`) because `validate_media` must keep
refusing PDFs for notes: `size_limit` now returns "PDFs cannot be
embedded in notes" for the MIME, since the shared map would otherwise
have made `staged-media://<id>.pdf` refs ingestable. The 50 MiB PDF cap
and both read routes are unchanged. `get_image_ids_for_session`
filtered `mime_type LIKE 'image/%'`, which would have dropped PDFs on a
session restart; it now admits `application/pdf` too.

Thumbnails: `get_attachment_thumbnail(image_id)` (Tauri command and web
dispatcher arm, both on `spawn_blocking`) returns `{ dataUrl, mimeType,
filename }`. Images return their own data URL, so the frontend can ask
for any attachment without knowing its MIME (chat history and queued
messages only have ids); a PDF gets page one rendered at a 512 px long
edge via `pdf::render_first_page` and cached as `<uuid>.thumb.png`
beside the file (`store::images::thumbnail_file_path`). `dataUrl` is
null when rendering is unavailable (non-macOS, or a page CoreGraphics
cannot draw) and the UI shows a document icon. A new
`remove_image_files` removes file and thumbnail together and replaces
the four hand-rolled `remove_file` sites (`delete_image` in both
dispatchers, `delete_media_file`, `cleanup_pending_images`);
`move_branch_image_files` moves the thumbnail with the PDF.

Agent prompt: neither transport takes a PDF. claude-agent-acp's
`promptToClaude` ignores blob resources outright (`// Ignore blob
resources (unsupported)`), codex-acp's app-server `InputItem` is text,
image, or localImage, and ACP advertises nothing that would distinguish
PDF support, so an `image` block carrying `application/pdf` would reach
the Claude API as an image and be rejected. The fallback path is taken:
`session_runner/attachments.rs` renders pages 1..=20 (`MAX_PROMPT_PAGES`)
at `MAX_EDGE_PX` as `image/png` blocks, each preceded by a text block
"page N of M of <filename>" (the last one also says which pages were
left out), and a PDF that cannot be rendered is reported in the prompt
text ("[Attached PDF x.pdf (N bytes) could not be rendered …]") rather
than silently dropped. To carry the captions, `acp-client` replaces the
`(base64, mime_type)` tuple with `PromptImage { data, mime_type, label }`
across `AgentDriver::run`, `SessionConnection::prompt`, the queued turn,
and `build_prompt_content_blocks`; the pikchr drivers and the simple
wrapper only change their signatures.

Frontend: `ImageViewerModal` detects a PDF from the `get_image_data`
prefix, builds a Blob URL (an `<iframe>` will not load `data:`), shows
it in a full-height `w-[90vw] h-[90vh]` frame, revokes the URL in the
effect cleanup, and forwards Escape from the frame window where the
webview allows a listener (the close button and backdrop remain for the
plugin-rendered case). No CSP change was needed: `tauri.conf.json` has
`csp: null` and the web shell sends none. A shared `AttachmentThumb`
component fills the composer strip, chat-history bubble, and queued
message slots with the thumbnail, a document icon while loading or when
unrendered, and a "PDF" badge; `attachmentPreview.ts` holds the
`localPreview` rule (use the uploaded bytes only for an `image/*` whose
browser label matches the stored MIME), the data-URL helpers, and the
Blob URL builder. Button copy says "attachment" where it said "image";
"Attach image or PDF" is unchanged. The timeline row keeps the image
icon with the `.pdf` filename. docs/inline-note-media.md describes the
new behaviour.

Tests: PDF storage tests assert `application/pdf`, `.pdf`, identical
bytes, and the renamed `pasted.pdf`/`scan.pdf` cases on every platform
(the non-macOS test now checks storage, not an error); thumbnail tests
cover an image's own data URL, the macOS render/cache/reuse/delete
cycle, and the absent-renderer case; `pdf.rs` gains `check` and
`render_pages` coverage; `attachments.rs` tests captioned pages, the
page cap, the unrenderable note, and the non-macOS note; the acp-client
test pins the text/image block order; `attachmentPreview.test.ts`
covers the preview rule and Blob URL; `validate_media("a.pdf", …)` pins
the new refusal.

Verified: `cargo clippy -D warnings` (CI config; `--tests` shows only
the two pre-existing baseline lints), `cargo test` (1070 passed; the
two failures, `timeout_kills_bridge_and_native_child…` and
`cancelled_capture_evicts_stale_inflight`, are the known
shell-spawning load flakes at load average 42 and pass in isolation),
acp-client `cargo test` (183 passed) and clippy, both `cargo fmt
--check`, vitest (1321 passed, --maxWorkers=3), `pnpm run check` (0
errors/warnings), prettier on the changed files. Manual end-to-end with
/tmp/pdf-support-fixture/onboarding-flow-chart.pdf through
`store_sniffed_image` and the thumbnail command (temporary test, not
committed): stored as application/pdf with identical bytes, 512x293
thumbnail in 250 ms then 308 µs from cache, one 2048x1170 prompt page
labelled "page 1 of 1 of onboarding-flow-chart.pdf", both files gone
after delete. Native rendering was checked with an offscreen WKWebView
loading the same Blob-URL iframe: the flow chart renders at vector
quality (Tauri and Safari share the engine). Headless Chrome hands the
frame to its PDF viewer but does not paint page content in a
screenshot, so web-mode page rendering and interactive zoom were not
exercised here; the dev app was not launched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…d view PDFs from their media URL

Follow-ups from review of c4b0950 (PDF attachments stored as PDF).

Prompt pages had a page cap but no byte budget, so a 20-page scan could
yield up to 200 MB of PNG rendered up front and sent in one JSON-RPC
prompt, which the Claude API refuses (32 MB per request, 5 MB per
image). `session_runner/attachments.rs` now renders pages lazily in
order through a new `pdf::Document` (`open`, `page_count`, `render_page`;
the eager `render_pages` is gone) and stops once the next page would
cross `MAX_PROMPT_PAGE_BYTES` (20 MB of encoded bytes), so memory holds
only the accepted set plus the page being decided. Each page is encoded
within `MAX_PROMPT_IMAGE_SIZE` (5 MB) by `pdf::fit_page`: PNG, then the
existing half-scale PNG retry, then — new, for the prompt only (`Fit {
jpeg_fallback }`) — a full-scale JPEG at quality 85 via the `image` crate
(already in the lock through arboard; its `jpeg` feature adds nothing
new) before the page is given up and noted in the prompt text. The
caption on the last page sent now says why the rest were left out
("pages 21–25 not included: over the 20-page limit" / "over the 20 MB
image budget"; a single omission is named as "page N"), and when not
even the first page fits the note goes into the prompt text instead of
onto an earlier attachment's label. `PageLimits` carries pages, both
byte caps, and the render edge (default `MAX_EDGE_PX`), so the tests
render letter pages at 256 px: the page-cap test, two budget cases, the
per-page refusal, and a `fit_page` chain test on stand-in bitmaps whose
PNG/half-PNG/JPEG sizes order like a photographic page's.

The thumbnail cache was a plain `fs::write` whose reader only checked
the PNG signature, so two slots asking for a new attachment at once
could serve a truncated PNG. `cache_thumbnail` writes to a per-writer
`<uuid>.thumb.<pid>-<n>.tmp` (`thumbnail_temp_file_path`) and renames
it into place, removes the temp file on failure, and `remove_image_files`
sweeps any stray `<uuid>.thumb.*.tmp` with the file and thumbnail.
Tests cover a clean render, a rename that cannot land, and a stray.

`ImageViewerModal` now points the PDF `<iframe>` at the attachment's
served media URL — `resolvePdfAttachmentUrl` beside `mediaUrl.ts`
resolves `<id>.pdf` by id without widening the note-embed regex — so
the file is streamed with Range instead of crossing IPC as base64 and
being decoded with a per-char loop; `dataUrlBytes`/`pdfBlobUrl` and the
Blob lifecycle are gone. Verified in an offscreen WKWebView with a
`staged-media` scheme handler: the custom scheme renders the fixture
natively inside an iframe (the frame is cross-origin, so the Escape
forwarding only attaches in web mode; the comment says so). The same
probe showed the web route's `sandbox` CSP directive blanks WebKit's
viewer (plugins are refused in sandboxed documents), so `serve_media`
sends `UNTRUSTED_PDF_CSP` (without `sandbox`) for `application/pdf`;
`lookup` already served the stored MIME with Range, and both routes are
now covered by tests for a stored PDF.

History thumbnails had become 120x120 squares with `object-fit: cover`,
cropping every non-square screenshot. `AttachmentThumb` gains
`fit="natural"` (width of the parent, own aspect, cropped only past the
parent's `--attachment-thumb-max-height`) and the history wrapper is
back to `width: 120px; max-height: 120px`; the composer strip and
queue were already square slots before and are unchanged.

acp-client's omitted-attachments notice counted PromptImages, so one
PDF read as "20 image attachment(s) omitted"; it now says "image
block(s)".

Verified: `cargo clippy -D warnings`, `cargo fmt --check`, `cargo test`
(1079 passed, no load flakes), acp-client clippy/fmt/`cargo test` (183
passed), vitest (1321 passed, --maxWorkers=3), `pnpm run check` (0
errors/warnings), prettier on the changed files (the docs file's only
complaint is the pre-existing line 18). Fixture end-to-end through a
temporary test (not committed) with
/tmp/pdf-support-fixture/onboarding-flow-chart.pdf: stored as
application/pdf with identical bytes, 512x293 thumbnail (30,671 B) in
167 ms then 228 µs from cache with no temp file left, one 2048x1170 PNG
prompt page (205,763 B) labelled "page 1 of 1 of
onboarding-flow-chart.pdf", images directory empty after delete.
Headless Chrome shows its PDF viewer for the web route under every CSP
variant but never paints pages, so web-mode rendering was checked in
WKWebView only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…per message

Follow-ups from review of 320e593 (PDF prompt page budget).

`MAX_EDGE_PX` was 2048, but the Claude API rejects any image over 2000 px
on a side once a request carries more than 20 image blocks, and it counts
the images resent from earlier turns and those inside tool results, so a
20-page PDF plus a single screenshot (or any later attachment in the same
session) would have turned every 2048 px page in the history into an
`invalid_request_error`. The constant is now 2000 with a comment saying
why; the high-resolution tier's native long edge is 2576 px, so a letter
page at ~182 DPI instead of ~186 loses nothing. The pdf.rs tests pass
`MAX_EDGE_PX` rather than a literal and their expectations are recomputed
from the box sizes: letter 612×792 pt renders 1545×2000 (rotated
2000×1545), the 3150×1800 pt fixture box 2000×1143, and the 4x-capped
cases are unchanged. Thumbnails (512 px) are unaffected.

The 20 MB encoded-image budget was per PDF: `used_bytes` was a local of
each `add` call while `session_runner` loops over every attachment, so two
PDFs could put 40 MB of pages (~54 MB as base64) into one prompt, past
the 32 MB request limit the comment cites. The counter now lives on
`PromptAttachments` and every image block — plain images included —
reserves against it. A PDF that reaches the budget truncates with the
existing "over the 20 MB image budget" caption (on its last page sent, or
in the prompt text when none fit); a plain image that would cross it is
noted in the prompt text as "[Attached image x.png (N bytes) not included
in this prompt: over the 20 MB image budget]" rather than silently
dropped, and is not counted, so a smaller one after it can still fit.
`PageLimits` is renamed `PromptLimits` now that `total_bytes` spans the
prompt, `MAX_PROMPT_PAGE_BYTES` becomes `MAX_PROMPT_IMAGE_BYTES`, and the
tests build a `PromptAttachments::with_limits(..)` (test-only) instead of
passing limits per call. The per-image comment is corrected: the direct
API accepts 10 MB of base64 per image (Bedrock and Vertex less), so 5 MB
of encoded bytes stays as the conservative ceiling.

Tests: two small PDFs sharing one budget (the second gets the one page
left and names the omission), an image before a PDF (the PDF gets one
page), a PDF that uses the budget up before an image (the image is noted,
the PDF's labels untouched), and a platform-independent two-images case.
docs/inline-note-media.md says the budget is shared by the message's
attachments.

Verified: `cargo clippy -D warnings` (CI config; `--all-targets` shows
only the two pre-existing baseline lints), `cargo fmt --check`, `cargo
test` (1081 passed). acp-client is untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…s spent

Follow-up from review of b5b77e9 (per-message prompt image budget).

When an earlier attachment had used up MAX_PROMPT_IMAGE_BYTES, each
later PDF still rendered its first page — a 2000 px CoreGraphics draw
plus the PNG/half-PNG/JPEG encode chain, ~150–250 ms — only for
`reserve` to refuse it. The page loop in
`session_runner/attachments.rs` now checks `used_bytes >= total_bytes`
before `render_page` and records the omission with the existing
over-budget caption, so the first page after the budget is spent is
never rendered. Pages that fit, or that fall under the per-page cap,
are decided exactly as before; the check only avoids work whose
outcome is already known.

The shared-budget test's last case (a PDF that fills the budget, then
an image noted in the prompt text) now also adds a second PDF and
asserts it is noted as "[Attached PDF b.pdf: pages 1–2 not included:
over the … image budget]" with no images added — the same outcome
the pre-check produces without rendering.

Verified: `cargo test --lib session_runner::attachments` (9 passed),
`cargo clippy -D warnings`, `cargo fmt --check`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…max-w-md

Opening a PDF attachment from the timeline showed a tall column about
14% of the window wide (725 px on a 5120 px window), with the landscape
page drawn as a small strip scaled to that width and white space above
and below. The PDF branch was reached and `w-[90vw] h-[90vh]` were
applied; the width was then capped by `Dialog.Content`'s own defaults.
Its `cn()` class list includes `max-w-[calc(100%-2rem)] sm:max-w-md`,
and tailwind-merge only lets the viewer's `max-w-[90vw]` replace the
unprefixed utility: the merged result is `sm:max-w-md max-w-[90vw]
w-[90vw] h-[90vh]`, so above the 640 px breakpoint `max-width` is 28rem,
which at the app's 13 px root font size is 364 CSS px — 728 physical px
at 2x, matching the report. The sibling viewers (`MediaViewerModal`,
`ToolResultImage`) already pass `sm:max-w-[90vw]` for the same reason
(#958, #969); `ImageViewerModal` had been missed since the shadcn
migration (#765), so its images were clamped to 364 px too.

The fix adds `sm:max-w-[90vw]` to the dialog's classes; the arbitrary
values are generated (the strings are literal in the file) and the
merged list becomes `max-w-[90vw] sm:max-w-[90vw] w-[90vw] h-[90vh]`.

Verified with a Vite harness mounting the HEAD and fixed components
(real app.css, bits-ui dialog) in an offscreen WKWebView at 1600x1000
with the fixture PDF served at `/api/media/<id>.pdf` and
`get_image_data` answered with the rendered 2048x1170 PNG; animations
were disabled with a user script so snapshots show the settled layout:
- PDF before: computed max-width 364px, dialog 364x900, iframe 362x855
  — the narrow column from the screenshot, page fitted to 362 px.
- PDF after: max-width 1440px (90vw), dialog 1440x900, iframe 1438x855;
  the flow chart fills the frame width at vector quality.
- Image before: dialog 364 px wide, img 330x189.
- Image after: dialog 800x515 (the shrink-to-fit width of the
  `left-1/2` fixed box, as in MediaViewerModal), img 766x438, so images
  display larger, not smaller, than before.

Checks: prettier, `pnpm run check` (0 errors/warnings), vitest (1321
passed, --maxWorkers=3). No Rust changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e merged commit ca633dd into main Oct 15, 2026
6 checks passed
@matt2e
matt2e deleted the pdf-support branch October 15, 2026 03:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant