perf(desktop): draw only what changed in Files changed on a key or a scroll

Merged
DA
2d ago
+340-61

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.tsx hands the bundle the same DiffListFile for a kept entry.
  • Parse once per answer. drawnLines and listPatch are cached per file object (@luke/panel/tasks/changes-model). threadsOn reads no lines for a file that has no thread.
  • Stable doors. toggle and markViewed in useChangesList read 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. FileHead mounts its two Tooltips the first time the pointer or the keyboard reaches the header, rather than 2 × N up front. The fold always carries aria-keyshortcuts. When it was keyboard focus that mounted the hints, the fold takes the focus back. This is the desktop's own tooltip.tsx; nothing in @luke/ui changes.
  • Stable threads. The Pull request tab keeps the threads it 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.

mainthis PR
#2855 (300 files) React per 10 J presses549 ms (17 commits)24 ms (10 commits)
#2855 long tasks per 10 J presses779 ms185 ms
#2855 React on load388 ms198 ms
#2855 first drawn line1058 ms746 ms
#2855 long tasks on load1197 ms831 ms
#2908 (122 files) React per 10 J presses232 ms20 ms
#2908 React during the 4 s scroll221 ms27 ms
#2908 React on load211 ms146 ms
#3466 (15 files, images + md) React during the scroll93 ms74 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):

lazy hint

Evidence

  • tsc --noEmit for 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, in mac-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. On main it 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".
  • 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.md change.
  • PRIVACY.md unchanged.
  • 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


Devin Review