From 6efe02e75adeb999d534bd61edf44597498fc23e Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 3 Nov 2026 00:53:32 +0000 Subject: [PATCH] agent pane: a chat opened in a split starts with Session Info closed Leo, 2026-11-03: "i also dont like how the session info overlay is when it goes to a right split honestly it's often fine to just not have it open by default on the right like it doesn't need to inherit that..." and "like if they want it open they can just do it on the right split one but otherwise it really doesn't have that much space pretty often". A subagent's pane and a chat its agent started (startedBy/spawnedBy) keep Session Info under a key of their own (sessionInfoPaneKey, "split:"), which starts closed instead of inheriting the parent's or the last choice. Opening it there sticks for that pane and never reaches the parent's. A subagent's pane shows its chat's session, so it no longer reads and writes the chat's own state. Also fixes the stale comment above showSubagent (review follow-up of #32677). Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-7a7568197607c3cdfb81 --- webviews/src/agent-session/acpmux/App.tsx | 15 +++- .../acpmux/session-info/SessionInfo.tsx | 7 +- .../acpmux/session-info/sessionInfoState.ts | 39 +++++++++- .../session-info-split-starts-closed.test.ts | 73 +++++++++++++++++++ 4 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 webviews/test/session-info-split-starts-closed.test.ts diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 0aad2f79ac92..ad36ca7c6bf8 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -277,6 +277,7 @@ import { setSessionInfoOpen as setSessionInfoPanelOpen, setSessionInfoSection, useSessionInfoOpen, + sessionInfoPaneKey, } from "./session-info/sessionInfoState"; import { openCommand, opensProcessTerminal } from "./processTerminal"; import { @@ -7435,7 +7436,13 @@ function AcpmuxPane() { [transcriptTasks], ); const sessionInfoOn = useHeaderState().sessionInfo; - const sessionInfoOpened = useSessionInfoOpen(shownSessionId); + // A pane opened beside another chat (a subagent's pane, a chat its agent started) has Session Info + // of its own, closed until the person opens it there (sessionInfoState.ts `sessionInfoPaneKey`). + const shownEntry = shownSessionId + ? snapshot.sessions?.find((entry) => entry.sessionId === shownSessionId) + : undefined; + const sessionInfoSplit = ownSubagentPane || !!(shownEntry?.startedBy || shownEntry?.spawnedBy); + const sessionInfoOpened = useSessionInfoOpen(sessionInfoPaneKey(shownSessionId, sessionInfoSplit)); const processRegistry = useProcessRegistry( processSource, shownSessionId, @@ -8346,8 +8353,9 @@ function AcpmuxPane() { foregroundNow.current = foreground; const backgroundHint = !quick && canBackground && snapshot.isWorking ? runInBackground : undefined; const [autoOpenSubagents, setAutoOpenSubagents] = useAutoOpenSubagents(); - /// Shows subagent `id`: in a pane of its own right of the chat (focused with `focus`) once the - /// summary card's switch is on, else in this pane (subagents/subagentPanes.ts). + /// Shows subagent `id`: a person's open in a pane of its own right of the chat, focused; a new + /// subagent's own open (`focus` false) only while the summary card's switch is on + /// (subagents/subagentPanes.ts). const showSubagent = useCallback( (id: string, focus = true) => void showSubagentIn(callNative, id, focus, { @@ -8658,6 +8666,7 @@ function AcpmuxPane() { ); const sessionInfo = useSessionInfo({ snapshot: sessionInfoSnapshot, + split: sessionInfoSplit, machineName, repository: githubRepository, cloudRepositoryAvailable, diff --git a/webviews/src/agent-session/acpmux/session-info/SessionInfo.tsx b/webviews/src/agent-session/acpmux/session-info/SessionInfo.tsx index 97476bbddd31..957e9d5946ca 100644 --- a/webviews/src/agent-session/acpmux/session-info/SessionInfo.tsx +++ b/webviews/src/agent-session/acpmux/session-info/SessionInfo.tsx @@ -87,6 +87,7 @@ import { import { bindSessionInfoPane, SESSION_INFO_WIDTH, + sessionInfoPaneKey, setSessionInfoOpen, setSessionInfoSection, setSessionInfoSourcesAll, @@ -375,6 +376,7 @@ export function useSessionInfo({ onSaveEnvironment, onShowCall, subagent, + split = false, covered = false, onUncover, onBeforeOpen, @@ -451,6 +453,9 @@ export function useSessionInfo({ onShowCall?: (toolCallId: string) => boolean; /// The subagent whose own view the pane shows: Session Info lists its things, not the chat's. subagent?: SubagentScope; + /// The pane was opened beside another chat (a subagent's pane, a chat its agent started): its + /// Session Info is its own and starts closed (sessionInfoState.ts `sessionInfoPaneKey`). + split?: boolean; /// Reconnects one of the chat's failed MCP servers by name (`_acpmux/mcp/reconnect`). onReconnectMcp?: (server: string) => Promise; /// Environment's Remote Control switch (App.tsx, remoteControl.ts): absent, no row. @@ -483,7 +488,7 @@ export function useSessionInfo({ // The chat's state is read from its first frame (`useSessionInfoOpen(paneSession)`), before the bind, // and the panel is keyed by the chat, so a pane switched to another chat shows that chat's // Session Info at once instead of playing the last chat's panel out. - const paneSession = snapshot.sessionId ?? snapshot.summary?.sessionId; + const paneSession = sessionInfoPaneKey(snapshot.sessionId ?? snapshot.summary?.sessionId, split); useLayoutEffect(() => bindSessionInfoPane(paneSession), [paneSession]); // Open alone: the width and the folds are the panel's (SessionInfoPresence), so a drag step or a fold // does not draw the whole chat pane again. diff --git a/webviews/src/agent-session/acpmux/session-info/sessionInfoState.ts b/webviews/src/agent-session/acpmux/session-info/sessionInfoState.ts index 0c27bc77b0fd..3fb93616e41e 100644 --- a/webviews/src/agent-session/acpmux/session-info/sessionInfoState.ts +++ b/webviews/src/agent-session/acpmux/session-info/sessionInfoState.ts @@ -7,6 +7,14 @@ // any pane is the default for a pane with none of its own yet (a new chat), so Session Info still // opens for a first chat and stays off for new chats once closed (Leo, 2026-10-20). With no storage // (a private window, a test) the page's own copy holds them. +// +// A chat opened in a split beside another chat (a subagent's pane, a chat its agent started) starts +// with Session Info closed: it does not inherit the parent's or the last choice (Leo, 2026-11-03: "i +// also dont like how the session info overlay is when it goes to a right split honestly it's often +// fine to just not have it open by default on the right like it doesn't need to inherit that" and +// "like if they want it open they can just do it on the right split one but otherwise it really +// doesn't have that much space pretty often"). Such a pane keeps its state under its own key +// (``sessionInfoPaneKey``), so opening it there sticks for that pane and never reaches the parent's. import { useSyncExternalStore } from "react"; export type SessionInfoSectionId = @@ -56,6 +64,16 @@ export const SESSION_INFO_INDEX_KEY = "cmux.agentPane.inspector.index"; export const PANES_KEPT = 200; const PANE_DAYS = 30; +/// The prefix of a split pane's key (``sessionInfoPaneKey``). +const SPLIT_PREFIX = "split:"; + +/// The key pane state is saved under for chat `sessionId`: the chat's own, or (`split`: a pane opened +/// beside another chat) one of its own, which starts closed. A subagent's pane shows its chat's +/// session, so without it the pane would read and write the chat's own Session Info. +export function sessionInfoPaneKey(sessionId: string | undefined, split = false): string | undefined { + return sessionId && split ? SPLIT_PREFIX + sessionId : sessionId || undefined; +} + export type SessionInfoState = { readonly open: boolean; readonly width: number; @@ -213,6 +231,15 @@ export function createSessionInfoStore(storage: () => Storage | undefined = () = let peeked: { key: string; raw: string | null; state: State | undefined } | undefined; const listeners = new Set<() => void>(); const current = (): State => (state ??= defaults()); + /// What a pane with no saved state of its own starts with: what it shows now, closed for a split + /// pane. The same object while `current()` is, so a snapshot read stays stable. + let closedOf: { from: State; state: State } | undefined; + const fresh = (key: string): State => { + const now = current(); + if (!key.startsWith(SPLIT_PREFIX) || !now.open) return now; + if (closedOf?.from !== now) closedOf = { from: now, state: { ...now, open: false } }; + return closedOf.state; + }; const update = (next: State, persist: boolean) => { if (persist && pane) save(pane, next); const changed = next !== state; @@ -229,14 +256,15 @@ export function createSessionInfoStore(storage: () => Storage | undefined = () = const key = SESSION_INFO_PANE_PREFIX + sessionId; const raw = read(key); if (peeked?.key !== key || peeked.raw !== raw) peeked = { key, raw, state: storedState(raw) }; - return peeked.state ?? current(); + return peeked.state ?? fresh(sessionId); }, subscribe(listener: () => void) { listeners.add(listener); return () => void listeners.delete(listener); }, /// The pane shows chat `sessionId` (a relaunch, a new chat's first session, another chat in the - /// pane): it shows that chat's saved state, or keeps what it shows and saves it as the chat's. + /// pane): it shows that chat's saved state, or keeps what it shows (closed under a split pane's + /// key, ``sessionInfoPaneKey``) and saves it as the chat's. /// No session (a new chat not started yet, a harness switch): nothing is saved until one comes, /// and the pane keeps what it shows. bind(sessionId: string | undefined) { @@ -244,8 +272,10 @@ export function createSessionInfoStore(storage: () => Storage | undefined = () = pane = sessionId || undefined; if (!pane) return; const saved = storedState(read(SESSION_INFO_PANE_PREFIX + pane)); - if (saved) update(saved, false); - else save(pane, current()); + if (saved) return update(saved, false); + const start = fresh(pane); + save(pane, start); + update(start, false); }, setOpen(open: boolean) { write(OPEN_KEY, open ? "1" : "0"); @@ -273,6 +303,7 @@ export function createSessionInfoStore(storage: () => Storage | undefined = () = pane = undefined; state = undefined; peeked = undefined; + closedOf = undefined; update(defaults(), false); }, }; diff --git a/webviews/test/session-info-split-starts-closed.test.ts b/webviews/test/session-info-split-starts-closed.test.ts new file mode 100644 index 000000000000..e345bb0697f1 --- /dev/null +++ b/webviews/test/session-info-split-starts-closed.test.ts @@ -0,0 +1,73 @@ +// A chat opened in a split beside another chat (a subagent's pane, a chat its agent started) starts +// with Session Info closed and keeps its own state (Leo, 2026-11-03: "it's often fine to just not have +// it open by default on the right like it doesn't need to inherit that" and "if they want it open +// they can just do it on the right split one"). Each pane is its own store over one storage. +import { expect, test } from "bun:test"; +import { createSessionInfoStore, sessionInfoPaneKey } from "../src/agent-session/acpmux/session-info/sessionInfoState"; + +function memoryStorage(): Storage { + const map = new Map(); + return { + get length() { + return map.size; + }, + key: (index: number) => [...map.keys()][index] ?? null, + getItem: (key: string) => map.get(key) ?? null, + setItem: (key: string, value: string) => void map.set(key, String(value)), + removeItem: (key: string) => void map.delete(key), + clear: () => map.clear(), + }; +} + +test("a split pane's key is its own; a plain pane's is the chat's", () => { + expect(sessionInfoPaneKey("chat-1")).toBe("chat-1"); + expect(sessionInfoPaneKey("chat-1", true)).toBe("split:chat-1"); + expect(sessionInfoPaneKey(undefined, true)).toBeUndefined(); +}); + +test("a chat opened in a split starts with Session Info closed; the parent keeps its own open", () => { + const storage = memoryStorage(); + const parent = createSessionInfoStore(() => storage); + parent.bind("chat-1"); + parent.setOpen(true); + expect(parent.current().open).toBe(true); + + // The subagent's pane shows the parent's session, opened as a split. + const child = createSessionInfoStore(() => storage); + const key = sessionInfoPaneKey("chat-1", true); + // Its first frame (before the bind) already reads closed, and the same object each read. + expect(child.shownFor(key).open).toBe(false); + expect(child.shownFor(key)).toBe(child.shownFor(key)); + child.bind(key); + expect(child.current().open).toBe(false); + + // A chat its agent started, opened beside it, starts closed too. + const started = createSessionInfoStore(() => storage); + started.bind(sessionInfoPaneKey("chat-2", true)); + expect(started.current().open).toBe(false); + + // The parent still shows it open, and a relaunched parent pane too. + expect(parent.current().open).toBe(true); + const relaunched = createSessionInfoStore(() => storage); + relaunched.bind("chat-1"); + expect(relaunched.current().open).toBe(true); +}); + +test("opening it in the split pane sticks for that pane and never reaches the parent", () => { + const storage = memoryStorage(); + const parent = createSessionInfoStore(() => storage); + parent.bind("chat-1"); + parent.setOpen(false); + + const child = createSessionInfoStore(() => storage); + child.bind(sessionInfoPaneKey("chat-1", true)); + child.setOpen(true); + + const childAgain = createSessionInfoStore(() => storage); + childAgain.bind(sessionInfoPaneKey("chat-1", true)); + expect(childAgain.current().open).toBe(true); + + const parentAgain = createSessionInfoStore(() => storage); + parentAgain.bind("chat-1"); + expect(parentAgain.current().open).toBe(false); +});