perf(ui): virtualize every list that grows with user data - #557
Merged
Merged
Conversation
The palette laid out one row per non-archived thread on every frame, so on a host with hundreds of threads the phone sheet opened from + stuttered while scrolling and typing. Results are now a cached row model rendered through ListState, rebuilt on query, content-hit and index changes.
PopupMenu laid every item into one unbounded column, so a data-fed menu such as the sidebar's project filter or new-draft picker, one row per project, ran past the window edge and its last rows were unreachable, most visibly at phone height with 44px touch rows. The items now sit in a vertical scroll area capped at the window's safe content height less the anchors' edge margin, so a menu that fits is unchanged and a longer one scrolls. Keyboard selection scrolls the chosen item into view; ScrollArea gains track_scroll so the menu can drive it with its own handle.
The desktop Grouped layout built and laid out every project header and, once a project was expanded, every thread row on each frame inside a plain scrolling column, so a host with hundreds of threads made every sidebar render proportional to the whole index. The grouped list is now flattened into project, thread, show-more, auto-archive notice and settled rows, built by one owner that keyboard navigation shares, and rendered through its own ListState like the Flat and compact lists, with the same bounce viewport and scrollbar. When the row keys change, the list is spliced keeping the row at the scroll top anchored; the compact list now uses the same helper for its anchor.
The Add agent dialog built and painted one row per ACP registry agent on every frame, so scrolling the growing registry on a phone repainted the whole list. The filtered agents now render through a ListState that is reset only when their ids change; the registry is measured whole once per reset or width change so the scrollbar and touch bounce see its full extent, and scroll frames paint only the visible rows.
The project icon picker built a tile for every image and subdirectory of the browsed folder on every frame, and each image tile requested its thumbnail from the host, so a large folder loaded every thumbnail up front and scrolled the whole grid. The matching entries now render as a uniform_list of rows, with the tiles per row derived from the grid's measured width, so only visible rows are built and only their thumbnails are requested. Browsing to another folder starts at the top.
A Markdown view is laid out at full height inside its outer scroller (the chat timeline, the plan panel), and its inner block list only builds the root blocks that intersect the outer content mask. That culling was per root block, so a single long block was built whole whenever any part of it was near the viewport: a 3,000-line code block rebuilt 3,000 line elements, and recomputed highlight runs by scanning every run for every line, on each frame; a long list rebuilt every item. Rendering also deep-cloned the parsed document three times per frame. The root list now holds items rather than blocks: a code block longer than 32 lines or a list longer than 8 items is split into consecutive items that together paint the same single block (one background, rounded only at the ends, same padding, same list numbering). A code item slices the highlight runs to its span and updates only its lines' selection state. The parsed document is shared behind an Rc instead of being cloned per frame. Selection content keys are item indices; a bounded copy slices the end blocks to the selected items, and a code block's lines between the selection ends are copied whole even if they were never painted. A streamed append keeps the measurement of every item of the growing block that still paints the same, so only its tail items are remeasured. measure_all stays: the view reports its exact height to the outer scroller, so every item is measured once per document and width; size hints would make the outer row height wrong. Tables remain one item: their column widths depend on every row and the track is one horizontal scroll area.
The branch pickers, diff turn menu, commit and approval file lists, the composer and settings model pickers and the composer trigger menu built one element per item on every frame, so long branch lists, OpenCode catalogs and large patches stuttered on phones. scroll::VirtualList is a bounded viewport over gpui's uniform_list (rows of one height) or list (rows measured as they come into view). It keeps its scroll state while rendered, registers wheel easing and the edge-chaining mask like overflow_y_scroll_area, optionally overlays the scrollbar, and without a definite height shrinks to its rows up to its cap. Row gaps move into the rows; the captions above the branch and turn lists stay pinned. The settings model picker now resolves only the shown profile's catalog instead of every enabled profile's on each render.
# Conflicts: # crates/ui/src/scroll.rs
An expanded Work Log built every folded activity of its run inside one timeline row, so opening the capsule of a long turn (hundreds of tool calls) laid out and painted all of them on every frame. Each rendered row also copied its whole turn's entries out of the store every frame. An expanded Work Log with folded activities now indexes as its header row, one row per folded activity and a row for the live window, so ListState only lays out the activities on screen. The header keeps the collapsed row's identity, and list sync splices the activity rows in and out instead of resetting, so expanding, collapsing, a sliding live window and the turn settling keep the reader's scroll position. Spacing between the parts is the Work Log's own, so the layout is unchanged. A row now copies only the entries it renders, and the turn's last row the turn for its trailer. A 400-activity expanded Work Log on a phone-sized window drew a frame in 13.2 ms before and 1.8 ms after (release build, M-series desktop).
The virtual palette list knew only the heights of rows it had laid out, so wheel and touch scrolling stopped short of the end of a long list. The height hint the sidebar seeded from its scrollbar is now scroll::list_height_hint, shared by the sidebar and the palette.
An expanded live file edit rebuilt its diff (parse, line diff and syntax highlighting of the whole patch) on every render and laid out every line inside its 240px scroll area. The latest edits auto-expand while a turn runs, so a long patch cost a rebuild and a full layout on every frame of the running turn's 100ms ticker. The built rows are now cached per row key in the chat view, rebuilt when the path, patch or theme changes and dropped when the row collapses or the session changes, and rendered through a uniform list that lays out only the visible lines. The list is sized from its widest line and scrolled by masks on both axes, so a long line now scrolls sideways (before, the scroll content was only as wide as the viewport and long lines were clipped) and neither axis moves the timeline while the diff can. For an 801-line patch, building the diff takes 60 ms and a cache hit 2 us; a phone-sized frame with it expanded drew in 136 ms before and 0.9 ms after (release build, M-series desktop).
An expanded disclosure card (orchestrate context, child-thread callback) built one div per line of its verbatim body on every frame, so a several-thousand-line context cost thousands of layout nodes per frame although the card shows 320px of it. The body is now one text element with the card's line height. GPUI shapes each newline-separated line, empty and trailing ones included, at that line height and wraps it at the card width, so the laid-out body is the same: a 3,000-line context measured 321 x 120220 px before and after on a phone width, and a short one with blank, tabbed and wrapped lines 321 x 220 px. A phone-sized frame with the 3,000-line context expanded drew in 10.8 ms before and 2.2 ms after (release build, M-series desktop).
A grouped sidebar at its top anchored to the first project's header, so a project moving up by recent activity was pushed above the viewport, and a list whose top row was archived or folded jumped to the top. A list at its top now stays there and a vanished row keeps its index. The palette reveals the keyboard selection, which is no longer in the tree once scrolled away, and rebuilds its actions when the active session changes.
The gap below a root block depends on whether it ends the document, but a streamed append kept the measured items of the old trailing block whenever its content was unchanged. Appending a new block after it left its final item one gap short, so the view reported a height 1rem short per streamed block boundary. The final item of a block is now kept only while the block's last-ness is unchanged, and the full-reparse path always compares the old last block.
With an expanded Work Log the live window is a row of its own. Each new activity folded the window's oldest activity into a new row inserted above it, and ListState kept the reader's offset into the live row, which now started one activity later, so a reader resting in it crept up one activity per new activity. When the turn ended the live row was replaced by folded rows, and a splice over the anchor resets it to the first new row at offset zero, a jump of up to five activities. List sync now reports such splices as carried: the new rows continue the old row's content in order. Activities folded out of the live window splice over it, and the view restores the reader's offset into the first new row. The offset may pass that row's end until it is measured; the next render moves it onto the row it lands in, so a later remeasure cannot clamp it.
uniform_list measures one row at its widest and gives every row that height, so a branch, path or model name too wide for its picker wrapped and overlapped the next row. Uniform rows now never wrap, and their labels truncate. A content-sized gpui list measures its rows at their widest too, so a short approval list whose long paths wrap on a phone was sized for single lines and hid its second file. Measured rows now keep the width the list last laid out at, and a list that lays out at a new width renders again. The editor binds Up and Down to cursor moves, which consume the keystroke before the composer's key-down listener, so the trigger menu highlight never moved. The composer now intercepts those actions while the menu is open, and VirtualList::reveal scrolls the highlighted row, and the current model when the model picker opens, into view so it stays laid out.
InlineDiffCache dropped an entry only when its row rendered collapsed or the session changed. An auto-expanded live edit that newer activities folded into a collapsed Work Log never rendered again, so its highlighted diff stayed for the rest of the session, and a long session with large patches grew the cache without bound. Each rendered entry is now marked, and every chat render first drops the entries the previous frame did not render, the same lifetime GPUI gives an element's state. A diff scrolled or folded away is built again when it returns; one on screen is still built once.
A copy read each leaf's selection from the range its inline painted last. Once long code blocks and lists were split into root-list items, the items between or around the selection's ends were no longer painted together, so a shift-click extending from an anchor scrolled away copied only the painted tail, and a drag that turned back before a scroll jump kept the stale lines it had left behind. Each endpoint is now resolved, when it is made, to a text position (root block, text leaf, code line, offset) from the text painted in the last frame, and remembered for the selection's life, across the clear that precedes a shift-click. A copy rebuilds the blocks holding the ends as detached copies selected exactly between those positions; other blocks keep the painted projections. Positions are forgotten when a reparse or a width change moves the content. Ends on text that is not a paragraph, heading or code line (a table cell, text beside an image) still use the painted projection.
The host now owns a running turn's requests (#556), so a request pushed straight into the timeline no longer reaches the status the composer reads.
Markdown residency sized the tail, and a position the list jumped to, with a 12-row hint and built 24 rows around it. An expanded Work Log fills the viewport with ~32px activity rows, so a tall viewport showed messages more than 36 rows from the tail that were not built, and they rendered as plain text until a scroll reported the exact visible range. Each timeline row now records that the list prepainted it, which GPUI's list does only for the rows on screen. After the frame the view adopts that range when the hint missed some of it, and while following the tail the hint keeps reaching up to the first row painted there, so streaming does not shrink the window back and rebuild. The initial tail of the residency test now builds the 15 rows a 700px viewport paints rather than the hint's 12.
Opening or closing an activity, its delayed auto-collapse, a fetched whole output and a stored command frame each invalidated the measured height of every row of the turn. With an expanded Work Log that is one row per activity, so a single toggle threw away hundreds of measured heights and the list estimated them again from hints until they were scrolled near. These now invalidate only the row that renders the activity, found from its entry. The collapsible toggles that change row structure keep remeasuring the turn.
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.
Why
On a phone, the sheet behind the
+in the top-right corner (the command palette) stuttered while scrolling and typing. With an empty query it built and laid out one row per non-archived thread on every frame — 862 on the reporting host. An audit ofcrates/uifound the same pattern in other data-driven lists, so this PR virtualizes every one that can grow with user data. The Flat and compact sidebar layouts and the chat timeline already usedListState; the other lists now do too.What changed
Lists that now lay out only what is on screen
ListState. The model is rebuilt on query, content-hit, index, settings and active-session changes, keeps its scroll position across store updates, and scrolls the keyboard selection into view. 300 threads, debug build: 83 ms → 1.9 ms per frame.ListState, sharingreplace_list_rowswith the compact list. That helper now leaves a list that is at its top where it is (so a project moving up by recent activity stays visible), and keeps the index when the anchored row disappears instead of jumping to the top.scroll::VirtualList(uniform rows useuniform_list, measured rows uselist, content-sized up to a cap): composer and diff branch pickers, diff turn-scope menu, commit dialog file list, composer and settings model pickers, the@///$trigger menu, and the file-change approval list. Uniform rows are kept to a single line (ellipsis) so they can never overlap. Measured rows are measured at the width the list lays out at, so wrapped paths are not cut off.ListState, measured once per reset so the scrollbar and bounce see the whole registry.uniform_listof rows, so offscreen thumbnails are neither built nor requested.Other fixes
scroll::list_height_hint— seeds unmeasured rows with an estimated height after layout, so wheel, bounce and scrollbar extents cover the whole list. It is extracted from the sidebar scrollbar and shared with the palette. Without it, a fast swipe stopped short of the end.Not changed
Tests
Each new test seeds a long list at phone or desktop size. It asserts a far row is not laid out, then reaches it by wheel, keyboard or
scroll_toand asserts it is. Each one failed on the code before its fix; the failure output is recorded in the commit messages and work notes. Main ones:palette::tests::phone_sheet_lays_out_only_the_visible_threads,sidebar::tests::grouped_layout_lays_out_only_the_visible_threads(also covers anchoring on archive and on project reorder).widgets::menu::tests::a_long_menu_stays_in_the_window_and_scrolls_to_its_last_item,acp_panel::tests::the_marketplace_paints_only_the_visible_agents,project_icon::tests::the_file_grid_builds_only_the_visible_thumbnails.VirtualList:commit_dialog::tests::a_long_change_list_lays_out_only_the_visible_files,composer::components::approval::tests::{a_long_file_change_request_lays_out_only_the_visible_files, a_short_request_shows_every_wrapped_path},composer::components::trigger_menu::tests::arrow_keys_keep_the_highlighted_mention_laid_out,scroll::tests::{virtual_lists_shrink_to_short_lists_and_cap_long_ones, uniform_rows_stay_one_line_when_their_text_is_too_wide}.markdown::view::tests::{long_blocks_in_a_timeline_row_lay_out_only_their_visible_lines, shift_click_extends_from_an_anchor_scrolled_away, a_scroll_jump_mid_drag_drops_text_left_behind, streamed_append_preserves_earlier_item_measurements}.chat::tests::{an_expanded_work_log_lays_out_only_the_activities_on_screen, a_paused_reader_on_the_live_window_stays_put_as_it_folds, a_live_file_edit_lays_out_only_the_diff_lines_on_screen, a_built_inline_diff_goes_once_its_row_leaves_the_screen, markdown_on_screen_above_an_expanded_work_log_is_built}.The approval test seeds its request through provider events, because #556 moved a running turn's requests to the host.
Checks run
On the final commit, macOS:
cargo fmt --all --check— cleancargo clippy --workspace --all-targets --locked -- -D warnings— cleancargo nextest run --workspace --locked— 976/977 passed. The failure istcode-traverse::transport a_restarted_machine_is_rejoined_and_buffered_writes_are_delivered, which hit its 20 s timeout. It is intermittent: the same test passed twice in three reruns, in about 1 s each. This branch does not touchcrates/traverse.cargo machete— cleanRUSTFLAGS='-D warnings' cargo check -p tcode-ios --target aarch64-apple-ios-sim --locked— cleanRUSTFLAGS='-D warnings' cargo check -p tcode-web --target wasm32-unknown-unknown --locked— cleanGaps
cargo run -p tcode-ui --example phone. The inline diff's horizontal scroll and the wrapped approval paths are the two intentional visual changes.cargo ndkcheck not run locally (no NDK); left to CI.