cmux NEXT agent chat: Session Info opens and closes per pane

Merged
TE
11d ago
+677-95

Leo, 2026-10-25, word for word:

"basically the video shows the session rail opening and closing it opens and closes on the other side too when you do one... they should all be individual per pane"

("Session rail" is Session Info, the agent chat's floating Inspector.)

Cause

webviews/src/agent-session/acpmux/inspector/inspectorState.ts kept open, width and folds in one set of localStorage keys (cmux.agentPane.inspector.open and friends, lines 1-5 and 36-38 on base), "kept in step across panes by the storage event" (the subscribe listener, lines 97-109 on base). Every chat pane is its own page on one origin, so a toggle in one pane wrote the key and every other pane followed its storage event.

Fix

  • inspectorState.ts now builds a per-page store (createInspectorStore). Each pane's open state, width and section folds are its own. No page listens for another page's writes, so a toggle, a drag or a fold in one pane never reaches another.
  • Keying: each chat gets its own localStorage key, cmux.agentPane.inspector.pane.<sessionId> ({ open, width, sections, at }). A pane writes only its own chat's key, so two panes saving at the same moment cannot overwrite each other. There is no shared blob and no read-modify-write across chats. The session id is what the page already has and it survives a relaunch. The page has no stable pane or tab id: the URL, tab row, header state and page-host payloads carry none (the review confirmed this), so no native change is made. With the same chat in two panes, the panes are independent while they run. After a relaunch both start from that chat's last choice. This is a known gap until the native side passes a pane id.
  • Switching chats: the chat's state is read during render (useInspectorOpen(sessionId) and useInspectorState(sessionId) read the chat's saved state before the pane binds to it), and the panel is keyed by chat. A pane switched from chat A (open) to chat B (saved closed) therefore shows B's state from its first frame, and A's panel does not play its exit animation over B.
  • No session yet (a new chat, or a harness switch's sessionless chat): the pane is unbound and saves nothing, so the previous chat's state is left alone. The pane keeps showing its current state, and once the session arrives that state is saved as the new chat's.
  • Default for a pane with no state yet: the last choice made in any pane. That choice is still written to the old keys, so an existing install's closed Inspector migrates as the default, and a first chat still opens with it (Leo, 2026-10-20).
  • Storage bounds: a small index key, cmux.agentPane.inspector.index ([sessionId, savedAt] pairs, at most 200), is updated on every save. Pruning works from the index: the pane's own chat is always kept, even after the clock is set back or on a timestamp tie, plus the newest 199 others touched in the last 30 days. Dropped chats' keys are deleted. A toggle, fold or resize reads only its chat's key and the index, never the rest of storage. Only the small index is read and rewritten; if another pane's write loses an update to it, pruning is delayed but no chat's state is lost. Before deleting a chat's key, the pane re-reads that key and deletes it only while its stored at is exactly the time the index gave. A chat another pane saved after this pane read the index, even at an earlier time after the clock was set back, is kept and listed again with its new time. The one full key scan runs once per page load, on that page's first save, and adds any chat key no index lists to the index, so pruning still reaches it.

Tests

  • test/inspector-per-pane-state.test.ts (new, jsdom, 11 tests). Each pane is its own copy of the module, imported under its own query the way each page loads its own, over one shared localStorage.
    • Covers: live isolation; relaunch; old-key migration; the same chat in two panes; two panes saving at once from a stale read, with both chats' changes kept; no session saving nothing until one arrives; bounds with the own chat always kept after a clock rollback; with 1,000 unrelated keys and 200 chats, 12 toggles, folds and resizes call key() and length zero times and getItem at most 24 times (spy; red at 82bba1e4); a chat key no index lists is still pruned; an eviction interleaving (pane A reads the index, pane B saves chat X fresh, pane A prunes) keeps X's state (red at c81cf1bf), including when B's clock was set back to before the index's time (red at 8749acb7).
    • Red on base for the behaviour: the relaunch and same-chat cases fail on assertions. The three store cases (concurrent save, no session, pruning) also fail at the previous head e44cfe5e on assertions. Green here.
  • test/chat-inspector.test.tsx, new test: "a pane switched to another chat shows that chat's Inspector at once". A MutationObserver records every data-presence change. Red at e44cfe5e (shown -> leaving), green here. The old "follows another pane's change" test now asserts the per-pane rule.
  • test/chat-inspector-per-pane.test.ts (new, browser lane, CMUX_BROWSER_TESTS=1). Two papercut panes share one browser context, so they share storage. Closing Session Info in the left pane leaves the right pane's open, and the left pane is still closed after a reload. Red on base (right: panel 0, pressed false), green here (Chromium; WebKit did not launch on huge-blue, and CI runs both).
  • All chat-inspector* tests pass alongside the new unit test (101). Typecheck, lint and fmt are clean.
  • Batches on huge-blue sometimes time out inspector-dead-clicks.test.tsx "every clickable thing" at the 5 s default, and the rest of that file then cascades with "overlapping act()". The test takes the same ~3.7 s with and without this change (3 runs each), so the timeouts are host load. A run alone or in a fresh batch passes.

Captures

Two panes showing the same chat side by side, after the toggle in the left pane is clicked.

Before (feat-cmux-next): the right pane closed too.
before

After: only the left pane closes.
after

Risk

Low, webview only. The only behavior change is that state no longer crosses panes: another chat or a new chat starts from the last choice instead of following live. The Inspector's own UI is not touched. Nearby PRs: #25116 (Session Info stays visible across pane changes) and #25148 (left rail beside Session Info) touch ChatInspector.tsx in other hunks, and a trial merge with each is clean.

Agent session: huge-blue

šŸ¤– Generated with Claude Code