Skip to content

perf(ui): virtualize every list that grows with user data - #557

Merged
Tryanks merged 29 commits into
mainfrom
fix/palette-virtual-list
Sep 30, 2026
Merged

Tryanks merged 29 commits into
mainfrom
fix/palette-virtual-list

Conversation

@Tryanks

@Tryanks Tryanks commented Sep 30, 2026

Copy link
Copy Markdown
Owner

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 of crates/ui found 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 used ListState; the other lists now do too.

What changed

Lists that now lay out only what is on screen

  • Command palette — results are a cached row model rendered through 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.
  • Sidebar, Grouped layout — flattened into rows on its own ListState, sharing replace_list_rows with 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.
  • Pickers and dialogs through the new scroll::VirtualList (uniform rows use uniform_list, measured rows use list, 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.
  • ACP marketplace — ListState, measured once per reset so the scrollbar and bounce see the whole registry.
  • Project icon file grid — a uniform_list of rows, so offscreen thumbnails are neither built nor requested.
  • Markdown — root code blocks over 32 lines and lists over 8 items are split into items that still paint as one block. Copy resolves a selection from its endpoints, not from whichever chunks were painted. A block that stops being the last one is remeasured. 3,000-line code block, scrolling, debug build: 649 ms → 13.7 ms per frame.
  • Chat
    • An expanded work log becomes one timeline row per folded activity. A paused reader stays in place as the live window folds and settles. 400 activities: 13.2 ms → 1.8 ms.
    • Inline file-edit diffs are built once per patch (60 ms → 2 µs on a hit) and virtualized. They also scroll horizontally now; before, long lines were clipped. The cache drops diffs whose rows stopped rendering. 801 lines: 136 ms → 0.9 ms.
    • An expanded disclosure is one text block (10.8 ms → 2.2 ms).
    • Markdown residency follows the rows the timeline actually draws.
    • Toggling an activity remeasures only its own row.

Other fixes

  • Popup menus are bounded by the window and scroll, instead of running off screen when there is one row per project.
  • Trigger menu — Up/Down had never moved the highlight, because the textarea consumed those keys. The composer now handles them while the menu is open.
  • 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

  • Markdown tables are still built whole. Column widths depend on every row and the table is one horizontal scroll area.
  • Provider dialog model list — its rows sit inline in one scrolling form, and virtualizing them would break Tab order into the custom-model field below.
  • Changed-files chips — measured at 5–10% of a small cost; a flex-wrap chip grid does not virtualize cleanly.
  • Off-screen rows are no longer in the tree. A context menu or rename on a row that scrolls away closes, as it already did in the Flat layout.

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_to and 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 and sidebar: 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).
  • Menus, ACP marketplace, icon grid: 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.
  • Pickers, dialogs and 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: 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: 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 — clean
  • cargo clippy --workspace --all-targets --locked -- -D warnings — clean
  • cargo nextest run --workspace --locked — 976/977 passed. The failure is tcode-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 touch crates/traverse.
  • cargo machete — clean
  • RUSTFLAGS='-D warnings' cargo check -p tcode-ios --target aarch64-apple-ios-sim --locked — clean
  • RUSTFLAGS='-D warnings' cargo check -p tcode-web --target wasm32-unknown-unknown --locked — clean

Gaps

  • Not looked at on screen. This machine has no screen-recording permission, so none of the surfaces were viewed in either theme or at a narrow width. Visual equivalence rests on bounds comparisons in tests: sidebar grouped rows, markdown split blocks, work-log rows and disclosure text match the old layout to the pixel. Worth a look with cargo run -p tcode-ui --example phone. The inline diff's horizontal scroll and the wrapped approval paths are the two intentional visual changes.
  • Android — cargo ndk check not run locally (no NDK); left to CI.

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.
@Tryanks
Tryanks merged commit 0596e57 into main Sep 30, 2026
7 checks passed
@Tryanks
Tryanks deleted the fix/palette-virtual-list branch September 30, 2026 13:45
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