mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-14 12:47:33 +00:00
518 lines
15 KiB
TypeScript
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,
|
|
};
|
|
}
|