cmux NEXT: the thread minimap feels like the Codex rail (band tracks every frame, jumps land, 250 ms card)

Merged
TE
16d ago
+7244-2129

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.

StateCodexStatus
RestOne 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
ScrollingThat 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)
HoverNo 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 cardOpens 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
ClickSmooth 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 / scrubEach row crossed jumps the transcript at once. Ticks switch with no transition, and the drag's click is swallowed.verified
Wheel over the railThe 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
KeyboardOption-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 chatsThe 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 loadingCodex loads the whole thread, so its rail never gains rows above the reader.assumed: the band should stay put and the rail glide
MotionReduce 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.

Codexfeat-cmux-next (what Leo uses)#20560 headthis PR
Ticks at restall 6 px, band brightera 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 row24 of 193, up to 30 of 197
Band after a clickthe clicked prompt is the first lit turnturn above stays litturn above stays litclicked prompt first
Card opens after hover250 ms (267-283 ms with render)466 ms (the app's 450 ms hover delay)466 ms266-267 ms
Tick hover growthspring, 160 ms50 ms linear (snaps)spring, 160 ms (99 ms to 95%)same
Click landing, drawn targetbubble 35 px down16 px35 px35 px
Click landing, a far target whose rows were never drawn35 pxrow not on screen (smooth scroll to an estimated offset)−262 px, above the view35 px
Older turns load above (30-turn page)n/aband jumps 150 px in one framejumps 163 pxfirst 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 target36 × 1050.5 × 1036 × 1036 × 10
Streaming: frames where a tick moved0000

What this PR changes on top of #20560

  • The band follows the scroll on the scroll's own frame. The transcript passes scrollTop down 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-lanes red.
  • thread-minimap-motion (WebKit), thread-minimap-rail, -current, thread-bookmarks, thread-turn-keys, hover-intent-cards, transcript-follow and transcript-fling all pass on Huge Blue. The rail's card tests now expect 250 ms.
  • tsc --noEmit is clean and vp fmt is clean on the touched files. Other parts of bun run check are 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, and settings/icons.tsx formatting.

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