feat(desktop,service,features): draw changed images before and after in Files changed
Summary
Files changed extras, E2 of the "Files changed extras" stack, stacked on #3304.
What was asked: show changed images (png, jpg, gif, webp, svg) before and after instead of "binary file", with stage-cli's cheap modes. Read the bytes for the base and head through the service, inside the agent's own repository only, reusing #3206's image proxy and contents-API path. Bound the size, and mind the CSP.
What the window does
- The image takes the place of lines. A changed image is drawn as the file's lead: the measured, full-width body #3304 introduced for files with no lines.
- Modified and renamed images get a segmented control for 2-up, Swipe and Onion skin. Swipe and onion skin each have a slider.
- An added image shows its one side, labelled "Added". A deleted image shows "Deleted".
- Each picture sits on a checkerboard, so transparency shows. In Swipe and Onion skin, both sides fill one canvas as large as the larger, so at full strength the after hides the before whatever their sizes.
- SVGs start as pictures. When an SVG has lines to draw, a
<>button in the header switches to its source, and back. - Lazy loading. A side is read only once its file is open and near the view (an
IntersectionObserveron the lead). It is then kept for the window's life (@luke/ui/picture-cache), so a collapsed or far-off image costs nothing. A side whose read failed is asked for again on Refresh changes. - CSP. Pictures draw from
data:URLs the window makes, whichimg-srcallows. The swipe clip and the onion-skin opacity are set through React'sstyle(the CSSOM), never style markup. - Threads on a pictured file stand in its lead, as on a binary file.
Where the bytes come from
- No new fetch path. Each side is read through the existing
CODING_AGENTS_GITHUB_IMAGEact, asraw.githubusercontent.com/<owner>/<name>/<sha>/<path>. #3206'sgithubImageOfreads that address as a FILE image. - The service then:
- reads the image through the contents API on the App's token, in the agent's own repository only
- accepts png/jpg/gif/webp/svg only, checks the bytes against the type's signature, and caps the size at
GITHUB_IMAGE_BOUNDS.MAX_BYTES(8 MB)
- An image the service won't answer reads "This image could not be shown."
Service changes
The changes answer gains two optional keys, headSha and baseSha:
headShacomes from one extraGET /pulls/{n}, run in parallel with the files and people reads the endpoint already makes.baseShais the merge base, not the base branch's tip, so a "before" picture is never one the pull request didn't have. It comes fromGET /compare/{base}...{head}?per_page=1(imageCommits), which runs only when the files include a modified, renamed or deleted image.- A pull request without such an image pays nothing extra.
- If the compare fails,
baseShais left out and the window shows the after side alone with "Previous version unavailable". It never falls back to the base branch's tip.
imageTypeOfPathis now exported from@luke/features/github-image, so the service and the window share one extension → type table.
Goldens
LUKE_UPDATE_FIXTURES=1 regenerated codingAgentChangesAnswerSchema and codingAgentChangesAnswerViewSchema. Each gains only the two optional string keys, headSha and baseSha, under pullRequest.
Screenshots
From a throwaway harness (fake app bridge, real bundles and stylesheet), since deleted.
| 2-up, with an added image above | Swipe (dark, split setting) |
|---|---|
![]() | ![]() |
| Onion skin | Merge base unreadable: after alone |
![]() | ![]() |
Narrow (600px, tree hidden):

Evidence
- Desktop and service
tsc: pass. pnpm lint,pnpm knip,pnpm discarded-effect,./scripts/repository-checks.sh: pass.pnpm exec vitest run apps/desktop/src/renderer/tasks packages/service/src/agents/coding-agents-app.test.ts packages/service/src/github packages/features/src: 150 files, 1752 tests passed.- New tests:
image-commits.test.ts:- an image changed in place reads the merge base from one
per_page=1compare - a deleted image needs the merge base too; an added image or a text file asks for no compare
- a compare that fails names no merge base, never the base branch's tip
- an image changed in place reads the merge base from one
agent-changes-tab.test.ts:- an image is read at the merge base and the head only once it comes near the view, and draws before/after with Swipe working
- with no merge base, the after side draws alone with "Previous version unavailable"
- an SVG draws as a picture until its source is asked for
- an image from a service that names no commits stays a binary file
- a side whose read failed is read again on Refresh changes
- an SVG renamed without changes offers no source
- the chosen comparison and slider survive Refresh changes
- after a refresh, an image far from the view reads nothing until it comes near again
pnpm check:affectedran out of memory in this sandbox, so the checks above were run one at a time instead.
Invariants
-
./scripts/check.shgreen. (CI) - JSON Schema goldens: changed, explained above.
- Every behavior change has a test that fails without it; no change-detector test added.
- Gateway envelope goldens unchanged.
- No new
asassertion. - No
Effect.run*outside the edges. - No new raw async primitives.
- No discarded Effect.
- Test count: +11, listed above.
- No hand-rolled file replaced.
- No
CLAUDE.md/AGENTS.mdsentence names a changed part. -
PRIVACY.mdunchanged. Images stay inside the list'sph-no-capture. - No dependency change.
- Barrel/door rule untouched.
Notes
- #3304 has merged; this PR is restacked on
main.



