Skip to content

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

Merged
teamleaderleo merged 80 commits into
feat-cmux-nextfrom
fix/thread-minimap-feel
Oct 20, 2026
Merged

teamleaderleo merged 80 commits into
feat-cmux-nextfrom
fix/thread-minimap-feel

Conversation

@teamleaderleo

@teamleaderleo teamleaderleo commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

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 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 Hover intent: tooltips and hover cards wait, then stay warm, app-wide #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 (cmux-next: thread minimap matches the Codex rail; bookmarks show and jump #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

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

teamleaderleo and others added 30 commits October 14, 2026 01:43
Behavior tests for _acpmux/bookmark_get and _acpmux/bookmark_set before
they exist: restart, prompt-only seqs, fork, transfer, peer forwarding,
the pane relay's scope and params, and the minimap popover's store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_acpmux/bookmark_get and _acpmux/bookmark_set keep the thread minimap's
marks in SessionMeta.bookmarks: seqs of the chat's user_message records,
checked against its log and capped at 1000. A change logs a mux
bookmarks record, as tags do, so attached panes hear it; the chat keeps
its place in the session list. A fork carries the marks of the prompts
it copies under its own seqs; a move carries them (the log keeps its
seqs) and the source keeps none. Peers' chats forward to their owner.

The pane relay allows both methods for a chat in the pane's scope with
only sessionId, seq and bookmarked. The minimap's store reads them from
acpmux, toggles optimistically with rollback, and imports this pane's
old localStorage marks for the chat once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…(red)

Behavior tests for the rail's thresholds, hover magnification, card timing,
keyboard focus, scrub, the bookmark dot and row button, and the bookmark
steps, plus a WebKit motion test and the Swift action/page-event test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…jump

The rail now follows the Codex desktop app's measured behavior: it shows
from 4 prompts with 48 px left of the column, 16 px in and centered, with
10 px x 36 px rows and 6 px ticks that scroll past 64 rows or 70% of the
window. Hover magnifies the rows (26/20/14/10 px) in 160 ms on Codex's
spring, the preview card opens after 250 ms (at once within 300 ms of a
close, or on keyboard focus) and closes 100 ms after leaving. A click
scrolls on cubic-bezier(0.35, 0.05, 0, 1) (~400 ms for ~650 px), landing
the bubble 35 px below the top, and highlights it for 1400 ms; an
unmounted target jumps. Dragging scrubs. Every row is a tab stop.

Bookmarks show as a 2 px dot past the tick (a click on its spot toggles
it), in the card, and in a new button on the prompt's own row. Previous
and Next Bookmarked Prompt are palette actions with no default key; the
turn steps keep Ctrl-Opt-Up/Down, now on Codex's 200 ms scroll curve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bookmarks now live in acpmux (#20556). The rail's dot, its clickable
spot, the card button and the prompt row's button read and toggle them
through bookmarks.ts's useBookmarks/promptSeq (threadMinimap/promptMarks.ts),
and show only for prompts acpmux has logged. Tests set a bookmark
transport and use user-<seq> prompt ids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Generated by scripts/measure/export-action-surfaces.sh on the fleet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…for acpmux (red)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… holds them

set_bookmark changes, logs and saves the marks under the chat's move
lock, so concurrent sets log in the order they change the marks and a
move begins before or after a change. The prompt check scans the log on
a blocking thread. The pane forgets an old localStorage mark only once
bookmark_set succeeds for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WebKit does not focus a clicked button, so in the app Tab after a click
left the rail for the transcript. Codex's rail (Chromium) focuses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…amples

CI's headless WebKit paced frames unevenly, so a sample 40 ms into the
transition could come long after it ended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ead-minimap-codex

# Conflicts:
#	webviews/src/agent-session/acpmux/direct.ts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- vp fmt webviews/test/ime-composition-keys.test.ts
- cargo fmt in Native/OptChat/optchat-chief

Autofix-Run: https://github.com/manaflow-ai/cmux/actions/runs/38566033765
…mux-turn-bookmarks

