Skip to content

feat(desktop): expand context lines in Files changed - #3762

Merged
luke-agent[bot] merged 21 commits into
mainfrom
luke/files-changed-expand-context
Oct 19, 2026
Merged

luke-agent[bot] merged 21 commits into
mainfrom
luke/files-changed-expand-context

Conversation

@dastratakos

@dastratakos dastratakos commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

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 fix(desktop,service,features): draw bot badges and repository screenshots on the Pull request tab #3206 contents path. There is no new route and no service change.
  • The read is ACT_KIND.TASKS_FILE (GET /api/plans/{taskId}/file) with raw: true and ref: headSha, on the App's installation token, under the route's own refusals and 1 MiB bound. headSha already comes back with the changes answer (feat(desktop,service,features): draw changed images before and after in Files changed #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 no blobSha, 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 \n only, so CRLF files keep their \r on both sides (also found by Devin).

When the file is read

  • Never up front. Pierre's loadDiffFiles asks 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-wire so 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 (planId null).
  • 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 expandHunk inside CodeView. 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 (commentLines is 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 --noEmit for 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/tasks and packages/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.
  • Bundle: diff-view.js is 256,351 gzipped bytes, under the 256,515 baseline from perf(desktop): highlight Files changed off the main thread #3752. panel.js is 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 blobSha on each file.
  • Behaviour tested.
  • No new as.
  • No new Effect.run*.
  • No raw async primitives.
  • No discarded Effect.
  • Test count +14.
  • Deps unchanged.
  • PRIVACY.md unchanged. File text was already read through this route.
  • No AGENTS.md change.

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.error for that one load. Its arrows are gone from the next draw.

Made with Luke

dastratakos and others added 12 commits October 18, 2026 20:56
…anged

The Files changed view led with a strip naming who opened the pull
request, its reviewers, and who authored its commits. The Pull request
tab's header above it already names the opener and the reviewers, so the
strip goes, and the view leads with its files.

"Commits by" stood nowhere else, so what only it read goes too: the
changes answer's commitAuthors, the service's paged read of the pull
request's commits, the co-author trailers read off them, and the App's
bot avatar they were drawn by (GitHubApp.botAvatarUrl).

Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
…scroll

Every draw of Files changed rebuilt every file's entry: it parsed each
patch again to place review threads, spelled each list patch afresh, and
reconciled every header and its two hints, so a J press on a pull request
of 300 files spent about 55 ms in React and a scroll redrew the whole tab
for each file it passed.

Each file's entry is now kept while nothing it is drawn from changed, its
parsed lines and list patch are read once per answer, the list's doors
stay the same doors across draws, the top file is drawn only while no
file is stood on, the threads the Pull request tab hands down are kept
while the conversation and the minute stand, and a header mounts its
hints only once the pointer or the keyboard comes to it.

Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
…ader's hint on the first hover

Two findings from review of the lazy header hints and the quiet top file:
the top file was held back while any file was stood on, even one the
filter left out, so the bar named a stale file; and the pointer's first
arrival on a fold or a letter mounted its hint without telling it the
pointer was there.

Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
…ter event alone

Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
Shiki colouring a pull request's files was most of what a scroll through
Files changed cost the window's thread: on a pull request of 122 files,
about 1.3 s of a 4 s scroll, with frames up to a second long while a large
file was highlighted, and again each time a file scrolled back into view.

The list now hands highlighting to Pierre's own pool of two workers. The
build bundles Pierre's worker whole as one classic script, its WebAssembly
engine left out, and hands the diff bundle its text, which each worker is
started from as a blob:, the one place the panel's policy admits a worker
from. Each patch is read under a name of its own, so the pool's cache hands
a file scrolled back into view its colours again. Where no worker can
start, the list highlights on the window's own thread as before
(@luke/panel/diff-view/highlight-pool).

The diff bundle grows by the worker's own copy of Shiki, about 77 KB
gzipped; the panel's bundle does not grow.

Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
The pool is kept for the window's life, so a list drawn after the diff
settings changed was highlighted in the look the pool was first started
in. Each list now hands the pool its own look as it is drawn, as a list
already did when its look changed while it stood.

Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
Each gap between a changed file's hunks, and the unchanged lines before
the first and after the last, now opens as GitHub's diff does: twenty
lines up or down a press, or all of them at once.

The file is read whole only the first time one of its gaps is opened, at
the pull request's head, through the contents read a File tab already
makes (TASKS_FILE, raw, at that commit), and kept for the answer's life.
Its text before is turned back from that text and its patch, every line
no hunk holds being the same on both sides, so one read stands for both;
a text that does not match the patch at every hunk opens no gap rather
than drawing lines of another commit. No file is read whole for an agent
on no task, or where the task's repository is not the pull request's.

Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
@vercel

vercel Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
luke-web Ready Ready Preview Oct 19, 2026 3:40am UTC

Request Review

luke-agent Bot added a commit that referenced this pull request Oct 18, 2026
Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
dastratakos and others added 2 commits October 18, 2026 23:35
…hole, and say how they work

A file the service never reads whole, a secret one, now offers no arrows
on its gaps, and a file whose read the service refused, or whose text is
not the one its patch was made against, closes its gaps on the next draw
and says why in one quiet line above its first hunk. Pierre offers the
arrows only for a file changed in place or moved with changes, so a file
whose gaps are closed is told it only moved, which draws its hunks alike.

The secret rule moves to @luke/features/task-wire, so the window and the
service read one rule.

A gap's arrow hangs a hint as the window's controls do: "Show 20 more
lines" with "⇧-click for all" on a long gap, "Show these lines" on a
short one.

Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
luke-agent Bot added a commit that referenced this pull request Oct 18, 2026
Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
@luke-agent
luke-agent Bot marked this pull request as ready for review October 18, 2026 23:36
@luke-agent
luke-agent Bot enabled auto-merge October 18, 2026 23:36
devin-ai-integration[bot]

This comment was marked as resolved.

@luke-agent
luke-agent Bot disabled auto-merge October 18, 2026 23:40
dastratakos and others added 2 commits October 18, 2026 23:55
…nts its patch was made against, line endings kept

Two findings from review of reading a file whole. The service reads a pull
request's files and its head beside each other, so a push between them
could pair a patch with a head whose hunks still match but whose lines
between them do not, and those lines were drawn as unchanged on both
sides. Each changed file now names the git object its contents after are
(blobSha, from GitHub's own listing), and the window opens a file's gaps
only where the text it read is that object.

And a file whose lines end in a carriage return lost its gaps, because
jsdiff drops the return from a patch's lines while the file's text keeps
it; the patch is now read as written, each line split at its newline
alone, and turned back with its endings as they were.

Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
dastratakos and others added 2 commits October 19, 2026 00:12
…s lines and its refusals

Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
@luke-agent
luke-agent Bot added this pull request to the merge queue Oct 19, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 19, 2026
Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com>
@luke-agent
luke-agent Bot enabled auto-merge October 19, 2026 03:37
@luke-agent
luke-agent Bot added this pull request to the merge queue Oct 19, 2026
Merged via the queue into main with commit a85aba6 Oct 19, 2026
11 checks passed
@luke-agent
luke-agent Bot deleted the luke/files-changed-expand-context branch October 19, 2026 03:55

This branch was successfully deployed

1 active deployment
Preview — 1b3b3b98 Deployed Oct 19, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant