mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-21 08:32:04 +08:00
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:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user