1118 lines
39 KiB
TypeScript
1118 lines
39 KiB
TypeScript
/**
|
|
* 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<WorktreeSource, string> = {
|
|
'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<WorktreeSource, Worktree[]>();
|
|
|
|
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 (
|
|
<button
|
|
onClick={onSelect}
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
onMouseLeave={() => setIsHovered(false)}
|
|
className="flex w-full items-center gap-1.5 px-4 py-1.5 text-left transition-colors"
|
|
style={buttonStyle}
|
|
>
|
|
<GitBranch
|
|
className="size-3.5 shrink-0"
|
|
style={{ color: isSelected ? '#34d399' : 'var(--color-text-muted)' }}
|
|
/>
|
|
{worktree.isMainWorktree && <WorktreeBadge source={worktree.source} isMain />}
|
|
<span
|
|
className="flex-1 truncate font-mono text-xs"
|
|
style={{ color: isSelected ? 'var(--color-text)' : 'var(--color-text-muted)' }}
|
|
>
|
|
{truncateMiddle(worktree.name, 28)}
|
|
</span>
|
|
<span className="shrink-0 text-[10px]" style={{ color: 'var(--color-text-muted)' }}>
|
|
{worktree.totalSessions ?? worktree.sessions.length}
|
|
</span>
|
|
{isSelected && <Check className="size-3.5 shrink-0 text-indigo-400" />}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
// 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<HTMLDivElement>(null);
|
|
const countRef = useRef<HTMLSpanElement>(null);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
const [showCountTooltip, setShowCountTooltip] = useState(false);
|
|
const [isWorktreeDropdownOpen, setIsWorktreeDropdownOpen] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [selectedProviderIds, setSelectedProviderIds] = useState<Set<TeamProviderId>>(
|
|
() => new Set<TeamProviderId>(SESSION_PROVIDER_IDS)
|
|
);
|
|
const worktreeDropdownRef = useRef<HTMLDivElement>(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<Record<TeamProviderId, number>>(() => {
|
|
const counts: Record<TeamProviderId, number> = {
|
|
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 = (
|
|
<div className="shrink-0 space-y-0">
|
|
{/* Project combobox */}
|
|
<div className="px-2 py-1.5">
|
|
<Combobox
|
|
options={projectComboboxOptions}
|
|
value={activeProjectValue ?? ''}
|
|
onValueChange={handleProjectValueChange}
|
|
placeholder="Select Project"
|
|
searchPlaceholder="Search..."
|
|
emptyMessage="Nothing found"
|
|
className="text-[12px]"
|
|
resetLabel="Reset selection"
|
|
onReset={clearActiveProject}
|
|
renderOption={(option, isSelected) => {
|
|
const sessionCount = (option.meta?.sessionCount as number) ?? 0;
|
|
const path = option.meta?.path as string | undefined;
|
|
return (
|
|
<>
|
|
<Check
|
|
className={cn(
|
|
'mr-2 size-3.5 shrink-0',
|
|
isSelected ? 'text-indigo-400 opacity-100' : 'opacity-0'
|
|
)}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<p
|
|
className={cn(
|
|
'truncate',
|
|
isSelected
|
|
? 'font-medium text-[var(--color-text)]'
|
|
: 'text-[var(--color-text-muted)]'
|
|
)}
|
|
>
|
|
{option.label}
|
|
</p>
|
|
{path ? (
|
|
<p className="truncate text-[10px] text-[var(--color-text-muted)]">{path}</p>
|
|
) : null}
|
|
</div>
|
|
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
|
{sessionCount}
|
|
</span>
|
|
</>
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Worktree selector (grouped mode only, when multiple worktrees) */}
|
|
{viewMode === 'grouped' && activeRepo && hasMultipleWorktrees && (
|
|
<div ref={worktreeDropdownRef} className="relative w-full">
|
|
<button
|
|
onClick={() => setIsWorktreeDropdownOpen(!isWorktreeDropdownOpen)}
|
|
className="flex w-full items-center justify-between px-3 py-1 text-left transition-colors"
|
|
style={{
|
|
backgroundColor: isWorktreeDropdownOpen
|
|
? 'var(--color-surface-raised)'
|
|
: 'transparent',
|
|
color: isWorktreeDropdownOpen ? 'var(--color-text)' : 'var(--color-text-muted)',
|
|
}}
|
|
>
|
|
<div className="flex flex-1 items-center gap-1.5 overflow-hidden">
|
|
<GitBranch
|
|
className="size-3.5 shrink-0"
|
|
style={{ color: isWorktreeDropdownOpen ? '#34d399' : 'rgba(52, 211, 153, 0.7)' }}
|
|
/>
|
|
{activeWorktree?.isMainWorktree ? (
|
|
<WorktreeBadge source={activeWorktree.source} isMain />
|
|
) : (
|
|
activeWorktree?.source && <WorktreeBadge source={activeWorktree.source} />
|
|
)}
|
|
<span className="truncate font-mono text-[11px]">
|
|
{truncateMiddle(worktreeName, 24)}
|
|
</span>
|
|
</div>
|
|
<ChevronDown
|
|
className={`size-3.5 shrink-0 transition-transform ${isWorktreeDropdownOpen ? 'rotate-180' : ''}`}
|
|
style={{ color: 'var(--color-text-muted)' }}
|
|
/>
|
|
</button>
|
|
|
|
{isWorktreeDropdownOpen && (
|
|
<>
|
|
<div
|
|
role="presentation"
|
|
className="fixed inset-0 z-10"
|
|
onClick={() => setIsWorktreeDropdownOpen(false)}
|
|
/>
|
|
<div
|
|
className="absolute inset-x-0 top-full z-20 mt-0 max-h-[300px] overflow-y-auto py-1 shadow-xl"
|
|
style={{
|
|
backgroundColor: 'var(--color-surface-sidebar)',
|
|
borderWidth: '1px',
|
|
borderTopWidth: '0',
|
|
borderStyle: 'solid',
|
|
borderColor: 'var(--color-border)',
|
|
}}
|
|
>
|
|
<div
|
|
className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-wider"
|
|
style={{ color: 'var(--color-text-muted)' }}
|
|
>
|
|
Switch Worktree
|
|
</div>
|
|
{mainWorktree && (
|
|
<WorktreeItem
|
|
worktree={mainWorktree}
|
|
isSelected={mainWorktree.id === effectiveSelectedWorktreeId}
|
|
onSelect={() => handleSelectWorktree(mainWorktree)}
|
|
/>
|
|
)}
|
|
{worktreeGroups.map((group) => (
|
|
<div key={group.source}>
|
|
<div
|
|
className="mt-1 px-4 py-1.5 text-[9px] font-medium uppercase tracking-wider"
|
|
style={{
|
|
borderTopWidth: '1px',
|
|
borderTopStyle: 'solid',
|
|
borderTopColor: 'var(--color-border)',
|
|
color: 'var(--color-text-muted)',
|
|
}}
|
|
>
|
|
{group.label}
|
|
</div>
|
|
{group.worktrees.map((worktree) => (
|
|
<WorktreeItem
|
|
key={worktree.id}
|
|
worktree={worktree}
|
|
isSelected={worktree.id === effectiveSelectedWorktreeId}
|
|
onSelect={() => handleSelectWorktree(worktree)}
|
|
/>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
className="mb-[5px] flex shrink-0 items-center gap-1.5 border-b px-2 py-1"
|
|
style={{ borderColor: 'var(--color-border)' }}
|
|
>
|
|
<Search className="size-3 shrink-0 text-text-muted" />
|
|
<input
|
|
ref={searchInputRef}
|
|
type="text"
|
|
placeholder="Search sessions..."
|
|
value={searchQuery}
|
|
onChange={(event) => setSearchQuery(event.target.value)}
|
|
className="min-w-0 flex-1 bg-transparent text-[12px] text-text placeholder:text-text-muted focus:outline-none"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
type="button"
|
|
className="shrink-0 text-text-muted hover:text-text-secondary"
|
|
onClick={() => {
|
|
setSearchQuery('');
|
|
searchInputRef.current?.focus();
|
|
}}
|
|
aria-label="Clear session search"
|
|
>
|
|
<X className="size-3" />
|
|
</button>
|
|
)}
|
|
<SessionFiltersPopover
|
|
selectedProviderIds={selectedProviderIds}
|
|
providerCounts={providerCounts}
|
|
onProviderIdsChange={setSelectedProviderIds}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
if (!selectedProjectId) {
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
{projectSelector}
|
|
<div className="flex flex-1 items-center justify-center p-4">
|
|
<div className="text-center text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
|
<p>Select a project to view sessions</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="flex h-full flex-col">
|
|
{projectSelector}
|
|
<div className="space-y-3 p-4">
|
|
{widths.map((w, i) => (
|
|
<div key={i} className="space-y-2">
|
|
<div
|
|
className="skeleton-shimmer h-3 rounded-sm"
|
|
style={{ backgroundColor: 'var(--skeleton-base-dim)', width: w.header }}
|
|
/>
|
|
<div
|
|
className="skeleton-shimmer h-4 rounded-sm"
|
|
style={{ backgroundColor: 'var(--skeleton-base)', width: w.title }}
|
|
/>
|
|
<div
|
|
className="skeleton-shimmer h-3 rounded-sm"
|
|
style={{ backgroundColor: 'var(--skeleton-base-dim)', width: w.sub }}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (sessionsError) {
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
{projectSelector}
|
|
<div className="p-4">
|
|
<div
|
|
className="rounded-lg border p-3 text-sm"
|
|
style={{
|
|
borderColor: 'var(--color-border)',
|
|
backgroundColor: 'var(--color-surface-raised)',
|
|
color: 'var(--color-text-muted)',
|
|
}}
|
|
>
|
|
<p className="mb-1 font-semibold" style={{ color: 'var(--color-text)' }}>
|
|
Error loading sessions
|
|
</p>
|
|
<p>{sessionsError}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (sessions.length === 0) {
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
{projectSelector}
|
|
<div className="flex flex-1 items-center justify-center p-4">
|
|
<div className="text-center text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
|
<MessageSquareOff className="mx-auto mb-2 size-8 opacity-50" />
|
|
<p className="mb-2">No sessions found</p>
|
|
<p className="text-xs opacity-70">This project has no sessions yet</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (filteredSessions.length === 0 && !sessionsHasMore) {
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
{projectSelector}
|
|
<div className="flex flex-1 items-center justify-center p-4">
|
|
<div className="text-center text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
|
<Search className="mx-auto mb-2 size-8 opacity-50" />
|
|
<p className="mb-2">No matching sessions</p>
|
|
<p className="text-xs opacity-70">
|
|
{hasActiveSearch || hasActiveProviderFilter
|
|
? 'Try another query or reset the provider filter.'
|
|
: 'This project has no matching sessions yet.'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full flex-col overflow-hidden">
|
|
{projectSelector}
|
|
<div className="flex items-center gap-2 px-2 py-1.5">
|
|
<Calendar className="size-3.5" style={{ color: 'var(--color-text-muted)' }} />
|
|
<h2
|
|
className="text-[12px] font-semibold text-text-secondary"
|
|
style={{ color: 'var(--color-text-secondary)' }}
|
|
>
|
|
{sessionSortMode === 'most-context' ? 'By Context' : 'Sessions'}
|
|
</h2>
|
|
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- tooltip trigger via hover, not interactive */}
|
|
<span
|
|
ref={countRef}
|
|
className="text-[10px]"
|
|
style={{ color: 'var(--color-text-muted)', opacity: 0.6 }}
|
|
onMouseEnter={() => setShowCountTooltip(true)}
|
|
onMouseLeave={() => setShowCountTooltip(false)}
|
|
>
|
|
({filteredSessions.length}
|
|
{sessionsHasMore ? '+' : ''})
|
|
</span>
|
|
{showCountTooltip &&
|
|
sessionsHasMore &&
|
|
countRef.current &&
|
|
createPortal(
|
|
<div
|
|
className="pointer-events-none fixed z-50 w-48 rounded-md px-2.5 py-1.5 text-[11px] leading-snug shadow-lg"
|
|
style={{
|
|
top: countRef.current.getBoundingClientRect().bottom + 6,
|
|
left:
|
|
countRef.current.getBoundingClientRect().left +
|
|
countRef.current.getBoundingClientRect().width / 2 -
|
|
96,
|
|
backgroundColor: 'var(--color-surface-overlay)',
|
|
border: '1px solid var(--color-border-emphasis)',
|
|
color: 'var(--color-text-secondary)',
|
|
}}
|
|
>
|
|
{filteredSessions.length} matching sessions loaded so far — scroll down to load more.
|
|
{sessionSortMode === 'most-context'
|
|
? ' Context sorting only ranks loaded sessions.'
|
|
: ''}
|
|
</div>,
|
|
document.body
|
|
)}
|
|
<div className="ml-auto flex items-center gap-0.5">
|
|
{/* Multi-select toggle */}
|
|
<button
|
|
onClick={toggleSidebarMultiSelect}
|
|
className="rounded p-1 transition-colors hover:bg-white/5"
|
|
title={sidebarMultiSelectActive ? 'Exit selection mode' : 'Select sessions'}
|
|
style={{
|
|
color: sidebarMultiSelectActive ? '#818cf8' : 'var(--color-text-muted)',
|
|
}}
|
|
>
|
|
<CheckSquare className="size-3.5" />
|
|
</button>
|
|
{/* Show hidden sessions toggle - only when hidden sessions exist */}
|
|
{hasHiddenSessions && (
|
|
<button
|
|
onClick={toggleShowHiddenSessions}
|
|
className="rounded p-1 transition-colors hover:bg-white/5"
|
|
title={showHiddenSessions ? 'Hide hidden sessions' : 'Show hidden sessions'}
|
|
style={{
|
|
color: showHiddenSessions ? '#818cf8' : 'var(--color-text-muted)',
|
|
}}
|
|
>
|
|
{showHiddenSessions ? <Eye className="size-3.5" /> : <EyeOff className="size-3.5" />}
|
|
</button>
|
|
)}
|
|
{/* Sort mode toggle */}
|
|
<button
|
|
onClick={() =>
|
|
setSessionSortMode(sessionSortMode === 'recent' ? 'most-context' : 'recent')
|
|
}
|
|
className="rounded p-1 transition-colors hover:bg-white/5"
|
|
title={sessionSortMode === 'recent' ? 'Sort by context consumption' : 'Sort by recent'}
|
|
style={{
|
|
color: sessionSortMode === 'most-context' ? '#818cf8' : 'var(--color-text-muted)',
|
|
}}
|
|
>
|
|
<ArrowDownWideNarrow className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bulk action bar - shown when sessions are selected */}
|
|
{sidebarMultiSelectActive && sidebarSelectedSessionIds.length > 0 && (
|
|
<div
|
|
className="flex items-center gap-1.5 border-b px-3 py-1.5"
|
|
style={{
|
|
borderColor: 'var(--color-border)',
|
|
backgroundColor: 'var(--color-surface-raised)',
|
|
}}
|
|
>
|
|
<span
|
|
className="text-[11px] font-medium"
|
|
style={{ color: 'var(--color-text-secondary)' }}
|
|
>
|
|
{sidebarSelectedSessionIds.length} selected
|
|
</span>
|
|
<div className="ml-auto flex items-center gap-1">
|
|
<button
|
|
onClick={handleBulkPin}
|
|
className="rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors hover:bg-white/5"
|
|
style={{ color: 'var(--color-text-secondary)' }}
|
|
title="Pin selected sessions"
|
|
>
|
|
<Pin className="inline-block size-3" /> Pin
|
|
</button>
|
|
<button
|
|
onClick={handleBulkHide}
|
|
className="rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors hover:bg-white/5"
|
|
style={{ color: 'var(--color-text-secondary)' }}
|
|
title="Hide selected sessions"
|
|
>
|
|
<EyeOff className="inline-block size-3" /> Hide
|
|
</button>
|
|
{showHiddenSessions && someSelectedAreHidden && (
|
|
<button
|
|
onClick={handleBulkUnhide}
|
|
className="rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors hover:bg-white/5"
|
|
style={{ color: 'var(--color-text-secondary)' }}
|
|
title="Unhide selected sessions"
|
|
>
|
|
<Eye className="inline-block size-3" /> Unhide
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={clearSidebarSelection}
|
|
className="rounded p-0.5 transition-colors hover:bg-white/5"
|
|
style={{ color: 'var(--color-text-muted)' }}
|
|
title="Cancel selection"
|
|
>
|
|
<X className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div ref={parentRef} className="flex-1 overflow-y-auto">
|
|
<div
|
|
style={{
|
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
|
width: '100%',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
|
const item = virtualItems[virtualRow.index];
|
|
if (!item) return null;
|
|
|
|
return (
|
|
<div
|
|
key={virtualRow.key}
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
height: `${virtualRow.size}px`,
|
|
transform: `translateY(${virtualRow.start}px)`,
|
|
}}
|
|
>
|
|
{item.type === 'pinned-header' ? (
|
|
<div
|
|
className="sticky top-0 flex h-full items-center gap-1.5 border-t px-2 py-1.5 text-[11px] font-semibold text-text-secondary backdrop-blur-sm"
|
|
style={{
|
|
backgroundColor:
|
|
'color-mix(in srgb, var(--color-surface-sidebar) 95%, transparent)',
|
|
color: 'var(--color-text-secondary)',
|
|
borderColor: 'var(--color-border-emphasis)',
|
|
}}
|
|
>
|
|
<Pin className="size-3" />
|
|
Pinned
|
|
</div>
|
|
) : item.type === 'header' ? (
|
|
<div
|
|
className="sticky top-0 flex h-full items-center border-t px-2 py-1.5 text-[11px] font-semibold text-text-secondary backdrop-blur-sm"
|
|
style={{
|
|
backgroundColor:
|
|
'color-mix(in srgb, var(--color-surface-sidebar) 95%, transparent)',
|
|
color: 'var(--color-text-secondary)',
|
|
borderColor: 'var(--color-border-emphasis)',
|
|
}}
|
|
>
|
|
{item.category}
|
|
</div>
|
|
) : item.type === 'loader' ? (
|
|
<div
|
|
className="flex h-full items-center justify-center"
|
|
style={{ color: 'var(--color-text-muted)' }}
|
|
>
|
|
{sessionsLoadingMore ? (
|
|
<>
|
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
|
<span className="text-xs">Loading more sessions...</span>
|
|
</>
|
|
) : (
|
|
<span className="text-xs opacity-50">Scroll to load more</span>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<SessionItem
|
|
session={item.session}
|
|
isActive={selectedSessionId === item.session.id}
|
|
isPinned={item.isPinned}
|
|
isHidden={item.isHidden}
|
|
multiSelectActive={sidebarMultiSelectActive}
|
|
isSelected={selectedSet.has(item.session.id)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
DateGroupedSessions.displayName = 'DateGroupedSessions';
|