Files
claudecodeui/src/hooks/useProjectsState.ts

518 lines
15 KiB
TypeScript

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<string>;
};
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<Project[]>([]);
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [selectedSession, setSelectedSession] = useState<ProjectSession | null>(null);
const [activeTab, setActiveTab] = useState<AppTab>('chat');
const [sidebarOpen, setSidebarOpen] = useState(false);
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
const [loadingProgress, setLoadingProgress] = useState<LoadingProgress | null>(null);
const [isInputFocused, setIsInputFocused] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [settingsInitialTab, setSettingsInitialTab] = useState('agents');
const [externalMessageUpdate, setExternalMessageUpdate] = useState(0);
const loadingProgressTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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,
};
}