Repository navigation
gpui: keep a long chat transcript on its scroll layer while it scrolls - #56
Merged
Merged
Conversation
This was referenced Oct 10, 2026
sunli829
marked this pull request as ready for review
October 12, 2026 02:36
A list row on a scroll layer was found changed when it read an entity written since it was rendered, unless the write was made while the row rendered. Writes made while the row was laid out, prepainted or painted counted as someone else's: a chart filling its path cache as it paints made its row, and every row like it, render again on every scrolled frame, so the layer bought nothing. Those writes are now the row's own, as a view's writes while it renders are. The write generation is shared with the thread, as the read logs are, for the list to know where it stands without an app at hand.
A list's layer now keeps its rows' paint operations and replays those of the rows that reach the viewport into the frame, moved by the scroll, rather than rasterizing them into tiles. A composited frame costs what painting the rows shown costs, without rendering, laying out or prepainting them again. With nothing to rasterize, a list's layer is no longer kept off for what tiles cannot do: rows painting paths, or drawing over the paths of rows before them, are replayed in order with the rest, and a background that cannot be baked does not matter, as the rows are drawn over whatever is under them. Nor is it demoted: painting its rows afresh costs what drawing them without the layer does, and the frames between two changes composite them. In a chat transcript, whose tall rows paint charts and rounded tables over a translucent window, the tiled layer was demoted or kept off on most frames; replayed, it is composited on nearly all of them. OverlayReach, which only lists used, goes.
ListState::set_offset_from_scrollbar moved the list's state version, which counts changes of the list's content as well as of its offset, so every frame of a scrollbar drag painted the list's layer afresh, rendering every row it shows. The offset it sets is now noted as offsets code sets are (ScrollLog::take_offsets_set), matched to the painted list by its state, and taken for a scroll, as a wheel's is: the frame composites the layer and renders only the rows the drag brings. Without layers the version still moves.
sunli829
force-pushed
the
fast-chat-scroll-cpu
branch
from
October 12, 2026 03:47
35cd970 to
7c61fc5
Compare
sunli829
added a commit
that referenced
this pull request
Oct 12, 2026
…57) **Experimental, stacked on #56.** Changes hover semantics for every app, so it is a separate PR for discussion. A wheel moves what is under the pointer without the pointer moving: every frame of a wheel scroll hovered another row, rendering the row it left and the row it entered again. A hover could also start an animation (a hover card fading in, a toolbar showing) that kept the list off its layer. In Allsum's long transcript, wheel scrolling cost about what it costs without layers. For 100 ms after a wheel event, while the pointer stays where the event was, nothing is hovered: the frame's hit test, and the one a list foretells for its rows, are empty. When the wheel stops the window is drawn again (a timer refreshes it) and hover follows the pointer; a pointer that moves ends it at once. - `fast/layers/hover_freeze.rs`: the state (per window, in `WindowLayers`), `note_wheel` (from `input::before_dispatch`), `begin_draw` (from `retained::begin_frame`), `hit_test`. - `window.rs`: `mouse_hit_test` made `pub(crate)`; the post-prepaint hit test goes through `hover_freeze::hit_test`. `script/upstream-allowlist` raises window.rs's added budget from 80 to 82 for these two lines. - gpui's own unit tests scroll under a still pointer to test hover as rows move, so the freeze is off under `cfg(test)`; `hover_held_still_while_the_wheel_scrolls_renders_no_row_for_it` turns it on (and fails with it off). Measured in Allsum (experimental build, same binary, toggled at runtime): wheel CPU 37 % without, 21 % with; drag unchanged (~20 %). Open questions: - Clearing hover (rather than keeping the last hovered element) makes a hovered row lose its hover style for the duration of the scroll. Hitbox ids are per frame, so keeping the old hit test is not safe as is. - Part of the gain may come from hover-started animations rather than hover itself; an investigation of which requests drop the layer during wheel scrolling (GPUI Kit's scrollbar fade, ai-chat's outline, a chart's presence animation) is ongoing. `script/check-upstream`: 0 violations. `cargo test -p gpui --features test-support --lib` (681 passed), clippy pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
sunli829
added a commit
to longbridge/gpui-kit
that referenced
this pull request
Oct 12, 2026
…a closed popup (#3431) ## Background Allsum users reported high CPU when dragging the scrollbar of a longer AI chat conversation. Under gpui-fast's retained views, whatever did not change since the last frame is drawn from it; two things in `gpui-base` kept a long list of rendered markdown from ever being unchanged. Both changes are in `gpui-base` (AGENTS.md asks for an explicit request before changing Base; this was requested as part of the Allsum investigation). ## Changes - **`text/inline.rs`: read the pointer only under hovered text.** `Inline::paint` read `window.mouse_position()` on every paint to find the link under the pointer. Reading the pointer makes the text depend on every pointer move, so a scrollbar drag, or any mouse move, drew every paragraph of the transcript again. It now reads the pointer only when its own hitbox is hovered; text not under the pointer has no link under it, so the result is the same. - **`popup.rs`: a closed popup asks for no frame.** A `Popup` asked for an animation frame the first time it measured its trigger, so that its content could be placed on the next frame. A closed popup has no content to place, yet every list row coming into view with one asked for a frame, and a list whose rows ask for frames is kept off its scroll layer. It now asks only when it has content; opened later, it places its content at once from the trigger bounds it already noted. ## Measurements In Allsum, with the gpui-fast fixes (longbridge/gpui-fast#56) and longbridge/ai-chat#438: turning off the inline-text change raised scrollbar-drag CPU from ~19 % to ~38 %; turning off the popup change raised it to ~27.5 %. With everything, wheel 47 % → 22 % and drag 32 % → 20 % against main. ## Tests - New `a_closed_popup_asks_for_no_frame_and_opens_at_once` (a closed popup renders its view once; opened, its content is placed on that frame). - **Not yet run locally**: resolving the workspace needs the `quickjs` git dependency, whose fetch is still in progress here. The changed code compiles (built into Allsum on top of 02daace). I will update this once the tests have run. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
trancong12102
added a commit
to aislopware/gpui-fast
that referenced
this pull request
Oct 12, 2026
Since longbridge#56 a scrollbar setting a list's offset leaves the list state's version alone, for the list to be drawn from its layer, and counts as a scroll only once a scroll container decides on its layer. A frame after code set the offset without notifying the view holding the list (a bounce, a test drawing) then drew that view from the last frame: the list stayed where it was, and its wheel listener scrolled on from the old offset (gpui-kit's ScrollBounce list tests: -50 where -1000). Offsets set since the last frame are taken in as the frame begins, and the view that laid out a list scrolled so is built again, as if notified; its list composites from its layer as for a wheel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Users of Allsum reported high CPU when dragging the scrollbar of a long conversation. Profiling Allsum (release build, a long "Greeting" session with charts, tables and markdown) showed the transcript list's scroll layer bought almost nothing: rows were rendered again on every frame, and the layer was demoted or kept off on most frames. Three fixes here, one commit each.
Stacked on #48. The base branch
fast-robust-retention-on-mainis #48 merged into currentmain(conflict infast/tests/mod.rsresolved), so this diff shows only these changes.1. A list row's writes while it is drawn are its own
Writes made while a row was laid out, prepainted or painted counted as someone else's. A chart filling its own path cache as it paints made its row (and every row like it) render again on every scrolled frame. Those writes are now the row's own, as a view's writes while it renders already are. The write generation is shared with the thread like the read logs, for the list to read without an app at hand.
Test:
rows_writing_what_they_read_as_they_are_drawn_keep_the_layer(fails without the fix: every shown row re-renders per scroll).2. Replay a list's layer instead of compositing tiles
A list's layer keeps its rows' paint operations and replays those reaching the viewport into the frame, moved by the scroll. No tiles are rasterized for lists, so:
Overscan and the per-frame render budget are unchanged.
OverlayReach(lists only) is removed.Tests that encoded tile, overlay or demotion behaviour for lists were rewritten to assert the new behaviour; every
matches_layers_offcomparison passes unchanged.composites()in the test helpers now also counts replayed frames.This is the change most worth discussing: it trades GPU-side tile compositing for CPU-side replay of display lists for lists. In Allsum it was the largest single win for scrollbar drag.
3. A list's scrollbar drag is a scroll
ListState::set_offset_from_scrollbarbumped the list's state version, which counts content changes too, so every drag frame repainted the layer. It now notes the offset like other code-set offsets (ScrollLog::take_offsets_set, matched to the painted list by its state), which counts as a scroll. Without layers the version still moves.Test:
a_list_dragged_by_its_scrollbar_is_composited(fails without the fix).Measured in Allsum (experimental build: these fixes, the hover freeze of the stacked PR and the app-side fixes; the zero-overscan variant of replay. Without hover freeze, wheel CPU was ~37 %; drag was unchanged)
Measured again with the code of these PRs
Allsum release build against this branch stack (#56, #57 and the lingering-nodes fix of #55), GPUI Kit's draft fix and longbridge/ai-chat#438, same session, quiet machine:
Keeping the overscan unchanged (unlike the experimental build) lost nothing.
The app-side fixes are in separate PRs (GPUI Kit, ai-chat). Hover freezing while the wheel scrolls is a separate draft stacked on this one.
script/check-upstream: 0 violations.cargo test -p gpui --features test-support --lib(681 passed),cargo test -p gpui_perf,cargo clippy -p gpui --features test-support --lib --testspass.🤖 Generated with Claude Code