fix(clients): large pasted images no longer break later Claude image reads#19364

merged2
main
t3/fix-13647-client
opened10d agobyTtheo
View on GitHubTwo-way sync enabledPR last imported 3d ago

Pasting a screenshot over 2000px into a running Claude turn broke every later image read in that thread. Once a request holds more than 20 images, Anthropic rejects any image over 2000px on a side. Claude Code scales images in a turn's first message, but sends images pasted mid-turn as they are.

Now clients scale the image when it is attached, before upload. Any PNG or JPEG whose long edge is over 2000px is scaled down to 2000px:

  • PNG stays PNG and keeps transparency. JPEG stays JPEG at quality 0.92 on web and 0.85 on mobile, the mobile app's existing photo quality. EXIF orientation is applied.
  • Images at or under 2000px are not re-encoded. Only the header is read to decide. GIF, WebP, and SVG are not changed.
  • If scaling fails (the browser cannot decode the image, or native render fails), the original is attached as before. The fix cannot make attaching worse than today.

Where:

  • Shared rule: packages/client-runtime/src/imageAttachmentSize.ts holds the 2000px edge and the fit math.
  • Web and desktop: compressImageToByteLimit scales first, with createImageBitmap and a canvas, asynchronously at attach time. This covers paste, drop, the file picker, HEIC photos, and desktop SnapShots. SnapShot accessibility coordinates already rescale from the returned size. The re-encode for the 10 MB byte cap now also stops at 2000px instead of 2048px.
  • Mobile: uses expo-image-manipulator, which the app already ships. No new native module. It covers the photo library, clipboard paste, native paste, and the share sheet.

The 10 MB upload limit is still checked on the scaled bytes. Previews show the scaled image.

Not covered: images picked on mobile through the Files document picker are stored as file attachments and are not scaled.

Proof

  • Web: apps/web/src/lib/imageCompression.test.ts has new tests:
    • A 3024×1964 PNG under the byte cap becomes a 2000×1299 PNG, decoded with imageOrientation: "from-image" and with no matte.
    • A JPEG stored landscape with EXIF rotation becomes a 1500×2000 JPEG.
    • A 2000px PNG and a 3000px GIF pass through without being decoded.
    • The two scaling tests fail against main's imageCompression.ts and pass with this change.
  • Mobile: apps/mobile/src/lib/composerImages.test.ts has new tests:
    • A 3024×1964 clipboard PNG is rendered to 2000×1299 and saved as PNG.
    • A 2000px clipboard image is kept byte for byte.
    • The scaling test fails with the fit call removed.
  • Targeted tests pass: web 38, mobile composer and sharing 64. Typecheck passes for web, mobile, and client-runtime. Lint and format pass on the changed files.
  • No browser or device run was made, and there is no screen capture. All web and mobile tests stub the canvas or the native renderer.

Reviewed with sol-loop: 1 round with GPT-6.1 Sol. Approved. One nit was declined: a scaled PNG still over 10 MB falls back to the existing WebP or JPEG byte-cap re-encode, which is unchanged behavior.

The diagnosis and repro come from #19353, which fixed this on the server but decoded images on the main thread.

Fixes #13647

Model and harness: Claude Opus 5.5 in Claude Code via T3 Code.

🤖 Generated with Claude Code

Checks

Checks in progress

Added 2 commits

  • fix(clients): attached images over 2000px are scaled down before uploaded4ccd7
  • test(mobile): photo library bounds now expect the 2000px edge8171969
Synced from GitHub

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — The fix adds automatic PNG/JPEG downscaling across multiple existing web and mobile attachment paths and changes the default image edge from 2048px to 2000px. This affects image fidelity, previews, and persisted snapshot dimensions, so the product-facing behavior warrants human review.

Notes:

  • No code objects were reviewed. Approvability was decided on eligibility alone.

You can add or adjust custom eligibility rules. Learn more.

Synced from GitHub

Review in Change Stack →

✨ Finishing Touches: 💡 1
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

Synced from GitHub

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ProviderMetricMain baselineThis PRImpactPR ceiling
CodexTotal thread wire4.9 KiB5.0 KiB+23 B (+0.5%)6.8 KiB✅
CodexThread snapshot wire3.8 KiB3.8 KiB0 B (0.0%)4.9 KiB✅
CodexLive turn WebSocket wire1.2 KiB1.2 KiB+23 B (+1.9%)2.0 KiB✅
CodexLive turn WebSocket decoded20.8 KiB20.9 KiB+41 B (+0.2%)29.3 KiB✅
CodexLive turn messages12+1 (+100.0%)8✅
ClaudeTotal thread wire5.0 KiB5.0 KiB+31 B (+0.6%)6.8 KiB✅
ClaudeThread snapshot wire3.8 KiB3.8 KiB0 B (0.0%)4.9 KiB✅
ClaudeLive turn WebSocket wire1.2 KiB1.2 KiB+31 B (+2.6%)2.0 KiB✅
ClaudeLive turn WebSocket decoded21.2 KiB21.2 KiB+41 B (+0.2%)29.3 KiB✅
ClaudeLive turn messages12+1 (+100.0%)8✅

Baseline: ffceb61 · PR result: 8171969 · 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: 108.5 KiB
  • Claude decoded thread snapshot: 108.8 KiB

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