import { type ReactNode } from 'react'; import { Activity, Archive, Folder, MessageSquare, RotateCcw, Search, Trash2 } from 'lucide-react'; import type { TFunction } from 'i18next'; import { ScrollArea } from '../../../../shared/view/ui'; import type { Project } from '../../../../types/app'; import type { ReleaseInfo } from '../../../../types/sharedTypes'; import type { ConversationSearchResults, SearchProgress } from '../../hooks/useSidebarController'; import type { ArchivedProjectListItem, ArchivedSessionListItem, SidebarSearchMode } from '../../types/types'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; import { getAllSessions } from '../../utils/utils'; import SidebarFooter from './SidebarFooter'; import SidebarHeader from './SidebarHeader'; import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList'; function HighlightedSnippet({ snippet, highlights }: { snippet: string; highlights: { start: number; end: number }[] }) { const parts: ReactNode[] = []; let cursor = 0; for (const h of highlights) { if (h.start > cursor) { parts.push(snippet.slice(cursor, h.start)); } parts.push( {snippet.slice(h.start, h.end)} ); cursor = h.end; } if (cursor < snippet.length) { parts.push(snippet.slice(cursor)); } return ( {parts} ); } type ArchivedSessionGroup = { key: string; projectId: string | null; projectDisplayName: string; projectPath: string | null; isProjectArchived: boolean; sessions: ArchivedSessionListItem[]; latestActivity: string | null; }; /** * Groups archived sessions by project metadata so the archive view preserves * the same mental model as the active sidebar: projects first, then sessions. */ function groupArchivedSessionsByProject(sessions: ArchivedSessionListItem[]): ArchivedSessionGroup[] { const groups = new Map(); for (const session of sessions) { const key = session.projectId ?? session.projectPath ?? `session:${session.sessionId}`; const existingGroup = groups.get(key); if (existingGroup) { existingGroup.sessions.push(session); if (!existingGroup.latestActivity || (session.lastActivity && session.lastActivity > existingGroup.latestActivity)) { existingGroup.latestActivity = session.lastActivity; } continue; } groups.set(key, { key, projectId: session.projectId, projectDisplayName: session.projectDisplayName, projectPath: session.projectPath, isProjectArchived: session.isProjectArchived, sessions: [session], latestActivity: session.lastActivity, }); } return [...groups.values()].sort((groupA, groupB) => { const a = groupA.latestActivity ?? ''; const b = groupB.latestActivity ?? ''; return b.localeCompare(a); }); } function formatCompactArchivedAge(dateString: string | null): string { if (!dateString) { return ''; } const date = new Date(dateString); if (Number.isNaN(date.getTime())) { return ''; } const diffInMinutes = Math.floor(Math.max(0, Date.now() - date.getTime()) / (1000 * 60)); if (diffInMinutes < 1) { return '<1m'; } if (diffInMinutes < 60) { return `${diffInMinutes}m`; } const diffInHours = Math.floor(diffInMinutes / 60); if (diffInHours < 24) { return `${diffInHours}hr`; } return `${Math.floor(diffInHours / 24)}d`; } type SidebarContentProps = { isPWA: boolean; isMobile: boolean; isLoading: boolean; projects: Project[]; runningSessionsCount: number; archivedProjects: ArchivedProjectListItem[]; archivedSessions: ArchivedSessionListItem[]; archivedSessionsCount: number; isArchivedSessionsLoading: boolean; searchFilter: string; onSearchFilterChange: (value: string) => void; onClearSearchFilter: () => void; searchMode: SidebarSearchMode; onSearchModeChange: (mode: SidebarSearchMode) => void; conversationResults: ConversationSearchResults | null; isSearching: boolean; searchProgress: SearchProgress | null; onRestoreArchivedProject: (projectId: string) => void; onArchivedSessionClick: (session: ArchivedSessionListItem) => void; onRestoreArchivedSession: (sessionId: string) => void; onDeleteArchivedSession: (session: ArchivedSessionListItem) => void; // Conversation result clicks pass back the DB projectId (or null when the // server couldn't resolve it). Consumers must handle the null case. onConversationResultClick: (projectId: string | null, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => void; onRefresh: () => void; isRefreshing: boolean; onCreateProject: () => void; onCollapseSidebar: () => void; updateAvailable: boolean; releaseInfo: ReleaseInfo | null; latestVersion: string | null; currentVersion: string; onShowVersionModal: () => void; onShowSettings: () => void; projectListProps: SidebarProjectListProps; t: TFunction; }; export default function SidebarContent({ isPWA, isMobile, isLoading, projects, runningSessionsCount, archivedProjects, archivedSessions, archivedSessionsCount, isArchivedSessionsLoading, searchFilter, onSearchFilterChange, onClearSearchFilter, searchMode, onSearchModeChange, conversationResults, isSearching, searchProgress, onRestoreArchivedProject, onArchivedSessionClick, onRestoreArchivedSession, onDeleteArchivedSession, onConversationResultClick, onRefresh, isRefreshing, onCreateProject, onCollapseSidebar, updateAvailable, releaseInfo, latestVersion, currentVersion, onShowVersionModal, onShowSettings, projectListProps, t, }: SidebarContentProps) { const showConversationSearch = searchMode === 'conversations' && searchFilter.trim().length >= 2; const hasPartialResults = conversationResults && conversationResults.results.length > 0; const groupedArchivedSessions = groupArchivedSessionsByProject(archivedSessions); return (
{showConversationSearch ? ( isSearching && !hasPartialResults ? (

{t('search.searching')}

{searchProgress && (

{t('search.projectsScanned', { count: searchProgress.scannedProjects })}/{searchProgress.totalProjects}

)}
) : !isSearching && conversationResults && conversationResults.results.length === 0 ? (

{t('search.noResults')}

{t('search.tryDifferentQuery')}

) : hasPartialResults ? (

{t('search.matches', { count: conversationResults.totalMatches })}

{isSearching && searchProgress && (

{searchProgress.scannedProjects}/{searchProgress.totalProjects}

)}
{isSearching && searchProgress && (
)} {conversationResults.results.map((projectResult) => (
{projectResult.projectDisplayName}
{projectResult.sessions.map((session) => ( ))}
))}
) : null ) : searchMode === 'running' ? ( projectListProps.filteredProjects.length === 0 ? (

{t('running.emptyTitle', 'No sessions running')}

{runningSessionsCount > 0 ? t('running.noMatchingSessions', 'No running sessions match this search.') : t('running.emptyDescription', 'Active work will appear here while a provider is processing.')}

) : (
{t('running.title', 'Running now')}
{runningSessionsCount}
) ) : searchMode === 'archived' ? ( isArchivedSessionsLoading ? (

{t('archived.loadingTitle', 'Loading archive...')}

{t('archived.loadingDescription', 'Fetching hidden workspaces and sessions you can restore later.')}

) : archivedProjects.length === 0 && groupedArchivedSessions.length === 0 ? (

{archivedSessionsCount > 0 ? t('archived.noMatchingSessions', 'No matching archived items') : t('archived.emptyTitle', 'No archived items')}

{archivedSessionsCount > 0 ? t('archived.tryDifferentSearch', 'Try a different search term.') : t('archived.emptyDescription', 'Archived workspaces and sessions will appear here when you hide them from the active list.')}

) : (

{`${archivedSessionsCount} ${t( archivedSessionsCount === 1 ? 'archived.sessionCountOne' : 'archived.sessionCountOther', archivedSessionsCount === 1 ? 'archived item' : 'archived items', )}`}

{archivedProjects.map((project) => { const projectSessions = getAllSessions(project); return (
{project.displayName} {t('archived.projectArchived', 'Project archived')}

{project.fullPath}

{projectSessions.length > 0 && (
{projectSessions.map((session) => ( ))}
)}
); })} {groupedArchivedSessions.map((group) => (
{group.projectDisplayName} {group.isProjectArchived && ( {t('archived.projectArchived', 'Project archived')} )}
{group.projectPath && (

{group.projectPath}

)}
{group.sessions.length}
{group.sessions.map((session) => (
))}
))}
) ) : ( )}
); }