fix: normalize project session payloads

The sidebar had to understand cursorSessions, codexSessions,
and other provider buckets because /api/projects exposed
provider-shaped arrays.

That leaked backend adapter storage into project state and made
frontend behavior drift each time a provider needed another bucket
or exception.

Return one sessions list with provider metadata instead. Project
state, search, and running-session filtering now share one contract,
while provider-specific storage remains behind the backend boundary.
This commit is contained in:
Haileyesus
2026-06-15 13:43:18 +03:00
parent 2abb45636b
commit d0adddbbda
9 changed files with 72 additions and 234 deletions

View File

@@ -30,10 +30,6 @@ type ProjectApiView = {
isArchived: boolean; isArchived: boolean;
isStarred: boolean; isStarred: boolean;
sessions: []; sessions: [];
cursorSessions: [];
codexSessions: [];
geminiSessions: [];
opencodeSessions: [];
sessionMeta: { sessionMeta: {
hasMore: false; hasMore: false;
total: 0; total: 0;
@@ -82,10 +78,6 @@ function mapProjectRowToApiView(projectRow: ProjectRepositoryRow): ProjectApiVie
isArchived: Boolean(projectRow.isArchived), isArchived: Boolean(projectRow.isArchived),
isStarred: Boolean(projectRow.isStarred), isStarred: Boolean(projectRow.isStarred),
sessions: [], sessions: [],
cursorSessions: [],
codexSessions: [],
geminiSessions: [],
opencodeSessions: [],
sessionMeta: { sessionMeta: {
hasMore: false, hasMore: false,
total: 0, total: 0,

View File

@@ -9,13 +9,12 @@ import { AppError } from '@/shared/utils.js';
type SessionSummary = { type SessionSummary = {
id: string; id: string;
provider: string;
summary: string; summary: string;
messageCount: number; messageCount: number;
lastActivity: string; lastActivity: string;
}; };
type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode', SessionSummary[]>;
type SessionRepositoryRow = { type SessionRepositoryRow = {
provider: string; provider: string;
session_id: string; session_id: string;
@@ -31,10 +30,6 @@ export type ProjectListItem = {
fullPath: string; fullPath: string;
isStarred: boolean; isStarred: boolean;
sessions: SessionSummary[]; sessions: SessionSummary[];
cursorSessions: SessionSummary[];
codexSessions: SessionSummary[];
geminiSessions: SessionSummary[];
opencodeSessions: SessionSummary[];
sessionMeta: { sessionMeta: {
hasMore: boolean; hasMore: boolean;
total: number; total: number;
@@ -64,7 +59,7 @@ type SessionPaginationOptions = {
}; };
type ProjectSessionsPageResult = { type ProjectSessionsPageResult = {
sessionsByProvider: SessionsByProvider; sessions: SessionSummary[];
total: number; total: number;
hasMore: boolean; hasMore: boolean;
}; };
@@ -72,10 +67,6 @@ type ProjectSessionsPageResult = {
export type ProjectSessionsPageApiView = { export type ProjectSessionsPageApiView = {
projectId: string; projectId: string;
sessions: SessionSummary[]; sessions: SessionSummary[];
cursorSessions: SessionSummary[];
codexSessions: SessionSummary[];
geminiSessions: SessionSummary[];
opencodeSessions: SessionSummary[];
sessionMeta: { sessionMeta: {
hasMore: boolean; hasMore: boolean;
total: number; total: number;
@@ -129,39 +120,18 @@ function normalizeSessionPagination(options: SessionPaginationOptions = {}): { l
function mapSessionRowToSummary(row: SessionRepositoryRow): SessionSummary { function mapSessionRowToSummary(row: SessionRepositoryRow): SessionSummary {
return { return {
id: row.session_id, id: row.session_id,
provider: row.provider,
summary: row.custom_name || '', summary: row.custom_name || '',
messageCount: 0, messageCount: 0,
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(), lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
}; };
} }
function bucketSessionRowsByProvider(rows: SessionRepositoryRow[]): SessionsByProvider {
const byProvider: SessionsByProvider = {
claude: [],
cursor: [],
codex: [],
gemini: [],
opencode: [],
};
for (const row of rows) {
const provider = row.provider as keyof SessionsByProvider;
const bucket = byProvider[provider];
if (!bucket) {
continue;
}
bucket.push(mapSessionRowToSummary(row));
}
return byProvider;
}
function readProjectSessionsIncludingArchived(projectPath: string): ProjectSessionsPageResult { function readProjectSessionsIncludingArchived(projectPath: string): ProjectSessionsPageResult {
const rows = sessionsDb.getSessionsByProjectPathIncludingArchived(projectPath) as SessionRepositoryRow[]; const rows = sessionsDb.getSessionsByProjectPathIncludingArchived(projectPath) as SessionRepositoryRow[];
return { return {
sessionsByProvider: bucketSessionRowsByProvider(rows), sessions: rows.map(mapSessionRowToSummary),
total: rows.length, total: rows.length,
hasMore: false, hasMore: false,
}; };
@@ -183,7 +153,7 @@ function readProjectSessionsPageByPath(
const total = sessionsDb.countSessionsByProjectPath(projectPath); const total = sessionsDb.countSessionsByProjectPath(projectPath);
return { return {
sessionsByProvider: bucketSessionRowsByProvider(rows), sessions: rows.map(mapSessionRowToSummary),
total, total,
hasMore: pagination.offset + rows.length < total, hasMore: pagination.offset + rows.length < total,
}; };
@@ -205,7 +175,7 @@ function broadcastProgress(progress: ProgressUpdate) {
} }
/** /**
* Reads all projects from DB and returns provider-bucketed session summaries. * Reads all projects from DB and returns normalized session summaries.
*/ */
export async function getProjectsWithSessions( export async function getProjectsWithSessions(
options: GetProjectsWithSessionsOptions = {} options: GetProjectsWithSessionsOptions = {}
@@ -253,11 +223,7 @@ export async function getProjectsWithSessions(
displayName, displayName,
fullPath: projectPath, fullPath: projectPath,
isStarred: Boolean(row.isStarred), isStarred: Boolean(row.isStarred),
sessions: sessionsPage.sessionsByProvider.claude, sessions: sessionsPage.sessions,
cursorSessions: sessionsPage.sessionsByProvider.cursor,
codexSessions: sessionsPage.sessionsByProvider.codex,
geminiSessions: sessionsPage.sessionsByProvider.gemini,
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
sessionMeta: { sessionMeta: {
hasMore: sessionsPage.hasMore, hasMore: sessionsPage.hasMore,
total: sessionsPage.total, total: sessionsPage.total,
@@ -310,11 +276,7 @@ export async function getArchivedProjectsWithSessions(
fullPath: row.project_path, fullPath: row.project_path,
isStarred: Boolean(row.isStarred), isStarred: Boolean(row.isStarred),
isArchived: true, isArchived: true,
sessions: sessionsPage.sessionsByProvider.claude, sessions: sessionsPage.sessions,
cursorSessions: sessionsPage.sessionsByProvider.cursor,
codexSessions: sessionsPage.sessionsByProvider.codex,
geminiSessions: sessionsPage.sessionsByProvider.gemini,
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
sessionMeta: { sessionMeta: {
hasMore: sessionsPage.hasMore, hasMore: sessionsPage.hasMore,
total: sessionsPage.total, total: sessionsPage.total,
@@ -343,11 +305,7 @@ export async function getProjectSessionsPage(
const sessionsPage = readProjectSessionsPageByPath(projectRow.project_path, options); const sessionsPage = readProjectSessionsPageByPath(projectRow.project_path, options);
return { return {
projectId: projectRow.project_id, projectId: projectRow.project_id,
sessions: sessionsPage.sessionsByProvider.claude, sessions: sessionsPage.sessions,
cursorSessions: sessionsPage.sessionsByProvider.cursor,
codexSessions: sessionsPage.sessionsByProvider.codex,
geminiSessions: sessionsPage.sessionsByProvider.gemini,
opencodeSessions: sessionsPage.sessionsByProvider.opencode,
sessionMeta: { sessionMeta: {
hasMore: sessionsPage.hasMore, hasMore: sessionsPage.hasMore,
total: sessionsPage.total, total: sessionsPage.total,

View File

@@ -11,10 +11,6 @@ export type SessionResult = {
interface SessionsResponse { interface SessionsResponse {
sessions?: ProjectSession[]; sessions?: ProjectSession[];
cursorSessions?: ProjectSession[];
codexSessions?: ProjectSession[];
geminiSessions?: ProjectSession[];
opencodeSessions?: ProjectSession[];
} }
export function useSessionsSource(projectId: string | undefined, enabled: boolean) { export function useSessionsSource(projectId: string | undefined, enabled: boolean) {
@@ -29,17 +25,10 @@ export function useSessionsSource(projectId: string | undefined, enabled: boolea
); );
}, },
parse: (data) => { parse: (data) => {
const all: ProjectSession[] = [ return (data.sessions ?? []).map<SessionResult>((s) => ({
...(data.sessions ?? []),
...(data.cursorSessions ?? []),
...(data.codexSessions ?? []),
...(data.geminiSessions ?? []),
...(data.opencodeSessions ?? []),
];
return all.map<SessionResult>((s) => ({
id: s.id, id: s.id,
label: (s.title || s.summary || s.name || s.id) as string, label: (s.title || s.summary || s.name || s.id) as string,
provider: s.__provider, provider: (s.__provider || s.provider) as LLMProvider | undefined,
})); }));
}, },
}); });

View File

@@ -594,16 +594,7 @@ export function useSidebarController({
return sortedProjects.reduce<Project[]>((acc, project) => { return sortedProjects.reduce<Project[]>((acc, project) => {
const sessions = (project.sessions ?? []).filter((session) => activeSessionIds.has(String(session.id))); const sessions = (project.sessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
const cursorSessions = (project.cursorSessions ?? []).filter((session) => activeSessionIds.has(String(session.id))); const runningCount = sessions.length;
const codexSessions = (project.codexSessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
const geminiSessions = (project.geminiSessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
const opencodeSessions = (project.opencodeSessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
const runningCount =
sessions.length
+ cursorSessions.length
+ codexSessions.length
+ geminiSessions.length
+ opencodeSessions.length;
if (runningCount === 0) { if (runningCount === 0) {
return acc; return acc;
@@ -612,10 +603,6 @@ export function useSidebarController({
acc.push({ acc.push({
...project, ...project,
sessions, sessions,
cursorSessions,
codexSessions,
geminiSessions,
opencodeSessions,
sessionMeta: { sessionMeta: {
...project.sessionMeta, ...project.sessionMeta,
total: runningCount, total: runningCount,

View File

@@ -61,10 +61,6 @@ export type SidebarProps = {
}; };
export type SessionViewModel = { export type SessionViewModel = {
isCursorSession: boolean;
isCodexSession: boolean;
isGeminiSession: boolean;
isOpenCodeSession: boolean;
isActive: boolean; isActive: boolean;
sessionName: string; sessionName: string;
sessionTime: string; sessionTime: string;

View File

@@ -1,6 +1,6 @@
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { Project } from '../../../types/app'; import type { LLMProvider, Project, ProjectSession } from '../../../types/app';
import type { ProjectSortOrder, SettingsProject, SessionViewModel, SessionWithProvider } from '../types/types'; import type { ProjectSortOrder, SettingsProject, SessionViewModel, SessionWithProvider } from '../types/types';
export const readProjectSortOrder = (): ProjectSortOrder => { export const readProjectSortOrder = (): ProjectSortOrder => {
@@ -61,6 +61,13 @@ const getUpdatedTimestamp = (session: SessionWithProvider): string => {
return String(session.lastActivity || ''); return String(session.lastActivity || '');
}; };
const getSessionProvider = (session: ProjectSession): LLMProvider => {
const provider = session.__provider ?? session.provider;
return typeof provider === 'string' && provider.trim()
? provider as LLMProvider
: 'claude';
};
export const getSessionDate = (session: SessionWithProvider): Date => { export const getSessionDate = (session: SessionWithProvider): Date => {
return new Date(getUpdatedTimestamp(session) || getCreatedTimestamp(session) || 0); return new Date(getUpdatedTimestamp(session) || getCreatedTimestamp(session) || 0);
}; };
@@ -82,10 +89,6 @@ export const createSessionViewModel = (
const diffInMinutes = Math.floor((currentTime.getTime() - sessionDate.getTime()) / (1000 * 60)); const diffInMinutes = Math.floor((currentTime.getTime() - sessionDate.getTime()) / (1000 * 60));
return { return {
isCursorSession: session.__provider === 'cursor',
isCodexSession: session.__provider === 'codex',
isGeminiSession: session.__provider === 'gemini',
isOpenCodeSession: session.__provider === 'opencode',
isActive: diffInMinutes < 10, isActive: diffInMinutes < 10,
sessionName: getSessionName(session, t), sessionName: getSessionName(session, t),
sessionTime: getSessionTime(session), sessionTime: getSessionTime(session),
@@ -94,32 +97,10 @@ export const createSessionViewModel = (
}; };
export const getAllSessions = (project: Project): SessionWithProvider[] => { export const getAllSessions = (project: Project): SessionWithProvider[] => {
const claudeSessions = [...(project.sessions || [])].map((session) => ({ return (project.sessions || []).map((session) => ({
...session, ...session,
__provider: 'claude' as const, __provider: getSessionProvider(session),
})); })).sort(
const cursorSessions = (project.cursorSessions || []).map((session) => ({
...session,
__provider: 'cursor' as const,
}));
const codexSessions = (project.codexSessions || []).map((session) => ({
...session,
__provider: 'codex' as const,
}));
const geminiSessions = (project.geminiSessions || []).map((session) => ({
...session,
__provider: 'gemini' as const,
}));
const opencodeSessions = (project.opencodeSessions || []).map((session) => ({
...session,
__provider: 'opencode' as const,
}));
return [...claudeSessions, ...cursorSessions, ...codexSessions, ...geminiSessions, ...opencodeSessions].sort(
(a, b) => getSessionDate(b).getTime() - getSessionDate(a).getTime(), (a, b) => getSessionDate(b).getTime() - getSessionDate(a).getTime(),
); );
}; };

View File

@@ -179,7 +179,7 @@ export default function SidebarSessionItem({
</div> </div>
</div> </div>
{!isProcessing && !sessionView.isCursorSession && ( {!isProcessing && (
<button <button
className="ml-1 flex h-5 w-5 items-center justify-center rounded-md bg-red-50 opacity-70 transition-transform active:scale-95 dark:bg-red-900/20" className="ml-1 flex h-5 w-5 items-center justify-center rounded-md bg-red-50 opacity-70 transition-transform active:scale-95 dark:bg-red-900/20"
onClick={(event) => { onClick={(event) => {
@@ -309,7 +309,7 @@ export default function SidebarSessionItem({
> >
<Edit2 className="h-3 w-3 text-gray-600 dark:text-gray-400" /> <Edit2 className="h-3 w-3 text-gray-600 dark:text-gray-400" />
</button> </button>
{!isProcessing && !sessionView.isCursorSession && ( {!isProcessing && (
<button <button
className="flex h-6 w-6 items-center justify-center rounded bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40" className="flex h-6 w-6 items-center justify-center rounded bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40"
onClick={(event) => { onClick={(event) => {

View File

@@ -52,60 +52,36 @@ type RegisterOptimisticSessionArgs = {
summary?: string | null; summary?: string | null;
}; };
type SessionBucketKey = 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions'; type ProjectSessionPage = Pick<Project, 'sessions' | 'sessionMeta'>;
type ProjectSessionBuckets = Pick<Project, SessionBucketKey>;
type ProjectSessionPage = ProjectSessionBuckets & Pick<Project, 'sessionMeta'>;
const DEFAULT_PROVIDER: LLMProvider = 'claude'; 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 => { const readSelectedProvider = (): LLMProvider => {
try { try {
const storedProvider = localStorage.getItem('selected-provider'); const storedProvider = localStorage.getItem('selected-provider');
return isLLMProvider(storedProvider) ? storedProvider : DEFAULT_PROVIDER; return storedProvider ? storedProvider as LLMProvider : DEFAULT_PROVIDER;
} catch { } catch {
return DEFAULT_PROVIDER; return DEFAULT_PROVIDER;
} }
}; };
const getProjectSessionBucketValues = (project: Partial<Project>): ProjectSessionBuckets => { const getSessionProvider = (session: ProjectSession): LLMProvider => {
const buckets = {} as ProjectSessionBuckets; const provider = session.__provider ?? session.provider;
for (const bucketKey of SESSION_BUCKET_KEYS) { return typeof provider === 'string' && provider.trim()
buckets[bucketKey] = project[bucketKey] ?? []; ? provider as LLMProvider
} : DEFAULT_PROVIDER;
return buckets;
}; };
const normalizeSessionProvider = (session: ProjectSession): ProjectSession => ({
...session,
__provider: getSessionProvider(session),
});
const projectsHaveChanges = ( const projectsHaveChanges = (
prevProjects: Project[], prevProjects: Project[],
nextProjects: Project[], nextProjects: Project[],
includeExternalSessions: boolean,
): boolean => { ): boolean => {
if (prevProjects.length !== nextProjects.length) { if (prevProjects.length !== nextProjects.length) {
return true; return true;
@@ -117,25 +93,14 @@ const projectsHaveChanges = (
return true; return true;
} }
const baseChanged = return (
nextProject.projectId !== prevProject.projectId || nextProject.projectId !== prevProject.projectId ||
nextProject.displayName !== prevProject.displayName || nextProject.displayName !== prevProject.displayName ||
nextProject.fullPath !== prevProject.fullPath || nextProject.fullPath !== prevProject.fullPath ||
Boolean(nextProject.isStarred) !== Boolean(prevProject.isStarred) || Boolean(nextProject.isStarred) !== Boolean(prevProject.isStarred) ||
serialize(nextProject.sessionMeta) !== serialize(prevProject.sessionMeta) || serialize(nextProject.sessionMeta) !== serialize(prevProject.sessionMeta) ||
serialize(nextProject.sessions) !== serialize(prevProject.sessions) || serialize(nextProject.sessions) !== serialize(prevProject.sessions) ||
serialize(nextProject.taskmaster) !== serialize(prevProject.taskmaster); serialize(nextProject.taskmaster) !== serialize(prevProject.taskmaster)
if (baseChanged) {
return true;
}
if (!includeExternalSessions) {
return false;
}
return EXTERNAL_SESSION_BUCKET_KEYS.some((bucketKey) =>
serialize(nextProject[bucketKey]) !== serialize(prevProject[bucketKey]),
); );
}); });
}; };
@@ -167,25 +132,11 @@ const mergeTaskMasterCache = (nextProjects: Project[], previousProjects: Project
}; };
const getProjectSessions = (project: Project): ProjectSession[] => { const getProjectSessions = (project: Project): ProjectSession[] => {
return SESSION_BUCKET_KEYS.flatMap((bucketKey) => project[bucketKey] ?? []); return project.sessions ?? [];
}; };
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)));
@@ -222,13 +173,10 @@ const mergeExpandedSessionPages = (previousProjects: Project[], incomingProjects
return incomingProject; return incomingProject;
} }
const mergedProject: Project = { ...incomingProject }; const mergedProject: Project = {
for (const bucketKey of SESSION_BUCKET_KEYS) { ...incomingProject,
mergedProject[bucketKey] = mergeSessionProviderLists( sessions: mergeSessionProviderLists(incomingProject.sessions ?? [], previousProject.sessions ?? []),
incomingProject[bucketKey] ?? [], };
previousProject[bucketKey] ?? [],
);
}
const totalSessions = Number(incomingProject.sessionMeta?.total ?? previousLoadedCount); const totalSessions = Number(incomingProject.sessionMeta?.total ?? previousLoadedCount);
mergedProject.sessionMeta = { mergedProject.sessionMeta = {
@@ -245,13 +193,10 @@ const mergeProjectSessionPage = (
existingProject: Project, existingProject: Project,
sessionsPage: ProjectSessionPage, sessionsPage: ProjectSessionPage,
): Project => { ): Project => {
const mergedProject: Project = { ...existingProject }; const mergedProject: Project = {
for (const bucketKey of SESSION_BUCKET_KEYS) { ...existingProject,
mergedProject[bucketKey] = mergeSessionProviderLists( sessions: mergeSessionProviderLists(existingProject.sessions ?? [], sessionsPage.sessions ?? []),
existingProject[bucketKey] ?? [], };
sessionsPage[bucketKey] ?? [],
);
}
const totalSessions = Number(sessionsPage.sessionMeta?.total ?? existingProject.sessionMeta?.total ?? 0); const totalSessions = Number(sessionsPage.sessionMeta?.total ?? existingProject.sessionMeta?.total ?? 0);
mergedProject.sessionMeta = { mergedProject.sessionMeta = {
@@ -285,35 +230,35 @@ const getSessionAliasIds = (event: SessionUpsertedEvent): Set<string> => {
}; };
/** /**
* Upserts one session into the matching provider bucket of a project. * Upserts one session into a project's normalized session list.
* *
* Existing rows are updated in place (summary/lastActivity changes from the * Existing rows are updated in place (summary/lastActivity changes from the
* watcher); new rows are prepended since the watcher only fires for sessions * watcher); new rows are prepended since the watcher only fires for sessions
* 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 = SESSION_BUCKET_BY_PROVIDER[event.provider] ?? SESSION_BUCKET_BY_PROVIDER[DEFAULT_PROVIDER]; const sessions = project.sessions ?? [];
const bucket = project[bucketKey] ?? [];
const aliasIds = getSessionAliasIds(event); const aliasIds = getSessionAliasIds(event);
const normalizedSession: ProjectSession = { const normalizedSession: ProjectSession = {
...event.session, ...event.session,
id: event.sessionId, id: event.sessionId,
__provider: event.provider,
}; };
const existingIndex = bucket.findIndex((session) => aliasIds.has(String(session.id))); const existingIndex = sessions.findIndex((session) => aliasIds.has(String(session.id)));
let nextBucket: ProjectSession[]; let nextSessions: ProjectSession[];
let inserted = false; let inserted = false;
if (existingIndex >= 0) { if (existingIndex >= 0) {
let changed = false; let changed = false;
nextBucket = []; nextSessions = [];
for (const [index, session] of bucket.entries()) { for (const [index, session] of sessions.entries()) {
if (index === existingIndex) { if (index === existingIndex) {
const updated = { ...session, ...normalizedSession }; const updated = { ...session, ...normalizedSession };
if (serialize(session) !== serialize(updated)) { if (serialize(session) !== serialize(updated)) {
changed = true; changed = true;
} }
nextBucket.push(updated); nextSessions.push(updated);
continue; continue;
} }
@@ -322,18 +267,18 @@ const upsertSessionIntoProject = (project: Project, event: SessionUpsertedEvent)
continue; continue;
} }
nextBucket.push(session); nextSessions.push(session);
} }
if (!changed) { if (!changed) {
return project; return project;
} }
} else { } else {
nextBucket = [normalizedSession, ...bucket]; nextSessions = [normalizedSession, ...sessions];
inserted = true; inserted = true;
} }
const next: Project = { ...project, [bucketKey]: nextBucket }; const next: Project = { ...project, sessions: nextSessions };
if (inserted) { if (inserted) {
const total = Number(project.sessionMeta?.total ?? 0) + 1; const total = Number(project.sessionMeta?.total ?? 0) + 1;
next.sessionMeta = { next.sessionMeta = {
@@ -352,29 +297,21 @@ 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,
...getProjectSessionBucketValues(project), sessions: project.sessions ?? [],
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 removeSessionFromProject = (project: Project, sessionIdToDelete: string): Project => {
const nextBuckets = {} as ProjectSessionBuckets; const sessions = project.sessions ?? [];
let removedFromProject = false; const nextSessions = sessions.filter((session) => session.id !== sessionIdToDelete);
if (nextSessions.length === sessions.length) {
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; return project;
} }
const updatedProject: Project = { const updatedProject: Project = {
...project, ...project,
...nextBuckets, sessions: nextSessions,
}; };
const totalSessions = Math.max(0, Number(project.sessionMeta?.total ?? 0) - 1); const totalSessions = Math.max(0, Number(project.sessionMeta?.total ?? 0) - 1);
@@ -484,7 +421,7 @@ export function useProjectsState({
return mergedProjects; return mergedProjects;
} }
return projectsHaveChanges(prevProjects, mergedProjects, true) return projectsHaveChanges(prevProjects, mergedProjects)
? mergedProjects ? mergedProjects
: prevProjects; : prevProjects;
}); });
@@ -701,7 +638,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,
...getProjectSessionBucketValues({}), sessions: [],
sessionMeta: { hasMore: false, total: 0 }, sessionMeta: { hasMore: false, total: 0 },
} as Project; } as Project;
@@ -783,17 +720,18 @@ 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 match = findProjectSessionById(project, sessionId); const match = project.sessions?.find((session) => session.id === sessionId);
if (match) { if (match) {
const normalizedSession = normalizeSessionProvider(match);
const shouldUpdateProject = selectedProject?.projectId !== project.projectId; const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
const shouldUpdateSession = const shouldUpdateSession =
selectedSession?.id !== sessionId || selectedSession.__provider !== match.provider; selectedSession?.id !== sessionId || selectedSession.__provider !== normalizedSession.__provider;
if (shouldUpdateProject) { if (shouldUpdateProject) {
setSelectedProject(project); setSelectedProject(project);
} }
if (shouldUpdateSession) { if (shouldUpdateSession) {
setSelectedSession({ ...match.session, __provider: match.provider }); setSelectedSession(normalizedSession);
} }
return; return;
} }
@@ -897,7 +835,7 @@ export function useProjectsState({
const mergedProjects = mergeExpandedSessionPages(projects, projectsWithTaskMaster); const mergedProjects = mergeExpandedSessionPages(projects, projectsWithTaskMaster);
setProjects((prevProjects) => setProjects((prevProjects) =>
projectsHaveChanges(prevProjects, mergedProjects, true) ? mergedProjects : prevProjects, projectsHaveChanges(prevProjects, mergedProjects) ? mergedProjects : prevProjects,
); );
if (!selectedProject) { if (!selectedProject) {

View File

@@ -29,6 +29,7 @@ export interface ProjectSession {
updated_at?: string; updated_at?: string;
lastActivity?: string; lastActivity?: string;
messageCount?: number; messageCount?: number;
provider?: LLMProvider;
__provider?: LLMProvider; __provider?: LLMProvider;
// Tags the session with the owning project's DB `projectId` so UI handlers // Tags the session with the owning project's DB `projectId` so UI handlers
// (session switching, sidebar focus, etc.) can match against selectedProject. // (session switching, sidebar focus, etc.) can match against selectedProject.
@@ -60,10 +61,6 @@ export interface Project {
path?: string; path?: string;
isStarred?: boolean; isStarred?: boolean;
sessions?: ProjectSession[]; sessions?: ProjectSession[];
cursorSessions?: ProjectSession[];
codexSessions?: ProjectSession[];
geminiSessions?: ProjectSession[];
opencodeSessions?: ProjectSession[];
sessionMeta?: ProjectSessionMeta; sessionMeta?: ProjectSessionMeta;
taskmaster?: ProjectTaskmasterInfo; taskmaster?: ProjectTaskmasterInfo;
[key: string]: unknown; [key: string]: unknown;