fix: create one unified function for frontend session processing

This commit is contained in:
Haileyesus
2026-06-15 13:36:35 +03:00
parent 1b336e9aa9
commit 677d330981

View File

@@ -52,8 +52,56 @@ type RegisterOptimisticSessionArgs = {
summary?: string | null; summary?: string | null;
}; };
type SessionBucketKey = 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions';
type ProjectSessionBuckets = Pick<Project, SessionBucketKey>;
type ProjectSessionPage = ProjectSessionBuckets & Pick<Project, 'sessionMeta'>;
const DEFAULT_PROVIDER: LLMProvider = 'claude';
const SESSION_PROVIDER_BUCKETS: ReadonlyArray<{ provider: LLMProvider; key: SessionBucketKey }> = [
{ provider: 'claude', key: 'sessions' },
{ provider: 'cursor', key: 'cursorSessions' },
{ provider: 'codex', key: 'codexSessions' },
{ provider: 'gemini', key: 'geminiSessions' },
{ provider: 'opencode', key: 'opencodeSessions' },
];
const SESSION_BUCKET_KEYS = SESSION_PROVIDER_BUCKETS.map(({ key }) => key);
const EXTERNAL_SESSION_BUCKET_KEYS = SESSION_PROVIDER_BUCKETS
.filter(({ key }) => key !== 'sessions')
.map(({ key }) => key);
const SESSION_BUCKET_BY_PROVIDER = SESSION_PROVIDER_BUCKETS.reduce(
(byProvider, { provider, key }) => {
byProvider[provider] = key;
return byProvider;
},
{} as Record<LLMProvider, SessionBucketKey>,
);
const serialize = (value: unknown) => JSON.stringify(value ?? null); const serialize = (value: unknown) => JSON.stringify(value ?? null);
const isLLMProvider = (value: unknown): value is LLMProvider => (
typeof value === 'string' && value in SESSION_BUCKET_BY_PROVIDER
);
const readSelectedProvider = (): LLMProvider => {
try {
const storedProvider = localStorage.getItem('selected-provider');
return isLLMProvider(storedProvider) ? storedProvider : DEFAULT_PROVIDER;
} catch {
return DEFAULT_PROVIDER;
}
};
const getProjectSessionBucketValues = (project: Partial<Project>): ProjectSessionBuckets => {
const buckets = {} as ProjectSessionBuckets;
for (const bucketKey of SESSION_BUCKET_KEYS) {
buckets[bucketKey] = project[bucketKey] ?? [];
}
return buckets;
};
const projectsHaveChanges = ( const projectsHaveChanges = (
prevProjects: Project[], prevProjects: Project[],
nextProjects: Project[], nextProjects: Project[],
@@ -86,11 +134,8 @@ const projectsHaveChanges = (
return false; return false;
} }
return ( return EXTERNAL_SESSION_BUCKET_KEYS.some((bucketKey) =>
serialize(nextProject.cursorSessions) !== serialize(prevProject.cursorSessions) || serialize(nextProject[bucketKey]) !== serialize(prevProject[bucketKey]),
serialize(nextProject.codexSessions) !== serialize(prevProject.codexSessions) ||
serialize(nextProject.geminiSessions) !== serialize(prevProject.geminiSessions) ||
serialize(nextProject.opencodeSessions) !== serialize(prevProject.opencodeSessions)
); );
}); });
}; };
@@ -122,17 +167,25 @@ const mergeTaskMasterCache = (nextProjects: Project[], previousProjects: Project
}; };
const getProjectSessions = (project: Project): ProjectSession[] => { const getProjectSessions = (project: Project): ProjectSession[] => {
return [ return SESSION_BUCKET_KEYS.flatMap((bucketKey) => project[bucketKey] ?? []);
...(project.sessions ?? []),
...(project.codexSessions ?? []),
...(project.cursorSessions ?? []),
...(project.geminiSessions ?? []),
...(project.opencodeSessions ?? []),
];
}; };
const countLoadedProjectSessions = (project: Project): number => getProjectSessions(project).length; const countLoadedProjectSessions = (project: Project): number => getProjectSessions(project).length;
const findProjectSessionById = (
project: Project,
sessionId: string,
): { session: ProjectSession; provider: LLMProvider } | null => {
for (const { provider, key } of SESSION_PROVIDER_BUCKETS) {
const session = project[key]?.find((candidate) => candidate.id === sessionId);
if (session) {
return { session, provider };
}
}
return null;
};
const mergeSessionProviderLists = (baseSessions: ProjectSession[], additionalSessions: ProjectSession[]): ProjectSession[] => { const mergeSessionProviderLists = (baseSessions: ProjectSession[], additionalSessions: ProjectSession[]): ProjectSession[] => {
const merged = [...baseSessions]; const merged = [...baseSessions];
const seenSessionIds = new Set(baseSessions.map((session) => String(session.id))); const seenSessionIds = new Set(baseSessions.map((session) => String(session.id)));
@@ -169,14 +222,13 @@ const mergeExpandedSessionPages = (previousProjects: Project[], incomingProjects
return incomingProject; return incomingProject;
} }
const mergedProject: Project = { const mergedProject: Project = { ...incomingProject };
...incomingProject, for (const bucketKey of SESSION_BUCKET_KEYS) {
sessions: mergeSessionProviderLists(incomingProject.sessions ?? [], previousProject.sessions ?? []), mergedProject[bucketKey] = mergeSessionProviderLists(
cursorSessions: mergeSessionProviderLists(incomingProject.cursorSessions ?? [], previousProject.cursorSessions ?? []), incomingProject[bucketKey] ?? [],
codexSessions: mergeSessionProviderLists(incomingProject.codexSessions ?? [], previousProject.codexSessions ?? []), previousProject[bucketKey] ?? [],
geminiSessions: mergeSessionProviderLists(incomingProject.geminiSessions ?? [], previousProject.geminiSessions ?? []), );
opencodeSessions: mergeSessionProviderLists(incomingProject.opencodeSessions ?? [], previousProject.opencodeSessions ?? []), }
};
const totalSessions = Number(incomingProject.sessionMeta?.total ?? previousLoadedCount); const totalSessions = Number(incomingProject.sessionMeta?.total ?? previousLoadedCount);
mergedProject.sessionMeta = { mergedProject.sessionMeta = {
@@ -191,16 +243,15 @@ const mergeExpandedSessionPages = (previousProjects: Project[], incomingProjects
const mergeProjectSessionPage = ( const mergeProjectSessionPage = (
existingProject: Project, existingProject: Project,
sessionsPage: Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' | 'sessionMeta'>, sessionsPage: ProjectSessionPage,
): Project => { ): Project => {
const mergedProject: Project = { const mergedProject: Project = { ...existingProject };
...existingProject, for (const bucketKey of SESSION_BUCKET_KEYS) {
sessions: mergeSessionProviderLists(existingProject.sessions ?? [], sessionsPage.sessions ?? []), mergedProject[bucketKey] = mergeSessionProviderLists(
cursorSessions: mergeSessionProviderLists(existingProject.cursorSessions ?? [], sessionsPage.cursorSessions ?? []), existingProject[bucketKey] ?? [],
codexSessions: mergeSessionProviderLists(existingProject.codexSessions ?? [], sessionsPage.codexSessions ?? []), sessionsPage[bucketKey] ?? [],
geminiSessions: mergeSessionProviderLists(existingProject.geminiSessions ?? [], sessionsPage.geminiSessions ?? []), );
opencodeSessions: mergeSessionProviderLists(existingProject.opencodeSessions ?? [], sessionsPage.opencodeSessions ?? []), }
};
const totalSessions = Number(sessionsPage.sessionMeta?.total ?? existingProject.sessionMeta?.total ?? 0); const totalSessions = Number(sessionsPage.sessionMeta?.total ?? existingProject.sessionMeta?.total ?? 0);
mergedProject.sessionMeta = { mergedProject.sessionMeta = {
@@ -233,21 +284,6 @@ const getSessionAliasIds = (event: SessionUpsertedEvent): Set<string> => {
return ids; return ids;
}; };
/**
* 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. * Upserts one session into the matching provider bucket of a project.
* *
@@ -256,7 +292,7 @@ const providerBucketKey = (
* with fresh activity. `sessionMeta.total` grows only on insert. * with fresh activity. `sessionMeta.total` grows only on insert.
*/ */
const upsertSessionIntoProject = (project: Project, event: SessionUpsertedEvent): Project => { const upsertSessionIntoProject = (project: Project, event: SessionUpsertedEvent): Project => {
const bucketKey = providerBucketKey(event.provider); const bucketKey = SESSION_BUCKET_BY_PROVIDER[event.provider] ?? SESSION_BUCKET_BY_PROVIDER[DEFAULT_PROVIDER];
const bucket = project[bucketKey] ?? []; const bucket = project[bucketKey] ?? [];
const aliasIds = getSessionAliasIds(event); const aliasIds = getSessionAliasIds(event);
const normalizedSession: ProjectSession = { const normalizedSession: ProjectSession = {
@@ -316,15 +352,41 @@ const projectFromRegistration = (project: Project): Project => ({
fullPath: project.fullPath || project.path || '', fullPath: project.fullPath || project.path || '',
displayName: project.displayName, displayName: project.displayName,
isStarred: project.isStarred, isStarred: project.isStarred,
sessions: project.sessions ?? [], ...getProjectSessionBucketValues(project),
cursorSessions: project.cursorSessions ?? [],
codexSessions: project.codexSessions ?? [],
geminiSessions: project.geminiSessions ?? [],
opencodeSessions: project.opencodeSessions ?? [],
sessionMeta: project.sessionMeta ?? { hasMore: false, total: countLoadedProjectSessions(project) }, sessionMeta: project.sessionMeta ?? { hasMore: false, total: countLoadedProjectSessions(project) },
taskmaster: project.taskmaster, taskmaster: project.taskmaster,
}); });
const removeSessionFromProject = (project: Project, sessionIdToDelete: string): Project => {
const nextBuckets = {} as ProjectSessionBuckets;
let removedFromProject = false;
for (const bucketKey of SESSION_BUCKET_KEYS) {
const currentBucket = project[bucketKey] ?? [];
const nextBucket = currentBucket.filter((session) => session.id !== sessionIdToDelete);
nextBuckets[bucketKey] = nextBucket;
removedFromProject = removedFromProject || nextBucket.length !== currentBucket.length;
}
if (!removedFromProject) {
return project;
}
const updatedProject: Project = {
...project,
...nextBuckets,
};
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', 'preview']); const VALID_TABS: Set<string> = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'preview']);
const isValidTab = (tab: string): tab is AppTab => { const isValidTab = (tab: string): tab is AppTab => {
@@ -639,11 +701,7 @@ export function useProjectsState({
fullPath: upsert.project.fullPath, fullPath: upsert.project.fullPath,
displayName: upsert.project.displayName, displayName: upsert.project.displayName,
isStarred: upsert.project.isStarred, isStarred: upsert.project.isStarred,
sessions: [], ...getProjectSessionBucketValues({}),
cursorSessions: [],
codexSessions: [],
geminiSessions: [],
opencodeSessions: [],
sessionMeta: { hasMore: false, total: 0 }, sessionMeta: { hasMore: false, total: 0 },
} as Project; } as Project;
@@ -725,77 +783,17 @@ export function useProjectsState({
// Project membership is resolved through `projectId` after the migration. // Project membership is resolved through `projectId` after the migration.
for (const project of projects) { for (const project of projects) {
const claudeSession = project.sessions?.find((session) => session.id === sessionId); const match = findProjectSessionById(project, sessionId);
if (claudeSession) { if (match) {
const shouldUpdateProject = selectedProject?.projectId !== project.projectId; const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
const shouldUpdateSession = const shouldUpdateSession =
selectedSession?.id !== sessionId || selectedSession.__provider !== 'claude'; selectedSession?.id !== sessionId || selectedSession.__provider !== match.provider;
if (shouldUpdateProject) { if (shouldUpdateProject) {
setSelectedProject(project); setSelectedProject(project);
} }
if (shouldUpdateSession) { if (shouldUpdateSession) {
setSelectedSession({ ...claudeSession, __provider: 'claude' }); setSelectedSession({ ...match.session, __provider: match.provider });
}
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; return;
} }
@@ -815,27 +813,9 @@ export function useProjectsState({
return; 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({ setSelectedSession({
id: sessionId, id: sessionId,
__provider: normalizedProvider, __provider: readSelectedProvider(),
__projectId: selectedProject.projectId, __projectId: selectedProject.projectId,
summary: '', summary: '',
}); });
@@ -903,43 +883,7 @@ export function useProjectsState({
} }
setProjects((prevProjects) => setProjects((prevProjects) =>
prevProjects.map((project) => { prevProjects.map((project) => removeSessionFromProject(project, sessionIdToDelete)),
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], [navigate, selectedSession?.id],
@@ -1022,7 +966,7 @@ export function useProjectsState({
throw new Error(message); 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; let mergedProjectForSelection: Project | null = null;
setProjects((previousProjects) => setProjects((previousProjects) =>