feat(desktop): expand context lines in Files changed
Summary
The developer asked for expandable context lines in the Pull request tab's Files changed, as GitHub and stage-cli have. Every collapsed gap in a changed file now expands: the "N unmodified lines" between hunks, the lines before the first hunk, and the lines after the last. Each press opens 20 lines up or down, and expand-all (or shift-click) opens the whole gap. These are Pierre's own separator controls.
How the window gets the file
- The file is read through the existing #3206 contents path. There is no new route and no service change.
- The read is
ACT_KIND.TASKS_FILE(GET /api/plans/{taskId}/file) withraw: trueandref: headSha, on the App's installation token, under the route's own refusals and 1 MiB bound.headShaalready comes back with the changes answer (#3638). - Only the head is read. The base text is rebuilt from it with
textsOf(@luke/panel/tasks/changes-model): every line outside a hunk is the same on both sides, so the base is the head with each hunk turned back, keeping its context and-lines and dropping its+lines. "No newline at end of file" is handled on either side. - Same-contents guard: each changed file now carries the git blob that GitHub's own file listing names for its contents after (
blobSha, an optional key on the changes answer). The window opens a file's gaps only when the git blob SHA of the text it read (blobShaOf) matches that blob. The service reads the files and the head side by side, so a push between those reads could otherwise pair a patch with newer lines in its gaps (found by Devin). A service from before this change sends noblobSha, and the hunk check below stands alone. - Mismatch guard: if any hunk's context or
+lines differ from the head text at that hunk's place, the file opens no gap. It never draws lines from another commit. - Line endings: the patch is read as written, each line split at
\nonly, so CRLF files keep their\ron both sides (also found by Devin).
When the file is read
- Never up front. Pierre's
loadDiffFilesasks for a file the first time one of its gaps is pressed, and hydrates that file's patch in place. - The view keeps each file's read for as long as the answer stands, so there is at most one read per file. A new push (Refresh) re-reads.
No dead arrows
-
Gaps are offered only where they can open. Pierre offers a gap's arrows only for a file whose kind is "changed in place" or "moved with changes". It draws hunks the same whatever the kind, so a file that must not expand is handed to Pierre told it "only moved" (
drawnAs, with no cache key, so Pierre doesn't reuse a drawing that had arrows). That gives per-file control without patching Pierre. -
Known up front: secret paths (
.env*) and files not changed in place get no arrows. The secret rule moves to@luke/features/task-wireso the window and the service share one rule. -
Refused after reading: a file the route refuses ("too large or not plain text"), a file whose head doesn't match its patch, and a failed read all close that file's gaps on the next draw. Each shows one quiet line above the first hunk saying why:
- "Can't load more of this file: it's too large or not plain text."
- "…: it changed since these changes were read."
- "… just now."
The line sits above the file's first hunk rather than inside each gap, because the separators are Pierre's own markup.
-
Hint on the arrows: a long gap's arrow says "Show 20 more lines" with "⇧-click for all"; a short gap's arrow says "Show these lines". It's the window's own
TooltipPill, anchored to the arrow inside Pierre's shadow root.
Which files can expand
- Only files that are modified, or renamed with changes, and whose patch is drawn.
- Added, deleted, binary, too-large and omitted files open no gap, and neither do image leads.
Where nothing is read
- An agent on no task (
planIdnull). - A pull request whose head the service didn't name.
- A task whose repository isn't the pull request's (compared case-insensitively with the repository named in the pull request's URL). The route reads the task's repository, so it can't promise to reach the pull request's otherwise.
Extra GitHub requests: none up front, since blobSha comes from the file listing GitHub already returns. Each file costs one contents read at headSha on its first expand. The service keeps the App's reach and installation token cached, so a warm expand is just that one request; a cold one also mints a token, as a File tab read does today.
Layout, threads and selection
- Expansion is Pierre's own
expandHunkinsideCodeView. It re-measures the item, so sticky headers and the top-file tracking follow. - I checked that an expanded gap is still expanded after scrolling far away and back.
- Threads stay anchored to their lines. A thread on a line outside the hunks stays above the file as before; moving it onto the expanded line is a follow-up.
- Comments on expanded lines aren't offered, because GitHub's API only takes comments on lines in the diff (
commentLinesis unchanged).
Highlighting: hydrated files are highlighted in the worker pool from #3752.
Screenshots
PR #2908's build.mjs in the real AgentPullRequestTab, drawn by the harness, with a review thread on line 356.
| light | dark | |
|---|---|---|
| A 347-line gap, collapsed | ![]() | ![]() |
| One press: 20 lines | ![]() | ![]() |
| Expand all | ![]() | ![]() |
| The thread still under line 356 | ![]() | ![]() |
A gap's arrow hint, light and dark:


A refused file after its first press: its arrows are gone and it says why (light, dark):


The trailing gap reads "More unchanged context may be available" before the file is read, then opens to the file's end:


Evidence
tsc --noEmitfor desktop and panel: clean../scripts/oxlint.sh: 0 errors.pnpm knip: clean../scripts/repository-checks.sh: passed.biome check .: no errors.vitest run --project desktop --project panel: 222 files, 2254 tests, all passed.packages/service/src/tasksandpackages/features/src: 775 passed.- New tests:
changes-model.test.ts: the base rebuilt correctly wherever the hunks fall (middle, first line, last line, additions only, removals only, hunks far apart); all four newline-at-end cases; a rename turned back; added, deleted and omitted files not turned back; a mismatched context line, added line or short file refused.agent-changes-tab.test.ts:- A file is read once, at
headSha, raw, and only when its gap is first opened. Nothing is read up front, and an added file isn't read. - A refusal, or a head that doesn't match the patch, opens no gap.
- Nothing is read for an agent on no task, an unnamed head, or a task in another repository. The repository match ignores case.
- Secret and added files are drawn with no arrows.
- A refused read and a mismatched head each close the file's gaps and say why.
- A gap arrow's hint gives the long-gap and short-gap wording.
- A file is read once, at
- Bundle:
diff-view.jsis 256,351 gzipped bytes, under the 256,515 baseline from #3752.panel.jsis unchanged within noise. - In the harness, in Chromium, I checked by hand that one press opens 20 lines, expand-all opens the whole gap, and the trailing gap opens. Exactly one file read went out, and expanded gaps survived scrolling away and back.
Invariants
- Checks green locally, as listed above.
- JSON Schema goldens: the two changes-answer schemas gain the optional
blobShaon each file. - Behaviour tested.
- No new
as. - No new
Effect.run*. - No raw async primitives.
- No discarded Effect.
- Test count +14.
- Deps unchanged.
-
PRIVACY.mdunchanged. File text was already read through this route. - No
AGENTS.mdchange.
Notes
- Follow-up: move a thread on a line outside the hunks onto that line once it's expanded.
- Follow-up: reading by agent rather than by task would serve agents on no task. That would mean an agent-keyed contents read on the agent's own reach; it needs a decision first.
- The first press on a file whose read is then refused logs Pierre's own
console.errorfor that one load. Its arrows are gone from the next draw.
Made with Luke







