Files
claudecodeui/src/hooks/useProjectsState.ts
Haileyesus 591b18e9e3 feat(sidebar): improve running session state tracking
Add a running-session view to the sidebar, including header controls, running counts, empty states, and row-level processing indicators so active provider work is visible outside the current chat.

Hydrate running state after refresh through a status-only /api/providers/sessions/running endpoint backed by chatRunRegistry.listRunningRuns, then sync and poll the frontend processingSessions map from AppContent without attaching to chat streams or replaying messages.

Preserve fresh local processing entries during sync so newly sent messages are not cleared before the backend registry catches up, and clear completed sessions once the status endpoint no longer reports them.

Thread active session state through sidebar project/session components, show rotating loaders for processing sessions, and keep the running search mode expanded and filterable.

Fix optimistic local user-message dedupe so repeated prompts are only collapsed when a matching server echo appears from the same send window, preventing sent messages from disappearing until assistant completion.

Add registry test coverage for listing currently running app sessions.

Tests: npx eslint on changed files; npx tsc --noEmit -p tsconfig.json; npx tsc --noEmit -p server/tsconfig.json; npx tsx --tsconfig server/tsconfig.json --test server/modules/websocket/tests/chat-run-registry.test.ts.
2026-06-11 20:04:38 +03:00

1057 lines
35 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;
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;
};
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;
};
/**
* Resolves which provider bucket on a `Project` holds sessions for a provider.
* The legacy payload keeps Claude sessions in `sessions` and the other
* providers in their own arrays.
*/
const providerBucketKey = (
provider: LLMProvider,
): 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' => {
if (provider === 'cursor') return 'cursorSessions';
if (provider === 'codex') return 'codexSessions';
if (provider === 'gemini') return 'geminiSessions';
if (provider === 'opencode') return 'opencodeSessions';
return 'sessions';
};
/**
* Upserts one session into the matching provider bucket of a project.
*
* 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 bucketKey = providerBucketKey(event.provider);
const bucket = project[bucketKey] ?? [];
const existingIndex = bucket.findIndex((session) => session.id === event.sessionId);
let nextBucket: ProjectSession[];
if (existingIndex >= 0) {
const existing = bucket[existingIndex];
const updated = { ...existing, ...event.session };
if (serialize(existing) === serialize(updated)) {
return project;
}
nextBucket = [...bucket];
nextBucket[existingIndex] = updated;
} else {
nextBucket = [event.session, ...bucket];
}
const next: Project = { ...project, [bucketKey]: nextBucket };
if (existingIndex < 0) {
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 ?? [],
cursorSessions: project.cursorSessions ?? [],
codexSessions: project.codexSessions ?? [],
geminiSessions: project.geminiSessions ?? [],
opencodeSessions: project.opencodeSessions ?? [],
sessionMeta: project.sessionMeta ?? { hasMore: false, total: countLoadedProjectSessions(project) },
taskmaster: project.taskmaster,
});
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,
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, 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]);
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: [],
cursorSessions: [],
codexSessions: [],
geminiSessions: [],
opencodeSessions: [],
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;
});
};
return subscribe(handleEvent);
}, [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 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
// (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;
}
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');
}
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,
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,
};
}