# Conflicts:
#	cmux-tui/crates/acpmux/tests/peer_integration.rs
#	webviews/src/agent-session/acpmux/direct.ts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ead-minimap-codex

# Conflicts:
#	Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionCatalog.swift
#	cmux-tui/crates/acpmux/src/hub/fork.rs
#	cmux-tui/crates/acpmux/src/hub/resolve.rs
#	cmux-tui/crates/acpmux/src/server/requests.rs
#	cmux-tui/crates/acpmux/src/store.rs
#	cmux-tui/crates/cmux-agent-pane-policy/policy.json
#	cmux-tui/crates/cmux-agent-pane-policy/tests/cases/params.json
#	cmux-tui/crates/cmux-agent-pane-policy/tests/cases/sessions.json
#	webviews/src/agent-session/acpmux/App.tsx
#	webviews/src/agent-session/acpmux/direct.ts
#	webviews/src/agent-session/acpmux/threadMinimap/ThreadMinimap.tsx
#	webviews/src/agent-session/acpmux/threadMinimap/model.ts
#	webviews/src/agent-session/acpmux/threadMinimap/threadMinimap.css
#	webviews/test/thread-bookmarks.test.tsx
Base's hover-intent and elevation tests now drive the rewritten rail
(.acpmux-minimap__card, the rows' pointerenter/pointerleave), and the
rail test pins the app hover intent (450 ms delay, warm 500 ms, gone as
the pointer leaves unless it enters the card) instead of CardTiming's
250/300/100 ms.

heavy --mem 3G bun test test/hover-intent-cards.test.tsx test/subtle-elevation.test.ts test/thread-minimap-rail.test.tsx
(fail) the card follows the app's hover intent: it waits the hover delay, follows the pointer at once while warm, and goes as the pointer leaves
  thread-minimap-rail.test.tsx:210 expect(card()).toBeNull() after 400 ms: the card opened at 250 ms
(fail) the minimap's turn preview waits the hover delay, then follows the pointer at once until the rail cools
  hover-intent-cards.test.tsx:117 expect(visible()).toBe(false) right after leaving: CardTiming keeps it 100 ms
(fail) one subtle elevation scale > .lg-sheet casts --elevation-2 (base red, ledger.css untouched)
The .acpmux-minimap__card casts --elevation-2 case passes (the merge already moved the card to it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The preview card now runs on ui/hoverIntent's useHoverIntent, as base's
minimap did: the first waits the hover delay, the card then follows the
pointer from row to row at once while the rail is warm, and it goes as
the pointer leaves the rail (moving into the card, or back, keeps it).
Keyboard focus still shows it at once; Escape dismisses it and focus-out
closes it on that frame. cardTiming.ts and the CARD_* constants are gone.

thread-bookmarks.test.tsx goes back to the PR's version: the merge took
base's half, which drives the old rail's pointermove on two prompts.

heavy --mem 3G bun test test/hover-intent-cards.test.tsx test/subtle-elevation.test.ts test/thread-minimap-rail.test.tsx
  46 pass, 1 fail (.lg-sheet casts --elevation-2: base red, ledger.css untouched)
heavy --mem 3G bun test test/thread-minimap-current.test.tsx test/thread-bookmarks.test.tsx test/thread-turn-keys.test.tsx test/hover-intent-parity.test.ts test/hover-row-sweep.test.ts test/thread-minimap-motion.test.ts
  7 pass, 0 fail (browser lanes skipped locally)
heavy --mem 4G bun run check: pass

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
export-action-surfaces.sh on 6cf5407 (fleet step a2d9f3a7): 961 actions (base's
chat-identifier actions plus this PR's transcript actions).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
export-action-surfaces.sh on eec9482 (fleet step fa811efc): 963 actions (base's
Music actions added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y captures

debug.agent_pane seed_rows fixture "short": one-line prompts answered
"ok N", the same content as the Codex reference chat, so tick and highlight
density compare like for like.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lawrencecchen and others added 4 commits October 20, 2026 01:30
… row (tab-switch-rewrite)

syncSelection walked every cell twice per switch: the full separator pass and the occlusion pass
(TabOcclusion samples each cell's motion path). At rest no tab overlaps another and no mask is on, so
occlusion runs only while a strip move is in flight (movesInFlight, cleared by the move's completion),
and the separator rule is applied to the lines next to the old and new selected tab through a slot
index built once per layout (TabSlotIndex), which reveal also reads. A multi-selection change keeps
the full pass. TabStripSwitchSeparatorTests proves the lines equal a full pass after any switch order.

Benches (fleet, aws-m4pro-5, same host A/B against a9439ce): TabSwitchPathBench apply p50
60 tabs 0.42 -> 0.31 ms, 30 tabs 0.21 -> 0.15 ms; strip separators 38 us -> 2.5 us at 60 tabs,
constant. TabSwitchPathBench now prints the per-stage split (BenchSpans switch.*), and
TabStripSelectionBench each strip step. scripts/cmux-next/tab-switch-matrix.py also reports each
Chromium and WebKit page's own first frame after a switch (content_frame_ms).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fleet render at 39b0b0b still drew the Name field at its ideal width:
the Grid sized to its cells, so maxWidth on a cell had nothing to fill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Grid kept the Name field at its ideal width (the maxWidth frame sat
outside the drawn card) and wrapped the Chief checkbox. Captions over the
controls need no label column, so long labels in any language fit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@teamleaderleo
teamleaderleo temporarily deployed to cmux-tui-merge-tree October 20, 2026 08:32 — with GitHub Actions Inactive
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review of base-merge 7938b34 and fix 6521796 (subagent, correctness first)

I reviewed both commits by reading, grepping and running small Python checks. Nothing was built or run.

7938b34

  • The only conflict was the generated actions.md. I resolved it by hand to the base's copy plus this PR's two bookmarked-prompt actions: 1058 actions, palette 1021, 191 entries in the global guiOnly list.
  • In action-surfaces.json, the base's 1056 rows are unchanged, and the only rows added are the PR's two. There are no duplicate keys, and the rows are in generator order.
  • cmux-shortcuts.generated.json and the defaults JSON both parse and are consistent with that.
  • The code auto-merges are clean:
    • AgentHandlers.swift binds each AgentTranscriptStep case once. The base's AgentTeamsLaunch exists.
    • In App.tsx, the PR's change set is intact.
  • Blocker found: the global guiOnly list kept the transcript actions in their old place. The generator lists them after home.stopChief, because they now come from AgentTranscriptActionCatalog.

6521796 (the fix)

  • This commit is one line. It moves the four transcript ids to after home.stopChief.
  • The generated block in actions.md is now byte-identical to the one the autofix produced at 2487cb4, so reportIsFresh should pass. Nothing else changed.

Verdict: MERGE

lawrencecchen and others added 7 commits October 20, 2026 01:33
…k is one flat menu (red)

Lawrence 2026-10-20: Move to Top left a workspace under groups that were
pinned (the Pinned band lists above the machine sections), but nothing
showed they were pinned and Pin/Unpin could only pin. And the header's
right-click opened the editor, whose More Group Actions row held the menu.

Red: SidebarDocPinnedGroupTests (a Pinned-band group maps pinned),
WorkspaceGroupFlatMenuTests (common rows top-level, no catch-all folder,
every row in the palette), GroupPaletteDefaultTests (right-click shows the
menu, editor rows without More). Design: plans/cmux-next/sidebar-ordering-DESIGN.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ight-click is one flat menu

Root cause (plans/cmux-next/sidebar-ordering-DESIGN.md): the groups Move to
Top left a workspace under were pinned. Pin Group moves a group into the
Pinned band, above every machine section, but SidebarDocSections mapped
every document group with pinned: false, so no pin glyph showed and
Pin/Unpin always pinned. A Pinned-band group now maps pinned (the header's
pin, Unpin Group in the menu and editor), and Pin/Unpin toggles from what
the sidebar shows.

The header's right-click opens the group's menu at once (no editor, no More
Group Actions row): Color, Set/Remove Icon, Pin/Unpin, Collapse All/Expand
All/Collapse Others, Move to Top, Ungroup, Close Group, Delete Group and
Close Other Groups are top-level; Move and Sort keep their lists. The editor
offers Pin and Move to Top itself. New Workspace in Group, Pin/Unpin Group,
Mark Read/Unread, Clear Notifications and Edit Group Config join the palette.
Pin and Collapse rows read as the change they make. Strings in all 21 locales
(19 need review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ActionSurfaceParityTests.everyMenuKeepsAShortTopLevel (Lawrence 2026-10-01:
at most 12 top-level rows) failed at 21. The rows Lawrence named stay
top-level (New Workspace in Group, Rename, Color, Pin/Unpin, Collapse/Expand,
Move to Top, Ungroup, Close Group) with Delete Group and Close Other Groups;
Options ▸ takes icon, read state, collapse-all, config and Duplicate, and
Move ▸ takes the group sorts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…p-level rows

Its one-row Copy folder showed inline as a 13th row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Regenerated on the fleet (key a219-exports-820ba663b9c,
scripts/measure/export-action-surfaces.sh). check-group-menus.py passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… (the pattern that broke CEFPumpScheduleTests)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…minimap-feel

# Conflicts:
#	plans/cmux-next/actions.md
@teamleaderleo
teamleaderleo temporarily deployed to cmux-tui-merge-tree October 20, 2026 08:37 — with GitHub Actions Inactive
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review of base-merge 452faf1 (subagent, correctness first)

I checked this merge with git and Python by reading only; nothing was built. The only files both sides changed are the generated exports.

  • actions.md: it differs from the base in exactly the five lines this PR changes from its merge base:

    • the agents count
    • the counts heading, now 1073 actions
    • the palette count, now 1036 (CLI, right-click and MCP are unchanged because both new actions are palette-only)
    • the cli guiOnly list, from 189 to 191
    • the contextMenu guiOnly list, from 20 to 22

    Both lists are byte-identical to the PR head's.

  • action-surfaces.json:

    • It has the base's 1071 rows unchanged plus the PR's two bookmarked-prompt rows.
    • The four transcript rows are in the PR head's position.
    • There are no duplicate keys, and the top-level keys equal the base's.
  • cmux-shortcuts.generated.json: the base's 211 entries are unchanged. previousTurn and nextTurn are ordered as in the PR head.

  • File set: the merge differs from the base in exactly the PR's 35 files.

Verdict: MERGE

@teamleaderleo
teamleaderleo merged commit d85ec8a into feat-cmux-next Oct 20, 2026
@teamleaderleo
teamleaderleo deleted the fix/thread-minimap-feel branch October 20, 2026 08:39
Comment on lines +93 to +95
/// Passes over every cell, by kind (`occlusion`, `separators`, `frames`): what a change cost, for
/// the switch work budget (a tab switch at rest runs none, at any tab count).
var rowPasses = TabStripRowPasses()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Test counters ship with tabs

TabStripView.rowPasses adds counters to shipping code solely for TabSwitchWorkBudgetTests; no production code reads them. This violates the repository requirement that test-only seams stay out of production Sources/.

Move this test scaffolding into the test target and inspect existing internal state through @testable import, following the reference fix in #6452.

Rule Used: Flag Swift files under a production Sources path (matching /Sources/ and not under /Tests/) that add a test-only or debug-only seam: a #if DEBUG (or other test-build-guarded) extension/member exposing internal state only for tests or a debugg... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

teamleaderleo added a commit that referenced this pull request Oct 23, 2026
… the chat (#24058)

* test(webviews): the wave rail is the default and its band reaches the bottom (red)

Leo 2026-10-22: "it wouldn't show the highlighted bottom text where I was
scrolling at the bottom, and it just didn't go to the bottom" and "I didn't
want the mini map." These fail until the compact rail (the wave from #22228)
is the default arm again and the rail's own list keeps the whole band in
view, reaching its end at the bottom of the chat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cfc-5eec-7700-94d7-9a3a1f7d889a&host=Air-Blue

* fix(webviews): the wave rail is the thread rail again, and its band reaches the bottom

Leo 2026-10-22: "Why did you turn it into a whole mini map? ... I didn't
want the mini map." and "IT'S JUST THE FUCKING LINES THEY DONT FUCKIG
ANIMTATE". His "the rail feels short" meant the band never reached the
bottom ticks: "it wouldn't show the highlighted bottom text where I was
scrolling at the bottom, and it just didn't go to the bottom."

- thread-minimap-layout defaults to compact again: the Codex rail from
  #22228, one tick per prompt, the band that follows the scroll and the
  Dock-style magnify under the pointer. The full-height map (#22784) and
  off stay selectable from cmux.experiments.
- The rail's own list (a chat with more prompts than the rail has room
  for) kept only the band's first row in view, a third of the way down, so
  the prompts below it sat under the bottom fade or past the rail's end.
  railScrollTop keeps the whole band in view and clear of the fades, and
  a band holding the last prompt scrolls the list to its end, where no
  fade draws: at the bottom of the chat the bottom ticks show lit.
- The list keeps overflow: hidden (#23735), so it never draws a scroller,
  and the rail stays sticky with no height: no layout shift on a tab switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cfc-5eec-7700-94d7-9a3a1f7d889a&host=Air-Blue

* test(webviews): the rail's lit ticks stay clear of the transcript's faded edges (red)

Review of #24058: 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. In a short pane the rail filled the
view less 8 px each end, so the first and last lit ticks drew inside those
fades at every offset short of the chat's ends: the bottom ticks Leo asked
to see lit were hidden again.

- thread-minimap-bottom: a 320 px pane, every offset; and the model's
  fade constants must cover the CSS's bands.
- thread-minimap-feel (Chromium): a 420 px window, the real mask's bands
  read from the scroller's computed style.
- thread-minimap-rail: railHeight leaves room for the bands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cfc-5eec-7700-94d7-9a3a1f7d889a&host=Air-Blue

* fix(webviews): the rail keeps its ticks clear of the transcript's faded edges

The rail sits inside the transcript, so the transcript's mask fades it:
18 px under the top once scrolled, 18 px (56 px with Jump to latest) above
the view's foot while more lies below. railRoom keeps the rail's rows out of
both bands at their widest (so the rail never moves as they come and go),
and the rail centers on the view as before, moved only as far as that takes.
App passes the mask's foot (the bottom stack's top), so a composer grown
while following does not cover the bottom ticks either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cfc-5eec-7700-94d7-9a3a1f7d889a&host=Air-Blue

* test(webviews): every lit tick draws whole in the rail, down to a 96 px pane (red)

Review round 2 of #24058: in a 96 px transcript (the CSS minimum) with four
prompts the rail was 18 px tall, and a band in the middle of the chat put its
lit tick at 10..20 px, past the rail's end. The list's own 40 px edge fades
also covered a rail that short.

- thread-minimap-room: model sweep over pane heights 96 to 640 px, 1 to 70
  prompts, every band of 1 to 4 prompts scrolled down and back up. Every lit
  tick must sit whole inside the rail, and, where the pane has room, clear of
  the transcript's faded edges. railHeight takes the band's size (a new
  fifth argument; the red commit ignores it).
- thread-minimap-tiny: renders panes 96 to 336 px, 4 to 70 prompts, offsets
  down the whole chat, and checks every lit tick against the rail, the
  list's fades and the transcript's mask.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cfc-5eec-7700-94d7-9a3a1f7d889a&host=Air-Blue

* fix(webviews): the rail is never shorter than the band, down to a 96 px pane

Review round 2 of #24058: in a 96 px transcript the rail was 18 px, shorter
than its own 40 px edge fades, and a band in the middle of the chat drew its
lit tick past the rail's end (railScrollTop fell back to 0 when the fade
clearances conflicted).

- railHeight takes the number of prompts on screen and never returns less
  than their rows: in a pane too short for the band and the transcript
  mask's clearance, the clearance gives way first, up to the whole view.
  railTop then centers the rail in the view.
- The list's edge fades are a quarter of a short rail at most (railFade,
  --acpmux-minimap-fade) and never draw over the band (railFades).
- railScrollTop keeps the band whole inside the list when the fade
  clearances conflict, instead of falling back to 0.
- The rail hides only if the view has no room for one row; the transcript's
  96 px minimum always has room.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cfc-5eec-7700-94d7-9a3a1f7d889a&host=Air-Blue

* test(webviews): the bottom rail test reads only the mask fades that draw, and has room to run

maskFades took the smallest --acpmux-fade-bottom in conversation.css, which is one of the 0px
rules (find open, Reduce Transparency, Increase Contrast) that turn the fade off, so the CSS
check failed at once. Those rules draw nothing; only the nonzero bands count. The two render
sweeps take about 5 and 7 s in jsdom, past bun's 5 s default when run with plain `bun test`
(CI's runner raises it to 30 s), so the file sets 60 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cfc-5eec-7700-94d7-9a3a1f7d889a&host=Air-Blue

* test(webviews): the short-pane rail sweeps run in a full-height window, as the app does

The rail caps its height at 70% of the window (globalThis.innerHeight), and bun's
globalThis has none, so the rail fell back to 70% of the pane. In a 320 px pane that is
224 px, centered, nowhere near the transcript's 18 px fades, so the short-pane test passed
on the code it was written against (34b6d18) as well as on the fix. In the app the window
is taller than its transcript and the rail filled the pane less 8 px a side, into the
fades. Both sweeps now run with a 900 px window: the 320 px test fails on 34b6d18 again,
and the tiny sweep fails at 96 to 176 px on 3eec766 (it failed at 96 to 128 before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cfc-5eec-7700-94d7-9a3a1f7d889a&host=Air-Blue

* test(webviews): a token streaming into the last reply builds no tick again (red)

#24058 review round 3: every streamed token gives the transcript a new rows snapshot, and the
rail maps every prompt again for it, building each tick's label and element: O(prompts) work per
token on the main thread, now that the rail is the default.

thread-minimap-stream renders a 300-prompt chat and streams 20 tokens into the last reply,
counting the tick labels the rail builds (each tick it renders reads its label string once). It
expects none per token, the card to still preview the streamed reply, and a new prompt to add
its tick while the earlier tick elements stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cfc-5eec-7700-94d7-9a3a1f7d889a&host=Air-Blue

* fix(webviews): a streamed token renders no tick on the rail

#24058 review round 3: each streamed token is a new rows snapshot, and the rail mapped every
prompt again for it, building each tick's label and element: O(prompts) per token.

- minimapTurns also returns `ticks` (each prompt's key, id and text), which keeps the previous
  list, by identity, while the prompts are the same. It compares only the turns from the first
  changed one on; the earlier ones are the previous objects.
- The rail draws its rows through a memoized TickList that takes `ticks` and the band, hover,
  focus and card state, so a render that changes none of these (a token in the live reply)
  renders no row. `turns` still changes with the reply, so the card previews it as it streams.
- Per token the rail now does the reply's turn (as before) plus a compare from the changed turn
  to the end; the ticks are not visited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cfc-5eec-7700-94d7-9a3a1f7d889a&host=Air-Blue

* test(webviews): a token reads rows bounded by what changed, and a hovered rail keeps the band (red)

#24058 review round 4.

- thread-minimap-stream: minimapTurns reads (through a counting proxy) the same few rows for a
  streamed token in a 300- and a 3000-prompt chat, given the transcript's change point; it used
  to compare every row from the first. A mid-chat edit and an inserted row read the same turns
  as a fresh read, and a change point for other rows is ignored. A new prompt keeps every
  earlier tick element, not only the first.
- thread-minimap-bottom: with a tick hovered, and with one focused, scrolling the 90-prompt chat
  from top to bottom keeps every lit tick inside the rail's unfaded area. The rail used to stop
  following the band while hovered or focused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cfc-5eec-7700-94d7-9a3a1f7d889a&host=Air-Blue

* fix(webviews): a token reads only the live turn's rows, and a hovered rail keeps the band

#24058 review round 4.

- minimapTurns took the first changed row by comparing every row from the first, O(rows) per
  token. VirtualTranscript already finds that point once per snapshot (its arriving rows); it
  now keeps it (RowsChange) and passes it to the rail, which uses it when it is the step from
  the rail's last rows to these and compares from the start otherwise. The kept turns are found
  by binary search. A streamed token now reads the live turn's rows only: 1202 row reads for
  both a 300- and a 3000-prompt chat in the test (12002 for 3000 before).
- A turn whose reply streamed on past its preview (replyPreview stops once full) keeps its
  object, so the turn list stays the same object too.
- The rail followed the band only with no tick hovered or focused, so scrolling the chat under
  a hovered or focused rail took the band out of it. It follows always now; railScrollTop keeps
  the list where it is while the whole band shows clear of the fades, so the tick under the
  pointer moves only when the band would leave. Only a scrub holds the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cfc-5eec-7700-94d7-9a3a1f7d889a&host=Air-Blue

* test(webviews): the magnified tick follows a still pointer, and a token copies no turns (red)

#24058 review round 5.

- thread-minimap-bottom: hover tick 3 with the pointer at y 35, then wheel the chat with the
  pointer still: once the rail's list moves, the magnified tick must be the one under the
  pointer at every offset. It stayed tick 3.
- thread-minimap-stream: the previous turns behind a counting proxy: a streamed token whose
  preview is still filling must update the list in place (same array) with about log2(prompts)
  reads, at 300 and 3000 prompts. It copied every earlier turn (slice). And, through
  VirtualTranscript: another chat with repeating row ids, the same chat replaced wholesale, then
  cut, each show their own tick labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cfc-5eec-7700-94d7-9a3a1f7d889a&host=Air-Blue

* fix(webviews): the magnified tick follows a still pointer, and a token copies no turns

#24058 review round 5.

- The rail's list follows the band with a tick hovered, but hover is a row index set on
  pointerenter, and a list that scrolls under a still pointer fires none: the old tick stayed
  magnified while another sat under the pointer. The list records the pointer's last clientY
  (pointerover, pointermove; cleared when the pointer leaves the rail), and when it moves the
  list while hovered, the hovered row and the card's are the row now under that point.
- minimapTurns copied every earlier turn (slice) for each snapshot, O(prompts) per token while
  a reply's preview fills. It now reads only the turns from the changed one, compares them with
  the previous list's, and updates that list in place when they differ (the previous result is
  spent once passed in). The live turn is found by binary search: about log2(prompts) reads.
  ThreadMinimap renders on each snapshot and reads the list then; `ticks` keeps its own
  identity rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cfc-5eec-7700-94d7-9a3a1f7d889a&host=Air-Blue

* Test: the rail keeps committed turns, and follows the pointer when focus scrolls it

Red at this commit (#24058 review round 6):
- a dropped render (the next rows read, never committed) leaves the
  committed turns as they were; minimapTurns writes the previous list in
  place, so the committed list takes the dropped prompt;
- End or an arrow key that focuses a tick past the list's end, with the
  pointer still on the rail, magnifies the tick under the pointer; the
  focus scroll only moves listTop, so the old tick stays magnified.

The still-pointer test now puts the list 140 px down, so the rail's own
offset counts, and the default test reads a stored cmux.experiments
choice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cfc-5eec-7700-94d7-9a3a1f7d889a&host=Air-Blue

* Agent chat: the rail leaves committed turns alone and follows the pointer on any list scroll

Two findings from #24058 review round 6:
- minimapTurns wrote the previous turns list in place, during render. A
  render React drops then left the committed rail holding rows it never
  committed. The result now carries a spare list (the one before its
  turns) and changed turns go there, never into the previous list; a
  record of how far the spare still holds the committed turns means a
  streamed token writes the live turn alone, still no copy of the others.
  ThreadMinimap stores the committed result in a layout effect, not in
  the memo.
- A key that focuses a tick past the list's end scrolls the list under a
  pointer that did not move, and only listTop followed. The list's scroll
  handler now remaps the hovered tick from the last pointer position, as
  the follow effect does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cfc-5eec-7700-94d7-9a3a1f7d889a&host=Air-Blue

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
teamleaderleo added a commit that referenced this pull request Oct 23, 2026
…%, and stays lit on hover

Carries over the parts of #24065 that #24058 does not cover, built on #24058's
rail (railScrollTop, followPointer). The full-height map stays as #24058 left it.

- At rest a row coming on screen brightens, and one leaving dims, over the motion
  scale's micro step (120 ms), so the band glides down the rail. Hover, scrub,
  data-reduce and prefers-reduced-motion switch at once; the full map keeps its look.
- Lit ticks draw at --acpmux-minimap-lit, 75% by default (was 60%), over rest at 40%.
  lit.experiment.ts keeps 60/70/75/85% for the gallery's compare view and dogfood.
- The prompts on screen stay lit while the pointer is on the rail (#22228 dimmed
  them back to rest there).

Tests: thread-minimap-default-wave (default arm, each arm reaches the rail, the
CSS); thread-minimap-motion reads the bar's transitions as a list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16030-bf28-7072-b1f6-b83bbd0beacc&host=cosmic-gray
teamleaderleo added a commit that referenced this pull request Oct 23, 2026
…and glides at rest, shows at 75%, and stays lit on hover (#24526)

* cmux NEXT agent chat: the rail's lit band glides at rest, shows at 75%, and stays lit on hover

Carries over the parts of #24065 that #24058 does not cover, built on #24058's
rail (railScrollTop, followPointer). The full-height map stays as #24058 left it.

- At rest a row coming on screen brightens, and one leaving dims, over the motion
  scale's micro step (120 ms), so the band glides down the rail. Hover, scrub,
  data-reduce and prefers-reduced-motion switch at once; the full map keeps its look.
- Lit ticks draw at --acpmux-minimap-lit, 75% by default (was 60%), over rest at 40%.
  lit.experiment.ts keeps 60/70/75/85% for the gallery's compare view and dogfood.
- The prompts on screen stay lit while the pointer is on the rail (#22228 dimmed
  them back to rest there).

Tests: thread-minimap-default-wave (default arm, each arm reaches the rail, the
CSS); thread-minimap-motion reads the bar's transitions as a list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16030-bf28-7072-b1f6-b83bbd0beacc&host=cosmic-gray

* cmux NEXT agent chat: only the scroll's band moves glide; focus, hover and leaving snap

Codex review of #24526 (BLOCK at 61de4e9):
- A key focusing a tick made it active while the rail stayed at rest, so the
  focused row faded in. Active ticks never take the glide now.
- The pointer leaving the rail dropped data-hovering in the same commit that
  changed the hovered row's tone, so leaving faded too. A commit that changes the
  active row, the hover or the scrub marks the rail data-snap and its rows switch
  at once; the rail's next render that changes none of these drops the mark.
- thread-minimap-motion asserted durations with a pattern a single 0.12s also
  matched. It now reads transitionProperty: exactly "transform, opacity", each
  0.12s with no delay.

thread-minimap-default-wave: the glide's selectors exclude data-snap and the
active tick, and a VirtualTranscript render marks data-snap on focus and blur and
clears it on the next band move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a16030-bf28-7072-b1f6-b83bbd0beacc&host=cosmic-gray

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch was previously deployed

2 inactive deployments
artifacts — 452faf19 Deployed Oct 20, 2026 by teamleaderleo
cmux-tui-merge-tree — 452faf19 Deployed Oct 20, 2026 by teamleaderleo
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.

2 participants