Merge remote-tracking branch 'origin/feat/unify-websocket-2' into browser-use-independent

This commit is contained in:
Simos Mikelatos
2026-06-15 16:12:10 +00:00
76 changed files with 4520 additions and 2080 deletions

View File

@@ -2,34 +2,86 @@ 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 {
AppSocketMessage,
AppTab,
LLMProvider,
LoadingProgress,
Project,
ProjectSession,
ProjectsUpdatedMessage,
} from '../types/app';
import type { SessionActivityMap } from './useSessionProtection';
type UseProjectsStateArgs = {
sessionId?: string;
navigate: NavigateFunction;
latestMessage: AppSocketMessage | null;
/** Subscription to the unified websocket event stream. */
subscribe: (listener: (event: ServerEvent) => void) => () => void;
isMobile: boolean;
activeSessions: Set<string>;
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[],
includeExternalSessions: boolean,
): boolean => {
if (prevProjects.length !== nextProjects.length) {
return true;
@@ -41,28 +93,14 @@ const projectsHaveChanges = (
return true;
}
const baseChanged =
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);
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)
serialize(nextProject.taskmaster) !== serialize(prevProject.taskmaster)
);
});
};
@@ -94,13 +132,7 @@ const mergeTaskMasterCache = (nextProjects: Project[], previousProjects: Project
};
const getProjectSessions = (project: Project): ProjectSession[] => {
return [
...(project.sessions ?? []),
...(project.codexSessions ?? []),
...(project.cursorSessions ?? []),
...(project.geminiSessions ?? []),
...(project.opencodeSessions ?? []),
];
return project.sessions ?? [];
};
const countLoadedProjectSessions = (project: Project): number => getProjectSessions(project).length;
@@ -144,10 +176,6 @@ const mergeExpandedSessionPages = (previousProjects: Project[], incomingProjects
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);
@@ -163,15 +191,11 @@ const mergeExpandedSessionPages = (previousProjects: Project[], incomingProjects
const mergeProjectSessionPage = (
existingProject: Project,
sessionsPage: Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' | 'sessionMeta'>,
sessionsPage: ProjectSessionPage,
): 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);
@@ -185,40 +209,119 @@ const mergeProjectSessionPage = (
return mergedProject;
};
const isUpdateAdditive = (
currentProjects: Project[],
updatedProjects: Project[],
selectedProject: Project | null,
selectedSession: ProjectSession | null,
): boolean => {
if (!selectedProject || !selectedSession) {
return true;
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 currentSelectedProject = currentProjects.find((project) => project.projectId === selectedProject.projectId);
const updatedSelectedProject = updatedProjects.find((project) => project.projectId === selectedProject.projectId);
if (!currentSelectedProject || !updatedSelectedProject) {
return false;
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,
};
}
const currentSelectedSession = getProjectSessions(currentSelectedProject).find(
(session) => session.id === selectedSession.id,
);
const updatedSelectedSession = getProjectSessions(updatedSelectedProject).find(
(session) => session.id === selectedSession.id,
);
return next;
};
if (!currentSelectedSession || !updatedSelectedSession) {
return false;
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;
}
return (
currentSelectedSession.id === updatedSelectedSession.id &&
currentSelectedSession.title === updatedSelectedSession.title &&
currentSelectedSession.created_at === updatedSelectedSession.created_at &&
currentSelectedSession.updated_at === updatedSelectedSession.updated_at
);
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']);
@@ -242,7 +345,7 @@ const readPersistedTab = (): AppTab => {
export function useProjectsState({
sessionId,
navigate,
latestMessage,
subscribe,
isMobile,
activeSessions,
}: UseProjectsStateArgs) {
@@ -289,7 +392,18 @@ export function useProjectsState({
const [newSessionTrigger, setNewSessionTrigger] = useState(0);
const loadingProgressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastHandledMessageRef = useRef<AppSocketMessage | 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 {
@@ -307,7 +421,7 @@ export function useProjectsState({
return mergedProjects;
}
return projectsHaveChanges(prevProjects, mergedProjects, true)
return projectsHaveChanges(prevProjects, mergedProjects)
? mergedProjects
: prevProjects;
});
@@ -325,6 +439,75 @@ export function useProjectsState({
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) => {
@@ -391,98 +574,135 @@ export function useProjectsState({
}
}, [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(() => {
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);
const handleEvent = (event: ServerEvent) => {
if (event.kind === 'loading_progress') {
if (loadingProgressTimeoutRef.current) {
clearTimeout(loadingProgressTimeoutRef.current);
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);
}
setLoadingProgress(event as unknown as LoadingProgress);
if (event.phase === 'complete') {
loadingProgressTimeoutRef.current = setTimeout(() => {
setLoadingProgress(null);
loadingProgressTimeoutRef.current = null;
}, 500);
}
return;
}
}
const hasActiveSession = Boolean(selectedSession && activeSessions.has(selectedSession.id));
if (event.kind !== 'session_upserted') {
return;
}
const updatedProjectsWithTaskMaster = mergeTaskMasterCache(projectsMessage.projects, projects);
const updatedProjects = mergeExpandedSessionPages(projects, updatedProjectsWithTaskMaster);
const upsert = event as SessionUpsertedEvent;
if (!upsert.sessionId || !upsert.session) {
return;
}
if (
hasActiveSession &&
!isUpdateAdditive(projects, updatedProjects, selectedProject, selectedSession)
) {
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) =>
projectsHaveChanges(previousProjects, updatedProjects, true) ? updatedProjects : previousProjects,
);
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 (!selectedProject) {
return;
}
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 updatedSelectedProject = updatedProjects.find(
(project) => project.projectId === selectedProject.projectId,
);
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;
if (!updatedSelectedProject) {
return;
}
return [...previousProjects, upsertSessionIntoProject(newProject, upsert)];
}
if (serialize(updatedSelectedProject) !== serialize(selectedProject)) {
setSelectedProject(updatedSelectedProject);
}
const updatedProject = upsertSessionIntoProject(existingProject, upsert);
if (updatedProject === existingProject) {
return previousProjects;
}
if (!selectedSession) {
return;
}
return previousProjects.map((project) =>
project.projectId === existingProject.projectId ? updatedProject : project,
);
});
const updatedSelectedSession = getProjectSessions(updatedSelectedProject).find(
(session) => session.id === selectedSession.id,
);
// 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;
});
if (!updatedSelectedSession) {
setSelectedSession(null);
}
}, [latestMessage, selectedProject, selectedSession, activeSessions, projects]);
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 () => {
@@ -500,86 +720,29 @@ export function useProjectsState({
// 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 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 !== 'claude';
selectedSession?.id !== sessionId || selectedSession.__provider !== normalizedSession.__provider;
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' });
setSelectedSession(normalizedSession);
}
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.
// 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;
}
@@ -588,27 +751,9 @@ export function useProjectsState({
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,
__provider: readSelectedProvider(),
__projectId: selectedProject.projectId,
summary: '',
});
@@ -635,11 +780,6 @@ export function useProjectsState({
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
@@ -681,43 +821,7 @@ export function useProjectsState({
}
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;
}),
prevProjects.map((project) => removeSessionFromProject(project, sessionIdToDelete)),
);
},
[navigate, selectedSession?.id],
@@ -731,7 +835,7 @@ export function useProjectsState({
const mergedProjects = mergeExpandedSessionPages(projects, projectsWithTaskMaster);
setProjects((prevProjects) =>
projectsHaveChanges(prevProjects, mergedProjects, true) ? mergedProjects : prevProjects,
projectsHaveChanges(prevProjects, mergedProjects) ? mergedProjects : prevProjects,
);
if (!selectedProject) {
@@ -800,7 +904,7 @@ export function useProjectsState({
throw new Error(message);
}
const sessionsPage = (await response.json()) as Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' | 'sessionMeta'>;
const sessionsPage = (await response.json()) as ProjectSessionPage;
let mergedProjectForSelection: Project | null = null;
setProjects((previousProjects) =>
@@ -840,6 +944,7 @@ export function useProjectsState({
projects,
selectedProject,
selectedSession,
activeSessions,
onProjectSelect: handleProjectSelect,
onSessionSelect: handleSessionSelect,
onNewSession: handleNewSession,
@@ -866,6 +971,7 @@ export function useProjectsState({
isLoadingProjects,
isMobile,
loadingProgress,
activeSessions,
projects,
settingsInitialTab,
selectedProject,
@@ -894,6 +1000,7 @@ export function useProjectsState({
openSettings,
fetchProjects,
refreshProjectsSilently,
registerOptimisticSession,
sidebarSharedProps,
handleProjectSelect,
handleSessionSelect,