mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-07 05:45:39 +08:00
907 lines
30 KiB
TypeScript
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,
|
|
};
|
|
}
|