import React from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import type { Session } from '@opencode-ai/sdk/v2'; // Archived buckets routinely grow into the hundreds/thousands; virtualize // when we cross this row count so the DOM stays bounded. const ARCHIVED_VIRTUALIZE_THRESHOLD = 50; // Compact rows in the archived bucket without nested subagents render // around 24-32px; virtua measures mounted rows and uses this as the initial hint. const ARCHIVED_ROW_ESTIMATE_PX = 28; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Icon } from "@/components/icon/Icon"; import { cn } from '@/lib/utils'; import { sessionEvents } from '@/lib/sessionEvents'; import type { MainTab } from '@/stores/useUIStore'; import { SessionFolderItem } from '../SessionFolderItem'; import type { SortableDragHandleProps } from './sortableItems'; import { DroppableFolderWrapper, SessionFolderDndScope } from './sessionFolderDnd'; import type { GroupSearchData, SessionGroup, SessionNode } from './types'; import { isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils'; import { compareSessionsByLifecycleOrder, EMPTY_SESSION_ORDER_RANKS } from '@/sync/session-ordering'; import { collectSubtreeContainingId, computeNodeStructureKey, nodeHasPinnedMembershipChange, nodeContainsSessionId, resolveMenuOpenSessionId, selectFolderRootNodes, } from './sessionNodeItemUtils'; import type { SessionNodeRenderExtras } from './sessionNodeItemUtils'; import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore'; import { useI18n } from '@/lib/i18n'; import { useChildStoreManager } from '@/sync/sync-context'; import { CollapsedActivityIndicator } from './collapsedActivityIndicator'; import { getSessionNodesActivityState, mergeCollapsedActivityStates, type CollapsedActivityState, } from './collapsedActivityState'; type DeleteFolderConfirm = { scopeKey: string; folderId: string; folderName: string; subFolderCount: number; sessionCount: number; } | null; type Props = { group: SessionGroup; groupKey: string; projectId?: string | null; hideGroupLabel?: boolean; hasSessionSearchQuery: boolean; normalizedSessionSearchQuery: string; groupSearchDataByGroup: WeakMap; visibleSessionCount?: number; collapsedGroups: Set; hideDirectoryControls: boolean; collapsedFolderIds: Set; toggleFolderCollapse: (folderId: string) => void; renameFolder: (scopeKey: string, folderId: string, name: string) => void; deleteFolder: (scopeKey: string, folderId: string) => void; showDeletionDialog: boolean; setDeleteFolderConfirm: React.Dispatch>; renderSessionNode: ( node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean, secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null, renderContext?: 'project' | 'recent', renderExtras?: SessionNodeRenderExtras, ) => React.ReactNode; showMoreGroupSessions: (groupKey: string, currentVisibleCount: number) => void; resetGroupSessionLimit: (groupKey: string) => void; mobileVariant: boolean; alwaysShowActions: boolean; activeProjectId: string | null; setActiveProjectIdOnly: (id: string) => void; setActiveMainTab: (tab: MainTab) => void; setSessionSwitcherOpen: (open: boolean) => void; openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null; targetFolderId?: string }) => void; addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void; createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null; renamingFolderId: string | null; renameFolderDraft: string; setRenameFolderDraft: React.Dispatch>; setRenamingFolderId: React.Dispatch>; pinnedSessionIds: Set; expandedParents: Set; sessionOrderIndex: Map; editingId: string | null; editTitle: string; openSidebarMenuKey: string | null; activeActivitySessionIds: Set; unreadActivitySessionIds: Set; notifyOnSubtasks: boolean; onToggleCollapsedGroup: (groupKey: string) => void; dragHandleProps?: SortableDragHandleProps | null; compactBodyPadding?: boolean; /** * Optional scroll container ref threaded from the outer ScrollableOverlay. * When provided, the virtualization effect can resolve the scrolling * ancestor synchronously and skip the getComputedStyle walk on every * render of an expanded archived bucket. */ scrollContainerRef?: React.RefObject; }; const groupContainsSessionId = (group: SessionGroup, sessionId: string | null): boolean => { if (!sessionId) return false; return group.sessions.some((node) => nodeContainsSessionId(node, sessionId)); }; const groupHasPinnedMembershipChange = ( group: SessionGroup, prevPinnedSessionIds: Set, nextPinnedSessionIds: Set, ): boolean => { return group.sessions.some((node) => nodeHasPinnedMembershipChange( node, node, prevPinnedSessionIds, nextPinnedSessionIds, group.directory, group.directory, )); }; const groupHasSessionOrderChange = ( group: SessionGroup, prevSessionOrderIndex: Map, nextSessionOrderIndex: Map, ): boolean => { const visit = (node: SessionNode): boolean => { const sessionId = node.session.id; if (prevSessionOrderIndex.get(sessionId) !== nextSessionOrderIndex.get(sessionId)) return true; return node.children.some(visit); }; return group.sessions.some(visit); }; const groupHasActivityMembershipChange = ( group: SessionGroup, prevSessionIds: Set, nextSessionIds: Set, ): boolean => { const visit = (node: SessionNode): boolean => { if (prevSessionIds.has(node.session.id) !== nextSessionIds.has(node.session.id)) return true; return node.children.some(visit); }; return group.sessions.some(visit); }; const groupHasAnyActivityMembership = (group: SessionGroup, sessionIds: Set): boolean => { const visit = (node: SessionNode): boolean => { if (sessionIds.has(node.session.id)) return true; return node.children.some(visit); }; return group.sessions.some(visit); }; const groupHasExpansionMembershipChange = ( group: SessionGroup, prevExpandedParents: Set, nextExpandedParents: Set, ): boolean => { const bucketTag = group.isArchivedBucket ? 'archived' : 'active'; const visit = (node: SessionNode): boolean => { const key = `project:${bucketTag}:${node.session.id}`; if (prevExpandedParents.has(key) !== nextExpandedParents.has(key)) return true; return node.children.some(visit); }; return group.sessions.some(visit); }; const areGroupPropsEqual = (prev: Props, next: Props): boolean => { // Bail on Object.is for the props that drive the most work: the group // itself, its key, and the group-level chrome. These change rarely and // any change should force a re-render of this group. if (prev.group !== next.group) return false; if (prev.groupKey !== next.groupKey) return false; if (prev.projectId !== next.projectId) return false; if (prev.hideGroupLabel !== next.hideGroupLabel) return false; if (prev.compactBodyPadding !== next.compactBodyPadding) return false; if (prev.groupSearchDataByGroup !== next.groupSearchDataByGroup) return false; if (prev.visibleSessionCount !== next.visibleSessionCount) return false; if (prev.collapsedGroups !== next.collapsedGroups && prev.collapsedGroups.has(prev.groupKey) !== next.collapsedGroups.has(next.groupKey)) { return false; } if (prev.pinnedSessionIds !== next.pinnedSessionIds && groupHasPinnedMembershipChange(next.group, prev.pinnedSessionIds, next.pinnedSessionIds)) { return false; } if (prev.expandedParents !== next.expandedParents && groupHasExpansionMembershipChange(next.group, prev.expandedParents, next.expandedParents)) { return false; } if (prev.sessionOrderIndex !== next.sessionOrderIndex && groupHasSessionOrderChange(next.group, prev.sessionOrderIndex, next.sessionOrderIndex)) { return false; } if (prev.editingId !== next.editingId && (groupContainsSessionId(prev.group, prev.editingId) || groupContainsSessionId(next.group, next.editingId))) { return false; } if (prev.editTitle !== next.editTitle && (groupContainsSessionId(prev.group, prev.editingId) || groupContainsSessionId(next.group, next.editingId))) { return false; } if (prev.openSidebarMenuKey !== next.openSidebarMenuKey) { const prevMenuSessionId = resolveMenuOpenSessionId(prev.group.sessions, prev.openSidebarMenuKey, 'project', Boolean(prev.group.isArchivedBucket)); const nextMenuSessionId = resolveMenuOpenSessionId(next.group.sessions, next.openSidebarMenuKey, 'project', Boolean(next.group.isArchivedBucket)); if (prevMenuSessionId || nextMenuSessionId) return false; } if (prev.activeActivitySessionIds !== next.activeActivitySessionIds && groupHasActivityMembershipChange(next.group, prev.activeActivitySessionIds, next.activeActivitySessionIds)) { return false; } if (prev.unreadActivitySessionIds !== next.unreadActivitySessionIds && groupHasActivityMembershipChange(next.group, prev.unreadActivitySessionIds, next.unreadActivitySessionIds)) { return false; } if (prev.notifyOnSubtasks !== next.notifyOnSubtasks && groupHasAnyActivityMembership(next.group, next.unreadActivitySessionIds)) { return false; } // Other props are typically stable references from the parent. Default // to reference equality (the cheap path) and only re-render when the // parent actually swapped something. return ( prev.hasSessionSearchQuery === next.hasSessionSearchQuery && prev.normalizedSessionSearchQuery === next.normalizedSessionSearchQuery && prev.hideDirectoryControls === next.hideDirectoryControls && prev.collapsedFolderIds === next.collapsedFolderIds && prev.toggleFolderCollapse === next.toggleFolderCollapse && prev.renameFolder === next.renameFolder && prev.deleteFolder === next.deleteFolder && prev.showDeletionDialog === next.showDeletionDialog && prev.setDeleteFolderConfirm === next.setDeleteFolderConfirm && prev.renderSessionNode === next.renderSessionNode && prev.showMoreGroupSessions === next.showMoreGroupSessions && prev.resetGroupSessionLimit === next.resetGroupSessionLimit && prev.mobileVariant === next.mobileVariant && prev.alwaysShowActions === next.alwaysShowActions && prev.activeProjectId === next.activeProjectId && prev.setActiveProjectIdOnly === next.setActiveProjectIdOnly && prev.setActiveMainTab === next.setActiveMainTab && prev.setSessionSwitcherOpen === next.setSessionSwitcherOpen && prev.openNewSessionDraft === next.openNewSessionDraft && prev.addSessionToFolder === next.addSessionToFolder && prev.createFolderAndStartRename === next.createFolderAndStartRename && prev.renamingFolderId === next.renamingFolderId && prev.renameFolderDraft === next.renameFolderDraft && prev.setRenameFolderDraft === next.setRenameFolderDraft && prev.setRenamingFolderId === next.setRenamingFolderId && prev.onToggleCollapsedGroup === next.onToggleCollapsedGroup && prev.dragHandleProps === next.dragHandleProps && prev.scrollContainerRef === next.scrollContainerRef ); }; function SessionGroupSectionBase(props: Props): React.ReactNode { const { t } = useI18n(); const { group, groupKey, projectId, hideGroupLabel, hasSessionSearchQuery, normalizedSessionSearchQuery, groupSearchDataByGroup, visibleSessionCount, collapsedGroups, hideDirectoryControls, collapsedFolderIds, toggleFolderCollapse, renameFolder, deleteFolder, showDeletionDialog, setDeleteFolderConfirm, renderSessionNode, showMoreGroupSessions, resetGroupSessionLimit, mobileVariant, alwaysShowActions, activeProjectId, setActiveProjectIdOnly, setActiveMainTab, setSessionSwitcherOpen, openNewSessionDraft, addSessionToFolder, createFolderAndStartRename, renamingFolderId, renameFolderDraft, setRenameFolderDraft, setRenamingFolderId, pinnedSessionIds, expandedParents, sessionOrderIndex, editingId, openSidebarMenuKey, activeActivitySessionIds, unreadActivitySessionIds, notifyOnSubtasks, onToggleCollapsedGroup, dragHandleProps, compactBodyPadding = false, scrollContainerRef, } = props; const compareSessionNodes = React.useCallback((a: SessionNode, b: SessionNode) => { const aIndex = sessionOrderIndex.get(a.session.id); const bIndex = sessionOrderIndex.get(b.session.id); if (aIndex !== undefined || bIndex !== undefined) { if (aIndex === undefined) return 1; if (bIndex === undefined) return -1; if (aIndex !== bIndex) return aIndex - bIndex; } return compareSessionsByLifecycleOrder(a.session, b.session, pinnedSessionIds, EMPTY_SESSION_ORDER_RANKS); }, [pinnedSessionIds, sessionOrderIndex]); const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null; const foldersMap = useSessionFoldersStore((state) => state.foldersMap); const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey); // PR state for the worktree sub-header (grouped display mode). const groupPrKey = React.useMemo(() => { if (group.isMain || group.isArchivedBucket || hideGroupLabel) return null; const directory = normalizePath(group.directory ?? null); const branch = group.branch?.trim(); return directory && branch ? getGitHubPrStatusKey(directory, branch) : null; }, [group.branch, group.directory, group.isArchivedBucket, group.isMain, hideGroupLabel]); const groupPrSummary = usePrVisualSummary(groupPrKey); const groupPrColor = groupPrSummary ? `var(--pr-${groupPrSummary.visualState})` : undefined; const childStores = useChildStoreManager(); const bootstrapDirectory = normalizePath(group.directory ?? null); const bootstrapState = React.useSyncExternalStore( React.useCallback( (notify) => bootstrapDirectory ? childStores.subscribeBootstrap(notify) : () => undefined, [bootstrapDirectory, childStores], ), React.useCallback( () => bootstrapDirectory ? childStores.getBootstrapState(bootstrapDirectory) : undefined, [bootstrapDirectory, childStores], ), React.useCallback(() => undefined, []), ); const maxVisible = hideDirectoryControls ? 10 : 5; const nonArchivedVisibleCount = Math.max(maxVisible, visibleSessionCount ?? maxVisible); const groupMatchesSearch = hasSessionSearchQuery ? searchData?.groupMatches === true : false; const shouldFilterGroupContents = hasSessionSearchQuery; const sourceGroupNodes = React.useMemo( () => [...(shouldFilterGroupContents ? (searchData?.filteredNodes ?? []) : group.sessions)] .sort(compareSessionNodes), [compareSessionNodes, group.sessions, searchData?.filteredNodes, shouldFilterGroupContents], ); const folderScopeKey = group.folderScopeKey ?? normalizePath(group.directory ?? null); // Merged flat groups list every contributing scope; single-scope groups // (archived buckets, VS Code workspaces) fall back to folderScopeKey. const folderScopes = React.useMemo>(() => { if (group.folderScopes && group.folderScopes.length > 0) return group.folderScopes; return folderScopeKey ? [{ scopeKey: folderScopeKey, directory: group.directory ?? null }] : []; }, [folderScopeKey, group.directory, group.folderScopes]); const scopeFolders = React.useMemo( () => folderScopes.flatMap(({ scopeKey, directory }) => (foldersMap[scopeKey] ?? []).map((folder) => ({ folder, scopeKey, scopeDirectory: directory }))), [folderScopes, foldersMap] ); const nodeBySessionId = React.useMemo(() => { const map = new Map(); const collectNodeLookup = (nodes: SessionNode[]) => { nodes.forEach((node) => { map.set(node.session.id, node); if (node.children.length > 0) { collectNodeLookup(node.children); } }); }; collectNodeLookup(sourceGroupNodes); return map; }, [sourceGroupNodes]); const allFoldersForGroupBase = React.useMemo(() => scopeFolders.map(({ folder, scopeKey, scopeDirectory }) => { const nodes = selectFolderRootNodes(folder.sessionIds, nodeBySessionId).sort(compareSessionNodes); return { folder, scopeKey, scopeDirectory, nodes }; }), [scopeFolders, nodeBySessionId, compareSessionNodes]); const allFoldersForGroup = React.useMemo(() => { const folderMapById = new Map(allFoldersForGroupBase.map((entry) => [entry.folder.id, entry])); const childFolderIdsByParentId = new Map(); for (const { folder } of allFoldersForGroupBase) { if (!folder.parentId) continue; const existing = childFolderIdsByParentId.get(folder.parentId); if (existing) { existing.push(folder.id); } else { childFolderIdsByParentId.set(folder.parentId, [folder.id]); } } const keepByFolderId = new Map(); const shouldKeepFolder = (folderId: string): boolean => { const cached = keepByFolderId.get(folderId); if (cached !== undefined) return cached; const entry = folderMapById.get(folderId); if (!entry) { keepByFolderId.set(folderId, false); return false; } const childFolderIds = childFolderIdsByParentId.get(folderId) ?? []; // For archived buckets, hide folders with no sessions unless descendants have content. if (group.isArchivedBucket && entry.nodes.length === 0) { const hasContentInChildren = childFolderIds.some((childId) => shouldKeepFolder(childId)); keepByFolderId.set(folderId, hasContentInChildren); return hasContentInChildren; } if (!hasSessionSearchQuery) { keepByFolderId.set(folderId, true); return true; } const folderMatches = entry.folder.name.toLowerCase().includes(normalizedSessionSearchQuery); if (folderMatches || entry.nodes.length > 0) { keepByFolderId.set(folderId, true); return true; } const hasMatchingChildren = childFolderIds.some((childId) => shouldKeepFolder(childId)); keepByFolderId.set(folderId, hasMatchingChildren); return hasMatchingChildren; }; return allFoldersForGroupBase.filter(({ folder }) => shouldKeepFolder(folder.id)); }, [allFoldersForGroupBase, group.isArchivedBucket, hasSessionSearchQuery, normalizedSessionSearchQuery]); const sessionIdsInFolders = React.useMemo(() => new Set(allFoldersForGroup.flatMap((f) => f.folder.sessionIds)), [allFoldersForGroup]); const ungroupedSessions = React.useMemo(() => sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id)), [sourceGroupNodes, sessionIdsInFolders]); const rootFolders = React.useMemo(() => allFoldersForGroup.filter(({ folder }) => !folder.parentId), [allFoldersForGroup]); const childFoldersByParentId = React.useMemo(() => { const map = new Map(); allFoldersForGroup.forEach((entry) => { if (!entry.folder.parentId) return; const children = map.get(entry.folder.parentId) ?? []; children.push(entry); map.set(entry.folder.parentId, children); }); return map; }, [allFoldersForGroup]); const folderActivityStateById = React.useMemo(() => { const foldersById = new Map(allFoldersForGroup.map((entry) => [entry.folder.id, entry] as const)); const result = new Map(); const visit = (folderId: string, seen: Set): CollapsedActivityState => { const cached = result.get(folderId); if (cached !== undefined) return cached; if (seen.has(folderId)) return null; seen.add(folderId); const entry = foldersById.get(folderId); let state = entry ? getSessionNodesActivityState(entry.nodes, activeActivitySessionIds, unreadActivitySessionIds, notifyOnSubtasks) : null; for (const child of childFoldersByParentId.get(folderId) ?? []) { state = mergeCollapsedActivityStates(state, visit(child.folder.id, seen)); if (state === 'active') break; } result.set(folderId, state); return state; }; allFoldersForGroup.forEach(({ folder }) => visit(folder.id, new Set())); return result; }, [activeActivitySessionIds, allFoldersForGroup, childFoldersByParentId, notifyOnSubtasks, unreadActivitySessionIds]); // Precompute the per-row "subtree contains editing session" lookup once per // render. The previous design walked the // node tree inside SessionNodeItem.areEqual for every row, which is O(M^2) // across the whole sidebar. These sets let areEqual answer with a single // Set.has lookup, so the cost is O(M) once per SessionGroupSection render. const renderContextForGroup = 'project' as const; const subtreeContainsEditing = React.useMemo(() => { const set = new Set(); collectSubtreeContainingId(sourceGroupNodes, editingId, set); allFoldersForGroup.forEach(({ nodes }) => { collectSubtreeContainingId(nodes, editingId, set); }); return set; }, [sourceGroupNodes, allFoldersForGroup, editingId]); const menuOpenSessionId = React.useMemo(() => { if (!openSidebarMenuKey) return null; const fromSource = resolveMenuOpenSessionId(sourceGroupNodes, openSidebarMenuKey, renderContextForGroup, Boolean(group.isArchivedBucket)); if (fromSource) return fromSource; for (const { nodes } of allFoldersForGroup) { const id = resolveMenuOpenSessionId(nodes, openSidebarMenuKey, renderContextForGroup, Boolean(group.isArchivedBucket)); if (id) return id; } return null; }, [openSidebarMenuKey, sourceGroupNodes, allFoldersForGroup, group.isArchivedBucket]); const buildNodeStructureKeyByNode = React.useCallback((nodes: SessionNode[]): WeakMap => { const map = new WeakMap(); const visit = (node: SessionNode): void => { map.set(node, computeNodeStructureKey(node)); for (const child of node.children) { visit(child); } }; nodes.forEach(visit); return map; }, []); const nodeStructureKeyBySourceNode = React.useMemo( () => buildNodeStructureKeyByNode(sourceGroupNodes), [buildNodeStructureKeyByNode, sourceGroupNodes], ); const nodeStructureKeyByFolderNode = React.useMemo( () => { const map = new WeakMap(); allFoldersForGroup.forEach(({ nodes }) => { nodes.forEach((node) => map.set(node, computeNodeStructureKey(node))); }); return map; }, [allFoldersForGroup], ); const resolveNodeStructureKey = React.useCallback((node: SessionNode): string => { return nodeStructureKeyBySourceNode.get(node) ?? nodeStructureKeyByFolderNode.get(node) ?? ''; }, [nodeStructureKeyBySourceNode, nodeStructureKeyByFolderNode]); const childRenderExtrasFor = React.useCallback((child: SessionNode) => ({ subtreeContainsEditing, menuOpenSessionId, nodeStructureKey: resolveNodeStructureKey(child), }), [subtreeContainsEditing, menuOpenSessionId, resolveNodeStructureKey]); const totalSessions = ungroupedSessions.length; const visibleSessions = group.isArchivedBucket ? ungroupedSessions : hasSessionSearchQuery ? ungroupedSessions : ungroupedSessions.slice(0, nonArchivedVisibleCount); const remainingCount = totalSessions - visibleSessions.length; const canShowLess = !group.isArchivedBucket && !hasSessionSearchQuery && totalSessions > maxVisible && remainingCount === 0; // Virtualize archived buckets, which can grow into the thousands. Active // groups retain normal flow because their incremental Show more control and // the shared ancestor scroller cannot expose an unmounted virtual tail. // Hooks below MUST stay above the search-empty early-return so they fire in // the same order every render — rules-of-hooks. const shouldVirtualize = group.isArchivedBucket === true && !hasSessionSearchQuery && visibleSessions.length >= ARCHIVED_VIRTUALIZE_THRESHOLD; // Check if any parent node is expanded - expanded parents render their // children inline, making them much taller than the fixed estimate. // When expanded parents exist, increase bufferSize to cover the extra height. const bucketTag = group.isArchivedBucket ? 'archived' : 'active'; const hasExpandedParent = shouldVirtualize && visibleSessions.some((node) => { if (node.children.length === 0) return false; const expansionKey = `project:${bucketTag}:${node.session.id}`; return expandedParents.has(expansionKey); }); const archivedVirtualContainerRef = React.useRef(null); const [archivedScrollEl, setArchivedScrollEl] = React.useState(null); // Offset of the virtual container from the scroll element's content origin. // virtua reads startMargin from Virtualizer options and uses it // to translate scrollTop into container-relative coordinates. Without this, // when the scroll element is an ancestor (the sidebar's ScrollableOverlay), // the virtualizer assumes the container starts at the top of the scroll // element and renders rows in the wrong subset / position. const [archivedScrollMargin, setArchivedScrollMargin] = React.useState(0); // Resolve the scrolling ancestor. When the parent has threaded a // `scrollContainerRef` (Layer 1.4), use it directly to skip the // `getComputedStyle` walk on every render of an expanded archived // bucket — the walk is one of the more expensive operations in the // hot path because it forces a style recalc on every parent up the // tree. Fall back to the legacy walk only when the ref is missing. // // We also still re-run when the archive flips between expanded/collapsed, // and on a ResizeObserver-driven layout change of the container, so a // dep-gated effect that only fires when shouldVirtualizeArchived flips // would miss the eventual mount and leave the scroll element null. const [, setLayoutVersion] = React.useState(0); React.useEffect(() => { if (!shouldVirtualize) return; const container = archivedVirtualContainerRef.current; if (!container) return; if (typeof ResizeObserver === 'undefined') return; const ro = new ResizeObserver(() => setLayoutVersion((v) => v + 1)); ro.observe(container); return () => ro.disconnect(); }, [shouldVirtualize]); // eslint-disable-next-line react-hooks/exhaustive-deps React.useLayoutEffect(() => { if (!shouldVirtualize) { if (archivedScrollEl !== null) setArchivedScrollEl(null); if (archivedScrollMargin !== 0) setArchivedScrollMargin(0); return; } const container = archivedVirtualContainerRef.current; if (!container) { // Bucket still collapsed — body not mounted. We'll re-run on the // render that mounts it. return; } let scrollEl: HTMLElement | null = archivedScrollEl; const providedScrollEl = scrollContainerRef?.current ?? null; if (providedScrollEl && providedScrollEl.contains(container)) { scrollEl = providedScrollEl; if (scrollEl !== archivedScrollEl) { setArchivedScrollEl(scrollEl); return; } } else if (!scrollEl || !scrollEl.contains(container)) { // Walk up to find the nearest scrolling ancestor. Only happens on // first mount or if the DOM tree restructured. let el: HTMLElement | null = container.parentElement; while (el) { const style = window.getComputedStyle(el); if (style.overflowY === 'auto' || style.overflowY === 'scroll') { scrollEl = el; break; } el = el.parentElement; } if (scrollEl !== archivedScrollEl) { setArchivedScrollEl(scrollEl); return; } } if (!scrollEl) return; const offset = container.getBoundingClientRect().top - scrollEl.getBoundingClientRect().top + scrollEl.scrollTop; setArchivedScrollMargin((prev) => (Math.abs(prev - offset) < 1 ? prev : offset)); }); // The scroll element is an ANCESTOR of this section (the sidebar's // ScrollableOverlay), so scrollMargin translates its scrollTop into // container-relative coordinates — the tanstack equivalent of virtua's // startMargin this replaces. // Enable ONLY once the ancestor scroll element is resolved. While the // virtualizer is disabled the core resets its cached scroll offset, so the // first enabled read takes initialOffset() from the LIVE scrollTop below — // making the core's attach-time scrollTo target the current position (a // visual no-op) instead of a stale 0 that reset the sidebar to the top. // The core only learns the offset from scroll events after that, so this // initial seeding is what makes the first render window correct too. const virtualizerReady = shouldVirtualize && archivedScrollEl !== null; const sessionVirtualizer = useVirtualizer({ count: visibleSessions.length, enabled: virtualizerReady, getScrollElement: () => archivedScrollEl, initialOffset: () => archivedScrollEl?.scrollTop ?? 0, estimateSize: () => ARCHIVED_ROW_ESTIMATE_PX, // Expanded parents render children inline and dwarf the row estimate; // widen the window so their extra height stays covered. overscan: hasExpandedParent ? 20 : 8, scrollMargin: archivedScrollMargin, getItemKey: (index) => visibleSessions[index]?.session.id ?? index, }); // Hooks below MUST stay above the search-empty early-return so they // fire in the same order every render — rules-of-hooks. const collectGroupSessions = React.useCallback((nodes: SessionNode[]): Session[] => { const collected: Session[] = []; const visit = (list: SessionNode[]) => { list.forEach((node) => { collected.push(node.session); if (node.children.length > 0) visit(node.children); }); }; visit(nodes); return collected; }, []); // Flat list of all sessions in this group (including nested children). // Used by both the "delete all archived" button and the "delete worktree" // button. Memoize so the recursive flatten only runs when the underlying // source group nodes change, not on every render. const allGroupSessions = React.useMemo( () => collectGroupSessions(sourceGroupNodes), [collectGroupSessions, sourceGroupNodes], ); // Precompute the per-folder "delete all sessions in folder" list once // per render. The previous design ran a recursive `collectFolderSessions` // walk inside each folder's render, which is O(F × (S + F)) per group // render. With F=50 folders and S=200 archived sessions this is // significant; the precompute makes it O(F + S) once. const folderSessionsForDeleteById = React.useMemo(() => { if (!group.isArchivedBucket) return new Map(); const result = new Map(); const childIdsByParentId = new Map(); for (const { folder } of allFoldersForGroup) { if (!folder.parentId) continue; const existing = childIdsByParentId.get(folder.parentId) ?? []; existing.push(folder.id); childIdsByParentId.set(folder.parentId, existing); } const visit = (targetFolderId: string, seen: Set): Session[] => { if (seen.has(targetFolderId)) return []; seen.add(targetFolderId); const directEntry = allFoldersForGroup.find(({ folder: candidate }) => candidate.id === targetFolderId); const collected: Session[] = directEntry ? collectGroupSessions(directEntry.nodes) : []; const childIds = childIdsByParentId.get(targetFolderId) ?? []; for (const childId of childIds) { collected.push(...visit(childId, seen)); } return collected; }; for (const { folder } of allFoldersForGroup) { result.set(folder.id, visit(folder.id, new Set())); } return result; }, [allFoldersForGroup, collectGroupSessions, group.isArchivedBucket]); if (hasSessionSearchQuery && !groupMatchesSearch && rootFolders.length === 0 && ungroupedSessions.length === 0) { return null; } const showBranchSubtitle = !group.isMain && Boolean(group.branch); const statusLine = group.branch && isBranchDifferentFromLabel(group.branch, group.label) ? { label: group.branch, color: null as string | null } : null; const groupActivityState = isCollapsed ? getSessionNodesActivityState(sourceGroupNodes, activeActivitySessionIds, unreadActivitySessionIds, notifyOnSubtasks) : null; const groupActivityIndicator = groupActivityState ? ( ) : null; type FolderEntry = (typeof allFoldersForGroup)[number]; const renderOneFolderItem = (entry: FolderEntry, displayName: string): React.ReactNode => { const { folder, scopeKey, scopeDirectory, nodes } = entry; const folderSessionsForDelete = folderSessionsForDeleteById.get(folder.id) ?? []; const isFolderCollapsed = hasSessionSearchQuery ? false : collapsedFolderIds.has(folder.id); return ( {(droppableRef, isDropTarget) => ( toggleFolderCollapse(folder.id)} onRename={(name) => { renameFolder(scopeKey, folder.id, name); }} onDelete={() => { if (group.isArchivedBucket) { // Delete sessions in the folder // Empty folders are auto-hidden by useArchivedAutoFolders sessionEvents.requestDelete({ sessions: folderSessionsForDelete, mode: 'session', }); return; } if (!showDeletionDialog) { deleteFolder(scopeKey, folder.id); return; } const subFolderCount = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id).length; const sessionCount = nodes.length; setDeleteFolderConfirm({ scopeKey, folderId: folder.id, folderName: folder.name, subFolderCount, sessionCount, }); }} renderSessionNode={renderSessionNode} getRenderExtras={resolveNodeStructureKey ? (node) => ({ subtreeContainsEditing, menuOpenSessionId, nodeStructureKey: resolveNodeStructureKey(node), childRenderExtrasFor, }) : undefined} groupDirectory={scopeDirectory ?? group.directory} projectId={projectId} mobileVariant={mobileVariant} alwaysShowActions={alwaysShowActions} isRenaming={renamingFolderId === folder.id} renameDraft={renamingFolderId === folder.id ? renameFolderDraft : undefined} onRenameDraftChange={(value) => setRenameFolderDraft(value)} onRenameSave={() => { const trimmed = renameFolderDraft.trim(); if (trimmed) { renameFolder(scopeKey, folder.id, trimmed); } setRenamingFolderId(null); setRenameFolderDraft(''); }} onRenameCancel={() => { setRenamingFolderId(null); setRenameFolderDraft(''); }} droppableRef={droppableRef} isDropTarget={isDropTarget} depth={0} onNewSession={() => { if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(projectId); setActiveMainTab('chat'); if (mobileVariant) setSessionSwitcherOpen(false); openNewSessionDraft({ selectedProjectId: projectId, directoryOverride: scopeDirectory ?? group.directory, targetFolderId: folder.id }); }} hideActions={false} archivedBucket={group.isArchivedBucket === true} /> )} ); }; // Folders render flat: nested folders keep their data-model parent link but // display at the same level with a "Parent / Child" path label, so sessions // never gain extra indentation. Collapsing a folder hides its whole subtree. const renderFolderItems = () => { const childEntriesByParentId = new Map(); for (const entry of allFoldersForGroup) { const parentId = entry.folder.parentId; if (!parentId) continue; const existing = childEntriesByParentId.get(parentId); if (existing) existing.push(entry); else childEntriesByParentId.set(parentId, [entry]); } const out: React.ReactNode[] = []; const visit = (entry: FolderEntry, parentPath: string) => { const displayName = parentPath ? `${parentPath} / ${entry.folder.name}` : entry.folder.name; out.push(renderOneFolderItem(entry, displayName)); const isFolderCollapsed = !hasSessionSearchQuery && collapsedFolderIds.has(entry.folder.id); if (isFolderCollapsed) return; (childEntriesByParentId.get(entry.folder.id) ?? []).forEach((child) => visit(child, displayName)); }; rootFolders.forEach((entry) => visit(entry, '')); return out; }; // Reserve room for the hover-revealed header actions (new draft + delete // worktree) so they never overlap the label / PR badge. const hasWorktreeDeleteAction = Boolean(!group.isMain && group.worktree); const groupHeaderRightPadding = alwaysShowActions ? (hasWorktreeDeleteAction ? 'pr-14' : 'pr-7') : (hasWorktreeDeleteAction ? 'pr-2 group-hover/gh:pr-14 group-focus-within/gh:pr-14' : 'pr-2 group-hover/gh:pr-7 group-focus-within/gh:pr-7'); const body = ( 0} onSessionDroppedOnFolder={(sessionId, folderId) => { const targetEntry = allFoldersForGroup.find(({ folder }) => folder.id === folderId); if (!targetEntry) return; // Clear membership in other scopes first — the store only dedupes // within one scope, and a session must live in a single folder. const foldersStore = useSessionFoldersStore.getState(); for (const { scopeKey } of folderScopes) { if (scopeKey === targetEntry.scopeKey) continue; if (foldersStore.getSessionFolderId(scopeKey, sessionId)) { foldersStore.removeSessionFromFolder(scopeKey, sessionId); } } addSessionToFolder(targetEntry.scopeKey, folderId, sessionId); }} > {renderFolderItems()} {shouldVirtualize ? (
{!virtualizerReady ? ( // At most one pre-paint frame: this wrapper must exist for the // layout effect to resolve the ancestor scroll element, which // re-renders synchronously before paint. Rendering the plain rows // meanwhile keeps the container's height real so the scroller // never collapses/clamps during the flip. visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', { subtreeContainsEditing, menuOpenSessionId, nodeStructureKey: resolveNodeStructureKey(node), childRenderExtrasFor, })) ) : (
{/* Absolutely positioned rows (canonical tanstack layout): with variable-height rows, flow-stacking can drift from the computed total height until measurements settle and overlap the content below the group. Per-item offsets cannot drift. item.start includes scrollMargin (ancestor-scroll offset), so subtract it. */} {sessionVirtualizer.getVirtualItems().map((item) => { const node = visibleSessions[item.index]; if (!node) return null; return (
{renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', { subtreeContainsEditing, menuOpenSessionId, nodeStructureKey: resolveNodeStructureKey(node), childRenderExtrasFor, })}
); })}
)}
) : ( visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', { subtreeContainsEditing, menuOpenSessionId, nodeStructureKey: resolveNodeStructureKey(node), childRenderExtrasFor, })) )} {totalSessions === 0 && allFoldersForGroup.length === 0 ? ( // pl-[26px] lines the text up with the worktree sub-header label // (gutter + icon + gap).
{group.isArchivedBucket ? t('sessions.sidebar.group.empty.noArchivedSessions') : bootstrapState === 'queued' || bootstrapState === 'running' ? ( {t('sessions.sidebar.group.empty.loadingSessions')} ) : bootstrapState === 'failed' && bootstrapDirectory ? ( {t('sessions.sidebar.group.empty.loadFailed')} ) : t('sessions.sidebar.group.empty.noSessionsInWorkspace')}
) : null} {remainingCount > 0 ? ( ) : null} {canShowLess ? ( ) : null}
); // Rows own their left gutter (aligned with the zone-header text), so the // group body adds no extra indentation. void compactBodyPadding; // Folder nesting is legacy-only: existing sub-folders keep working (path // labels), but the UI no longer offers creating new ones. void createFolderAndStartRename; const groupBodyPaddingClass = 'pb-2'; if (hideGroupLabel) { return
{body}
; } return (
onToggleCollapsedGroup(groupKey)} role="button" tabIndex={0} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onToggleCollapsedGroup(groupKey); } }} aria-label={isCollapsed ? t('sessions.sidebar.group.expandAria', { label: group.label }) : t('sessions.sidebar.group.collapseAria', { label: group.label })} aria-expanded={!isCollapsed} >

