import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { TFunction } from 'i18next'; import { api } from '../../../utils/api'; import { usePaletteOps } from '../../../contexts/PaletteOpsContext'; import type { Project, ProjectSession, LLMProvider } from '../../../types/app'; import type { DeleteProjectConfirmation, ProjectSortOrder, SessionDeleteConfirmation, SessionWithProvider, } from '../types/types'; import { clearLegacyStarredProjectIds, filterProjects, getAllSessions, readLegacyStarredProjectIds, readProjectSortOrder, sortProjects, } from '../utils/utils'; type SnippetHighlight = { start: number; end: number; }; type ConversationMatch = { role: string; snippet: string; highlights: SnippetHighlight[]; timestamp: string | null; provider?: string; messageUuid?: string | null; }; type ConversationSession = { sessionId: string; sessionSummary: string; provider?: string; matches: ConversationMatch[]; }; type ConversationProjectResult = { // Emitted by the provider search service so the sidebar can map a // match back to the Project in its current state by projectId. projectId: string | null; projectName: string; projectDisplayName: string; sessions: ConversationSession[]; }; export type ConversationSearchResults = { results: ConversationProjectResult[]; totalMatches: number; query: string; }; export type SearchProgress = { scannedProjects: number; totalProjects: number; }; type UseSidebarControllerArgs = { projects: Project[]; selectedProject: Project | null; selectedSession: ProjectSession | null; isLoading: boolean; isMobile: boolean; t: TFunction; onRefresh: () => Promise | void; onProjectSelect: (project: Project) => void; onSessionSelect: (session: ProjectSession) => void; onSessionDelete?: (sessionId: string) => void; onLoadMoreSessions?: (projectId: string) => Promise | void; // `projectId` is the DB-assigned identifier; callbacks use that post-migration. onProjectDelete?: (projectId: string) => void; setCurrentProject: (project: Project) => void; setSidebarVisible: (visible: boolean) => void; sidebarVisible: boolean; }; export function useSidebarController({ projects, selectedProject, selectedSession: _selectedSession, isLoading, isMobile, t, onRefresh, onProjectSelect, onSessionSelect, onSessionDelete, onLoadMoreSessions, onProjectDelete, setCurrentProject, setSidebarVisible, sidebarVisible, }: UseSidebarControllerArgs) { const paletteOps = usePaletteOps(); const [expandedProjects, setExpandedProjects] = useState>(new Set()); const [editingProject, setEditingProject] = useState(null); const [showNewProject, setShowNewProject] = useState(false); const [editingName, setEditingName] = useState(''); const [initialSessionsLoaded, setInitialSessionsLoaded] = useState>(new Set()); const [currentTime, setCurrentTime] = useState(new Date()); const [projectSortOrder, setProjectSortOrder] = useState('name'); const [isRefreshing, setIsRefreshing] = useState(false); const [editingSession, setEditingSession] = useState(null); const [editingSessionName, setEditingSessionName] = useState(''); const [searchFilter, setSearchFilter] = useState(''); const [deletingProjects, setDeletingProjects] = useState>(new Set()); const [deleteConfirmation, setDeleteConfirmation] = useState(null); const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState(null); const [showVersionModal, setShowVersionModal] = useState(false); const [searchMode, setSearchMode] = useState<'projects' | 'conversations'>('projects'); const [conversationResults, setConversationResults] = useState(null); const [isSearching, setIsSearching] = useState(false); const [searchProgress, setSearchProgress] = useState(null); const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); const [optimisticStarByProjectId, setOptimisticStarByProjectId] = useState>(new Map()); const [loadingMoreProjects, setLoadingMoreProjects] = useState>(new Set()); const searchSeqRef = useRef(0); const eventSourceRef = useRef(null); const starToggleSequenceByProjectRef = useRef>(new Map()); const migrationStartedRef = useRef(false); const onRefreshRef = useRef(onRefresh); const isSidebarCollapsed = !isMobile && !sidebarVisible; useEffect(() => { const timer = setInterval(() => { setCurrentTime(new Date()); }, 60000); return () => clearInterval(timer); }, []); useEffect(() => { setInitialSessionsLoaded(new Set()); }, [projects]); useEffect(() => { // Auto-expand only when the selected project identity changes. // Depending on the full `selectedProject` object (or `selectedSession`) causes // websocket-driven list refreshes to re-open projects users manually collapsed. const selectedProjectId = selectedProject?.projectId; if (!selectedProjectId) { return; } setExpandedProjects((prev) => { if (prev.has(selectedProjectId)) { return prev; } const next = new Set(prev); next.add(selectedProjectId); return next; }); }, [selectedProject?.projectId]); useEffect(() => { if (projects.length > 0 && !isLoading) { const loadedProjects = new Set(); projects.forEach((project) => { if (project.sessions && project.sessions.length >= 0) { loadedProjects.add(project.projectId); } }); setInitialSessionsLoaded(loadedProjects); } }, [projects, isLoading]); useEffect(() => { const loadSortOrder = () => { setProjectSortOrder(readProjectSortOrder()); }; loadSortOrder(); const handleStorageChange = (event: StorageEvent) => { if (event.key === 'claude-settings') { loadSortOrder(); } }; window.addEventListener('storage', handleStorageChange); const interval = setInterval(() => { if (document.hasFocus()) { loadSortOrder(); } }, 1000); return () => { window.removeEventListener('storage', handleStorageChange); clearInterval(interval); }; }, []); useEffect(() => { onRefreshRef.current = onRefresh; }, [onRefresh]); useEffect(() => { if (migrationStartedRef.current) { return; } const legacyStarredProjectIds = readLegacyStarredProjectIds(); if (legacyStarredProjectIds.length === 0) { return; } migrationStartedRef.current = true; const migrateLegacyStars = async () => { try { await api.migrateLegacyProjectStars(legacyStarredProjectIds); await onRefreshRef.current(); } catch (error) { console.error('[Sidebar] Failed to migrate legacy starred projects:', error); } finally { clearLegacyStarredProjectIds(); } }; void migrateLegacyStars(); }, [onRefresh]); useEffect(() => { setOptimisticStarByProjectId((previous) => { if (previous.size === 0) { return previous; } const next = new Map(previous); let changed = false; for (const [projectId, optimisticValue] of previous.entries()) { const project = projects.find((candidate) => candidate.projectId === projectId); if (!project) { next.delete(projectId); changed = true; continue; } if (Boolean(project.isStarred) === optimisticValue) { next.delete(projectId); changed = true; } } return changed ? next : previous; }); }, [projects]); // Debounce search text updates so both project filtering and conversation // SSE requests avoid running on every keypress. useEffect(() => { const timeout = setTimeout(() => { setDebouncedSearchQuery(searchFilter.trim()); }, 300); return () => { clearTimeout(timeout); }; }, [searchFilter]); // Debounced conversation search with SSE streaming useEffect(() => { if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } const query = debouncedSearchQuery; if (searchMode !== 'conversations' || query.length < 2) { searchSeqRef.current += 1; setConversationResults(null); setSearchProgress(null); setIsSearching(false); return; } setIsSearching(true); const seq = ++searchSeqRef.current; if (seq !== searchSeqRef.current) { return; } const url = api.searchConversationsUrl(query); const es = new EventSource(url); eventSourceRef.current = es; const accumulated: ConversationProjectResult[] = []; let totalMatches = 0; es.addEventListener('result', (evt) => { if (seq !== searchSeqRef.current) { es.close(); return; } try { const data = JSON.parse(evt.data) as { projectResult: ConversationProjectResult; totalMatches: number; scannedProjects: number; totalProjects: number; }; accumulated.push(data.projectResult); totalMatches = data.totalMatches; setConversationResults({ results: [...accumulated], totalMatches, query }); setSearchProgress({ scannedProjects: data.scannedProjects, totalProjects: data.totalProjects }); } catch { // Ignore malformed SSE data } }); es.addEventListener('progress', (evt) => { if (seq !== searchSeqRef.current) { es.close(); return; } try { const data = JSON.parse(evt.data) as { totalMatches: number; scannedProjects: number; totalProjects: number }; totalMatches = data.totalMatches; setSearchProgress({ scannedProjects: data.scannedProjects, totalProjects: data.totalProjects }); } catch { // Ignore malformed SSE data } }); es.addEventListener('done', () => { if (seq !== searchSeqRef.current) { es.close(); return; } es.close(); eventSourceRef.current = null; setIsSearching(false); setSearchProgress(null); if (accumulated.length === 0) { setConversationResults({ results: [], totalMatches: 0, query }); } }); es.addEventListener('error', () => { if (seq !== searchSeqRef.current) { es.close(); return; } es.close(); eventSourceRef.current = null; setIsSearching(false); setSearchProgress(null); if (accumulated.length === 0) { setConversationResults({ results: [], totalMatches: 0, query }); } }); return () => { if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } }; }, [debouncedSearchQuery, searchMode]); // All sidebar state keys (expanded, starred, loading, etc.) use the DB // `projectId` as their identifier after the migration. const toggleProject = useCallback((projectId: string) => { setExpandedProjects((prev) => { const next = new Set(); if (!prev.has(projectId)) { next.add(projectId); } return next; }); }, []); const handleSessionClick = useCallback( (session: SessionWithProvider, projectId: string) => { // Tag the session with its owning projectId so downstream handlers // can correlate it with the selectedProject in the app state. onSessionSelect({ ...session, __projectId: projectId }); }, [onSessionSelect], ); const resolveProjectStarState = useCallback( (projectId: string): boolean => { if (optimisticStarByProjectId.has(projectId)) { return Boolean(optimisticStarByProjectId.get(projectId)); } return projects.some((project) => project.projectId === projectId && Boolean(project.isStarred)); }, [optimisticStarByProjectId, projects], ); const toggleStarProject = useCallback((projectId: string) => { const previousStarState = resolveProjectStarState(projectId); const optimisticStarState = !previousStarState; const latestSequence = (starToggleSequenceByProjectRef.current.get(projectId) ?? 0) + 1; starToggleSequenceByProjectRef.current.set(projectId, latestSequence); setOptimisticStarByProjectId((previous) => { const next = new Map(previous); next.set(projectId, optimisticStarState); return next; }); const updateStar = async () => { try { const response = await api.toggleProjectStar(projectId); if (!response.ok) { const payload = (await response.json()) as { error?: string | { message?: string } }; const errorPayload = payload.error; const message = typeof errorPayload === 'string' ? errorPayload : errorPayload && typeof errorPayload === 'object' && errorPayload.message ? errorPayload.message : t('messages.updateProjectError'); throw new Error(message); } const payload = (await response.json()) as { isStarred?: boolean }; const isLatestSequence = starToggleSequenceByProjectRef.current.get(projectId) === latestSequence; if (!isLatestSequence) { return; } setOptimisticStarByProjectId((previous) => { const next = new Map(previous); next.set(projectId, Boolean(payload.isStarred)); return next; }); } catch (error) { const isLatestSequence = starToggleSequenceByProjectRef.current.get(projectId) === latestSequence; if (!isLatestSequence) { return; } setOptimisticStarByProjectId((previous) => { const next = new Map(previous); next.set(projectId, previousStarState); return next; }); console.error('[Sidebar] Failed to toggle project star:', error); alert(t('messages.updateProjectError')); } }; void updateStar(); }, [resolveProjectStarState, t]); const isProjectStarred = useCallback( (projectId: string) => resolveProjectStarState(projectId), [resolveProjectStarState], ); const getProjectSessions = useCallback((project: Project) => getAllSessions(project), []); const loadMoreSessionsForProject = useCallback(async (projectId: string) => { if (!onLoadMoreSessions) { return; } let shouldLoad = false; setLoadingMoreProjects((previous) => { if (previous.has(projectId)) { return previous; } shouldLoad = true; const next = new Set(previous); next.add(projectId); return next; }); if (!shouldLoad) { return; } try { await onLoadMoreSessions(projectId); } catch (error) { console.error('[Sidebar] Failed to load more sessions:', error); alert(t('messages.refreshError')); } finally { setLoadingMoreProjects((previous) => { const next = new Set(previous); next.delete(projectId); return next; }); } }, [onLoadMoreSessions, t]); const projectsWithResolvedStarState = useMemo(() => { if (optimisticStarByProjectId.size === 0) { return projects; } return projects.map((project) => { const optimisticStarState = optimisticStarByProjectId.get(project.projectId); if (optimisticStarState === undefined) { return project; } const currentStarState = Boolean(project.isStarred); if (currentStarState === optimisticStarState) { return project; } return { ...project, isStarred: optimisticStarState, }; }); }, [optimisticStarByProjectId, projects]); const sortedProjects = useMemo( () => sortProjects(projectsWithResolvedStarState, projectSortOrder), [projectSortOrder, projectsWithResolvedStarState], ); const filteredProjects = useMemo( () => filterProjects(sortedProjects, debouncedSearchQuery), [debouncedSearchQuery, sortedProjects], ); const startEditing = useCallback((project: Project) => { // `editingProject` is keyed by projectId so it stays stable across // display-name mutations that happen while the input is open. setEditingProject(project.projectId); setEditingName(project.displayName); }, []); const cancelEditing = useCallback(() => { setEditingProject(null); setEditingName(''); }, []); const saveProjectName = useCallback( // `projectId` is the DB primary key; the rename API resolves the path // through the `projects` table before writing the new display name. async (projectId: string) => { try { const response = await api.renameProject(projectId, editingName); if (response.ok) { await paletteOps.refreshProjects(); } else { console.error('Failed to rename project'); } } catch (error) { console.error('Error renaming project:', error); } finally { setEditingProject(null); setEditingName(''); } }, [editingName, paletteOps], ); const showDeleteSessionConfirmation = useCallback( // Kept with project/provider arguments for component wiring compatibility; // deletion now uses only `sessionId` via /api/providers/sessions/:sessionId. ( projectId: string, sessionId: string, sessionTitle: string, provider: SessionDeleteConfirmation['provider'] = 'claude', ) => { setSessionDeleteConfirmation({ projectId, sessionId, sessionTitle, provider }); }, [], ); const confirmDeleteSession = useCallback(async () => { if (!sessionDeleteConfirmation) { return; } const { sessionId } = sessionDeleteConfirmation; setSessionDeleteConfirmation(null); try { const response = await api.deleteSession(sessionId); if (response.ok) { onSessionDelete?.(sessionId); } else { const errorText = await response.text(); console.error('[Sidebar] Failed to delete session:', { status: response.status, error: errorText, }); alert(t('messages.deleteSessionFailed')); } } catch (error) { console.error('[Sidebar] Error deleting session:', error); alert(t('messages.deleteSessionError')); } }, [onSessionDelete, sessionDeleteConfirmation, t]); const requestProjectDelete = useCallback( (project: Project) => { setDeleteConfirmation({ project, sessionCount: getProjectSessions(project).length, }); }, [getProjectSessions], ); const confirmDeleteProject = useCallback(async (deleteData = false) => { if (!deleteConfirmation) { return; } const { project } = deleteConfirmation; setDeleteConfirmation(null); // Track in-flight deletes by projectId so the UI can disable actions // even if the project object is rebuilt while the request is flying. setDeletingProjects((prev) => new Set([...prev, project.projectId])); try { const response = await api.deleteProject(project.projectId, deleteData); if (response.ok) { onProjectDelete?.(project.projectId); } else { const data = (await response.json()) as { error?: string | { message?: string } }; const err = data.error; const message = typeof err === 'string' ? err : err && typeof err === 'object' && err.message ? err.message : t('messages.deleteProjectFailed'); alert(message); } } catch (error) { console.error('Error deleting project:', error); alert(t('messages.deleteProjectError')); } finally { setDeletingProjects((prev) => { const next = new Set(prev); next.delete(project.projectId); return next; }); } }, [deleteConfirmation, onProjectDelete, t]); const handleProjectSelect = useCallback( (project: Project) => { onProjectSelect(project); setCurrentProject(project); }, [onProjectSelect, setCurrentProject], ); const refreshProjects = useCallback(async () => { setIsRefreshing(true); try { await onRefresh(); } finally { setIsRefreshing(false); } }, [onRefresh]); const updateSessionSummary = useCallback( // `_projectId` and `_provider` are preserved for compatibility with // existing sidebar callback signatures; backend rename only needs sessionId. async (_projectId: string, sessionId: string, summary: string, _provider: LLMProvider) => { const trimmed = summary.trim(); if (!trimmed) { setEditingSession(null); setEditingSessionName(''); return; } try { const response = await api.renameSession(sessionId, trimmed); if (response.ok) { await onRefresh(); } else { console.error('[Sidebar] Failed to rename session:', response.status); alert(t('messages.renameSessionFailed')); } } catch (error) { console.error('[Sidebar] Error renaming session:', error); alert(t('messages.renameSessionError')); } finally { setEditingSession(null); setEditingSessionName(''); } }, [onRefresh, t], ); const collapseSidebar = useCallback(() => { setSidebarVisible(false); }, [setSidebarVisible]); const expandSidebar = useCallback(() => { setSidebarVisible(true); }, [setSidebarVisible]); return { isSidebarCollapsed, expandedProjects, editingProject, showNewProject, editingName, initialSessionsLoaded, currentTime, projectSortOrder, isRefreshing, editingSession, editingSessionName, searchFilter, deletingProjects, loadingMoreProjects, deleteConfirmation, sessionDeleteConfirmation, showVersionModal, filteredProjects, toggleProject, handleSessionClick, toggleStarProject, isProjectStarred, getProjectSessions, loadMoreSessionsForProject, startEditing, cancelEditing, saveProjectName, showDeleteSessionConfirmation, confirmDeleteSession, requestProjectDelete, confirmDeleteProject, handleProjectSelect, refreshProjects, updateSessionSummary, collapseSidebar, expandSidebar, setShowNewProject, setEditingName, setEditingSession, setEditingSessionName, searchMode, setSearchMode, conversationResults, isSearching, searchProgress, clearConversationResults: useCallback(() => { searchSeqRef.current += 1; if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } setIsSearching(false); setSearchProgress(null); setConversationResults(null); }, []), setSearchFilter, setDeleteConfirmation, setSessionDeleteConfirmation, setShowVersionModal, }; }