Agent chat: the wave rail is back, and its band reaches the bottom of the chat

Merged
TE
7d ago
+1729-235

Summary

Leo's words (2026-10-21/22):

like how the fuck did OUR FUCKING SCROLLING FUCKING WAVE ANIMATING THING FUCKING BREAK IN TEH FIRST FUCKING PLACE HWO FUCKING TURNED THA TUFKCIN GHIST ON

I SAID THE FUCKING SCROLLBAR FEELS SHORT ... WHY WONT YOU JUST LOKO AT THE ... REFERS ... IT'S JUST THE FUCKING LINES THEY DONT FUCKIG ANIMTATE

Look, all of this involves the goddamn scroll bar. The wave didn't look good enough, and I said it looked too short because it wouldn't show the highlighted bottom text where I was scrolling at the bottom, and it just didn't go to the bottom. It didn't show the bottom bits being highlighted. That's what it was. It wasn't being revealed. How the hell could you have misinterpreted that in that way? Why did you turn it into a whole mini map? ... I didn't want the mini map. I didn't want the mini map.

yo where'd the scrolling thing on the left go? we had this then a session fucked it up and now it's gone? plus the option to show it left or right? default on, default left? option for minimap like vscode on the other side?

Leo-ask: ask-91458ac821
Leo-ask: ask-8099100829
Leo-ask: ask-851f8cc020
Leo-ask: ask-61fa57a17a

This PR covers "where'd the scrolling thing on the left go", "default on, default left": the rail is on and on the left by default again. "the option to show it left or right" is #24131, and "option for minimap like vscode" is #24137.

The agent chat's thread rail is the wave again: the Codex-style rail from #22228, one tick per prompt, the prompts on screen lit as a band that follows the scroll, and the Dock-style magnify under the pointer (the lines animate). It shows by default, as it did before #22784 swapped it for the full-height map.

