Desktop app (Code tab) drops the image source path: pasted images are never written to image-cache and no `[Image: source: <path>]` line is injected
Summary
When an image is pasted into the Claude desktop app's Code tab, the model receives only the inline image block. In the terminal CLI, the same paste additionally writes the file to ~/.claude/image-cache/<session-id>/N.png and injects a companion user message [Image: source: <path>].
That one line is the difference between a model that can crop and upscale the image and a model that can only squint at it. The desktop engine already contains all of the code needed to do this: it is the same claude binary, and the omission is a single missing argument at the call site.
Environment
| | |
|---|---|
| Claude desktop app | 1.49585.0 (macOS, arm64, built 2026-09-08) |
| Claude Code bundled by the desktop app | 2.1.260 (also 2.1.258 present) at ~/Library/Application Support/Claude/claude-code/<version>/claude.app/Contents/MacOS/claude |
| Claude Code CLI (for comparison) | 2.1.266 |
| macOS | 15.3.2 |
Reproduction
- Paste a screenshot into the terminal CLI and send it. Inspect the session transcript at
~/.claude/projects/<slug>/<session-id>.jsonl. - Paste the same screenshot into the desktop app's Code tab, same working directory, same model. Inspect that session's transcript.
Expected
Both clients inject the source-path companion message, so the model can reach for the file with Bash/Read.
Actual
Only the CLI does.
CLI transcript contains an extra record:
{"type":"user","isMeta":true,"turnCompanion":true,
"message":{"role":"user","content":[{"type":"text",
"text":"[Image: source: /Users/<me>/.claude/image-cache/<session-id>/1.png]"}]},
"entrypoint":"cli","version":"2.1.266"}
The desktop transcript has no such record, and ~/.claude/image-cache/ contains no directory for that session. Measured across 8 sessions in one project: 4/4 entrypoint: "cli" sessions injected the path, 0/4 entrypoint: "claude-desktop" sessions did.
The image itself is byte-identical in pixel content between the two clients (852x525 in my case; ImageChops.difference(...).getbbox() is None, max channel diff 0), so this is purely about the missing path, not about image quality.
Root cause
In both binaries the prompt-submission path branches on how the image arrived. Minified, from 2.1.266:
// Branch A: image arrives as a content block in the prompt array <-- desktop takes this
for (let Je of w) if (Je.type === "image") {
let at = await wnr(Je, ro);
if (at.dimensions) { let Ot = m$e(at.dimensions); if (Ot) Zt.push(Ot) } // one argument
rt.push(at.block)
}
// Branch B: image arrives via pastedContents <-- terminal takes this
let ln = V ? await qVn(V, QU(I.setAppState, "storedImagePaths")) : new Map;
// ^ writes the file into ~/.claude/image-cache and returns id -> path
let Vt = await Promise.all(en.map(async (rt) => ({
id: rt.id, resized: await oh({...}),
sourcePath: rt.sourcePath ?? ln.get(rt.id)
})));
...
let Yt = m$e(Je.dimensions, Ot); // two arguments
And the formatter returns nothing without a source path:
function m$e(dims, sourcePath) {
const resized = dims.originalWidth !== dims.displayWidth
|| dims.originalHeight !== dims.displayHeight;
if (!resized && !sourcePath) return null;
const parts = [];
if (sourcePath) parts.push(`source: ${sourcePath}`);
if (resized) parts.push(`original ..., displayed at ...`);
return `[Image: ${parts.join(", ")}]`;
}
Branch A calls m$e(at.dimensions) with sourcePath undefined, so an image that was not downscaled produces null and nothing is injected. Nothing is written to image-cache on that branch either.
The desktop binary contains the identical image-cache, storedImagePaths and "[Image source: " symbols. The capability is present and simply not reached.
Impact
Without the path the model has no addressable handle on the image, so it cannot crop, upscale, threshold, or measure it. On any task with fine detail (grid puzzles, small text, dense charts, thin strokes) it answers from the rendered image alone.
Concretely: I gave the same visual-reasoning puzzle to four desktop sessions and they returned four different answers (A, B, C, D), all hedged, with zero tool calls. Terminal sessions given the same image immediately reached for PIL via the injected path.
The measurement behind that: a grid cell in the image is ~22.7 px, while a visual token covers a 28x28 px patch, so one cell occupies 0.66 of a patch and cannot be resolved. Cropping one row and upscaling 3x brings it to 5.92 patches per cell, at 1,558 visual tokens, still far under the 4,784 high-resolution cap. The model has ~8x its visual-token budget unspent and no way to spend it.
There is an added irony: the desktop session has 25 mcp__computer-use__* tools including app_screenshot, which could have recaptured the screen at full resolution. It never reached for them.
Suggested fix
On branch A, stage the inline image through the same image-cache writer used by pastedContents and pass the resulting path as the second argument to the formatter. No new plumbing appears to be needed.
Workaround
The bytes do reach disk in every client, just inside the session transcript rather than image-cache. A small script can walk ~/.claude/projects/<slug>/<session-id>.jsonl, pull the base64 out of the user records, and write real files. Verified working from a claude-desktop session.