{group.isArchivedBucket ? ( {isCollapsed ? : } {renderHighlightedText(group.label, normalizedSessionSearchQuery)} {groupActivityIndicator} ) : (!group.isMain || group.worktree) ? ( // Worktree sub-header in the flat visual language: slim // folder-style row with a PR-tinted branch icon and PR badge. {isCollapsed ? : } {renderHighlightedText(group.label, normalizedSessionSearchQuery)} {groupActivityIndicator} {groupPrSummary ? ( #{groupPrSummary.number} ) : null} ) : ( {renderHighlightedText(group.label, normalizedSessionSearchQuery)} {groupActivityIndicator} )}

{showBranchSubtitle && statusLine ? ( {group.isArchivedBucket ? ( ) : ( )} {statusLine.label} ) : null}
{group.isArchivedBucket && allGroupSessions.length > 0 ? (

{t('sessions.sidebar.group.actions.deleteArchivedSessions')}

) : null} {group.directory && !group.isMain && group.worktree ? (

{t('sessions.sidebar.group.actions.deleteWorktree')}

) : null} {group.directory ? (

{t('sessions.sidebar.project.actions.newDraftSession')}

) : null}
{!isCollapsed ?
{body}
: null}
); } export const SessionGroupSection = React.memo(SessionGroupSectionBase, areGroupPropsEqual);