diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 2f01034f..57a851fe 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -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 = ({ 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 = ({ 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 = ({ }); }, [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))); diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index 093beccd..63895a2a 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -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( diff --git a/packages/ui/src/components/session/sidebar/hooks/useSidebarPersistence.ts b/packages/ui/src/components/session/sidebar/hooks/useSidebarPersistence.ts index 668fa478..6faf5503 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSidebarPersistence.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSidebarPersistence.ts @@ -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(); + 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) {