feat(desktop,service,features): draw changed images before and after in Files changed

Merged
DA
1d ago
+1046-36

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 IntersectionObserver on 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, which img-src allows. The swipe clip and the onion-skin opacity are set through React's style (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_IMAGE act, as raw.githubusercontent.com/<owner>/<name>/<sha>/<path>. #3206's githubImageOf reads 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:

  • headSha comes from one extra GET /pulls/{n}, run in parallel with the files and people reads the endpoint already makes.
  • baseSha is the merge base, not the base branch's tip, so a "before" picture is never one the pull request didn't have. It comes from GET /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, baseSha is left out and the window shows the after side alone with "Previous version unavailable". It never falls back to the base branch's tip.
  • imageTypeOfPath is 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 aboveSwipe (dark, split setting)
2-upswipe
Onion skinMerge base unreadable: after alone
onionno base

Narrow (600px, tree hidden):

narrow

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=1 compare
      • 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
    • 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:affected ran out of memory in this sandbox, so the checks above were run one at a time instead.

Invariants

  • ./scripts/check.sh green. (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 as assertion.
  • 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.md sentence names a changed part.
  • PRIVACY.md unchanged. Images stay inside the list's ph-no-capture.
  • No dependency change.
  • Barrel/door rule untouched.

Notes

  • #3304 has merged; this PR is restacked on main.