Skip to content

perf(chat): virtualize the timeline by segment, not by turn - #547

Merged
Tryanks merged 1 commit into
mainfrom
perf/segment-rows
Sep 27, 2026
Merged

Tryanks merged 1 commit into
mainfrom
perf/segment-rows

Conversation

@Tryanks

@Tryanks Tryanks commented Sep 27, 2026

Copy link
Copy Markdown
Owner

Why

Thread 9553a8bb… (7.6 MB, 19k events, 13 turns) is unusable on a phone. The transport work (compressed wire, 4 KiB output previews, 512 KiB pages) and the list virtualization both key on the turn, and this thread has one still-running turn holding 985 items (345 commands, 224 assistant notes, 179 reasoning items) that folds into 449 segments. One list row = one turn, so every frame rebuilt those 449 segments, and Markdown residency counted its 8-turn margins over a 13-turn thread, keeping essentially every document parsed.

Reference: Zed's agent thread view (crates/agent_ui/src/conversation_view/thread_view.rs) gives GPUI's ListState one row per thread entry, never per turn, and splices rows in place as entries land.

What changes

  • One row per segment. TimelineRow (was TurnListItem) is a segment of a turn: a message, an error, a divider or a Work Log run. The turn's trailer (plan card, changed files, liveness/finished time, pending steers) renders on the turn's last row; a turn without segments keeps one empty row. segment_entries now also reports each segment's entry range so indexing and rendering fold the same way.
  • Row sync is an ordered identity diff. A row is identified by its first entry, so a run keeps its measured height while it streams. Rows that appear are spliced in place (ListState::splice keeps the anchor), rows above everything are the existing Prepend path with its history reservation, and rows that vanish or reorder still reset, except inside the partial first turn. A steer accepted mid-turn is now an in-place insertion rather than a reset.
  • Residency counts rows. Build/evict margins and the tail pin are in rows; a running turn is no longer pinned wholesale (it was pinning all 150+ documents of the running turn), only the tail rows are.
  • No clone on refold. Timeline::fold_stored folds the client's held records by reference; each history page used to clone the whole held log (≈2 MB here) before refolding.

Turn-keyed behaviour is unchanged: expansion keys, open_diff_for_turn, rewind, palette jump and the highlighted turn all resolve through rows_of_turn.

Tests

  • a_long_running_turn_virtualizes_into_one_row_per_segment (model): a 200-step running turn indexes to 402 rows with the live/last-assistant flags on the right rows, and the next command grows the last row in place.
  • a_long_running_turn_parses_only_the_markdown_near_the_viewport (view): a single 150-note running turn opens with fewer than 60 documents resident and note-0 not parsed. Fails before this change (151 resident).
  • List-sync tests now assert splices; residency tests use rows; chat layout tests updated for three rows per synthetic turn.

Checks run locally: cargo fmt --all --check, cargo clippy --workspace --all-targets --locked -- -D warnings, cargo nextest run --workspace --locked. Not exercised: iOS/Android/Web builds (CI), and a real phone against the affected thread.

A turn is not a list row any more; each of its segments (a message, an
error, a divider, a Work Log run) is, and the turn's trailer renders on
its last row. A running turn with hundreds of tool runs and interim
notes costs the rows on screen every frame instead of the whole turn.

Row sync aligns old and new rows by the identity of their first entry
and splices rows in place, so a run keeps its measured height while it
streams and a steer accepted mid-turn inserts a row rather than
resetting the list. Markdown residency counts rows instead of turns and
no longer pins every document of the running turn.

Timeline::fold_stored folds the client's held records by reference; a
history page used to clone the whole held log before refolding it.
@Tryanks
Tryanks merged commit bef57bd into main Sep 27, 2026
7 checks passed
@Tryanks
Tryanks deleted the perf/segment-rows branch September 27, 2026 16:50
yermakoffivan pushed a commit to yermakoffivan/tcode that referenced this pull request Sep 29, 2026
Since the timeline is virtualized by segment (Tryanks#547), a row read its turn's
entries from its first row's start to its last row's end. A pending steer
belongs to no segment, so one that followed a message fell outside that span
and the trailer never drew it: the steer vanished until the next tool run,
whose range reaches the turn's end, covered it again. The turn's span now
comes from the timeline itself.
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