perf(desktop): draw only what changed in Files changed on a key or a scroll
Summary
The developer said the Files changed tab feels laggy. Profiling the real AgentPullRequestTab showed that every draw of the view rebuilt every file's entry:
- It re-parsed each patch with jsdiff to place review threads (
threadsOn→drawnLines→parsePatch). - It respelled each list patch, so the bundle compared hundreds of KB of strings.
- It reconciled every header with its two Tooltips.
A J press redraws the view, and so does every file the list scrolls past (onTopFile). On a 300-file pull request that came to about 55 ms of React per J press.
This PR makes a draw touch only the files that changed:
- Entries are kept.
useEntries(agent-changes-tab.tsx) keeps each file's entry while its inputs stay the same by identity: the file, fold, standing, viewed, source, images, this file's threads, the comment, and the list's doors. A kept entry hands React the same header and note elements, so React leaves them as they are.diff-list.tsxhands the bundle the sameDiffListFilefor a kept entry. - Parse once per answer.
drawnLinesandlistPatchare cached per file object (@luke/panel/tasks/changes-model).threadsOnreads no lines for a file that has no thread. - Stable doors.
toggleandmarkViewedinuseChangesListread the latest folds and marks through refs, so a kept header still toggles the file as it is now. - Top file drawn only when it is shown. The file at the top is kept in a ref while a file is stood on, because the bar names the stood-on file then. Letting go (Escape) draws the latest top.
- Lazy header hints.
FileHeadmounts its two Tooltips the first time the pointer or the keyboard reaches the header, rather than 2 × N up front. The fold always carriesaria-keyshortcuts. When it was keyboard focus that mounted the hints, the fold takes the focus back. This is the desktop's owntooltip.tsx; nothing in@luke/uichanges. - Stable threads. The Pull request tab keeps the
threadsit hands to Files changed (useMemo/useCallback) while the conversation and the minute stay the same.
Before / after
The numbers come from a throwaway harness that mounts the real AgentPullRequestTab on Files changed. It is built by esbuild with the app's own plugins, uses production React with the profiler, and serves the real GitHub file answers. It ran in headless Chromium at 1280×800 under Playwright. Each figure is the median of 3 runs.
The scroll test moves the list 60 px per frame for 4 s. The key test presses J 10 times, 150 ms apart. "React" is the Profiler's actualDuration summed over the commits. Long tasks come from PerformanceObserver. This is Linux headless Chromium, not Electron on a Mac, so compare the columns rather than the absolute values.
main | this PR | |
|---|---|---|
| #2855 (300 files) React per 10 J presses | 549 ms (17 commits) | 24 ms (10 commits) |
| #2855 long tasks per 10 J presses | 779 ms | 185 ms |
| #2855 React on load | 388 ms | 198 ms |
| #2855 first drawn line | 1058 ms | 746 ms |
| #2855 long tasks on load | 1197 ms | 831 ms |
| #2908 (122 files) React per 10 J presses | 232 ms | 20 ms |
| #2908 React during the 4 s scroll | 221 ms | 27 ms |
| #2908 React on load | 211 ms | 146 ms |
| #3466 (15 files, images + md) React during the scroll | 93 ms | 74 ms |
What remains on these pull requests is Shiki highlighting on the main thread (about 1.3 s of a 4 s scroll on #2908), which the next PR moves to a worker. The rest is Pierre's own height reconciliation, and Pierre's tree recomputing its layout on each selection change (about 11 ms per J press).
Screenshot: a header's hint, mounted on hover (#2908):

Evidence
tsc --noEmitfor desktop and panel: clean../scripts/oxlint.sh: 0 warnings, 0 errors.pnpm knip: clean../scripts/repository-checks.sh: passed.biome check: clean. The one warning is pre-existing, inmac-terminal-tab.tsx.vitest run --project desktop --project panel: 221 files, 2221 tests passed.- New tests in
agent-changes-tab.test.ts(the last two come from Devin's review):- "a J press draws again only the files it stood on and off…": it counts the files drawn anew through
threads.draw. Onmainit fails, because c.ts and d.ts are drawn again too. - "a file's header mounts its hints as the keyboard comes to its fold, which keeps the focus": it fails if the refocus is removed.
- "the pointer's first arrival on a header's letter hangs its hint…": the newly mounted control is told that the pointer is already on it.
- "with the file stood on filtered out, the bar names the file the list scrolls to".
- "a J press draws again only the files it stood on and off…": it counts the files drawn anew through
- In the harness (Chromium), I checked by hand that hover and keyboard focus both show the lazily mounted hint, and that focus stays on the fold.
Invariants
- Checks green locally, as listed above.
- JSON Schema goldens unchanged.
- Every behaviour change has a test that fails without it.
- No change-detector tests.
- No new
as. - No new
Effect.run*. - No raw async primitives.
- No discarded Effect.
- Test count +4.
- No
AGENTS.md/CLAUDE.mdchange. -
PRIVACY.mdunchanged. - Deps unchanged.
Notes
This is the second PR of the Files changed stack. Next is moving Shiki into Pierre's worker pool.
Made with Luke