Files
claudecodeui/src/hooks/useProjectsState.ts

1014 lines
31 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { NavigateFunction } from 'react-router-dom';
import { api } from '../utils/api';
import type { ServerEvent } from '../contexts/WebSocketContext';
import type {
AppTab,
LLMProvider,
LoadingProgress,
Project,
ProjectSession,
} from '../types/app';
import type { SessionActivityMap } from './useSessionProtection';
type UseProjectsStateArgs = {
sessionId?: string;
navigate: NavigateFunction;
/** Subscription to the unified websocket event stream. */
subscribe: (listener: (event: ServerEvent) => void) => () => void;
isMobile: boolean;
activeSessions: SessionActivityMap;
};
/**
* Shape of the per-session sidebar delta broadcast by the backend file
* watcher (`kind: session_upserted`). It carries everything needed to upsert
* one session row in place — no full project-list snapshot is ever pushed.
*/
type SessionUpsertedEvent = ServerEvent & {
sessionId: string;
providerSessionId?: string | null;
provider: LLMProvider;
session: ProjectSession;
project: {
projectId: string;
path: string;
fullPath: string;
displayName: string;
isStarred: boolean;
} | null;
};
type FetchProjectsOptions = {
showLoadingState?: boolean;
};
type RegisterOptimisticSessionArgs = {
sessionId: string;
provider: LLMProvider;
project: Project;
summary?: string | null;
};
type ProjectSessionPage = Pick<Project, 'sessions' | 'sessionMeta'>;
const DEFAULT_PROVIDER: LLMProvider = 'claude';
const serialize = (value: unknown) => JSON.stringify(value ?? null);
const readSelectedProvider = (): LLMProvider => {
try {
const storedProvider = localStorage.getItem('selected-provider');
return storedProvider ? storedProvider as LLMProvider : DEFAULT_PROVIDER;
} catch {
return DEFAULT_PROVIDER;
}
};
const getSessionProvider = (session: ProjectSession): LLMProvider => {
const provider = session.__provider ?? session.provider;
return typeof provider === 'string' && provider.trim()
? provider as LLMProvider
: DEFAULT_PROVIDER;
};
const normalizeSessionProvider = (session: ProjectSession): ProjectSession => ({
...session,
__provider: getSessionProvider(session),
});
const projectsHaveChanges = (
prevProjects: Project[],
nextProjects: Project[],
): boolean => {
if (prevProjects.length !== nextProjects.length) {
return true;
}
return nextProjects.some((nextProject, index) => {
const prevProject = prevProjects[index];
if (!prevProject) {
return true;
}
return (
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)
);
});
};
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 ?? [];
};
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 ?? []),
};
const totalSessions = Number(incomingProject.sessionMeta?.total ?? previousLoadedCount);
mergedProject.sessionMeta = {
...incomingProject.sessionMeta,
total: totalSessions,
hasMore: countLoadedProjectSessions(mergedProject) < totalSessions,
};
return mergedProject;
});
};
const mergeProjectSessionPage = (
existingProject: Project,
sessionsPage: ProjectSessionPage,
): Project => {
const mergedProject: Project = {
...existingProject,
sessions: mergeSessionProviderLists(existingProject.sessions ?? [], sessionsPage.sessions ?? []),
};
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 getSessionAliasIds = (event: SessionUpsertedEvent): Set<string> => {
const ids = new Set<string>();
const add = (value: unknown) => {
if (typeof value !== 'string') {
return;
}
const trimmed = value.trim();
if (trimmed) {
ids.add(trimmed);
}
};
add(event.sessionId);
add(event.providerSessionId);
add(event.session?.id);
return ids;
};
/**
* Upserts one session into a project's normalized session list.
*
* Existing rows are updated in place (summary/lastActivity changes from the
* watcher); new rows are prepended since the watcher only fires for sessions
* with fresh activity. `sessionMeta.total` grows only on insert.
*/
const upsertSessionIntoProject = (project: Project, event: SessionUpsertedEvent): Project => {
const sessions = project.sessions ?? [];
const aliasIds = getSessionAliasIds(event);
const normalizedSession: ProjectSession = {
...event.session,
id: event.sessionId,
__provider: event.provider,
};
const existingIndex = sessions.findIndex((session) => aliasIds.has(String(session.id)));
let nextSessions: ProjectSession[];
let inserted = false;
if (existingIndex >= 0) {
let changed = false;
nextSessions = [];
for (const [index, session] of sessions.entries()) {
if (index === existingIndex) {
const updated = { ...session, ...normalizedSession };
if (serialize(session) !== serialize(updated)) {
changed = true;
}
nextSessions.push(updated);
continue;
}
if (aliasIds.has(String(session.id))) {
changed = true;
continue;
}
nextSessions.push(session);
}
if (!changed) {
return project;
}
} else {
nextSessions = [normalizedSession, ...sessions];
inserted = true;
}
const next: Project = { ...project, sessions: nextSessions };
if (inserted) {
const total = Number(project.sessionMeta?.total ?? 0) + 1;
next.sessionMeta = {
...project.sessionMeta,
total,
hasMore: countLoadedProjectSessions(next) < total,
};
}
return next;
};
const projectFromRegistration = (project: Project): Project => ({
projectId: project.projectId,
path: project.path || project.fullPath,
fullPath: project.fullPath || project.path || '',
displayName: project.displayName,
isStarred: project.isStarred,
sessions: project.sessions ?? [],
sessionMeta: project.sessionMeta ?? { hasMore: false, total: countLoadedProjectSessions(project) },
taskmaster: project.taskmaster,
});
const removeSessionFromProject = (project: Project, sessionIdToDelete: string): Project => {
const sessions = project.sessions ?? [];
const nextSessions = sessions.filter((session) => session.id !== sessionIdToDelete);
if (nextSessions.length === sessions.length) {
return project;
}
const updatedProject: Project = {
...project,
sessions: nextSessions,
};
const totalSessions = Math.max(0, Number(project.sessionMeta?.total ?? 0) - 1);
updatedProject.sessionMeta = {
...project.sessionMeta,
total: totalSessions,
hasMore: countLoadedProjectSessions(updatedProject) < totalSessions,
};
return updatedProject;
};
const VALID_TABS: Set<string> = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'browser']);
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,
subscribe,
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);
/**
* Ref mirrors for state the websocket subscription handler needs.
*
* The subscription is registered once (per `subscribe` identity) and events
* are dispatched synchronously outside React's render cycle, so the handler
* must read the latest values through refs instead of stale closures —
* re-subscribing on every state change would risk missing events.
*/
const selectedSessionRef = useRef(selectedSession);
selectedSessionRef.current = selectedSession;
const activeSessionsRef = useRef(activeSessions);
activeSessionsRef.current = activeSessions;
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)
? 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]);
const registerOptimisticSession = useCallback(({
sessionId: newSessionId,
provider,
project,
summary,
}: RegisterOptimisticSessionArgs) => {
if (!newSessionId || !project?.projectId) {
return;
}
const now = new Date().toISOString();
const optimisticSession: ProjectSession = {
id: newSessionId,
summary: summary ?? '',
messageCount: 0,
createdAt: now,
created_at: now,
updated_at: now,
lastActivity: now,
__provider: provider,
__projectId: project.projectId,
};
const upsert: SessionUpsertedEvent = {
kind: 'session_upserted',
sessionId: newSessionId,
provider,
session: optimisticSession,
project: {
projectId: project.projectId,
path: project.path || project.fullPath,
fullPath: project.fullPath || project.path || '',
displayName: project.displayName,
isStarred: Boolean(project.isStarred),
},
timestamp: now,
};
setProjects((previousProjects) => {
const existingProject = previousProjects.find((candidate) => candidate.projectId === project.projectId);
if (!existingProject) {
return [upsertSessionIntoProject(projectFromRegistration(project), upsert), ...previousProjects];
}
const updatedProject = upsertSessionIntoProject(existingProject, upsert);
if (updatedProject === existingProject) {
return previousProjects;
}
return previousProjects.map((candidate) =>
candidate.projectId === existingProject.projectId ? updatedProject : candidate,
);
});
setSelectedProject((previousProject) => {
if (!previousProject || previousProject.projectId !== project.projectId) {
return previousProject;
}
const updatedProject = upsertSessionIntoProject(previousProject, upsert);
return updatedProject === previousProject ? previousProject : updatedProject;
});
setSelectedSession((previousSession) => (
previousSession?.id === newSessionId
? { ...previousSession, ...optimisticSession }
: optimisticSession
));
}, []);
// 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]);
// Realtime sidebar updates. The backend pushes per-session deltas
// (`session_upserted`) instead of full project snapshots, so each event is
// a keyed upsert that can never clobber unrelated client state — no
// "suppress updates while a run is active" protection is needed anymore.
useEffect(() => {
const handleEvent = (event: ServerEvent) => {
if (event.kind === 'loading_progress') {
if (loadingProgressTimeoutRef.current) {
clearTimeout(loadingProgressTimeoutRef.current);
loadingProgressTimeoutRef.current = null;
}
setLoadingProgress(event as unknown as LoadingProgress);
if (event.phase === 'complete') {
loadingProgressTimeoutRef.current = setTimeout(() => {
setLoadingProgress(null);
loadingProgressTimeoutRef.current = null;
}, 500);
}
return;
}
if (event.kind !== 'session_upserted') {
return;
}
const upsert = event as SessionUpsertedEvent;
if (!upsert.sessionId || !upsert.session) {
return;
}
// The transcript of the currently viewed session changed on disk while
// no run is active here (e.g. edited from another client or the CLI):
// signal the chat view to reload its messages.
const currentSelectedSession = selectedSessionRef.current;
if (
currentSelectedSession
&& upsert.sessionId === currentSelectedSession.id
&& !activeSessionsRef.current.has(upsert.sessionId)
) {
setExternalMessageUpdate((prev) => prev + 1);
}
setProjects((previousProjects) => {
const targetProjectId = upsert.project?.projectId;
const existingProject = previousProjects.find((project) =>
targetProjectId ? project.projectId === targetProjectId : getProjectSessions(project).some((session) => session.id === upsert.sessionId),
);
if (!existingProject) {
// First session of a project this client has never seen: create the
// project entry from the event payload.
if (!upsert.project) {
return previousProjects;
}
const newProject: Project = {
projectId: upsert.project.projectId,
path: upsert.project.path,
fullPath: upsert.project.fullPath,
displayName: upsert.project.displayName,
isStarred: upsert.project.isStarred,
sessions: [],
sessionMeta: { hasMore: false, total: 0 },
} as Project;
return [...previousProjects, upsertSessionIntoProject(newProject, upsert)];
}
const updatedProject = upsertSessionIntoProject(existingProject, upsert);
if (updatedProject === existingProject) {
return previousProjects;
}
return previousProjects.map((project) =>
project.projectId === existingProject.projectId ? updatedProject : project,
);
});
// Keep the selected project reference in sync with the upsert.
setSelectedProject((previousProject) => {
if (!previousProject) {
return previousProject;
}
const matches = upsert.project
? previousProject.projectId === upsert.project.projectId
: getProjectSessions(previousProject).some((session) => session.id === upsert.sessionId);
if (!matches) {
return previousProject;
}
const updated = upsertSessionIntoProject(previousProject, upsert);
return updated === previousProject ? previousProject : updated;
});
const aliasedSelectedSessionId =
typeof upsert.providerSessionId === 'string' && upsert.providerSessionId !== upsert.sessionId
? upsert.providerSessionId
: null;
if (!aliasedSelectedSessionId) {
return;
}
const normalizedSelectedSession: ProjectSession = {
...upsert.session,
id: upsert.sessionId,
__provider: upsert.provider,
__projectId: upsert.project?.projectId ?? currentSelectedSession?.__projectId,
};
setSelectedSession((previousSession) => {
if (previousSession?.id !== aliasedSelectedSessionId) {
return previousSession;
}
return {
...previousSession,
...normalizedSelectedSession,
};
});
if (sessionId === aliasedSelectedSessionId) {
navigate(`/session/${upsert.sessionId}`);
}
};
return subscribe(handleEvent);
}, [navigate, sessionId, subscribe]);
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 match = project.sessions?.find((session) => session.id === sessionId);
if (match) {
const normalizedSession = normalizeSessionProvider(match);
const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
const shouldUpdateSession =
selectedSession?.id !== sessionId || selectedSession.__provider !== normalizedSession.__provider;
if (shouldUpdateProject) {
setSelectedProject(project);
}
if (shouldUpdateSession) {
setSelectedSession(normalizedSession);
}
return;
}
}
// Session id is in the URL but not yet present on any project payload
// (normal for a brand-new conversation: the composer allocates the id and
// navigates before the sidebar learns about the session via
// `session_upserted`). Without a `selectedSession`, chat state clears
// `currentSessionId` and the UI stops reading the session store even
// though messages stream under this id — so synthesize a placeholder.
if (selectedSession?.id === sessionId) {
return;
}
if (!selectedProject) {
return;
}
setSelectedSession({
id: sessionId,
__provider: readSelectedProvider(),
__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 === 'browser') {
setActiveTab('chat');
}
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) => removeSessionFromProject(project, sessionIdToDelete)),
);
},
[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) ? 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 ProjectSessionPage;
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,
activeSessions,
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,
activeSessions,
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,
registerOptimisticSession,
sidebarSharedProps,
handleProjectSelect,
handleSessionSelect,
handleNewSession,
handleSessionDelete,
loadMoreProjectSessions,
handleProjectDelete,
handleSidebarRefresh,
};
}