Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ import {
setSessionInfoOpen as setSessionInfoPanelOpen,
setSessionInfoSection,
useSessionInfoOpen,
sessionInfoPaneKey,
} from "./session-info/sessionInfoState";
import { openCommand, opensProcessTerminal } from "./processTerminal";
import {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -8658,6 +8666,7 @@ function AcpmuxPane() {
);
const sessionInfo = useSessionInfo({
snapshot: sessionInfoSnapshot,
split: sessionInfoSplit,
machineName,
repository: githubRepository,
cloudRepositoryAvailable,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import {
import {
bindSessionInfoPane,
SESSION_INFO_WIDTH,
sessionInfoPaneKey,
setSessionInfoOpen,
setSessionInfoSection,
setSessionInfoSourcesAll,
Expand Down Expand Up @@ -375,6 +376,7 @@ export function useSessionInfo({
onSaveEnvironment,
onShowCall,
subagent,
split = false,
covered = false,
onUncover,
onBeforeOpen,
Expand Down Expand Up @@ -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<unknown>;
/// Environment's Remote Control switch (App.tsx, remoteControl.ts): absent, no row.
Expand Down Expand Up @@ -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.
Expand Down
39 changes: 35 additions & 4 deletions webviews/src/agent-session/acpmux/session-info/sessionInfoState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -229,23 +256,26 @@ 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) {
if (sessionId === pane) return;
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");
Expand Down Expand Up @@ -273,6 +303,7 @@ export function createSessionInfoStore(storage: () => Storage | undefined = () =
pane = undefined;
state = undefined;
peeked = undefined;
closedOf = undefined;
update(defaults(), false);
},
};
Expand Down
73 changes: 73 additions & 0 deletions webviews/test/session-info-split-starts-closed.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
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);
});
Loading