import React from 'react'; import { Virtualizer } from 'virtua'; 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; // Active/worktree groups can also grow large (a single worktree with 80+ // sessions), and unlike the archive they're interactive from the start. // Virtualize eagerly for non-archived groups to keep the rendered row // count bounded. With overscan ~8 the visible behavior is identical. const ACTIVE_VIRTUALIZE_THRESHOLD = 30; // 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 { DroppableFolderWrapper, SessionFolderDndScope } from './sessionFolderDnd'; import type { SortableDragHandleProps } from './sortableItems'; import type { GroupSearchData, SessionGroup, SessionNode } from './types'; import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils'; import { collectSubtreeContainingId, computeNodeStructureKey, nodeContainsSessionId, resolveMenuOpenSessionId, } from './sessionNodeItemUtils'; import type { SessionNodeRenderExtras } from './sessionNodeItemUtils'; import type { SessionFolder } from '@/stores/useSessionFoldersStore'; import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; import { openExternalUrl } from '@/lib/url'; import { isVSCodeRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; 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; projectRepoStatus: Map; lastRepoStatus: boolean; 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?: { 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; currentSessionId: string | null; editingId: string | null; editTitle: string; openSidebarMenuKey: string | null; liveSessionById: Map; prVisualStateByDirectoryBranch: Map; 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 => { const visit = (node: SessionNode): boolean => { const sessionId = node.session.id; if (prevPinnedSessionIds.has(sessionId) !== nextPinnedSessionIds.has(sessionId)) return true; return node.children.some(visit); }; return group.sessions.some(visit); }; 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 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 groupHasResolvedSessionChange = ( group: SessionGroup, prevLiveSessionById: Map, nextLiveSessionById: Map, ): boolean => { const visit = (node: SessionNode): boolean => { const sessionId = node.session.id; if ((prevLiveSessionById.get(sessionId) ?? node.session) !== (nextLiveSessionById.get(sessionId) ?? node.session)) { return true; } return node.children.some(visit); }; return group.sessions.some(visit); }; const getProjectRepoStatusValue = (props: Props): boolean | null | undefined => { if (!props.projectId) return undefined; return props.projectRepoStatus.has(props.projectId) ? props.projectRepoStatus.get(props.projectId) : undefined; }; 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.projectRepoStatus !== next.projectRepoStatus && getProjectRepoStatusValue(prev) !== getProjectRepoStatusValue(next)) { 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.currentSessionId !== next.currentSessionId && (groupContainsSessionId(prev.group, prev.currentSessionId) || groupContainsSessionId(next.group, next.currentSessionId))) { 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.liveSessionById !== next.liveSessionById && groupHasResolvedSessionChange(next.group, prev.liveSessionById, next.liveSessionById)) { return false; } // Per-row / per-state props. The PR-visual-state map flips frequently // during bootstrap but a single group's value is usually stable, so we // compare only the value this group actually consumes instead of the // whole map reference. if (prev.prVisualStateByDirectoryBranch !== next.prVisualStateByDirectoryBranch) { const prevVal = prev.group?.directory && prev.group?.branch ? prev.prVisualStateByDirectoryBranch.get(`${prev.group.directory}::${prev.group.branch.trim()}`) : undefined; const nextVal = next.group?.directory && next.group?.branch ? next.prVisualStateByDirectoryBranch.get(`${next.group.directory}::${next.group.branch.trim()}`) : undefined; if (!Object.is(prevVal, nextVal)) 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.lastRepoStatus === next.lastRepoStatus && 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, projectRepoStatus, lastRepoStatus, showMoreGroupSessions, resetGroupSessionLimit, mobileVariant, alwaysShowActions, activeProjectId, setActiveProjectIdOnly, setActiveMainTab, setSessionSwitcherOpen, openNewSessionDraft, addSessionToFolder, createFolderAndStartRename, renamingFolderId, renameFolderDraft, setRenameFolderDraft, setRenamingFolderId, pinnedSessionIds, sessionOrderIndex, currentSessionId, editingId, openSidebarMenuKey, prVisualStateByDirectoryBranch, 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 compareSessionsByPinnedAndTime(a.session, b.session, pinnedSessionIds); }, [pinnedSessionIds, sessionOrderIndex]); const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null; const displayMode = useSessionDisplayStore((state) => state.displayMode); const foldersMap = useSessionFoldersStore((state) => state.foldersMap); // VS Code always uses the expanded layout (see SessionNodeItem). const isMinimalMode = displayMode === 'minimal' && !isVSCodeRuntime(); const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey); 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); const scopeFolders = React.useMemo( () => folderScopeKey ? (foldersMap[folderScopeKey] ?? []) : [], [folderScopeKey, 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) => { const nodes = folder.sessionIds .map((sid) => nodeBySessionId.get(sid)) .filter((n): n is SessionNode => Boolean(n)) .sort(compareSessionNodes); return { folder, 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]); // Precompute per-row "subtree contains active session" and "subtree contains // editing session" lookups 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 subtreeContainsActive = React.useMemo(() => { const set = new Set(); collectSubtreeContainingId(sourceGroupNodes, currentSessionId, set); allFoldersForGroup.forEach(({ nodes }) => { collectSubtreeContainingId(nodes, currentSessionId, set); }); return set; }, [sourceGroupNodes, allFoldersForGroup, currentSessionId]); 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) => ({ subtreeContainsActive, subtreeContainsEditing, menuOpenSessionId, nodeStructureKey: resolveNodeStructureKey(child), }), [subtreeContainsActive, 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 large groups. Archived buckets grow into the hundreds or // thousands of rows; active/worktree groups can also hit 80+ sessions // when a single worktree accumulates over time. Both paths share the // same virtua Virtualizer; the threshold just controls when we mount // it. The visible behavior is identical because virtua uses overscan // (8) for the buffer zone. All hooks below MUST stay above the // search-empty early-return so they fire in the same order every // render — rules-of-hooks. const shouldVirtualizeArchived = group.isArchivedBucket === true && !hasSessionSearchQuery && visibleSessions.length >= ARCHIVED_VIRTUALIZE_THRESHOLD; const shouldVirtualizeActive = group.isArchivedBucket !== true && !hasSessionSearchQuery && visibleSessions.length >= ACTIVE_VIRTUALIZE_THRESHOLD; const shouldVirtualize = shouldVirtualizeArchived || shouldVirtualizeActive; const archivedVirtualContainerRef = React.useRef(null); const archivedScrollRef = 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); archivedScrollRef.current = 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) { archivedScrollRef.current = scrollEl; 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) { archivedScrollRef.current = scrollEl; 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)); }); // 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; }, []); // The "delete all in group" handler closes over the full list of // sessions in this group. Memoize so the recursive flatten only runs // when the underlying source group nodes change, not on every render. const allGroupSessions = React.useMemo( () => (group.isArchivedBucket ? collectGroupSessions(sourceGroupNodes) : []), [collectGroupSessions, sourceGroupNodes, group.isArchivedBucket], ); // 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 isGitProject = projectId && projectRepoStatus.has(projectId) ? Boolean(projectRepoStatus.get(projectId)) : lastRepoStatus; const groupDirectoryKey = normalizePath(group.directory ?? null); const groupBranchKey = group.branch?.trim() ?? null; const prIndicator = groupDirectoryKey && groupBranchKey ? (prVisualStateByDirectoryBranch.get(`${groupDirectoryKey}::${groupBranchKey}`) ?? null) : null; const showInlinePrTitle = Boolean(prIndicator && group.branch); const showBranchSubtitle = !prIndicator && !group.isMain && Boolean(group.branch); const prVisualState = prIndicator?.visualState ?? null; const checksSummary = prIndicator && prIndicator.state === 'open' && prIndicator.checks ? t('sessions.sidebar.group.pr.checksPassed', { success: prIndicator.checks.success, total: prIndicator.checks.total, }) : null; const checksTail = prIndicator && prIndicator.state === 'open' && prIndicator.checks ? [ prIndicator.checks.failure > 0 ? t('sessions.sidebar.group.pr.failingCount', { count: prIndicator.checks.failure }) : null, prIndicator.checks.pending > 0 ? t('sessions.sidebar.group.pr.pendingCount', { count: prIndicator.checks.pending }) : null, ].filter((item): item is string => Boolean(item)).join(', ') : null; const mergeabilityLabel = prIndicator && prIndicator.state === 'open' ? (prIndicator.mergeableState === 'blocked' || prIndicator.mergeableState === 'dirty' ? t('sessions.sidebar.group.pr.conflictsOrBlocked') : (prIndicator.mergeableState === 'clean' || prIndicator.canMerge === true ? t('sessions.sidebar.group.pr.mergeable') : null)) : null; const mergeStateLabel = prIndicator && prIndicator.state === 'open' && prIndicator.mergeableState ? t('sessions.sidebar.group.pr.mergeState', { state: prIndicator.mergeableState }) : null; const baseBranchLabel = prIndicator?.base ?? null; const headBranchLabel = prIndicator?.head ?? null; const statusLine = (() => { if (!prIndicator) { return group.branch && isBranchDifferentFromLabel(group.branch, group.label) ? { label: group.branch, color: null as string | null } : null; } switch (prIndicator.visualState) { case 'merged': return { label: t('sessions.sidebar.group.pr.status.merged'), color: 'var(--pr-merged)' }; case 'open': return (prIndicator.canMerge === true || prIndicator.mergeableState === 'clean' || prIndicator.checks?.state === 'success') ? { label: t('sessions.sidebar.group.pr.status.readyToMerge'), color: 'var(--pr-open)' } : { label: t('sessions.sidebar.group.pr.status.open'), color: 'var(--pr-open)' }; case 'blocked': return { label: prIndicator.mergeableState === 'dirty' ? t('sessions.sidebar.group.pr.status.mergeConflicts') : t('sessions.sidebar.group.pr.status.mergeBlocked'), color: 'var(--pr-blocked)', }; case 'draft': return { label: t('sessions.sidebar.group.pr.status.draft'), color: 'var(--pr-draft)' }; case 'closed': return { label: t('sessions.sidebar.group.pr.status.closed'), color: 'var(--pr-closed)' }; default: return null; } })(); const branchIconColor = statusLine?.color ?? (prVisualState ? `var(--pr-${prVisualState})` : undefined); const handlePrLinkClick = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); const url = prIndicator?.url; if (!url) { return; } void openExternalUrl(url); }; const renderOneFolderItem = (folder: SessionFolder, nodes: SessionNode[], depth: number): React.ReactNode => { const directSubFolders = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id); const subFolderItems = directSubFolders.length > 0 ? <>{directSubFolders.map(({ folder: sf, nodes: sn }) => renderOneFolderItem(sf, sn, depth + 1))} : undefined; const folderSessionsForDelete = folderSessionsForDeleteById.get(folder.id) ?? []; return ( {(droppableRef, isDropTarget) => ( toggleFolderCollapse(folder.id)} onRename={(name) => { if (folderScopeKey) renameFolder(folderScopeKey, 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 (!folderScopeKey) return; if (!showDeletionDialog) { deleteFolder(folderScopeKey, folder.id); return; } const subFolderCount = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id).length; const sessionCount = nodes.length; setDeleteFolderConfirm({ scopeKey: folderScopeKey, folderId: folder.id, folderName: folder.name, subFolderCount, sessionCount, }); }} renderSessionNode={renderSessionNode} getRenderExtras={resolveNodeStructureKey ? (node) => ({ subtreeContainsActive, subtreeContainsEditing, menuOpenSessionId, nodeStructureKey: resolveNodeStructureKey(node), childRenderExtrasFor, }) : undefined} groupDirectory={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 && folderScopeKey) { renameFolder(folderScopeKey, folder.id, trimmed); } setRenamingFolderId(null); setRenameFolderDraft(''); }} onRenameCancel={() => { setRenamingFolderId(null); setRenameFolderDraft(''); }} droppableRef={droppableRef} isDropTarget={isDropTarget} depth={depth} onNewSession={() => { if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(projectId); setActiveMainTab('chat'); if (mobileVariant) setSessionSwitcherOpen(false); openNewSessionDraft({ directoryOverride: group.directory, targetFolderId: folder.id }); }} onNewSubFolder={depth === 0 ? () => { if (!folderScopeKey) return; createFolderAndStartRename(folderScopeKey, folder.id); } : undefined} hideActions={false} archivedBucket={group.isArchivedBucket === true} /> )} ); }; const renderFolderItems = () => rootFolders.map(({ folder, nodes }) => renderOneFolderItem(folder, nodes, 0)); const hasWorktreeDeleteAction = Boolean(!group.isMain && group.worktree); const groupHeaderRightPadding = alwaysShowActions ? (hasWorktreeDeleteAction ? 'pr-14' : 'pr-7') : isMinimalMode ? (hasWorktreeDeleteAction ? 'pr-2 group-hover/gh:pr-14 group-focus-within/gh:pr-14' : 'pr-2') : (hasWorktreeDeleteAction ? 'pr-5 group-hover/gh:pr-14 group-focus-within/gh:pr-14' : 'pr-5'); const body = ( 0} onSessionDroppedOnFolder={(sessionId, folderId) => { if (folderScopeKey) addSessionToFolder(folderScopeKey, folderId, sessionId); }} > {renderFolderItems()} {shouldVirtualize ? (
{(node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', { subtreeContainsActive, subtreeContainsEditing, menuOpenSessionId, nodeStructureKey: resolveNodeStructureKey(node), childRenderExtrasFor, }) as React.ReactElement}
) : ( visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', { subtreeContainsActive, subtreeContainsEditing, menuOpenSessionId, nodeStructureKey: resolveNodeStructureKey(node), childRenderExtrasFor, })) )} {totalSessions === 0 && allFoldersForGroup.length === 0 ? (
{group.isArchivedBucket ? t('sessions.sidebar.group.empty.noArchivedSessions') : t('sessions.sidebar.group.empty.noSessionsInWorkspace')}
) : null} {remainingCount > 0 ? ( ) : null} {canShowLess ? ( ) : null}
); const groupBodyPaddingClass = compactBodyPadding ? 'pb-2 pl-1' : 'pb-3 pl-4'; 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} >

