mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-05 20:25:52 +00:00
New conversations were crossing a short but important consistency gap. The route could already point at a newly created session id while the projects payload had not refreshed yet, and realtime/optimistic messages could still be keyed under a provisional id. In that window the UI could stop reading the active session store, briefly render the conversation as missing, and then repopulate it a moment later. That same gap also made duplication more likely. Optimistic local user messages could survive long enough to appear beside the persisted copy, and finalized assistant streaming rows could sit directly next to the server-backed assistant message with the same content before realtime state was cleared. The result was a chat view that felt unstable exactly when a new session was being created. This commit makes session-id reconciliation a first-class part of the chat flow instead of assuming every layer will agree immediately. The session store now understands canonical session aliases and can migrate one conversation from a provisional id to the real id without dropping its in-memory state. The route navigation path can replace the provisional URL entry instead of stacking it in history, and the project/session selection logic keeps a synthetic selected session alive long enough for the sidebar and project payloads to catch up. The practical goal is to keep one visible conversation throughout the whole creation lifecycle: no dead window between websocket events and project refresh, no stale provisional URL after the real id is known, and no extra optimistic/local bubbles when server history catches up.
885 lines
28 KiB
TypeScript
885 lines
28 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)
|
|
);
|
|
});
|
|
};
|
|
|
|
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 ?? []),
|
|
];
|
|
};
|
|
|
|
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 ?? []),
|
|
};
|
|
|
|
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' | '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 ?? []),
|
|
};
|
|
|
|
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 =
|
|
(selectedSession && activeSessions.has(selectedSession.id)) ||
|
|
(activeSessions.size > 0 && Array.from(activeSessions).some((id) => id.startsWith('new-session-')));
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
// 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'
|
|
: '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 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)
|
|
);
|
|
|
|
if (!removedFromProject) {
|
|
return project;
|
|
}
|
|
|
|
const updatedProject: Project = {
|
|
...project,
|
|
sessions,
|
|
cursorSessions,
|
|
codexSessions,
|
|
geminiSessions,
|
|
};
|
|
|
|
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' | '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,
|
|
};
|
|
}
|