import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { NavigateFunction } from 'react-router-dom'; import { api } from '../utils/api'; import type { AppSocketMessage, AppTab, LoadingProgress, Project, ProjectSession, ProjectsUpdatedMessage, } from '../types/app'; type UseProjectsStateArgs = { sessionId?: string; navigate: NavigateFunction; latestMessage: AppSocketMessage | null; isMobile: boolean; activeSessions: Set; }; 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.name !== prevProject.name || nextProject.displayName !== prevProject.displayName || nextProject.fullPath !== prevProject.fullPath || serialize(nextProject.sessionMeta) !== serialize(prevProject.sessionMeta) || serialize(nextProject.sessions) !== serialize(prevProject.sessions); if (baseChanged) { return true; } if (!includeExternalSessions) { return false; } return ( serialize(nextProject.cursorSessions) !== serialize(prevProject.cursorSessions) || serialize(nextProject.codexSessions) !== serialize(prevProject.codexSessions) ); }); }; const getProjectSessions = (project: Project): ProjectSession[] => { return [ ...(project.sessions ?? []), ...(project.codexSessions ?? []), ...(project.cursorSessions ?? []), ]; }; const isUpdateAdditive = ( currentProjects: Project[], updatedProjects: Project[], selectedProject: Project | null, selectedSession: ProjectSession | null, ): boolean => { if (!selectedProject || !selectedSession) { return true; } const currentSelectedProject = currentProjects.find((project) => project.name === selectedProject.name); const updatedSelectedProject = updatedProjects.find((project) => project.name === selectedProject.name); if (!currentSelectedProject || !updatedSelectedProject) { return false; } const currentSelectedSession = getProjectSessions(currentSelectedProject).find( (session) => session.id === selectedSession.id, ); const updatedSelectedSession = getProjectSessions(updatedSelectedProject).find( (session) => session.id === selectedSession.id, ); if (!currentSelectedSession || !updatedSelectedSession) { return false; } return ( currentSelectedSession.id === updatedSelectedSession.id && currentSelectedSession.title === updatedSelectedSession.title && currentSelectedSession.created_at === updatedSelectedSession.created_at && currentSelectedSession.updated_at === updatedSelectedSession.updated_at ); }; export function useProjectsState({ sessionId, navigate, latestMessage, isMobile, activeSessions, }: UseProjectsStateArgs) { const [projects, setProjects] = useState([]); const [selectedProject, setSelectedProject] = useState(null); const [selectedSession, setSelectedSession] = useState(null); const [activeTab, setActiveTab] = useState('chat'); 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); const loadingProgressTimeoutRef = useRef | null>(null); const fetchProjects = useCallback(async () => { try { setIsLoadingProjects(true); const response = await api.projects(); const projectData = (await response.json()) as Project[]; setProjects((prevProjects) => { if (prevProjects.length === 0) { return projectData; } return projectsHaveChanges(prevProjects, projectData, true) ? projectData : prevProjects; }); } catch (error) { console.error('Error fetching projects:', error); } finally { setIsLoadingProjects(false); } }, []); const openSettings = useCallback((tab = 'tools') => { setSettingsInitialTab(tab); setShowSettings(true); }, []); useEffect(() => { void fetchProjects(); }, [fetchProjects]); useEffect(() => { if (!latestMessage) { return; } if (latestMessage.type === 'loading_progress') { if (loadingProgressTimeoutRef.current) { clearTimeout(loadingProgressTimeoutRef.current); loadingProgressTimeoutRef.current = null; } setLoadingProgress(latestMessage as LoadingProgress); if (latestMessage.phase === 'complete') { loadingProgressTimeoutRef.current = setTimeout(() => { setLoadingProgress(null); loadingProgressTimeoutRef.current = null; }, 500); } return; } if (latestMessage.type !== 'projects_updated') { return; } const projectsMessage = latestMessage as ProjectsUpdatedMessage; if (projectsMessage.changedFile && selectedSession && selectedProject) { const normalized = projectsMessage.changedFile.replace(/\\/g, '/'); const changedFileParts = normalized.split('/'); if (changedFileParts.length >= 2) { const filename = changedFileParts[changedFileParts.length - 1]; const changedSessionId = filename.replace('.jsonl', ''); if (changedSessionId === selectedSession.id) { const isSessionActive = activeSessions.has(selectedSession.id); if (!isSessionActive) { setExternalMessageUpdate((prev) => prev + 1); } } } } const hasActiveSession = (selectedSession && activeSessions.has(selectedSession.id)) || (activeSessions.size > 0 && Array.from(activeSessions).some((id) => id.startsWith('new-session-'))); const updatedProjects = projectsMessage.projects; if ( hasActiveSession && !isUpdateAdditive(projects, updatedProjects, selectedProject, selectedSession) ) { return; } setProjects(updatedProjects); if (!selectedProject) { return; } const updatedSelectedProject = updatedProjects.find( (project) => project.name === selectedProject.name, ); if (!updatedSelectedProject) { return; } if (serialize(updatedSelectedProject) !== serialize(selectedProject)) { setSelectedProject(updatedSelectedProject); } if (!selectedSession) { return; } const updatedSelectedSession = getProjectSessions(updatedSelectedProject).find( (session) => session.id === selectedSession.id, ); if (!updatedSelectedSession) { setSelectedSession(null); } }, [latestMessage, selectedProject, selectedSession, activeSessions, projects]); useEffect(() => { return () => { if (loadingProgressTimeoutRef.current) { clearTimeout(loadingProgressTimeoutRef.current); loadingProgressTimeoutRef.current = null; } }; }, []); useEffect(() => { if (!sessionId || projects.length === 0) { return; } const shouldSwitchTab = !selectedSession || selectedSession.id !== sessionId; for (const project of projects) { const claudeSession = project.sessions?.find((session) => session.id === sessionId); if (claudeSession) { const shouldUpdateProject = selectedProject?.name !== project.name; const shouldUpdateSession = selectedSession?.id !== sessionId || selectedSession.__provider !== 'claude'; if (shouldUpdateProject) { setSelectedProject(project); } if (shouldUpdateSession) { setSelectedSession({ ...claudeSession, __provider: 'claude' }); } if (shouldSwitchTab) { setActiveTab('chat'); } return; } const cursorSession = project.cursorSessions?.find((session) => session.id === sessionId); if (cursorSession) { const shouldUpdateProject = selectedProject?.name !== project.name; const shouldUpdateSession = selectedSession?.id !== sessionId || selectedSession.__provider !== 'cursor'; if (shouldUpdateProject) { setSelectedProject(project); } if (shouldUpdateSession) { setSelectedSession({ ...cursorSession, __provider: 'cursor' }); } if (shouldSwitchTab) { setActiveTab('chat'); } return; } const codexSession = project.codexSessions?.find((session) => session.id === sessionId); if (codexSession) { const shouldUpdateProject = selectedProject?.name !== project.name; const shouldUpdateSession = selectedSession?.id !== sessionId || selectedSession.__provider !== 'codex'; if (shouldUpdateProject) { setSelectedProject(project); } if (shouldUpdateSession) { setSelectedSession({ ...codexSession, __provider: 'codex' }); } if (shouldSwitchTab) { setActiveTab('chat'); } return; } } }, [sessionId, projects, selectedProject?.name, 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 !== 'git' && activeTab !== 'preview') { setActiveTab('chat'); } const provider = localStorage.getItem('selected-provider') || 'claude'; if (provider === 'cursor') { sessionStorage.setItem('cursorSessionId', session.id); } if (isMobile) { const sessionProjectName = session.__projectName; const currentProjectName = selectedProject?.name; if (sessionProjectName !== currentProjectName) { setSidebarOpen(false); } } navigate(`/session/${session.id}`); }, [activeTab, isMobile, navigate, selectedProject?.name], ); const handleNewSession = useCallback( (project: Project) => { setSelectedProject(project); setSelectedSession(null); setActiveTab('chat'); navigate('/'); if (isMobile) { setSidebarOpen(false); } }, [isMobile, navigate], ); const handleSessionDelete = useCallback( (sessionIdToDelete: string) => { if (selectedSession?.id === sessionIdToDelete) { setSelectedSession(null); navigate('/'); } setProjects((prevProjects) => prevProjects.map((project) => ({ ...project, sessions: project.sessions?.filter((session) => session.id !== sessionIdToDelete) ?? [], sessionMeta: { ...project.sessionMeta, total: Math.max(0, (project.sessionMeta?.total as number | undefined ?? 0) - 1), }, })), ); }, [navigate, selectedSession?.id], ); const handleSidebarRefresh = useCallback(async () => { try { const response = await api.projects(); const freshProjects = (await response.json()) as Project[]; setProjects((prevProjects) => projectsHaveChanges(prevProjects, freshProjects, true) ? freshProjects : prevProjects, ); if (!selectedProject) { return; } const refreshedProject = freshProjects.find((project) => project.name === selectedProject.name); 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); } }, [selectedProject, selectedSession]); const handleProjectDelete = useCallback( (projectName: string) => { if (selectedProject?.name === projectName) { setSelectedProject(null); setSelectedSession(null); navigate('/'); } setProjects((prevProjects) => prevProjects.filter((project) => project.name !== projectName)); }, [navigate, selectedProject?.name], ); const sidebarSharedProps = useMemo( () => ({ projects, selectedProject, selectedSession, onProjectSelect: handleProjectSelect, onSessionSelect: handleSessionSelect, onNewSession: handleNewSession, onSessionDelete: handleSessionDelete, onProjectDelete: handleProjectDelete, isLoading: isLoadingProjects, loadingProgress, onRefresh: handleSidebarRefresh, onShowSettings: () => setShowSettings(true), showSettings, settingsInitialTab, onCloseSettings: () => setShowSettings(false), isMobile, }), [ handleNewSession, handleProjectDelete, handleProjectSelect, handleSessionDelete, handleSessionSelect, handleSidebarRefresh, isLoadingProjects, isMobile, loadingProgress, projects, settingsInitialTab, selectedProject, selectedSession, showSettings, ], ); return { projects, selectedProject, selectedSession, activeTab, sidebarOpen, isLoadingProjects, loadingProgress, isInputFocused, showSettings, settingsInitialTab, externalMessageUpdate, setActiveTab, setSidebarOpen, setIsInputFocused, setShowSettings, openSettings, fetchProjects, sidebarSharedProps, handleProjectSelect, handleSessionSelect, handleNewSession, handleSessionDelete, handleProjectDelete, handleSidebarRefresh, }; }