mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-23 18:25:44 +08:00
Merge remote-tracking branch 'origin/feat/unify-websocket-2' into browser-use-independent
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user