mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-11 00:47:52 +00:00
- This fixes an issue where the sidebar was showing 6+ even when there were only 5 sessions, due to the hasMore logic not accounting for the case where there are exactly 6 sessions. It was also showing "Show more sessions" even where there were no more sessions to load. - This was because `hasMore` was sometimes `undefined` and the logic checked for hasMore !== false, which treated undefined as true. Now we explicitly check for hasMore === true to determine if there are more sessions to load.
449 lines
13 KiB
TypeScript
449 lines
13 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import type React from 'react';
|
|
import type { TFunction } from 'i18next';
|
|
import { api } from '../utils/api';
|
|
import type { Project, ProjectSession } from '../types/app';
|
|
import type {
|
|
AdditionalSessionsByProject,
|
|
DeleteProjectConfirmation,
|
|
LoadingSessionsByProject,
|
|
ProjectSortOrder,
|
|
SessionDeleteConfirmation,
|
|
SessionWithProvider,
|
|
} from '../components/sidebar/types';
|
|
import {
|
|
filterProjects,
|
|
getAllSessions,
|
|
loadStarredProjects,
|
|
persistStarredProjects,
|
|
readProjectSortOrder,
|
|
sortProjects,
|
|
} from '../components/sidebar/utils';
|
|
|
|
type UseSidebarControllerArgs = {
|
|
projects: Project[];
|
|
selectedProject: Project | null;
|
|
selectedSession: ProjectSession | null;
|
|
isLoading: boolean;
|
|
isMobile: boolean;
|
|
t: TFunction;
|
|
onRefresh: () => Promise<void> | void;
|
|
onProjectSelect: (project: Project) => void;
|
|
onSessionSelect: (session: ProjectSession) => void;
|
|
onSessionDelete?: (sessionId: string) => void;
|
|
onProjectDelete?: (projectName: string) => void;
|
|
setCurrentProject: (project: Project) => void;
|
|
setSidebarVisible: (visible: boolean) => void;
|
|
sidebarVisible: boolean;
|
|
};
|
|
|
|
export function useSidebarController({
|
|
projects,
|
|
selectedProject,
|
|
selectedSession,
|
|
isLoading,
|
|
isMobile,
|
|
t,
|
|
onRefresh,
|
|
onProjectSelect,
|
|
onSessionSelect,
|
|
onSessionDelete,
|
|
onProjectDelete,
|
|
setCurrentProject,
|
|
setSidebarVisible,
|
|
sidebarVisible,
|
|
}: UseSidebarControllerArgs) {
|
|
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set());
|
|
const [editingProject, setEditingProject] = useState<string | null>(null);
|
|
const [showNewProject, setShowNewProject] = useState(false);
|
|
const [editingName, setEditingName] = useState('');
|
|
const [loadingSessions, setLoadingSessions] = useState<LoadingSessionsByProject>({});
|
|
const [additionalSessions, setAdditionalSessions] = useState<AdditionalSessionsByProject>({});
|
|
const [initialSessionsLoaded, setInitialSessionsLoaded] = useState<Set<string>>(new Set());
|
|
const [currentTime, setCurrentTime] = useState(new Date());
|
|
const [projectSortOrder, setProjectSortOrder] = useState<ProjectSortOrder>('name');
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
const [editingSession, setEditingSession] = useState<string | null>(null);
|
|
const [editingSessionName, setEditingSessionName] = useState('');
|
|
const [searchFilter, setSearchFilter] = useState('');
|
|
const [deletingProjects, setDeletingProjects] = useState<Set<string>>(new Set());
|
|
const [deleteConfirmation, setDeleteConfirmation] = useState<DeleteProjectConfirmation | null>(null);
|
|
const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState<SessionDeleteConfirmation | null>(null);
|
|
const [showVersionModal, setShowVersionModal] = useState(false);
|
|
const [starredProjects, setStarredProjects] = useState<Set<string>>(() => loadStarredProjects());
|
|
|
|
const isSidebarCollapsed = !isMobile && !sidebarVisible;
|
|
|
|
useEffect(() => {
|
|
const timer = setInterval(() => {
|
|
setCurrentTime(new Date());
|
|
}, 60000);
|
|
|
|
return () => clearInterval(timer);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setAdditionalSessions({});
|
|
setInitialSessionsLoaded(new Set());
|
|
}, [projects]);
|
|
|
|
useEffect(() => {
|
|
if (selectedSession && selectedProject) {
|
|
setExpandedProjects((prev) => {
|
|
if (prev.has(selectedProject.name)) {
|
|
return prev;
|
|
}
|
|
const next = new Set(prev);
|
|
next.add(selectedProject.name);
|
|
return next;
|
|
});
|
|
}
|
|
}, [selectedSession, selectedProject]);
|
|
|
|
useEffect(() => {
|
|
if (projects.length > 0 && !isLoading) {
|
|
const loadedProjects = new Set<string>();
|
|
projects.forEach((project) => {
|
|
if (project.sessions && project.sessions.length >= 0) {
|
|
loadedProjects.add(project.name);
|
|
}
|
|
});
|
|
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);
|
|
};
|
|
}, []);
|
|
|
|
const handleTouchClick = useCallback(
|
|
(callback: () => void) =>
|
|
(event: React.TouchEvent<HTMLElement>) => {
|
|
const target = event.target as HTMLElement;
|
|
if (target.closest('.overflow-y-auto') || target.closest('[data-scroll-container]')) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
callback();
|
|
},
|
|
[],
|
|
);
|
|
|
|
const toggleProject = useCallback((projectName: string) => {
|
|
setExpandedProjects((prev) => {
|
|
const next = new Set<string>();
|
|
if (!prev.has(projectName)) {
|
|
next.add(projectName);
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const handleSessionClick = useCallback(
|
|
(session: SessionWithProvider, projectName: string) => {
|
|
onSessionSelect({ ...session, __projectName: projectName });
|
|
},
|
|
[onSessionSelect],
|
|
);
|
|
|
|
const toggleStarProject = useCallback((projectName: string) => {
|
|
setStarredProjects((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(projectName)) {
|
|
next.delete(projectName);
|
|
} else {
|
|
next.add(projectName);
|
|
}
|
|
|
|
persistStarredProjects(next);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const isProjectStarred = useCallback(
|
|
(projectName: string) => starredProjects.has(projectName),
|
|
[starredProjects],
|
|
);
|
|
|
|
const getProjectSessions = useCallback(
|
|
(project: Project) => getAllSessions(project, additionalSessions),
|
|
[additionalSessions],
|
|
);
|
|
|
|
const sortedProjects = useMemo(
|
|
() => sortProjects(projects, projectSortOrder, starredProjects, additionalSessions),
|
|
[additionalSessions, projectSortOrder, projects, starredProjects],
|
|
);
|
|
|
|
const filteredProjects = useMemo(
|
|
() => filterProjects(sortedProjects, searchFilter),
|
|
[searchFilter, sortedProjects],
|
|
);
|
|
|
|
const startEditing = useCallback((project: Project) => {
|
|
setEditingProject(project.name);
|
|
setEditingName(project.displayName);
|
|
}, []);
|
|
|
|
const cancelEditing = useCallback(() => {
|
|
setEditingProject(null);
|
|
setEditingName('');
|
|
}, []);
|
|
|
|
const saveProjectName = useCallback(
|
|
async (projectName: string) => {
|
|
try {
|
|
const response = await api.renameProject(projectName, editingName);
|
|
if (response.ok) {
|
|
if (window.refreshProjects) {
|
|
await window.refreshProjects();
|
|
} else {
|
|
window.location.reload();
|
|
}
|
|
} else {
|
|
console.error('Failed to rename project');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error renaming project:', error);
|
|
} finally {
|
|
setEditingProject(null);
|
|
setEditingName('');
|
|
}
|
|
},
|
|
[editingName],
|
|
);
|
|
|
|
const showDeleteSessionConfirmation = useCallback(
|
|
(
|
|
projectName: string,
|
|
sessionId: string,
|
|
sessionTitle: string,
|
|
provider: SessionDeleteConfirmation['provider'] = 'claude',
|
|
) => {
|
|
setSessionDeleteConfirmation({ projectName, sessionId, sessionTitle, provider });
|
|
},
|
|
[],
|
|
);
|
|
|
|
const confirmDeleteSession = useCallback(async () => {
|
|
if (!sessionDeleteConfirmation) {
|
|
return;
|
|
}
|
|
|
|
const { projectName, sessionId, provider } = sessionDeleteConfirmation;
|
|
setSessionDeleteConfirmation(null);
|
|
|
|
try {
|
|
const response =
|
|
provider === 'codex'
|
|
? await api.deleteCodexSession(sessionId)
|
|
: await api.deleteSession(projectName, 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 () => {
|
|
if (!deleteConfirmation) {
|
|
return;
|
|
}
|
|
|
|
const { project, sessionCount } = deleteConfirmation;
|
|
const isEmpty = sessionCount === 0;
|
|
|
|
setDeleteConfirmation(null);
|
|
setDeletingProjects((prev) => new Set([...prev, project.name]));
|
|
|
|
try {
|
|
const response = await api.deleteProject(project.name, !isEmpty);
|
|
|
|
if (response.ok) {
|
|
onProjectDelete?.(project.name);
|
|
} else {
|
|
const error = (await response.json()) as { error?: string };
|
|
alert(error.error || t('messages.deleteProjectFailed'));
|
|
}
|
|
} catch (error) {
|
|
console.error('Error deleting project:', error);
|
|
alert(t('messages.deleteProjectError'));
|
|
} finally {
|
|
setDeletingProjects((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(project.name);
|
|
return next;
|
|
});
|
|
}
|
|
}, [deleteConfirmation, onProjectDelete, t]);
|
|
|
|
const loadMoreSessions = useCallback(
|
|
async (project: Project) => {
|
|
const canLoadMore = project.sessionMeta?.hasMore === true;
|
|
if (!canLoadMore || loadingSessions[project.name]) {
|
|
return;
|
|
}
|
|
|
|
setLoadingSessions((prev) => ({ ...prev, [project.name]: true }));
|
|
|
|
try {
|
|
const currentSessionCount =
|
|
(project.sessions?.length || 0) + (additionalSessions[project.name]?.length || 0);
|
|
const response = await api.sessions(project.name, 5, currentSessionCount);
|
|
|
|
if (!response.ok) {
|
|
return;
|
|
}
|
|
|
|
const result = (await response.json()) as {
|
|
sessions?: ProjectSession[];
|
|
hasMore?: boolean;
|
|
};
|
|
|
|
setAdditionalSessions((prev) => ({
|
|
...prev,
|
|
[project.name]: [...(prev[project.name] || []), ...(result.sessions || [])],
|
|
}));
|
|
|
|
if (result.hasMore === false) {
|
|
project.sessionMeta = { ...project.sessionMeta, hasMore: false };
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading more sessions:', error);
|
|
} finally {
|
|
setLoadingSessions((prev) => ({ ...prev, [project.name]: false }));
|
|
}
|
|
},
|
|
[additionalSessions, loadingSessions],
|
|
);
|
|
|
|
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(
|
|
async (_projectName: string, _sessionId: string, _summary: string) => {
|
|
// Session rename endpoint is not currently exposed on the API.
|
|
setEditingSession(null);
|
|
setEditingSessionName('');
|
|
},
|
|
[],
|
|
);
|
|
|
|
const collapseSidebar = useCallback(() => {
|
|
setSidebarVisible(false);
|
|
}, [setSidebarVisible]);
|
|
|
|
const expandSidebar = useCallback(() => {
|
|
setSidebarVisible(true);
|
|
}, [setSidebarVisible]);
|
|
|
|
return {
|
|
isSidebarCollapsed,
|
|
expandedProjects,
|
|
editingProject,
|
|
showNewProject,
|
|
editingName,
|
|
loadingSessions,
|
|
additionalSessions,
|
|
initialSessionsLoaded,
|
|
currentTime,
|
|
projectSortOrder,
|
|
isRefreshing,
|
|
editingSession,
|
|
editingSessionName,
|
|
searchFilter,
|
|
deletingProjects,
|
|
deleteConfirmation,
|
|
sessionDeleteConfirmation,
|
|
showVersionModal,
|
|
starredProjects,
|
|
filteredProjects,
|
|
handleTouchClick,
|
|
toggleProject,
|
|
handleSessionClick,
|
|
toggleStarProject,
|
|
isProjectStarred,
|
|
getProjectSessions,
|
|
startEditing,
|
|
cancelEditing,
|
|
saveProjectName,
|
|
showDeleteSessionConfirmation,
|
|
confirmDeleteSession,
|
|
requestProjectDelete,
|
|
confirmDeleteProject,
|
|
loadMoreSessions,
|
|
handleProjectSelect,
|
|
refreshProjects,
|
|
updateSessionSummary,
|
|
collapseSidebar,
|
|
expandSidebar,
|
|
setShowNewProject,
|
|
setEditingName,
|
|
setEditingSession,
|
|
setEditingSessionName,
|
|
setSearchFilter,
|
|
setDeleteConfirmation,
|
|
setSessionDeleteConfirmation,
|
|
setShowVersionModal,
|
|
};
|
|
}
|