His original complaint is fixed. In a chat with more prompts than the rail has room for (64 rows, 70% of the window, or the transcript's height), the rail scrolls its own list. That list only kept the band's first row in view, a third of the way down. The prompts below it, including the chat's last ones, stayed under the 2.5rem bottom fade or past the rail's end. So scrolling to the bottom of the chat never showed the bottom ticks lit. railScrollTop (model.ts) now keeps the whole band in view and clear of both fades. When the band holds the last prompt, the list scrolls to its own end, where no fade is drawn, so the bottom ticks show lit.

The rail also keeps every lit tick clear of the transcript's faded edges, at every pane height down to the transcript's 96 px CSS minimum (review rounds 1 and 2):

  • The rail is inside .acpmux-scroll, whose mask fades rows out 18 px under its top once scrolled, and 18 px (56 px with Jump to latest) above its foot while more lies below. railRoom keeps the rail's rows clear of both bands at their widest, so the rail never moves as they come and go. App passes the mask's foot (the bottom stack's top), so a composer that grows while the view follows doesn't cover the bottom ticks either.
  • railHeight is never shorter than the band it has to show: it takes the number of prompts on screen, and in a pane too short for the band plus the mask clearance, the clearance gives way first, up to the whole view. railTop then centers the rail in the view.
  • The list's own edge fades are a quarter of a short rail at most (railFade, --acpmux-minimap-fade) and never draw over the band (railFades).
  • When the fade clearances conflict, railScrollTop keeps the band whole inside the list instead of falling back to 0.
  • When the rail hides: only if the view has no room for one 10 px row. The transcript's 96 px minimum always has room, so it never hides at a pane height the app allows.

The rail does no per-prompt work for a streamed token (review round 3). Each token is a new rows snapshot. minimapTurns now also returns ticks (each prompt's key, id and text), and keeps the previous list by identity while the prompts don't change. The rows draw through a memoized TickList, so a token in the live reply renders no tick. turns still follows the reply, so the hover card previews it as it streams. Per token, the rail redoes the live turn and compares from that turn to the end, without visiting the ticks. No timer, observer or animation loop is added.

Round 4 makes the per-token work bounded by what changed. minimapTurns used to find the first changed row by comparing every row from the start. VirtualTranscript already finds that point once per snapshot, and now passes it to the rail (RowsChange). The rail uses it only when it describes the step from its own last rows; otherwise it compares from the start. Kept turns are found by binary search. A streamed token now reads 2 rows in a 300-prompt chat and 2 in a 3000-prompt one; before, it read 1202 and 12002. A turn whose reply streamed past its full preview keeps its object, so the turn list does too. The rail also keeps following the band while a tick is hovered or focused: railScrollTop holds the list still while the whole band shows clear of the fades, and only a scrub holds it otherwise.

Round 5 fixes two things. First, hover is a row index set on pointerenter, and a list that scrolls under a still pointer fires none. So the list now records the pointer's last clientY, and when it moves while hovered, the magnified row and the card's row become the row under that point. Second, minimapTurns no longer copies the earlier turns for each snapshot. It reads only the turns from the changed one on and updates the previous list in place when they differ; the previous result is spent once passed in. It finds the live turn by binary search, so a token whose preview is still filling costs about log2(prompts) reads, not a copy of every turn.

  • thread-minimap-layout defaults to compact (the wave). full (the #22784 map) and off can still be picked from cmux.experiments. The map's code is untouched; a follow-up offers it as an App Store option.
  • No layout shift on a tab switch (ask-da3a746b23, fixed by #23735): the rail stays position: sticky; height: 0 over the transcript and moves no row. A new test renders the same chat with the wave and with off and checks that the rows, the spacer and thread placements, and the scroll offset are identical.
  • The rail's list draws no native scrollbar. #23285 removed scrollbar-width: none / ::-webkit-scrollbar { display: none } because they trip SCROLLBARS-THIN. #23735 then made the list overflow: hidden, which can still be scrolled from script and focus but never draws a scroller, so no guard exception is needed. This PR keeps that, and the test still pins it.

Takes back #22784: its full-height map was not what Leo asked for ("I didn't want the mini map"); it stays selectable as the full arm.
Takes back #23735: its default (off) hid the wave Leo wants back ("HWO FUCKING TURNED THA TUFKCIN GHIST ON"); its layout-shift and second-scroller fixes stay.

Overlap: none found. Open minimap PRs from other lanes change other parts: #24024 and #24032 (the hover card's and the bubble's glass), and #21183 (contrast).

Testing

  • webviews/test/thread-minimap-bottom.test.tsx (new): a 90-prompt chat at 600 px. Scrolled to the top and then down in 120 px steps, every lit tick has to draw inside the rail and clear of any fade. At the bottom, the last prompt is lit, the list is at its own end, and there is no bottom fade.
  • webviews/test/thread-minimap-bottom.test.tsx also checks that the model's fade constants cover every nonzero fade band in conversation.css, and that a 320 px pane in a 900 px window keeps every lit tick clear of the mask at every offset.
  • webviews/test/thread-minimap-room.test.ts (new): a model sweep over pane heights 96 to 640 px in 2 px steps, 1 to 70 prompts, and every band of 1 to 4 prompts, scrolled down and back up. Every lit tick sits whole inside the rail and, where the pane has room, clear of the transcript's fades.
  • webviews/test/thread-minimap-tiny.test.tsx (new): renders panes 96 to 336 px in 16 px steps with 4 to 70 prompts, moves through offsets down the whole chat, and checks every lit tick against the rail, the list's fades and the transcript's mask.
  • webviews/test/thread-minimap-stream.test.tsx (new): a 300-prompt chat streams 20 tokens into its last reply, and the test counts the tick labels the rail builds. It expects none per token, the card to still preview the streamed text, and a new prompt to add its tick while the earlier tick elements stay.
    It also counts the rows minimapTurns reads for one token through a proxy: the same few for 300 and 3000 prompts. A mid-chat edit and an inserted row give what a fresh read gives, a change point for other rows is ignored, and a new prompt keeps every earlier tick element.
  • webviews/test/thread-minimap-bottom.test.tsx also hovers a tick, and in a second test focuses one, then scrolls the 90-prompt chat from top to bottom. Every lit tick must stay inside the rail's unfaded area.
  • Also in thread-minimap-bottom: hover tick 3 with the pointer at y 35, then wheel the chat with the pointer still. The magnified tick must be the one under the pointer at every offset once the list moves. thread-minimap-stream puts the previous turns behind a counting proxy too: the list must be the same array, with under 30 reads at 300 and at 3000 prompts. A VirtualTranscript test switches to another chat with repeating row ids, then replaces that chat wholesale, then cuts it, and checks the tick labels after each step.
  • webviews/test/thread-minimap-feel.test.ts (Chromium): a 420 px window, with the real mask's bands read from the scroller's computed style.
  • webviews/test/thread-minimap-default-wave.test.tsx (was -default-off): the shipped arm is compact. A chat with no override draws the rail and no map. The wave moves no row compared with off. Previous Turn still steps. The list stays overflow: hidden, and the rail stays sticky with no height.
  • Gallery agent-pane.thread-minimap gains a seventy-prompts-bottom variant (scroll mid-thread, then to the bottom; waits for tick 69 lit and no bottom fade).
  • bun run check in webviews/ passes locally (Oxlint, Oxfmt, type check, layer checks).

Observed / Not run

Commands: scripts/ci/run-webviews-tests.sh with CMUX_WEB_TEST_FILES on the fleet, and bun test FILE (with CMUX_BROWSER_TESTS=1 for Chromium) in a scratch checkout on huge-blue.

  • Red commit 183ef0522486bb6c9cd833aa8be3f0bdeb5b2a73 (tests only; fleet step 5963180d4cfc401becdb365f): both new files fail. thread-minimap-bottom fails with at 6600 px the lit tick 38 draws at 380 px, outside 0 to 380 px of the rail: a lit prompt below the rail's end, which is Leo's "it just didn't go to the bottom". thread-minimap-default-wave fails with Expected: "compact" Received: "off", and finds no rail.
  • Fix commit 34b6d18ba82f727dce7fe94870dc5f39c6974d3d (fleet step a1ff52451d474998779717f6, aws-m4pro-3): the 10 rail files of that commit pass.
  • Round 1, mask clearance. On 34b6d18's code, with the head's tests (huge-blue): the 320 px short-pane test in thread-minimap-bottom fails. The fleet steps for a5b6138 and 44740c3 never left the queue and were cancelled, so 44740c3 was never tested. Two of its tests then failed when transcript-parity ran them: the CSS check read a 0px Reduce Transparency rule as the bottom band, and a render sweep went past bun's 5 s default. Both are fixed in 460529124fd.
  • Round 2, tiny panes. On red commit 3eec7663e04dbc223bc08061211891375511b8d2 (huge-blue): thread-minimap-room fails (96 px, 3 prompts, band 1-1: tick 1 at 10..20 in a 14 px rail), and thread-minimap-tiny fails at 96 to 176 px (4 prompts at 0 px: tick 0 at 0..10 of 0..-26 in 14 px).
  • Test fix 901214e8dee: bun's globalThis has no innerHeight, so these jsdom sweeps capped the rail at 70% of the pane, not of the window, and the 320 px test passed on 34b6d18's code too. They now run in a 900 px window, as in the app. That is why it fails on 34b6d18 above.
  • Round 3, per-token work. On red commit 06beea6e522 (huge-blue), thread-minimap-stream fails: every one of the 20 tokens rebuilt all 300 tick labels.
  • Head 975182f2fa453fbcfa452a44a8592ce14001d832, on huge-blue: thread-minimap-stream passes 2 of 2. The 14 rail files pass, as do transcript-long-chat-updates (3), transcript-steer-continuous (28) and minimap-reply-preview (13). Earlier head 901214e8deed8385213255a21386e3d79bc9bea7 also passed all 14 rail files. That is every thread-minimap* file in jsdom and Chromium (bottom 3, card-glass 2, current 1, default-wave 4, feel 5 in Chromium, motion 3 in Chromium, rail 11, room 1, tiny 16), plus thread-scroll-map-rail 6, thread-scroll-map 6, thread-bookmarks 5, hover-intent-cards 1 and thread-turn-keys 1.
  • Round 4. Red commit 46d72da4b3d3577b0829b72cf980d4d203600cec, on huge-blue: the read count fails (12002 reads for 3000 prompts against 1202 for 300), and both hover and focus sweeps fail. Fleet step edf5f48f6eabe5ddb9ea307e runs the same commit.
  • Head acb3e6b8681883b1d58bcf387c5ddc2f3b0b00c4, on huge-blue: 0 failures across the 18 touched files: stream 4, bottom 5, rail 13, tiny 16, feel 5 (Chromium), motion 5 (Chromium), default-wave 4, card-glass 2, current 1, room 1, scroll-map-rail 7, scroll-map 6, bookmarks 5, hover-intent-cards 1, turn-keys 1, transcript-long-chat-updates 3, transcript-steer-continuous 28, minimap-reply-preview 13. Fleet step 5073168ff8c63bd25ccb4ea6 runs the same files. Fleet step 6f951eca75ebd00d6057bc34 (aws-m4pro-8) passed the 17 files at 975182f2.
  • Round 5. Red commit 8bf2eb1ce9f22e39f5a4472580aaeb790ac23060, on huge-blue: the turn-copy check fails (a new array each token), and the still-pointer hover test fails. Head 7e1c436ae68d926f2630e143c664600d86d8fe22, on huge-blue: 0 failures in stream (5), bottom (6), rail, tiny, feel and the rest of the touched files. The one exception is thread-minimap-motion, whose click-curve check failed 3 of 3 times at acb3e6b8 and 3 of 3 with this change on a loaded huge-blue. That file is a base red since #24032 and gets its own PR.
  • bun run check in webviews/ passes locally (Oxlint, Oxfmt, type check, layer checks).
  • Not run: a live tagged build. No webview test or build was run on a Mac.

Changelog

Fixed: The agent chat's prompt rail is back: one tick per prompt, magnifying under the pointer, with the prompts on screen lit right down to the bottom of the chat

Proof

Gallery advisory run: agent-pane.thread-minimap (rest, long-chat, seventy-prompts, seventy-prompts-bottom).

Checklist

  • Behavior changes have added or updated tests
  • No new user-facing strings

Agent session: Air-Blue

🤖 Generated with Claude Code