fix(sidebar): key parent-expansion per render context (#1302)

The same session can appear in two render contexts at once — most
visibly under "Recent" and under its project's root group. Expansion
state was keyed by bare session id, so toggling the chevron in one
context flipped it in the other; the user clicked one row and a
duplicate elsewhere expanded/collapsed in sync.

Switch the expansion set to composite keys of the form
`<renderContext>:<active|archived>:<sessionId>`. SessionNodeItem
already computes this exact shape for its menu instance key
(menuInstanceKey); reuse it as expansionKey so the two render-time
instances of one session keep independent expand state. Updated:

- isExpanded reads `expandedParents.has(expansionKey)`.
- Chevron click / Enter / Space pass `expansionKey` to toggleParent
  instead of session.id.
- The memo-equality check compares the same composite key on both
  sides so it correctly re-renders when only one context's state
  changes.
- toggleParent's parameter is renamed `expansionKey` to reflect the
  new shape; the implementation is unchanged.
- The auto-expand-parent-on-subagent-navigation effect doesn't know
  which context the user will look at the parent in, so it fans the
  parentID out to all four (project|recent) × (active|archived)
  combinations.

Storage is bumped to `oc.sessions.expandedParents.v2`. Existing v1
data (bare session ids) is one-shot migrated by fanning each id
across all four contexts and rewritten under the v2 key; the v1 key
is then removed so a downgrade-then-reupgrade doesn't re-migrate.

Co-authored-by: vhqtvn <8930337+vhqtvn@users.noreply.github.com>
This commit is contained in:
vhqtvn
2026-05-18 17:57:05 +03:00
committed by GitHub
co-authored by vhqtvn
parent 7582db6df6
commit 9ab90d4f1a
3 changed files with 80 additions and 14 deletions
@@ -81,7 +81,13 @@ const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse';
const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject';
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents';
// v2 key holds composite "${renderContext}:${active|archived}:${sessionId}"
// entries so the same session in different render contexts (e.g. "Recent"
// and a project's root) has independent expand state. v1 held bare session
// ids; useSidebarPersistence migrates v1 data on first read by fanning each
// id into all four context combinations.
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents.v2';
const LEGACY_SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents';
const SESSION_PINNED_STORAGE_KEY = 'oc.sessions.pinned';
type PrVisualState = 'draft' | 'open' | 'blocked' | 'merged' | 'closed';
@@ -521,6 +527,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
safeStorage,
keys: {
sessionExpanded: SESSION_EXPANDED_STORAGE_KEY,
sessionExpandedLegacy: LEGACY_SESSION_EXPANDED_STORAGE_KEY,
projectCollapse: PROJECT_COLLAPSE_STORAGE_KEY,
sessionPinned: SESSION_PINNED_STORAGE_KEY,
groupOrder: GROUP_ORDER_STORAGE_KEY,
@@ -677,16 +684,25 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
sessionEvents.requestDirectoryDialog();
}, []);
// Auto-expand parent session when navigating to a subagent (child) session
// Auto-expand parent session when navigating to a subagent (child) session.
// We don't know which render context the user will look at the parent in
// (Recent, project root, archived bucket, ...), so fan out across all
// four combinations to ensure it's expanded wherever it appears.
React.useEffect(() => {
if (!currentSessionId) return;
const current = sessions.find((s) => s.id === currentSessionId);
const parentID = (current as Session & { parentID?: string | null })?.parentID;
if (!parentID) return;
const keysToAdd = [
`project:active:${parentID}`,
`project:archived:${parentID}`,
`recent:active:${parentID}`,
`recent:archived:${parentID}`,
];
setExpandedParents((prev) => {
if (prev.has(parentID)) return prev;
if (keysToAdd.every((k) => prev.has(k))) return prev;
const next = new Set(prev);
next.add(parentID);
keysToAdd.forEach((k) => next.add(k));
try {
safeStorage.setItem(SESSION_EXPANDED_STORAGE_KEY, JSON.stringify(Array.from(next)));
} catch { /* ignored */ }
@@ -694,13 +710,13 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
});
}, [currentSessionId, sessions, safeStorage]);
const toggleParent = React.useCallback((sessionId: string) => {
const toggleParent = React.useCallback((expansionKey: string) => {
setExpandedParents((prev) => {
const next = new Set(prev);
if (next.has(sessionId)) {
next.delete(sessionId);
if (next.has(expansionKey)) {
next.delete(expansionKey);
} else {
next.add(sessionId);
next.add(expansionKey);
}
try {
safeStorage.setItem(SESSION_EXPANDED_STORAGE_KEY, JSON.stringify(Array.from(next)));
@@ -59,7 +59,7 @@ type Props = {
setEditTitle: (value: string) => void;
handleSaveEdit: () => void;
handleCancelEdit: () => void;
toggleParent: (sessionId: string) => void;
toggleParent: (expansionKey: string) => void;
handleSessionSelect: (sessionId: string, sessionDirectory: string | null, isMissingDirectory: boolean, projectId?: string | null) => void;
handleSessionDoubleClick: () => void;
togglePinnedSession: (sessionId: string) => void;
@@ -157,7 +157,18 @@ const areEqual = (prev: Props, next: Props): boolean => {
}
}
if (prev.pinnedSessionIds.has(prevSessionId) !== next.pinnedSessionIds.has(nextSessionId)) return false;
if (prev.expandedParents.has(prevSessionId) !== next.expandedParents.has(nextSessionId)) return false;
// Expansion is keyed per render context, so compare the composite key
// matching the one isExpanded reads from in render. If a session appears
// in two contexts (project + recent), they have independent state.
{
const prevRenderContext = prev.renderContext ?? 'project';
const nextRenderContext = next.renderContext ?? 'project';
const prevArchived = prev.archivedBucket ?? false;
const nextArchived = next.archivedBucket ?? false;
const prevExpansionKey = `${prevRenderContext}:${prevArchived ? 'archived' : 'active'}:${prevSessionId}`;
const nextExpansionKey = `${nextRenderContext}:${nextArchived ? 'archived' : 'active'}:${nextSessionId}`;
if (prev.expandedParents.has(prevExpansionKey) !== next.expandedParents.has(nextExpansionKey)) return false;
}
if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return false;
if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return false;
if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return false;
@@ -312,7 +323,11 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const sessionTitle = resolvedSession.title || t('sessions.sidebar.session.untitled');
const hasChildren = node.children.length > 0;
const isPinnedSession = pinnedSessionIds.has(session.id);
const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(session.id);
// Per-render-context expansion key: the same session can appear in both
// the project's root and the "Recent" list, and expanding one should not
// expand the other. Matches the format of menuInstanceKey.
const expansionKey = menuInstanceKey;
const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(expansionKey);
const isSubtaskSession = Boolean((resolvedSession as Session & { parentID?: string | null }).parentID);
const unseenCount = useSessionUnseenCount(session.id);
const needsAttention = unseenCount > 0 && (!isSubtaskSession || notifyOnSubtasks);
@@ -529,13 +544,13 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
tabIndex={0}
onClick={(event) => {
event.stopPropagation();
toggleParent(session.id);
toggleParent(expansionKey);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
toggleParent(session.id);
toggleParent(expansionKey);
}
}}
className={cn(
@@ -6,10 +6,16 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
type SafeStorageLike = {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
removeItem?: (key: string) => void;
};
type Keys = {
sessionExpanded: string;
// v1 key, still on disk for users upgrading from pre-per-context expansion.
// When present, its bare-session-id entries are fanned out to all four
// (project|recent) × (active|archived) context combinations and rewritten
// under `sessionExpanded`. After migration the v1 key is removed.
sessionExpandedLegacy: string;
projectCollapse: string;
sessionPinned: string;
groupOrder: string;
@@ -17,6 +23,13 @@ type Keys = {
groupCollapse: string;
};
const LEGACY_EXPANSION_CONTEXT_PREFIXES = [
'project:active:',
'project:archived:',
'recent:active:',
'recent:archived:',
];
type Args = {
isVSCode: boolean;
hasLoadedGlobalSessions: boolean;
@@ -101,6 +114,28 @@ export const useSidebarPersistence = (args: Args) => {
if (Array.isArray(parsed)) {
setExpandedParents(new Set(parsed.filter((item) => typeof item === 'string')));
}
} else {
// No v2 data — migrate from v1 (bare session ids) if present.
const legacyRaw = safeStorage.getItem(keys.sessionExpandedLegacy);
if (legacyRaw) {
try {
const parsedLegacy = JSON.parse(legacyRaw);
if (Array.isArray(parsedLegacy)) {
const migrated = new Set<string>();
parsedLegacy.forEach((item) => {
if (typeof item !== 'string' || item.length === 0) return;
LEGACY_EXPANSION_CONTEXT_PREFIXES.forEach((prefix) => migrated.add(`${prefix}${item}`));
});
if (migrated.size > 0) {
setExpandedParents(migrated);
try { safeStorage.setItem(keys.sessionExpanded, JSON.stringify(Array.from(migrated))); } catch { /* ignored */ }
}
}
} catch {
// legacy data was malformed; ignore and let it expire
}
try { safeStorage.removeItem?.(keys.sessionExpandedLegacy); } catch { /* ignored */ }
}
}
const storedProjects = safeStorage.getItem(keys.projectCollapse);
if (storedProjects) {
@@ -112,7 +147,7 @@ export const useSidebarPersistence = (args: Args) => {
} catch {
// ignored
}
}, [keys.projectCollapse, keys.sessionExpanded, safeStorage, setCollapsedProjects, setExpandedParents]);
}, [keys.projectCollapse, keys.sessionExpanded, keys.sessionExpandedLegacy, safeStorage, setCollapsedProjects, setExpandedParents]);
React.useEffect(() => {
if (!hasLoadedGlobalSessions) {