cmux NEXT: the thread minimap feels like the Codex rail (band tracks every frame, jumps land, 250 ms card)
Leo (10-19), on the agent chat's left-edge rail: "it's been shit and has stayed shit for a while, it doesn't feel good like codex's". This makes the thread minimap behave like the Codex desktop app's prompt rail, in every state.
It builds on #20560, which rebuilt the rail from Codex's source and 60 Hz recordings but stopped at merge conflicts on 10-15. This branch merges current feat-cmux-next into it and fixes what still felt worse than Codex, measured in Chromium with a 60-turn chat. This PR replaces #20560.
The reference, per state
The source is the Codex reference spec in the private screenshots archive (references/codex-prompt-rail-2026-10-14/SPEC.md). It was read from Codex 26.930's shipped webview code (thread-user-message-navigation-rail-app-*.js) and checked against 60 Hz screen recordings. Leo's screenshots from 10-19 show the same states. Verified means seen in both the source and the frames. Assumed means it wasn't captured.
| State | Codex | Status |
|---|---|---|
| Rest | One 2 × 6 px tick per prompt, all the same length, on a fixed 10 px pitch. The rail sits 16 px in from the transcript's left edge and is centered vertically. It shows from 4 prompts, and only when there's ≥ 48 px of room. Every turn on screen is brighter (text at 60%; the rest are muted text at 40%). No tick is longer and none is white. | verified |
| Scrolling | That bright band follows the viewport live (an IntersectionObserver on each turn's block, −16 px top margin). There's no length change. | verified (one frame of observer latency) |
| Hover | No dwell. The hovered row grows to 26 px and turns the text color. Its neighbors taper to 20, 14 and 10 px on a 160 ms spring (linear(), about 0.8% overshoot), all rows together. Leaving eases back in 150 ms. The band dims to rest while the pointer is on the rail. | verified |
| Preview card | Opens 250 ms after the pointer reaches the rail. If one closed less than 300 ms ago, it opens at once. It moves to the hovered row with no animation and has no fade. It's 320 px wide with a 12 px radius, flush right of the row and centered on it. It shows the prompt in bold on one line, a bookmark button, and 3 lines of the reply. It closes 100 ms after the pointer leaves. | verified |
| Click | Smooth scroll, about 400 ms for about 650 px, on cubic-bezier(0.35, 0.05, 0, 1). The turn's block lands at the top with its bubble 35 px down, then the bubble fades from a highlight over 1400 ms. An unmounted target jumps at once. | verified (Chromium's distance-based duration is only measured at about 150 and 650 px) |
| Drag / scrub | Each row crossed jumps the transcript at once. Ticks switch with no transition, and the drag's click is swallowed. | verified |
| Wheel over the rail | The list scrolls itself when it overflows (overflow-y: auto). Codex portals the rail outside the scroll element; what a wheel does there wasn't recorded. Ours scrolls the transcript, or the rail's list when it overflows. | partly from source; not recorded |
| Keyboard | Option-Up/Down step prompts (200 ms, cubic-bezier(0.3, 0.2, 0, 1)). Rows are Tab stops: a focused row grows alone and opens its card at once. Return jumps and Escape closes. | verified. We keep Ctrl-Opt-Up/Down; changing a default key needs a design call. |
| Long chats | The rail caps at 64 rows or 70% of the window. Past that, it scrolls with 2.5rem edge fades and keeps the band in view. | from source; not exercised live |
| Older turns loading | Codex loads the whole thread, so its rail never gains rows above the reader. | assumed: the band should stay put and the rail glide |
| Motion | Reduce Motion makes everything instant. | verified |
Defects, measured
Each column is a Chromium run of the same 60-turn chat (replies of 1-9 paragraphs) in a 1280 × 820 window, measured with the real transcript and rail. "Band correct" compares the lit rows with the turns on screen by Codex's rule, read from the drawn rows each frame.
| Codex | feat-cmux-next (what Leo uses) | #20560 head | this PR | |
|---|---|---|---|---|
| Ticks at rest | all 6 px, band brighter | a 14 px accent tick for the turn being read (#19286), band at 100% vs rest 19% | all 6 px, 60% / 40% | same |
| Band wrong while wheel-scrolling | ~1 frame (observer) | 24 of 211 frames, up to 3 in a row | 24 of 193, up to 3 | 0 of 197 |
| Band after a click | the clicked prompt is the first lit turn | turn above stays lit | turn above stays lit | clicked prompt first |
| Card opens after hover | 250 ms (267-283 ms with render) | 466 ms (the app's 450 ms hover delay) | 466 ms | 266-267 ms |
| Tick hover growth | spring, 160 ms | 50 ms linear (snaps) | spring, 160 ms (99 ms to 95%) | same |
| Click landing, drawn target | bubble 35 px down | 16 px | 35 px | 35 px |
| Click landing, a far target whose rows were never drawn | 35 px | row not on screen (smooth scroll to an estimated offset) | −262 px, above the view | 35 px |
| Older turns load above (30-turn page) | n/a | band jumps 150 px in one frame | jumps 163 px | first frame 0 px, same turns lit, then a 200 ms glide (≤ 40 px a frame) to the regrown rail's center; a rail that already scrolls doesn't move at all |
| Hit target | 36 × 10 | 50.5 × 10 | 36 × 10 | 36 × 10 |
| Streaming: frames where a tick moved | 0 | 0 | 0 | 0 |
What this PR changes on top of #20560
- The band follows the scroll on the scroll's own frame. The transcript passes
scrollTopdown after React schedules the scroll's render, which is 1-3 frames late. The rail now listens to the scroller itself, in capture. When a scroll moves the band, it renders the rail synchronously (flushSync; only rows whose state changed draw again). A scroll that doesn't move the band renders nothing. - Which turns count as on screen now matches Codex's observer. Codex observes each turn's block, and a jump puts that block's top at the view's top with the bubble 35 px down (SPEC §4: bubble at y=79 with the viewport top at 44). So the 35 px is the turn block above the bubble, not scroll padding. Codex's −16 px root margin then means a turn stops counting once the next prompt's bubble is within 51 px of the top, so a prompt just jumped to is the first lit turn. The same rule at the bottom would light a turn whose bubble is up to 35 px below the view. Our turns have no block above the bubble, so ours lights a turn at the bottom once its bubble is on screen.
- A jump lands on its mark. Layout tops are estimates for rows that were never drawn, so a long jump used to land hundreds of px off. After the scroll (smooth or instant), the landing is corrected from the bubble's own position each frame until it holds (≤ 45 frames). Any input anywhere in the page ends the correction: the reader, Find, or Jump to Latest.
- Previous/Next Turn: a pending step is tracked by its turn, not its offset.
- Card delay is 250 ms (Codex's
delayOpen), set for the rail only. The rest of the app keeps the 450 ms hover delay from #20528. Under 200 ms still counts as passing over, so the 10-14 complaint (cards opening on every row the pointer crossed) stays fixed. - Older turns loading above the reader don't move the band.
- Until the transcript's restoring scroll arrives, the band reads the offset as the transcript will restore it, so it lights the same turns on every frame.
- A rail that scrolls absorbs the new rows into its own scroll, and its keep-in-view scroll skips that commit. A rail that grows starts where the band was and glides to center over 200 ms. A second page during a glide continues from where the first one got to.
- The hovered, focused, card and scrub rows stay on their prompts.
- Positions are computed from the rail's geometry, so no commit reads layout.
Tests
test/thread-minimap-feel.test.ts(new, Chromium browser lane) covers four things:- jump accuracy for a drawn and an undrawn target, and the band where it lands;
- hover reveal timing: magnification within a few frames, card at 230-400 ms;
- the band matching the drawn turns on every frame of a wheel scroll, with one stray frame allowed;
- no jump when older turns load, in three cases: 30 → 60 (the rail grows to its cap), 20 → 30, and 60 → 90 with the band in the top third of a rail that already scrolls.
- The test waits on frames, not timers. Red at f8266ab (#20560 plus the base merge): all 4 fail. Green at the head, 3 runs in a row on Huge Blue. CI doesn't reach it yet: the browser lane stops at base's
check:browser-lanesred. thread-minimap-motion(WebKit),thread-minimap-rail,-current,thread-bookmarks,thread-turn-keys,hover-intent-cards,transcript-followandtranscript-flingall pass on Huge Blue. The rail's card tests now expect 250 ms.tsc --noEmitis clean andvp fmtis clean on the touched files. Other parts ofbun run checkare red on base and outside this PR:agent-pane-older-rows-fade.test.ts(browser-lanes),syncMotion.gallery.css(type-scale), a text-focus ring, andsettings/icons.tsxformatting.
Gallery
agent-pane.thread-minimap gains three states:
rest: mid-thread, the band lit, every tick equal;scrubbing: a press on row 3, dragged to row 12;seventy-prompts: the rail at its cap, scrolled, with both edge fades.
It keeps long-chat (hover: fisheye plus card) and bookmarked. The gallery advisory run captures them.
Not in this PR
- The chat's scrollbar running the full pane height under the composer is a separate small PR.
- #20560's Swift side (the bookmark palette actions and the transcript step) comes along unchanged. The action exports (
plans/cmux-next/actions.md) take base's copy here, and the autofix bot regenerates them.
FYI @lawrencecchen, since you wrote the original rail.
Changelog
Changed: the agent chat's thread minimap looks and feels like the Codex rail: equal ticks with the on-screen turns lit, the lit band following the scroll every frame, a dock-style magnification and preview card on hover, and jumps that land exactly
🤖 Generated with Claude Code