/** * DateGroupedSessions - Sessions organized by date categories with virtual scrolling. * Uses @tanstack/react-virtual for efficient DOM rendering with infinite scroll. * Supports multi-select with bulk actions and hidden session filtering. */ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { getNonEmptyCategories, groupSessionsByDate, separatePinnedSessions, } from '@renderer/utils/dateGrouping'; import { parseSessionTitle } from '@renderer/utils/sessionTitleParser'; import { truncateMiddle } from '@renderer/utils/stringUtils'; import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider'; import { useVirtualizer } from '@tanstack/react-virtual'; import { ArrowDownWideNarrow, Calendar, Check, CheckSquare, ChevronDown, Eye, EyeOff, GitBranch, Loader2, MessageSquareOff, Pin, Search, X, } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { WorktreeBadge } from '../common/WorktreeBadge'; import { Combobox, type ComboboxOption } from '../ui/combobox'; import { resolveEffectiveSelectedRepositoryId } from './dateGroupedSessionsSelection'; import { SESSION_PROVIDER_IDS, SessionFiltersPopover } from './SessionFiltersPopover'; import { SessionItem } from './SessionItem'; import type { Session, Worktree, WorktreeSource } from '@renderer/types/data'; import type { DateCategory } from '@renderer/types/tabs'; import type { TeamProviderId } from '@shared/types'; // --------------------------------------------------------------------------- // Worktree grouping helpers (moved from SidebarHeader) // --------------------------------------------------------------------------- interface WorktreeGroup { source: WorktreeSource; label: string; worktrees: Worktree[]; mostRecent: number; } const SOURCE_LABELS: Record = { 'vibe-kanban': 'Vibe Kanban', conductor: 'Conductor', 'auto-claude': 'Auto Claude', '21st': '21st', 'claude-desktop': 'Claude Desktop', 'claude-code': 'Claude Code', ccswitch: 'ccswitch', git: 'Git', unknown: 'Other', }; function groupWorktreesBySource(worktrees: Worktree[]): { mainWorktree: Worktree | null; groups: WorktreeGroup[]; } { const mainWorktree = worktrees.find((w) => w.isMainWorktree) ?? null; const groupMap = new Map(); for (const wt of worktrees) { if (wt.isMainWorktree) continue; const existing = groupMap.get(wt.source) ?? []; existing.push(wt); groupMap.set(wt.source, existing); } const groups: WorktreeGroup[] = []; for (const [source, wts] of groupMap) { const sorted = [...wts].sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0)); const mostRecent = Math.max(...sorted.map((w) => w.mostRecentSession ?? 0)); groups.push({ source, label: SOURCE_LABELS[source] ?? source, worktrees: sorted, mostRecent }); } groups.sort((a, b) => b.mostRecent - a.mostRecent); return { mainWorktree, groups }; } // --------------------------------------------------------------------------- // WorktreeItem (inline, moved from SidebarHeader) // --------------------------------------------------------------------------- const WorktreeItem = ({ worktree, isSelected, onSelect, }: { worktree: Worktree; isSelected: boolean; onSelect: () => void; }): React.JSX.Element => { const [isHovered, setIsHovered] = useState(false); const buttonStyle: React.CSSProperties = isSelected ? { backgroundColor: 'var(--color-surface-raised)', color: 'var(--color-text)' } : { backgroundColor: isHovered ? 'var(--color-surface-raised)' : 'transparent', opacity: isHovered ? 0.5 : 1, }; return ( ); }; // Virtual list item types type VirtualItem = | { type: 'header'; category: DateCategory; id: string } | { type: 'pinned-header'; id: string } | { type: 'session'; session: Session; isPinned: boolean; isHidden: boolean; id: string } | { type: 'loader'; id: string }; /** * Item height constants for virtual scroll positioning. * CRITICAL: These values MUST match the actual rendered heights of components. * If SessionItem height changes, update SESSION_HEIGHT here AND add h-[Xpx] to SessionItem. * Mismatch causes items to overlap! */ const HEADER_HEIGHT = 28; const SESSION_HEIGHT = 54; // Must match h-[54px] in SessionItem.tsx const LOADER_HEIGHT = 36; const OVERSCAN = 5; function matchesSessionSearch(session: Session, query: string): boolean { if (!query) { return true; } const parsedTitle = parseSessionTitle(session.firstMessage); const providerId = inferTeamProviderIdFromModel(session.model); const haystack = [ parsedTitle.displayText, parsedTitle.projectName, session.firstMessage, session.projectPath, session.gitBranch, session.model, providerId, ] .filter(Boolean) .join('\n') .toLowerCase(); return haystack.includes(query); } export const DateGroupedSessions = memo((): React.JSX.Element => { const { sessions, selectedSessionId, selectedProjectId, sessionsLoading, sessionsError, sessionsHasMore, sessionsLoadingMore, fetchSessionsMore, pinnedSessionIds, sessionSortMode, setSessionSortMode, hiddenSessionIds, showHiddenSessions, toggleShowHiddenSessions, sidebarSelectedSessionIds, sidebarMultiSelectActive, clearSidebarSelection, toggleSidebarMultiSelect, hideMultipleSessions, unhideMultipleSessions, pinMultipleSessions, // Project / repository state repositoryGroups, selectedRepositoryId, selectedWorktreeId, selectWorktree, selectRepository, viewMode, projects, activeProjectId, setActiveProject, clearActiveProject, fetchRepositoryGroups, fetchProjects, } = useStore( useShallow((s) => ({ sessions: s.sessions, selectedSessionId: s.selectedSessionId, selectedProjectId: s.selectedProjectId, sessionsLoading: s.sessionsLoading, sessionsError: s.sessionsError, sessionsHasMore: s.sessionsHasMore, sessionsLoadingMore: s.sessionsLoadingMore, fetchSessionsMore: s.fetchSessionsMore, pinnedSessionIds: s.pinnedSessionIds, sessionSortMode: s.sessionSortMode, setSessionSortMode: s.setSessionSortMode, hiddenSessionIds: s.hiddenSessionIds, showHiddenSessions: s.showHiddenSessions, toggleShowHiddenSessions: s.toggleShowHiddenSessions, sidebarSelectedSessionIds: s.sidebarSelectedSessionIds, sidebarMultiSelectActive: s.sidebarMultiSelectActive, clearSidebarSelection: s.clearSidebarSelection, toggleSidebarMultiSelect: s.toggleSidebarMultiSelect, hideMultipleSessions: s.hideMultipleSessions, unhideMultipleSessions: s.unhideMultipleSessions, pinMultipleSessions: s.pinMultipleSessions, // Project / repository repositoryGroups: s.repositoryGroups, selectedRepositoryId: s.selectedRepositoryId, selectedWorktreeId: s.selectedWorktreeId, selectWorktree: s.selectWorktree, selectRepository: s.selectRepository, viewMode: s.viewMode, projects: s.projects, activeProjectId: s.activeProjectId, setActiveProject: s.setActiveProject, clearActiveProject: s.clearActiveProject, fetchRepositoryGroups: s.fetchRepositoryGroups, fetchProjects: s.fetchProjects, })) ); const parentRef = useRef(null); const countRef = useRef(null); const searchInputRef = useRef(null); const [showCountTooltip, setShowCountTooltip] = useState(false); const [isWorktreeDropdownOpen, setIsWorktreeDropdownOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [selectedProviderIds, setSelectedProviderIds] = useState>( () => new Set(SESSION_PROVIDER_IDS) ); const worktreeDropdownRef = useRef(null); // Fetch project data on mount or when viewMode changes. // Loading guards in the store actions prevent duplicate IPC calls // when the centralized init chain has already started a fetch. const repositoryGroupsLoading = useStore((s) => s.repositoryGroupsLoading); const repositoryGroupsInitialized = useStore((s) => s.repositoryGroupsInitialized); const repositoryGroupsError = useStore((s) => s.repositoryGroupsError); const projectsLoading = useStore((s) => s.projectsLoading); const projectsInitialized = useStore((s) => s.projectsInitialized); const projectsError = useStore((s) => s.projectsError); useEffect(() => { if ( viewMode === 'grouped' && !repositoryGroupsInitialized && !repositoryGroupsLoading && !repositoryGroupsError ) { void fetchRepositoryGroups(); } else if (viewMode === 'flat' && !projectsInitialized && !projectsLoading && !projectsError) { void fetchProjects(); } }, [ viewMode, repositoryGroupsInitialized, projectsInitialized, repositoryGroupsLoading, repositoryGroupsError, projectsLoading, projectsError, fetchRepositoryGroups, fetchProjects, ]); const effectiveSelectedWorktreeId = selectedWorktreeId ?? activeProjectId ?? selectedProjectId ?? null; const effectiveSelectedRepositoryId = useMemo( () => resolveEffectiveSelectedRepositoryId({ repositoryGroups, selectedRepositoryId, effectiveSelectedWorktreeId, }), [effectiveSelectedWorktreeId, repositoryGroups, selectedRepositoryId] ); const activeProjectValue = viewMode === 'grouped' ? effectiveSelectedRepositoryId : (activeProjectId ?? selectedProjectId ?? null); // Project combobox options const projectComboboxOptions = useMemo((): ComboboxOption[] => { const items = viewMode === 'grouped' ? repositoryGroups.filter( (repo) => repo.totalSessions > 0 || repo.id === effectiveSelectedRepositoryId ) : projects.filter( (project) => (project.totalSessions ?? project.sessions.length) > 0 || project.id === activeProjectValue ); return items.map((item) => { const sessionCount = viewMode === 'grouped' ? (item as (typeof repositoryGroups)[0]).totalSessions : ((item as (typeof projects)[0]).totalSessions ?? (item as (typeof projects)[0]).sessions.length); const path = viewMode === 'grouped' ? (item as (typeof repositoryGroups)[0]).worktrees[0]?.path : (item as (typeof projects)[0]).path; return { value: item.id, label: item.name, description: path, meta: { sessionCount, path }, }; }); }, [activeProjectValue, effectiveSelectedRepositoryId, projects, repositoryGroups, viewMode]); const handleProjectValueChange = (id: string): void => { if (viewMode === 'grouped') { const repositoryGroup = repositoryGroups.find((repo) => repo.id === id); if (repositoryGroup) { recordRecentProjectOpenPaths(repositoryGroup.worktrees.map((worktree) => worktree.path)); } selectRepository(id); return; } const project = projects.find((candidate) => candidate.id === id); if (project?.path) { recordRecentProjectOpenPaths([project.path]); } setActiveProject(id); }; // Worktree state const activeRepo = repositoryGroups.find((r) => r.id === effectiveSelectedRepositoryId); const activeWorktree = activeRepo?.worktrees.find((w) => w.id === effectiveSelectedWorktreeId); const worktrees = (activeRepo?.worktrees ?? []).filter( (w) => (w.totalSessions ?? w.sessions.length) > 0 ); const hasMultipleWorktrees = worktrees.length > 1; const worktreeGroupingResult = useMemo(() => groupWorktreesBySource(worktrees), [worktrees]); const mainWorktree = worktreeGroupingResult.mainWorktree; const worktreeGroups = worktreeGroupingResult.groups; const worktreeName = activeWorktree?.name ?? 'main'; const handleSelectWorktree = (worktree: Worktree): void => { recordRecentProjectOpenPaths([worktree.path]); selectWorktree(worktree.id); setIsWorktreeDropdownOpen(false); }; const hiddenSet = useMemo(() => new Set(hiddenSessionIds), [hiddenSessionIds]); const hasHiddenSessions = hiddenSessionIds.length > 0; const normalizedSearchQuery = searchQuery.trim().toLowerCase(); const hasActiveProviderFilter = selectedProviderIds.size !== SESSION_PROVIDER_IDS.length; const hasActiveSearch = normalizedSearchQuery.length > 0; // Filter out hidden sessions unless showHiddenSessions is on const visibleSessions = useMemo(() => { if (showHiddenSessions) return sessions; return sessions.filter((s) => !hiddenSet.has(s.id)); }, [sessions, hiddenSet, showHiddenSessions]); const searchedSessions = useMemo( () => visibleSessions.filter((session) => matchesSessionSearch(session, normalizedSearchQuery)), [visibleSessions, normalizedSearchQuery] ); const providerCounts = useMemo>(() => { const counts: Record = { anthropic: 0, codex: 0, gemini: 0, opencode: 0, }; for (const session of searchedSessions) { const providerId = inferTeamProviderIdFromModel(session.model); if (providerId) { counts[providerId] += 1; } } return counts; }, [searchedSessions]); const filteredSessions = useMemo(() => { if (!hasActiveProviderFilter) { return searchedSessions; } return searchedSessions.filter((session) => { const providerId = inferTeamProviderIdFromModel(session.model); return providerId ? selectedProviderIds.has(providerId) : false; }); }, [searchedSessions, hasActiveProviderFilter, selectedProviderIds]); // Separate pinned sessions from unpinned const { pinned: pinnedSessions, unpinned: unpinnedSessions } = useMemo( () => separatePinnedSessions(filteredSessions, pinnedSessionIds), [filteredSessions, pinnedSessionIds] ); // Group only unpinned sessions by date const groupedSessions = useMemo(() => groupSessionsByDate(unpinnedSessions), [unpinnedSessions]); // Get non-empty categories in display order const nonEmptyCategories = useMemo( () => getNonEmptyCategories(groupedSessions), [groupedSessions] ); // Sessions sorted by context consumption (for most-context sort mode) const contextSortedSessions = useMemo(() => { if (sessionSortMode !== 'most-context') return []; return [...filteredSessions].sort( (a, b) => (b.contextConsumption ?? 0) - (a.contextConsumption ?? 0) ); }, [filteredSessions, sessionSortMode]); // Flatten sessions with date headers into virtual list items const virtualItems = useMemo((): VirtualItem[] => { const items: VirtualItem[] = []; if (sessionSortMode === 'most-context') { // Flat list sorted by consumption - no date headers, no pinned section for (const session of contextSortedSessions) { items.push({ type: 'session', session, isPinned: pinnedSessionIds.includes(session.id), isHidden: hiddenSet.has(session.id), id: `session-${session.id}`, }); } } else { // Default: date-grouped view with pinned section if (pinnedSessions.length > 0) { items.push({ type: 'pinned-header', id: 'header-pinned', }); for (const session of pinnedSessions) { items.push({ type: 'session', session, isPinned: true, isHidden: hiddenSet.has(session.id), id: `session-${session.id}`, }); } } for (const category of nonEmptyCategories) { items.push({ type: 'header', category, id: `header-${category}`, }); for (const session of groupedSessions[category]) { items.push({ type: 'session', session, isPinned: false, isHidden: hiddenSet.has(session.id), id: `session-${session.id}`, }); } } } // Add loader item if there are more sessions to load if (sessionsHasMore) { items.push({ type: 'loader', id: 'loader', }); } return items; }, [ sessionSortMode, contextSortedSessions, pinnedSessionIds, hiddenSet, pinnedSessions, nonEmptyCategories, groupedSessions, sessionsHasMore, ]); // Estimate item size based on type const estimateSize = useCallback( (index: number) => { const item = virtualItems[index]; if (!item) return SESSION_HEIGHT; switch (item.type) { case 'header': case 'pinned-header': return HEADER_HEIGHT; case 'loader': return LOADER_HEIGHT; case 'session': default: return SESSION_HEIGHT; } }, [virtualItems] ); // Set up virtualizer // eslint-disable-next-line react-hooks/incompatible-library -- TanStack Virtual API limitation, not fixable in user code const rowVirtualizer = useVirtualizer({ count: virtualItems.length, getScrollElement: () => parentRef.current, estimateSize, overscan: OVERSCAN, }); // Get virtual items for dependency tracking const virtualRows = rowVirtualizer.getVirtualItems(); const virtualRowsLength = virtualRows.length; // Load more when scrolling near end useEffect(() => { if (virtualRowsLength === 0) return; const lastItem = virtualRows[virtualRowsLength - 1]; if (!lastItem) return; // If we're within 3 items of the end and there's more to load, fetch more if ( lastItem.index >= virtualItems.length - 3 && sessionsHasMore && !sessionsLoadingMore && !sessionsLoading ) { void fetchSessionsMore(); } }, [ virtualRows, virtualRowsLength, virtualItems.length, sessionsHasMore, sessionsLoadingMore, sessionsLoading, fetchSessionsMore, ]); // Bulk action helpers const selectedSet = useMemo( () => new Set(sidebarSelectedSessionIds), [sidebarSelectedSessionIds] ); const someSelectedAreHidden = useMemo( () => sidebarSelectedSessionIds.some((id) => hiddenSet.has(id)), [sidebarSelectedSessionIds, hiddenSet] ); const handleBulkHide = useCallback(() => { void hideMultipleSessions(sidebarSelectedSessionIds); clearSidebarSelection(); }, [hideMultipleSessions, sidebarSelectedSessionIds, clearSidebarSelection]); const handleBulkUnhide = useCallback(() => { const hiddenSelected = sidebarSelectedSessionIds.filter((id) => hiddenSet.has(id)); void unhideMultipleSessions(hiddenSelected); clearSidebarSelection(); }, [unhideMultipleSessions, sidebarSelectedSessionIds, hiddenSet, clearSidebarSelection]); const handleBulkPin = useCallback(() => { void pinMultipleSessions(sidebarSelectedSessionIds); clearSidebarSelection(); }, [pinMultipleSessions, sidebarSelectedSessionIds, clearSidebarSelection]); // Project selector (always rendered at top) const projectSelector = (
{/* Project combobox */}
{ const sessionCount = (option.meta?.sessionCount as number) ?? 0; const path = option.meta?.path as string | undefined; return ( <>

{option.label}

{path ? (

{path}

) : null}
{sessionCount} ); }} />
{/* Worktree selector (grouped mode only, when multiple worktrees) */} {viewMode === 'grouped' && activeRepo && hasMultipleWorktrees && (
{isWorktreeDropdownOpen && ( <>
setIsWorktreeDropdownOpen(false)} />
Switch Worktree
{mainWorktree && ( handleSelectWorktree(mainWorktree)} /> )} {worktreeGroups.map((group) => (
{group.label}
{group.worktrees.map((worktree) => ( handleSelectWorktree(worktree)} /> ))}
))}
)}
)}
setSearchQuery(event.target.value)} className="min-w-0 flex-1 bg-transparent text-[12px] text-text placeholder:text-text-muted focus:outline-none" /> {searchQuery && ( )}
); if (!selectedProjectId) { return (
{projectSelector}

Select a project to view sessions

); } if (sessionsLoading && sessions.length === 0) { const widths = [ { header: '30%', title: '75%', sub: '90%' }, { header: '22%', title: '60%', sub: '80%' }, { header: '26%', title: '85%', sub: '65%' }, ]; return (
{projectSelector}
{widths.map((w, i) => (
))}
); } if (sessionsError) { return (
{projectSelector}

Error loading sessions

{sessionsError}

); } if (sessions.length === 0) { return (
{projectSelector}

No sessions found

This project has no sessions yet

); } if (filteredSessions.length === 0 && !sessionsHasMore) { return (
{projectSelector}

No matching sessions

{hasActiveSearch || hasActiveProviderFilter ? 'Try another query or reset the provider filter.' : 'This project has no matching sessions yet.'}

); } return (
{projectSelector}

{sessionSortMode === 'most-context' ? 'By Context' : 'Sessions'}

{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- tooltip trigger via hover, not interactive */} setShowCountTooltip(true)} onMouseLeave={() => setShowCountTooltip(false)} > ({filteredSessions.length} {sessionsHasMore ? '+' : ''}) {showCountTooltip && sessionsHasMore && countRef.current && createPortal(
{filteredSessions.length} matching sessions loaded so far — scroll down to load more. {sessionSortMode === 'most-context' ? ' Context sorting only ranks loaded sessions.' : ''}
, document.body )}
{/* Multi-select toggle */} {/* Show hidden sessions toggle - only when hidden sessions exist */} {hasHiddenSessions && ( )} {/* Sort mode toggle */}
{/* Bulk action bar - shown when sessions are selected */} {sidebarMultiSelectActive && sidebarSelectedSessionIds.length > 0 && (
{sidebarSelectedSessionIds.length} selected
{showHiddenSessions && someSelectedAreHidden && ( )}
)}
{rowVirtualizer.getVirtualItems().map((virtualRow) => { const item = virtualItems[virtualRow.index]; if (!item) return null; return (
{item.type === 'pinned-header' ? (
Pinned
) : item.type === 'header' ? (
{item.category}
) : item.type === 'loader' ? (
{sessionsLoadingMore ? ( <> Loading more sessions... ) : ( Scroll to load more )}
) : ( )}
); })}
); }); DateGroupedSessions.displayName = 'DateGroupedSessions';