Agent chat: the wave rail draws only the ticks in its view (36 ms to 1.7 ms a frame on a 2,000-row chat)
The compact (wave) rail, which is on by default, now draws only the ticks in its own view. On a 2,000-row chat it used to mount 1,000 tick buttons. Every layout the transcript forced (it measures rows on each commit) then laid all of them out. That cost 36 ms a frame at p95 while scrolling and 44 ms while a reply streamed, which is the lag you can feel.
Leo (2026-10-25): "the mini map VS Code code thing ... I feel like we should have that. Although I don't know if that actually, like, causes potential lag."
This PR measures that lag and removes most of it from the rail that ships. The full-height minimap (#22784) stays off; its numbers are at the end.
Change
threadMinimap/tickWindow.ts(new):tickRangespicks the rows to draw.- These are the rows in the list's view, 12 past each edge, and the focused, hovered, revealed and card rows, so focus and
aria-describedbyalways have their element. - An offset past the list's end (a shorter chat in the same rail) reads as the end, so the rows still draw.
- These are the rows in the list's view, 12 past each edge, and the focused, hovered, revealed and card rows, so focus and
ThreadMinimap.tsxTickList: draws those ranges, with anaria-hiddenspacer for each gap.- The list stays as tall as all of its rows, so its scroll offset, the edge fades,
railScrollTopand each row's y are unchanged. - The ranges are memoized on their contents, so a streamed token renders no tick (as before).
- The list stays as tall as all of its rows, so its scroll offset, the edge fades,
- Accessibility: the list is an
ol. Each drawn tick sits in a 10 pxliwitharia-posinsetandaria-setsizegiving its place among every prompt, drawn or not. Spacers arearia-hidden. The tick buttons, their labels andaria-currentare unchanged. - Keyboard: ArrowUp/Down and Home/End to a row that isn't drawn yet draw it, then focus it (
reveal). Tab and Shift-Tab past the drawn rows do the same, so Tab still walks every prompt. - Rows that move (older turns arriving, earlier ones cut): the focused, hovered and card rows resolve through a key-to-row map. The map is built once per change of the ticks, never per token. The render that renumbers the rows already uses their new places, and the state is then set to them.
- The page-focused tick (a pointer's click focuses one too, without the keyboard's grow or card) is tracked by key from focus and blur on the list. It is always drawn.
- Placing the list: the layout effect no longer reads
list.scrollTopafter a commit. It keeps the offset it last set or that the list's scroll event reported (listOffset). A list that no longer overflows (another chat, fewer prompts) goes back to its top. - Rendering, layout and accessibility only: tick states, easing, the jump, the data and visibility are unchanged.
Review fixes (gpt-6-astra, CHANGES at a487804d)
- P1, focus lost on prepend: rows are resolved by key. Test: "older turns arriving above keep focus on the focused prompt's tick". It focuses prompt 300, loads 300 older turns, and checks that focus stays on the same element, now row 300.
- P1, blank rail after a long chat: the list resets when it stops overflowing, and
tickRangesclamps a stale offset. Tests:- "a rail scrolled to the end of a long chat shows every tick of a short one it switches to": a 2,000-turn chat scrolled to the end, then a 5-prompt session, shows ticks 0 to 4.
thread-minimap-tick-window.test.ts: the clamp.
- P1, list semantics:
ol/liwith posinset/setsize. Test: "each drawn tick is a list item that names its place among every prompt". - P2, weakened append test:
thread-minimap-streamagain checks that the drawn ticks are exactly the window's rows before and after a new prompt, and that every tick still in the window is the same element.
Re-review fixes (gpt-6-astra at b979455c)
-
P2, a clicked tick unmounted by scrolling: the page-focused tick is kept by key. Test: "a clicked tick keeps focus while the chat scrolls it out of the rail's window". It clicks the latest tick, scrolls the chat to its top, and checks that the tick is still connected and focused and that ArrowUp goes to the one above it.
-
P2, a search on every token after a cut: the key-to-row map replaces
findIndex, and the state is reconciled. Test: "after earlier turns go, streaming does no search of the ticks, and the card stays on its prompt".- Setup: focus the latest tick, cut the first 500 turns, then stream 200 tokens.
- Result: no tick-list element is visited by
findIndex(303,000 visits before the fix). The active tick and the card are still on "Question 1999", now row 1499.
-
P2 at 3663c328, a stale page-focus key: the key now carries its chat, and is dropped once it names no tick of the chat on the rail (a focused button removed with its chat or prompt may send no blur). Test: "a focused tick that goes away with its chat is not held drawn when the chat comes back". It clicks a tick, swaps in another chat, swaps the first back and scrolls to its top, and checks that the old tick is not drawn.
-
P2 at 87e959e8, a page-focus key outliving a hidden rail: the key holds only while the rail is shown, and is dropped whenever the page's focus is not in the list (a hidden rail, an unmounted list, any removal without a blur). Test: "a focused tick whose rail hides and comes back in the same chat is not held drawn". It clicks the first tick, cuts the same chat to 3 prompts so the rail goes, restores it and scrolls to the end, and checks that tick 0 is not drawn.
-
P2 at cbe60e27, a remounted list under a stale offset: the cached list offset resets while no list is mounted, so a rail hidden for lack of room (or prompts) is placed from its top again when it comes back. Test: "a scrolled rail hidden for lack of room comes back at its place, its rows drawn". It shrinks a 2,000-turn pane scrolled to the end to 5 px so the rail goes, restores 600 px, and checks that the new list is scrolled to the band with its first and last visible rows (and the latest prompt) drawn.
Each new test fails on the head before its fix and passes now.
All four new tests fail on the previous head (5 fail, 7 pass across the three files) and pass at this head.
Numbers (same harness both ways, re-run at this head)
The bench from #25173 (webviews/bench/thread-minimap-frame-cost.bench.ts, landed): a production build of the pane's dev entry and a stand-in acpmux with 1,000 turns (2,000 rows), Chromium at 1200x800. Each scenario runs for 4 s: idle; a wheel step of -120 every 16 ms; and a reply streaming 30 chunks/s with a new turn every second. Cost is main-thread busy time per painted frame, from a Chrome trace, as the median over 3 rounds. "Before" is base feat-cmux-next at the same commit.
| ms per frame, p95 (p50) | rail off (before run / after run) | before | after |
|---|---|---|---|
| idle | 0.62 (0.45) / 0.62 (0.41) | 0.63 (0.45) | 0.63 (0.42) |
| scroll | 18.43 (5.93) / 20.23 (6.05) | 53.94 (15.96), 145 frames in 4 s | 23.25 (5.07), 242 frames |
| stream | 21.58 (12.07) / 20.35 (11.68) | 65.22 (41.56), 95 frames | 26.14 (13.05), 237 frames |
Extra cost over rail off:
| before | after | |
|---|---|---|
| idle, p95 | +0.01 | +0.01 |
| scroll, p95 | +35.5 | +3.0 (at 3663c328: +8.5, +4.4) |
| scroll, mean | +0.2 | |
| stream, p95 | +43.6 | +5.8 (at 3663c328: +5.9, +7.5) |
| stream, mean | +2.7 | |
| stream, p50 | +1.4 |
Long tasks over 50 ms went from 2ā13 per run to 0ā1.
Not met: the 1 ms target for scroll and stream. Run to run, p95 moves by 2ā3 ms. Across the three 3-round runs of this change, the stream p95 extra was 2.7, 7.8 and 5.8 ms.
- Probing a forced layout after a text change in the last reply: about 3.2 ms with the rail's list hidden and about 4.1 ms with it drawn, so about 0.9 ms per transcript row measure. This does not shrink with fewer ticks.
- The
liwrapper added for semantics costs little, within noise. Compared 3 rounds each: no wrapper 25.7 ms,display: contents26.0 ms, blockli28.8 ms at stream p95. I kept the blockli, becausedisplay: contentsdrops list semantics in some engines. - Getting to 1 ms would mean drawing the ticks as one canvas or SVG layer with one roving focusable button. That would rebuild the tick states and easing that items I and J are changing now, so it is left for after them.
Full-height minimap (#22784), same harness, before this change: +57 ms scroll and +112 ms stream at p95 over rail off. It stays off, and no setting was added.
Captures (dark theme, 1,000-turn chat, compact rail, at this head)
The before and after captures are pixel-identical (PIL ImageChops.difference bbox is None for all three).
- Before: 1,000 ticks in the DOM, each a button directly in the
nav. - After: 74 ticks in the DOM, the first an
liwith posinset 472 and setsize 1000 inside anol.
| before | after | |
|---|---|---|
| at the latest message | ![]() | ![]() |
| scrolled to the middle | ![]() | ![]() |
| hovering a tick (fisheye and card) | ![]() | ![]() |
Tests
- New:
test/thread-minimap-long-chat.test.tsx(9 tests) andtest/thread-minimap-tick-window.test.ts(2 tests).- The long-chat test checks that a 2,000-turn chat mounts at most 120 ticks and that the list is as tall as all 2,000 rows.
- ArrowDown walks every prompt from the first to the last without ever mounting more than 120.
- Home and End jump across the chat, and Tab and Shift-Tab step 200 rows past the drawn ones.
- The other new tests are the prepend, short-chat and list-semantics cases above.
- Updated:
thread-minimap-stream(exact window and identity, above).thread-minimap-tinyandthread-minimap-bottom: their jsdom scroll clamp now counts spacer heights. - All 24 rail test files: 118 pass, 0 fail.
vp lintandvp fmt --checkare clean on the touched files.
Size
853 changed lines (802 added, 51 removed): 268 in product code and 585 in tests. Over 800 only through the review-round regression tests. The bench (337 lines) moved to #25173.
Risk
Low to medium. It is limited to the compact rail's DOM: the rows drawn, the list's offset, and the list markup (ol/li where it was a div of buttons). The main risk is a row missing for one frame. That could happen when the browser scrolls the list by more than 12 rows on focus before React renders the new range. The focused row itself is always drawn.
No new timers, observers or animation loops. Per-token work in the rail is O(1) unless older turns arrive. A key lookup (findIndex) runs only when a held row's index no longer matches its prompt, which happens when older turns arrive, never per token.
Overlaps
pr-overlaplists #25160 (every prompt on the rail, item G+H), which conflicts inThreadMinimap.tsx.- #24131 (rail on the right edge) conflicts in
ThreadMinimap.tsx. - #24538 conflicts in
thread-minimap-stream.test.tsx. - #25136 (tick states) is being rebased in the same files.
Agent session: huge-blue
š¤ Generated with Claude Code





