Files
claudecodeui/src/hooks/useProjectsState.ts
2026-05-13 17:43:10 +03:00

907 lines
30 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,
LLMProvider,
LoadingProgress,
Project,
ProjectSession,
ProjectsUpdatedMessage,
} from '../types/app';
type UseProjectsStateArgs = {
sessionId?: string;
navigate: NavigateFunction;
latestMessage: AppSocketMessage | null;
isMobile: boolean;
activeSessions: Set<string>;
};
type FetchProjectsOptions = {
showLoadingState?: boolean;
};
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, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' | 'sessionMeta'>,
): 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;
};
const isUpdateAdditive = (
currentProjects: Project[],
updatedProjects: Project[],
selectedProject: Project | null,
selectedSession: ProjectSession | null,
): boolean => {
if (!selectedProject || !selectedSession) {
return true;
}
const currentSelectedProject = currentProjects.find((project) => project.projectId === selectedProject.projectId);
const updatedSelectedProject = updatedProjects.find((project) => project.projectId === selectedProject.projectId);
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
);
};
const VALID_TABS: Set<string> = 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,
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>(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<LoadingProgress | null>(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<ReturnType<typeof setTimeout> | null>(null);
const lastHandledMessageRef = useRef<AppSocketMessage | null>(null);
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]);
// 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]);
useEffect(() => {
if (!latestMessage) {
return;
}
// `latestMessage` is event-like data. This effect also depends on local state
// (`projects`, `selectedProject`, `selectedSession`) to compute derived updates.
// Without this guard, handling one websocket message can update that local
// state, retrigger the effect, and re-handle the same websocket message.
if (lastHandledMessageRef.current === latestMessage) {
return;
}
lastHandledMessageRef.current = latestMessage;
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.updatedSessionId && selectedSession && selectedProject) {
if (projectsMessage.updatedSessionId === selectedSession.id) {
const isSessionActive = activeSessions.has(selectedSession.id);
if (!isSessionActive) {
setExternalMessageUpdate((prev) => prev + 1);
}
}
}
const hasActiveSession = Boolean(selectedSession && activeSessions.has(selectedSession.id));
const updatedProjectsWithTaskMaster = mergeTaskMasterCache(projectsMessage.projects, projects);
const updatedProjects = mergeExpandedSessionPages(projects, updatedProjectsWithTaskMaster);
if (
hasActiveSession &&
!isUpdateAdditive(projects, updatedProjects, selectedProject, selectedSession)
) {
return;
}
setProjects((previousProjects) =>
projectsHaveChanges(previousProjects, updatedProjects, true) ? updatedProjects : previousProjects,
);
if (!selectedProject) {
return;
}
const updatedSelectedProject = updatedProjects.find(
(project) => project.projectId === selectedProject.projectId,
);
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;
}
// 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 (common
// right after `session_created` + navigate, before the next projects refresh).
// Without a `selectedSession`, chat state clears `currentSessionId` and the
// UI stops reading the session store even though messages stream under this id.
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');
}
const provider = localStorage.getItem('selected-provider') || 'claude';
if (provider === 'cursor') {
sessionStorage.setItem('cursorSessionId', session.id);
}
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<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' | 'sessionMeta'>;
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,
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,
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,
sidebarSharedProps,
handleProjectSelect,
handleSessionSelect,
handleNewSession,
handleSessionDelete,
loadMoreProjectSessions,
handleProjectDelete,
handleSidebarRefresh,
};
}