import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { NavigateFunction } from 'react-router-dom'; import { api } from '../utils/api'; import type { ServerEvent } from '../contexts/WebSocketContext'; import type { AppTab, LLMProvider, LoadingProgress, Project, ProjectSession, } from '../types/app'; import type { SessionActivityMap } from './useSessionProtection'; type UseProjectsStateArgs = { sessionId?: string; navigate: NavigateFunction; /** Subscription to the unified websocket event stream. */ subscribe: (listener: (event: ServerEvent) => void) => () => void; isMobile: boolean; activeSessions: SessionActivityMap; }; /** * Shape of the per-session sidebar delta broadcast by the backend file * watcher (`kind: session_upserted`). It carries everything needed to upsert * one session row in place — no full project-list snapshot is ever pushed. */ type SessionUpsertedEvent = ServerEvent & { sessionId: string; provider: LLMProvider; session: ProjectSession; project: { projectId: string; path: string; fullPath: string; displayName: string; isStarred: boolean; } | null; }; type FetchProjectsOptions = { showLoadingState?: boolean; }; type RegisterOptimisticSessionArgs = { sessionId: string; provider: LLMProvider; project: Project; summary?: string | null; }; const serialize = (value: unknown) => JSON.stringify(value ?? null); const projectsHaveChanges = ( prevProjects: Project[], nextProjects: Project[], includeExternalSessions: boolean, ): boolean => { if (prevProjects.length !== nextProjects.length) { return true; } return nextProjects.some((nextProject, index) => { const prevProject = prevProjects[index]; if (!prevProject) { return true; } const baseChanged = nextProject.projectId !== prevProject.projectId || nextProject.displayName !== prevProject.displayName || nextProject.fullPath !== prevProject.fullPath || Boolean(nextProject.isStarred) !== Boolean(prevProject.isStarred) || serialize(nextProject.sessionMeta) !== serialize(prevProject.sessionMeta) || serialize(nextProject.sessions) !== serialize(prevProject.sessions) || serialize(nextProject.taskmaster) !== serialize(prevProject.taskmaster); if (baseChanged) { return true; } if (!includeExternalSessions) { return false; } return ( serialize(nextProject.cursorSessions) !== serialize(prevProject.cursorSessions) || serialize(nextProject.codexSessions) !== serialize(prevProject.codexSessions) || serialize(nextProject.geminiSessions) !== serialize(prevProject.geminiSessions) || serialize(nextProject.opencodeSessions) !== serialize(prevProject.opencodeSessions) ); }); }; const mergeTaskMasterCache = (nextProjects: Project[], previousProjects: Project[]): Project[] => { if (previousProjects.length === 0) { return nextProjects; } // Keyed by `projectId` (the DB primary key) so caches stay correct across // renames and other mutations that might have changed the display name. const previousTaskMasterByProject = new Map( previousProjects .filter((project) => Boolean(project.taskmaster)) .map((project) => [project.projectId, project.taskmaster]), ); return nextProjects.map((project) => { const cachedTaskMasterInfo = previousTaskMasterByProject.get(project.projectId); if (!cachedTaskMasterInfo) { return project; } return { ...project, taskmaster: cachedTaskMasterInfo, }; }); }; const getProjectSessions = (project: Project): ProjectSession[] => { return [ ...(project.sessions ?? []), ...(project.codexSessions ?? []), ...(project.cursorSessions ?? []), ...(project.geminiSessions ?? []), ...(project.opencodeSessions ?? []), ]; }; const countLoadedProjectSessions = (project: Project): number => getProjectSessions(project).length; const mergeSessionProviderLists = (baseSessions: ProjectSession[], additionalSessions: ProjectSession[]): ProjectSession[] => { const merged = [...baseSessions]; const seenSessionIds = new Set(baseSessions.map((session) => String(session.id))); for (const session of additionalSessions) { const sessionId = String(session.id); if (seenSessionIds.has(sessionId)) { continue; } merged.push(session); seenSessionIds.add(sessionId); } return merged; }; const mergeExpandedSessionPages = (previousProjects: Project[], incomingProjects: Project[]): Project[] => { if (previousProjects.length === 0) { return incomingProjects; } const previousByProjectId = new Map(previousProjects.map((project) => [project.projectId, project])); return incomingProjects.map((incomingProject) => { const previousProject = previousByProjectId.get(incomingProject.projectId); if (!previousProject) { return incomingProject; } const previousLoadedCount = countLoadedProjectSessions(previousProject); const incomingLoadedCount = countLoadedProjectSessions(incomingProject); if (previousLoadedCount <= incomingLoadedCount) { return incomingProject; } const mergedProject: Project = { ...incomingProject, sessions: mergeSessionProviderLists(incomingProject.sessions ?? [], previousProject.sessions ?? []), cursorSessions: mergeSessionProviderLists(incomingProject.cursorSessions ?? [], previousProject.cursorSessions ?? []), codexSessions: mergeSessionProviderLists(incomingProject.codexSessions ?? [], previousProject.codexSessions ?? []), geminiSessions: mergeSessionProviderLists(incomingProject.geminiSessions ?? [], previousProject.geminiSessions ?? []), opencodeSessions: mergeSessionProviderLists(incomingProject.opencodeSessions ?? [], previousProject.opencodeSessions ?? []), }; const totalSessions = Number(incomingProject.sessionMeta?.total ?? previousLoadedCount); mergedProject.sessionMeta = { ...incomingProject.sessionMeta, total: totalSessions, hasMore: countLoadedProjectSessions(mergedProject) < totalSessions, }; return mergedProject; }); }; const mergeProjectSessionPage = ( existingProject: Project, sessionsPage: Pick, ): Project => { const mergedProject: Project = { ...existingProject, sessions: mergeSessionProviderLists(existingProject.sessions ?? [], sessionsPage.sessions ?? []), cursorSessions: mergeSessionProviderLists(existingProject.cursorSessions ?? [], sessionsPage.cursorSessions ?? []), codexSessions: mergeSessionProviderLists(existingProject.codexSessions ?? [], sessionsPage.codexSessions ?? []), geminiSessions: mergeSessionProviderLists(existingProject.geminiSessions ?? [], sessionsPage.geminiSessions ?? []), opencodeSessions: mergeSessionProviderLists(existingProject.opencodeSessions ?? [], sessionsPage.opencodeSessions ?? []), }; const totalSessions = Number(sessionsPage.sessionMeta?.total ?? existingProject.sessionMeta?.total ?? 0); mergedProject.sessionMeta = { ...existingProject.sessionMeta, ...sessionsPage.sessionMeta, total: totalSessions, hasMore: countLoadedProjectSessions(mergedProject) < totalSessions, }; return mergedProject; }; /** * Resolves which provider bucket on a `Project` holds sessions for a provider. * The legacy payload keeps Claude sessions in `sessions` and the other * providers in their own arrays. */ const providerBucketKey = ( provider: LLMProvider, ): 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' => { if (provider === 'cursor') return 'cursorSessions'; if (provider === 'codex') return 'codexSessions'; if (provider === 'gemini') return 'geminiSessions'; if (provider === 'opencode') return 'opencodeSessions'; return 'sessions'; }; /** * Upserts one session into the matching provider bucket of a project. * * Existing rows are updated in place (summary/lastActivity changes from the * watcher); new rows are prepended since the watcher only fires for sessions * with fresh activity. `sessionMeta.total` grows only on insert. */ const upsertSessionIntoProject = (project: Project, event: SessionUpsertedEvent): Project => { const bucketKey = providerBucketKey(event.provider); const bucket = project[bucketKey] ?? []; const existingIndex = bucket.findIndex((session) => session.id === event.sessionId); let nextBucket: ProjectSession[]; if (existingIndex >= 0) { const existing = bucket[existingIndex]; const updated = { ...existing, ...event.session }; if (serialize(existing) === serialize(updated)) { return project; } nextBucket = [...bucket]; nextBucket[existingIndex] = updated; } else { nextBucket = [event.session, ...bucket]; } const next: Project = { ...project, [bucketKey]: nextBucket }; if (existingIndex < 0) { const total = Number(project.sessionMeta?.total ?? 0) + 1; next.sessionMeta = { ...project.sessionMeta, total, hasMore: countLoadedProjectSessions(next) < total, }; } return next; }; const projectFromRegistration = (project: Project): Project => ({ projectId: project.projectId, path: project.path || project.fullPath, fullPath: project.fullPath || project.path || '', displayName: project.displayName, isStarred: project.isStarred, sessions: project.sessions ?? [], cursorSessions: project.cursorSessions ?? [], codexSessions: project.codexSessions ?? [], geminiSessions: project.geminiSessions ?? [], opencodeSessions: project.opencodeSessions ?? [], sessionMeta: project.sessionMeta ?? { hasMore: false, total: countLoadedProjectSessions(project) }, taskmaster: project.taskmaster, }); const VALID_TABS: Set = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'preview']); const isValidTab = (tab: string): tab is AppTab => { return VALID_TABS.has(tab) || tab.startsWith('plugin:'); }; const readPersistedTab = (): AppTab => { try { const stored = localStorage.getItem('activeTab'); if (stored && isValidTab(stored)) { return stored as AppTab; } } catch { // localStorage unavailable } return 'chat'; }; export function useProjectsState({ sessionId, navigate, subscribe, isMobile, activeSessions, }: UseProjectsStateArgs) { const [projects, setProjects] = useState([]); const [selectedProject, setSelectedProject] = useState(null); const [selectedSession, setSelectedSession] = useState(null); const [activeTab, setActiveTab] = useState(readPersistedTab); useEffect(() => { try { localStorage.setItem('activeTab', activeTab); } catch { // Silently ignore storage errors } }, [activeTab]); const [sidebarOpen, setSidebarOpen] = useState(false); const [isLoadingProjects, setIsLoadingProjects] = useState(true); const [loadingProgress, setLoadingProgress] = useState(null); const [isInputFocused, setIsInputFocused] = useState(false); const [showSettings, setShowSettings] = useState(false); const [settingsInitialTab, setSettingsInitialTab] = useState('agents'); const [externalMessageUpdate, setExternalMessageUpdate] = useState(0); /** * `newSessionTrigger` is an explicit, monotonic intent signal for user-driven * New Session actions. * * It exists because `handleNewSession` can be invoked while the app is already in * the same visible state (`selectedSession === null`, `activeTab === 'chat'`, * route already `/`). In that case, React/router updates are idempotent and no * downstream reset logic runs. * * Usage across the codebase: * 1) Produced here in `handleNewSession` via increment (always changes). * 2) Returned from this hook and threaded through: * useProjectsState -> AppContent -> MainContent -> ChatInterface. * 3) Consumed in `useChatSessionState` as an effect dependency to forcibly clear * chat-local state (`currentSessionId`, pending draft message, streaming flags, * pending session storage keys, pagination/scroll artifacts). * * Keeping this signal dedicated avoids coupling resets to unrelated counters/events * (for example websocket/project refresh updates) that could cause accidental resets. */ const [newSessionTrigger, setNewSessionTrigger] = useState(0); const loadingProgressTimeoutRef = useRef | null>(null); /** * Ref mirrors for state the websocket subscription handler needs. * * The subscription is registered once (per `subscribe` identity) and events * are dispatched synchronously outside React's render cycle, so the handler * must read the latest values through refs instead of stale closures — * re-subscribing on every state change would risk missing events. */ const selectedSessionRef = useRef(selectedSession); selectedSessionRef.current = selectedSession; const activeSessionsRef = useRef(activeSessions); activeSessionsRef.current = activeSessions; const fetchProjects = useCallback(async ({ showLoadingState = true }: FetchProjectsOptions = {}) => { try { if (showLoadingState) { setIsLoadingProjects(true); } const response = await api.projects(); const projectData = (await response.json()) as Project[]; setProjects((prevProjects) => { const projectsWithTaskMaster = mergeTaskMasterCache(projectData, prevProjects); const mergedProjects = mergeExpandedSessionPages(prevProjects, projectsWithTaskMaster); if (prevProjects.length === 0) { return mergedProjects; } return projectsHaveChanges(prevProjects, mergedProjects, true) ? mergedProjects : prevProjects; }); } catch (error) { console.error('Error fetching projects:', error); } finally { if (showLoadingState) { setIsLoadingProjects(false); } } }, []); const refreshProjectsSilently = useCallback(async () => { // Keep chat view stable while still syncing sidebar/session metadata in background. await fetchProjects({ showLoadingState: false }); }, [fetchProjects]); const registerOptimisticSession = useCallback(({ sessionId: newSessionId, provider, project, summary, }: RegisterOptimisticSessionArgs) => { if (!newSessionId || !project?.projectId) { return; } const now = new Date().toISOString(); const optimisticSession: ProjectSession = { id: newSessionId, summary: summary ?? '', messageCount: 0, createdAt: now, created_at: now, updated_at: now, lastActivity: now, __provider: provider, __projectId: project.projectId, }; const upsert: SessionUpsertedEvent = { kind: 'session_upserted', sessionId: newSessionId, provider, session: optimisticSession, project: { projectId: project.projectId, path: project.path || project.fullPath, fullPath: project.fullPath || project.path || '', displayName: project.displayName, isStarred: Boolean(project.isStarred), }, timestamp: now, }; setProjects((previousProjects) => { const existingProject = previousProjects.find((candidate) => candidate.projectId === project.projectId); if (!existingProject) { return [upsertSessionIntoProject(projectFromRegistration(project), upsert), ...previousProjects]; } const updatedProject = upsertSessionIntoProject(existingProject, upsert); if (updatedProject === existingProject) { return previousProjects; } return previousProjects.map((candidate) => candidate.projectId === existingProject.projectId ? updatedProject : candidate, ); }); setSelectedProject((previousProject) => { if (!previousProject || previousProject.projectId !== project.projectId) { return previousProject; } const updatedProject = upsertSessionIntoProject(previousProject, upsert); return updatedProject === previousProject ? previousProject : updatedProject; }); setSelectedSession((previousSession) => ( previousSession?.id === newSessionId ? { ...previousSession, ...optimisticSession } : optimisticSession )); }, []); // Hydrates TaskMaster details for the given `projectId`. The project // identifier comes directly from the DB-driven /api/projects response. const hydrateProjectTaskMaster = useCallback(async (projectId: string) => { if (!projectId) { return; } try { const response = await api.projectTaskmaster(projectId); if (!response.ok) { return; } const data = (await response.json()) as { taskmaster?: Project['taskmaster'] }; const taskMasterInfo = data.taskmaster; if (!taskMasterInfo) { return; } setProjects((previousProjects) => previousProjects.map((project) => project.projectId === projectId ? { ...project, taskmaster: taskMasterInfo } : project, ), ); setSelectedProject((previousProject) => { if (!previousProject || previousProject.projectId !== projectId) { return previousProject; } return { ...previousProject, taskmaster: taskMasterInfo, }; }); } catch (error) { console.error(`Error fetching TaskMaster info for project ${projectId}:`, error); } }, []); const openSettings = useCallback((tab = 'tools') => { setSettingsInitialTab(tab); setShowSettings(true); }, []); useEffect(() => { void fetchProjects(); }, [fetchProjects]); useEffect(() => { if (!selectedProject?.projectId) { return; } void hydrateProjectTaskMaster(selectedProject.projectId); }, [hydrateProjectTaskMaster, selectedProject?.projectId]); // Auto-select the project when there is only one, so the user lands on the new session page useEffect(() => { if (!isLoadingProjects && projects.length === 1 && !selectedProject && !sessionId) { setSelectedProject(projects[0]); } }, [isLoadingProjects, projects, selectedProject, sessionId]); // Realtime sidebar updates. The backend pushes per-session deltas // (`session_upserted`) instead of full project snapshots, so each event is // a keyed upsert that can never clobber unrelated client state — no // "suppress updates while a run is active" protection is needed anymore. useEffect(() => { const handleEvent = (event: ServerEvent) => { if (event.kind === 'loading_progress') { if (loadingProgressTimeoutRef.current) { clearTimeout(loadingProgressTimeoutRef.current); loadingProgressTimeoutRef.current = null; } setLoadingProgress(event as unknown as LoadingProgress); if (event.phase === 'complete') { loadingProgressTimeoutRef.current = setTimeout(() => { setLoadingProgress(null); loadingProgressTimeoutRef.current = null; }, 500); } return; } if (event.kind !== 'session_upserted') { return; } const upsert = event as SessionUpsertedEvent; if (!upsert.sessionId || !upsert.session) { return; } // The transcript of the currently viewed session changed on disk while // no run is active here (e.g. edited from another client or the CLI): // signal the chat view to reload its messages. const currentSelectedSession = selectedSessionRef.current; if ( currentSelectedSession && upsert.sessionId === currentSelectedSession.id && !activeSessionsRef.current.has(upsert.sessionId) ) { setExternalMessageUpdate((prev) => prev + 1); } setProjects((previousProjects) => { const targetProjectId = upsert.project?.projectId; const existingProject = previousProjects.find((project) => targetProjectId ? project.projectId === targetProjectId : getProjectSessions(project).some((session) => session.id === upsert.sessionId), ); if (!existingProject) { // First session of a project this client has never seen: create the // project entry from the event payload. if (!upsert.project) { return previousProjects; } const newProject: Project = { projectId: upsert.project.projectId, path: upsert.project.path, fullPath: upsert.project.fullPath, displayName: upsert.project.displayName, isStarred: upsert.project.isStarred, sessions: [], cursorSessions: [], codexSessions: [], geminiSessions: [], opencodeSessions: [], sessionMeta: { hasMore: false, total: 0 }, } as Project; return [...previousProjects, upsertSessionIntoProject(newProject, upsert)]; } const updatedProject = upsertSessionIntoProject(existingProject, upsert); if (updatedProject === existingProject) { return previousProjects; } return previousProjects.map((project) => project.projectId === existingProject.projectId ? updatedProject : project, ); }); // Keep the selected project reference in sync with the upsert. setSelectedProject((previousProject) => { if (!previousProject) { return previousProject; } const matches = upsert.project ? previousProject.projectId === upsert.project.projectId : getProjectSessions(previousProject).some((session) => session.id === upsert.sessionId); if (!matches) { return previousProject; } const updated = upsertSessionIntoProject(previousProject, upsert); return updated === previousProject ? previousProject : updated; }); }; return subscribe(handleEvent); }, [subscribe]); useEffect(() => { return () => { if (loadingProgressTimeoutRef.current) { clearTimeout(loadingProgressTimeoutRef.current); loadingProgressTimeoutRef.current = null; } }; }, []); useEffect(() => { if (!sessionId || projects.length === 0) { return; } // Project membership is resolved through `projectId` after the migration. for (const project of projects) { const claudeSession = project.sessions?.find((session) => session.id === sessionId); if (claudeSession) { const shouldUpdateProject = selectedProject?.projectId !== project.projectId; const shouldUpdateSession = selectedSession?.id !== sessionId || selectedSession.__provider !== 'claude'; if (shouldUpdateProject) { setSelectedProject(project); } if (shouldUpdateSession) { setSelectedSession({ ...claudeSession, __provider: 'claude' }); } return; } const cursorSession = project.cursorSessions?.find((session) => session.id === sessionId); if (cursorSession) { const shouldUpdateProject = selectedProject?.projectId !== project.projectId; const shouldUpdateSession = selectedSession?.id !== sessionId || selectedSession.__provider !== 'cursor'; if (shouldUpdateProject) { setSelectedProject(project); } if (shouldUpdateSession) { setSelectedSession({ ...cursorSession, __provider: 'cursor' }); } return; } const codexSession = project.codexSessions?.find((session) => session.id === sessionId); if (codexSession) { const shouldUpdateProject = selectedProject?.projectId !== project.projectId; const shouldUpdateSession = selectedSession?.id !== sessionId || selectedSession.__provider !== 'codex'; if (shouldUpdateProject) { setSelectedProject(project); } if (shouldUpdateSession) { setSelectedSession({ ...codexSession, __provider: 'codex' }); } return; } const geminiSession = project.geminiSessions?.find((session) => session.id === sessionId); if (geminiSession) { const shouldUpdateProject = selectedProject?.projectId !== project.projectId; const shouldUpdateSession = selectedSession?.id !== sessionId || selectedSession.__provider !== 'gemini'; if (shouldUpdateProject) { setSelectedProject(project); } if (shouldUpdateSession) { setSelectedSession({ ...geminiSession, __provider: 'gemini' }); } return; } const opencodeSession = project.opencodeSessions?.find((session) => session.id === sessionId); if (opencodeSession) { const shouldUpdateProject = selectedProject?.projectId !== project.projectId; const shouldUpdateSession = selectedSession?.id !== sessionId || selectedSession.__provider !== 'opencode'; if (shouldUpdateProject) { setSelectedProject(project); } if (shouldUpdateSession) { setSelectedSession({ ...opencodeSession, __provider: 'opencode' }); } return; } } // Session id is in the URL but not yet present on any project payload // (normal for a brand-new conversation: the composer allocates the id and // navigates before the sidebar learns about the session via // `session_upserted`). Without a `selectedSession`, chat state clears // `currentSessionId` and the UI stops reading the session store even // though messages stream under this id — so synthesize a placeholder. if (selectedSession?.id === sessionId) { return; } if (!selectedProject) { return; } let providerFromStorage: string | null = null; try { providerFromStorage = localStorage.getItem('selected-provider'); } catch { providerFromStorage = null; } const normalizedProvider: LLMProvider = providerFromStorage === 'cursor' ? 'cursor' : providerFromStorage === 'codex' ? 'codex' : providerFromStorage === 'gemini' ? 'gemini' : providerFromStorage === 'opencode' ? 'opencode' : 'claude'; setSelectedSession({ id: sessionId, __provider: normalizedProvider, __projectId: selectedProject.projectId, summary: '', }); }, [sessionId, projects, selectedProject, selectedSession?.id, selectedSession?.__provider]); const handleProjectSelect = useCallback( (project: Project) => { setSelectedProject(project); setSelectedSession(null); navigate('/'); if (isMobile) { setSidebarOpen(false); } }, [isMobile, navigate], ); const handleSessionSelect = useCallback( (session: ProjectSession) => { setSelectedSession(session); if (activeTab === 'tasks' || activeTab === 'preview') { setActiveTab('chat'); } if (isMobile) { // Sessions are tagged with the owning project's DB `projectId` when // picked from the sidebar (see useSidebarController); compare against // the current selection's `projectId` so we know whether to collapse // the sidebar after navigation. const sessionProjectId = session.__projectId; const currentProjectId = selectedProject?.projectId; if (sessionProjectId !== currentProjectId) { setSidebarOpen(false); } } navigate(`/session/${session.id}`); }, [activeTab, isMobile, navigate, selectedProject?.projectId], ); const handleNewSession = useCallback( (project: Project) => { setSelectedProject(project); setSelectedSession(null); setActiveTab('chat'); setNewSessionTrigger((previous) => previous + 1); navigate('/'); if (isMobile) { setSidebarOpen(false); } }, [isMobile, navigate], ); const handleSessionDelete = useCallback( (sessionIdToDelete: string) => { if (selectedSession?.id === sessionIdToDelete) { setSelectedSession(null); navigate('/'); } setProjects((prevProjects) => prevProjects.map((project) => { const sessions = project.sessions?.filter((session) => session.id !== sessionIdToDelete) ?? []; const cursorSessions = project.cursorSessions?.filter((session) => session.id !== sessionIdToDelete) ?? []; const codexSessions = project.codexSessions?.filter((session) => session.id !== sessionIdToDelete) ?? []; const geminiSessions = project.geminiSessions?.filter((session) => session.id !== sessionIdToDelete) ?? []; const opencodeSessions = project.opencodeSessions?.filter((session) => session.id !== sessionIdToDelete) ?? []; const removedFromProject = ( sessions.length !== (project.sessions?.length ?? 0) || cursorSessions.length !== (project.cursorSessions?.length ?? 0) || codexSessions.length !== (project.codexSessions?.length ?? 0) || geminiSessions.length !== (project.geminiSessions?.length ?? 0) || opencodeSessions.length !== (project.opencodeSessions?.length ?? 0) ); if (!removedFromProject) { return project; } const updatedProject: Project = { ...project, sessions, cursorSessions, codexSessions, geminiSessions, opencodeSessions, }; const totalSessions = Math.max(0, Number(project.sessionMeta?.total ?? 0) - 1); updatedProject.sessionMeta = { ...project.sessionMeta, total: totalSessions, hasMore: countLoadedProjectSessions(updatedProject) < totalSessions, }; return updatedProject; }), ); }, [navigate, selectedSession?.id], ); const handleSidebarRefresh = useCallback(async () => { try { const response = await api.projects(); const freshProjects = (await response.json()) as Project[]; const projectsWithTaskMaster = mergeTaskMasterCache(freshProjects, projects); const mergedProjects = mergeExpandedSessionPages(projects, projectsWithTaskMaster); setProjects((prevProjects) => projectsHaveChanges(prevProjects, mergedProjects, true) ? mergedProjects : prevProjects, ); if (!selectedProject) { return; } const refreshedProject = mergedProjects.find((project) => project.projectId === selectedProject.projectId); if (!refreshedProject) { return; } if (serialize(refreshedProject) !== serialize(selectedProject)) { setSelectedProject(refreshedProject); } if (!selectedSession) { return; } const refreshedSession = getProjectSessions(refreshedProject).find( (session) => session.id === selectedSession.id, ); if (refreshedSession) { // Keep provider metadata stable when refreshed payload doesn't include __provider. const normalizedRefreshedSession = refreshedSession.__provider || !selectedSession.__provider ? refreshedSession : { ...refreshedSession, __provider: selectedSession.__provider }; if (serialize(normalizedRefreshedSession) !== serialize(selectedSession)) { setSelectedSession(normalizedRefreshedSession); } } } catch (error) { console.error('Error refreshing sidebar:', error); } }, [projects, selectedProject, selectedSession]); const loadMoreProjectSessions = useCallback(async (projectId: string) => { const project = projects.find((candidate) => candidate.projectId === projectId); if (!project) { return; } const loadedCount = countLoadedProjectSessions(project); const totalCount = Number(project.sessionMeta?.total ?? 0); if (totalCount > 0 && loadedCount >= totalCount) { return; } const response = await api.projectSessions(projectId, { limit: 20, offset: loadedCount, }); if (!response.ok) { const payload = (await response.json().catch(() => ({}))) as { error?: string | { message?: string } }; const errorPayload = payload.error; const message = typeof errorPayload === 'string' ? errorPayload : errorPayload && typeof errorPayload === 'object' && errorPayload.message ? errorPayload.message : `Failed to load more sessions for project ${projectId}`; throw new Error(message); } const sessionsPage = (await response.json()) as Pick; let mergedProjectForSelection: Project | null = null; setProjects((previousProjects) => previousProjects.map((candidate) => { if (candidate.projectId !== projectId) { return candidate; } const mergedProject = mergeProjectSessionPage(candidate, sessionsPage); mergedProjectForSelection = mergedProject; return mergedProject; }), ); if (selectedProject?.projectId === projectId && mergedProjectForSelection) { setSelectedProject(mergedProjectForSelection); } }, [projects, selectedProject?.projectId]); // `projectId` is the DB identifier passed from the sidebar's delete flow // after the migration away from folder-derived project names. const handleProjectDelete = useCallback( (projectId: string) => { if (selectedProject?.projectId === projectId) { setSelectedProject(null); setSelectedSession(null); navigate('/'); } setProjects((prevProjects) => prevProjects.filter((project) => project.projectId !== projectId)); }, [navigate, selectedProject?.projectId], ); const sidebarSharedProps = useMemo( () => ({ projects, selectedProject, selectedSession, activeSessions, onProjectSelect: handleProjectSelect, onSessionSelect: handleSessionSelect, onNewSession: handleNewSession, onSessionDelete: handleSessionDelete, onLoadMoreSessions: loadMoreProjectSessions, onProjectDelete: handleProjectDelete, isLoading: isLoadingProjects, loadingProgress, onRefresh: handleSidebarRefresh, onShowSettings: () => setShowSettings(true), showSettings, settingsInitialTab, onCloseSettings: () => setShowSettings(false), isMobile, }), [ handleNewSession, handleProjectDelete, handleProjectSelect, handleSessionDelete, loadMoreProjectSessions, handleSessionSelect, handleSidebarRefresh, isLoadingProjects, isMobile, loadingProgress, activeSessions, projects, settingsInitialTab, selectedProject, selectedSession, showSettings, ], ); return { projects, selectedProject, selectedSession, activeTab, sidebarOpen, isLoadingProjects, loadingProgress, isInputFocused, showSettings, settingsInitialTab, externalMessageUpdate, newSessionTrigger, setActiveTab, setSidebarOpen, setIsInputFocused, setShowSettings, openSettings, fetchProjects, refreshProjectsSilently, registerOptimisticSession, sidebarSharedProps, handleProjectSelect, handleSessionSelect, handleNewSession, handleSessionDelete, loadMoreProjectSessions, handleProjectDelete, handleSidebarRefresh, }; }