One chat in two tabs: Session Info and the transcript place are kept per tab; a Session Info popover gets its glass
Leo, 2026-11-03, on one chat open as a tab in two workspaces: "i feel like right split is fine on click by default (extra view doesn't really affect anything), and you can riht click to go to it. but of course we just have to be prepared to mitigate oddities"
So two views of one session have to be safe. Two pieces of per-view state were kept by session, not by tab, so a reload of one tab took the other tab's state:
- Session Info (open, width, folds) in the page's localStorage,
cmux.agentPane.inspector.pane.<sessionId>. Session keying came from #25169 ("Session Info opens and closes per pane", 2026-10-25), carried through the rename in #29921. - The transcript's place (
AgentPaneScrollAnchors,chat.scrollAnchor/chat.scrollAnchor.get), keyed bysessionIdsince #19445 (cx-6bgzj); moved to the state files in #27749 and #28275.
What changes
- The host names the pane's tab:
AgentPaneModel.tabKey(the store's tab id, resolved at each call).AgentUnsentDrafts.wirealready gets the tab key for every tab's model, a spare's too when a tab adopts it, so it sets it there. No App-target lines added. - Scroll places: both hosts (
AgentPaneBridgeandAgentPageProvider) pass the pane's tab to the store. Entries now carrytab, and an at-latest entry is kept for its tab, so that tab's reload doesn't fall back to the other tab's place. Lookup order: the tab's own entry, then the chat's most recent entry from any tab. That includes old tabless entries, so existing places still restore, and a tab opened on the chat later still opens where the chat last rested. The page never names its own tab. - Session Info: the handshake carries
tab. A spare that a tab adopts after its handshake getsacpmux-pane-tab.sessionInfoState.tskeeps a tab's state undertab:<tab>:<paneKey>, and each save also writes the chat's key. So the chat key holds the latest state in any tab, which is the fallback for a tab with no state of its own (including chats saved before this change). Both keys go in the index and are pruned the same way. Split keys (split:) work as before. With no tab (an older host, the gallery, tests), nothing changes. - Ghost panel: see below.
Ghost panel (confirmed in code, fixed here)
Leo saw a faint copy of Session Info ("Background tasks", "Connectors", "Agent memory"...) mixed into the chat transcript. One path does this. AgentPaneSessionInfoPanel.updateBacking() gave the native panel its overlay glass only when the page sent foot. But a short pane's popover (pane under POPOVER_BELOW_HEIGHT, stage overlay) lies over the transcript and sends no foot: foot={stopsAboveComposer ? dockHeight : undefined} with stopsAboveComposer = overChat && !popover. So that panel's text sat over the transcript text with no fill (the panel itself has backgroundColor = nil). Now the page sends overChat whenever the panel lies over the chat, and the panel takes its glass on overChat || foot. Pages that predate overChat behave as before.
I found no path where the page says closed while the native panel stays shown: every pane.sessionInfo update, reload (pageLoaded()) and unmount clears it. One gap I did not verify: a reload that never reaches ready (a load failure) would leave the old panel up. Session keying (item 1) could make a reloaded tab show the other tab's open state, but each pane's native panel still follows its own page.
Tests
webviews/test/session-info-per-tab-state.test.ts(new): two tabs of one chat keep their own open, width and folds across a reload, including the first frame before the bind. A tab with no entry of its own reads the chat's old session-keyed entry and then keeps its own. Both entries are in the index. A spare that a tab adopts after binding re-reads under its tab and notifies listeners.webviews/test/native-session-info-page.test.tsx: a panel over the chat postsoverChat: trueeven with nofoot.ChatScrollRestoreTests: two tabs of one chat keep their own place across a reload, and a tab at its latest message stays there instead of taking the other tab's place. An old tabless file entry is the fallback for a tab with none and is then replaced by the tab's own.AgentPaneNativeSessionInfoTests.aPanelOverTheChatGetsItsGlassEvenWithoutAFoot.
Red on base (the first commit, tests only): cd webviews && bun test test/session-info-per-tab-state.test.ts test/native-session-info-page.test.tsx gives 2 fail and 1 error. Green at the head: 31 pass across those files plus session-info-split-starts-closed and session-info-per-pane-state. The Swift tests reference API that doesn't exist on base (tab: and liesOverChat), so they fail to build there.
Observed / Not run
- Observed: the focused Bun tests above (red on base, green on head),
vp fmtandvp linton the changed web files, the cmux-next prepush gate, and the app compile check at the head. - Not run: the Swift suites themselves (
CmuxNextAgentPaneTests). I have not run two real tabs of one chat in a DEV build.
Downgrade note: an older app that reads a places file containing an at-latest tab entry (no anchor) fails to decode it and starts with no kept places. It loses scroll places only, nothing else.
Changelog
- Fixed: one chat open in two tabs keeps each tab's Session Info and its transcript's place across a reload, and a short pane's Session Info popover no longer shows the chat through it.
Agent session: agent session
🤖 Generated with Claude Code
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.
Summary by cubic
Keeping two tabs of one chat safe now: each tab stores its own Session Info (open state, width, folds) and transcript scroll position, so a reload in one tab no longer picks up the other tab's state. A Session Info popover over the transcript also gets its proper glass background, fixing the chat showing through it.
- Scroll places and Session Info are keyed by tab, with the chat's latest state as fallback for tabs with no entry of their own (including older saved data).
- The host passes the pane's tab ID through the handshake and scroll anchor calls; pages adopted after handshake get it via a new event.
- The native Session Info panel receives
overChatfrom the page so a short-pane popover (which sends nofoot) gets its glass.