{showInlinePrTitle && prIndicator ? ( {isCollapsed ? : } {prIndicator.url ? ( ) : ( #{prIndicator.number} )}

{(baseBranchLabel || headBranchLabel) ? (
{baseBranchLabel && headBranchLabel ? ( <> {baseBranchLabel} {headBranchLabel} ) : ( {baseBranchLabel ?? headBranchLabel ?? ''} )}
) : null} {mergeStateLabel ?
{mergeStateLabel}
: null} {(mergeabilityLabel || checksSummary) ? (
{mergeabilityLabel ?? ''} {mergeabilityLabel && checksSummary ? ' • ' : ''} {checksSummary ?? ''} {checksTail ? ` (${checksTail})` : ''}
) : null}
{group.branch} ) : group.isArchivedBucket ? ( {isCollapsed ? : } {renderHighlightedText(group.label, normalizedSessionSearchQuery)} ) : (!group.isMain || group.worktree) ? ( {isCollapsed ? : } {renderHighlightedText(group.label, normalizedSessionSearchQuery)} ) : ( renderHighlightedText(group.label, normalizedSessionSearchQuery) )}

{showBranchSubtitle && statusLine ? ( {group.isArchivedBucket ? ( ) : (!group.isMain || isGitProject) ? ( showInlinePrTitle && prIndicator ? (
{(baseBranchLabel || headBranchLabel) ? (
{baseBranchLabel && headBranchLabel ? ( <> {baseBranchLabel} {headBranchLabel} ) : ( {baseBranchLabel ?? headBranchLabel ?? ''} )}
) : null} {mergeStateLabel ?
{mergeStateLabel}
: null} {(mergeabilityLabel || checksSummary) ? (
{mergeabilityLabel ?? ''} {mergeabilityLabel && checksSummary ? ' • ' : ''} {checksSummary ?? ''} {checksTail ? ` (${checksTail})` : ''}
) : null}
) : ( ) ) : null} {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);