refactor: implement pagination for project sessions loading

This commit is contained in:
Haileyesus
2026-04-27 21:22:56 +03:00
parent 14e6b5b7b2
commit 9a8fb116ef
11 changed files with 439 additions and 63 deletions

View File

@@ -151,6 +151,32 @@ export const sessionsDb = {
.all(projectPath) as SessionRow[];
},
getSessionsByProjectPathPage(projectPath: string, limit: number, offset: number): SessionRow[] {
const db = getConnection();
return db
.prepare(
`SELECT session_id, provider, project_path, jsonl_path, custom_name, created_at, updated_at
FROM sessions
WHERE project_path = ?
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, session_id DESC
LIMIT ? OFFSET ?`
)
.all(projectPath, limit, offset) as SessionRow[];
},
countSessionsByProjectPath(projectPath: string): number {
const db = getConnection();
const row = db
.prepare(
`SELECT COUNT(*) AS count
FROM sessions
WHERE project_path = ?`
)
.get(projectPath) as { count: number } | undefined;
return Number(row?.count ?? 0);
},
deleteSessionsByProjectPath(projectPath: string): void {
const db = getConnection();
db.prepare(`DELETE FROM sessions WHERE project_path = ?`).run(projectPath);

View File

@@ -4,7 +4,7 @@ import { createProject, updateProjectDisplayName } from '@/modules/projects/serv
import { startCloneProject } from '@/modules/projects/services/project-clone.service.js';
import { getProjectTaskMaster } from '@/modules/projects/services/projects-has-taskmaster.service.js';
import { AppError, asyncHandler } from '@/shared/utils.js';
import { getProjectsWithSessions } from '@/modules/projects/services/projects-with-sessions-fetch.service.js';
import { getProjectSessionsPage, getProjectsWithSessions } from '@/modules/projects/services/projects-with-sessions-fetch.service.js';
import { deleteOrArchiveProject } from '@/modules/projects/services/project-delete.service.js';
import { applyLegacyStarredProjectIds, toggleProjectStar } from '@/modules/projects/services/project-star.service.js';
@@ -36,6 +36,23 @@ function readOptionalNumericQueryValue(value: unknown): number | null {
return Number.isNaN(parsedValue) ? null : parsedValue;
}
function parseNonNegativeIntQuery(value: unknown, name: string, fallback: number): number {
const rawValue = readQueryStringValue(value).trim();
if (!rawValue) {
return fallback;
}
const parsedValue = Number.parseInt(rawValue, 10);
if (Number.isNaN(parsedValue) || parsedValue < 0) {
throw new AppError(`${name} must be a non-negative integer`, {
code: 'INVALID_QUERY_PARAMETER',
statusCode: 400,
});
}
return parsedValue;
}
function resolveRouteErrorMessage(error: unknown): string {
if (error instanceof AppError) {
return error.message;
@@ -56,6 +73,17 @@ router.get(
}),
);
router.get(
'/:projectId/sessions',
asyncHandler(async (req, res) => {
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
const limit = parseNonNegativeIntQuery(req.query.limit, 'limit', 20);
const offset = parseNonNegativeIntQuery(req.query.offset, 'offset', 0);
const sessionsPage = await getProjectSessionsPage(projectId, { limit, offset });
res.json(sessionsPage);
}),
);
router.post(
'/create-project',
asyncHandler(async (req, res) => {

View File

@@ -5,6 +5,7 @@ import { projectsDb, sessionsDb } from '@/modules/database/index.js';
import { sessionSynchronizerService } from '@/modules/providers/index.js';
import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js';
import type { RealtimeClientConnection } from '@/shared/types.js';
import { AppError } from '@/shared/utils.js';
type SessionSummary = {
id: string;
@@ -15,6 +16,14 @@ type SessionSummary = {
type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini', SessionSummary[]>;
type SessionRepositoryRow = {
provider: string;
session_id: string;
custom_name?: string | null;
updated_at?: string | null;
created_at?: string | null;
};
export type ProjectListItem = {
projectId: string;
path: string;
@@ -40,8 +49,36 @@ type ProgressUpdate = {
type GetProjectsWithSessionsOptions = {
skipSynchronization?: boolean;
sessionsLimit?: number;
sessionsOffset?: number;
};
type SessionPaginationOptions = {
limit?: number;
offset?: number;
};
type ProjectSessionsPageResult = {
sessionsByProvider: SessionsByProvider;
total: number;
hasMore: boolean;
};
export type ProjectSessionsPageApiView = {
projectId: string;
sessions: SessionSummary[];
cursorSessions: SessionSummary[];
codexSessions: SessionSummary[];
geminiSessions: SessionSummary[];
sessionMeta: {
hasMore: boolean;
total: number;
};
};
const DEFAULT_PROJECT_SESSIONS_PAGE_SIZE = 20;
const MAX_PROJECT_SESSIONS_PAGE_SIZE = 200;
/**
* Generate better display name from path.
*/
@@ -73,17 +110,26 @@ export async function generateDisplayName(projectName: string, actualProjectDir:
return projectPath;
}
/**
* Group the `sessions` table rows for a project by provider.
*/
function buildSessionsByProviderFromDb(projectPath: string): SessionsByProvider {
const rows = sessionsDb.getSessionsByProjectPath(projectPath) as Array<{
provider: string;
session_id: string;
custom_name?: string | null;
updated_at?: string | null;
created_at?: string | null;
}>;
function normalizeSessionPagination(options: SessionPaginationOptions = {}): { limit: number; offset: number } {
const rawLimit = Number.isFinite(options.limit) ? Math.floor(Number(options.limit)) : DEFAULT_PROJECT_SESSIONS_PAGE_SIZE;
const rawOffset = Number.isFinite(options.offset) ? Math.floor(Number(options.offset)) : 0;
return {
limit: Math.min(Math.max(1, rawLimit), MAX_PROJECT_SESSIONS_PAGE_SIZE),
offset: Math.max(0, rawOffset),
};
}
function mapSessionRowToSummary(row: SessionRepositoryRow): SessionSummary {
return {
id: row.session_id,
summary: row.custom_name || '',
messageCount: 0,
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
};
}
function bucketSessionRowsByProvider(rows: SessionRepositoryRow[]): SessionsByProvider {
const byProvider: SessionsByProvider = {
claude: [],
cursor: [],
@@ -98,21 +144,34 @@ function buildSessionsByProviderFromDb(projectPath: string): SessionsByProvider
continue;
}
bucket.push({
id: row.session_id,
summary: row.custom_name || '',
messageCount: 0,
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
});
}
for (const provider of Object.keys(byProvider) as Array<keyof SessionsByProvider>) {
byProvider[provider].sort((a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime());
bucket.push(mapSessionRowToSummary(row));
}
return byProvider;
}
/**
* Reads one paginated project session slice from the DB and groups rows by provider.
*/
function readProjectSessionsPageByPath(
projectPath: string,
options: SessionPaginationOptions = {},
): ProjectSessionsPageResult {
const pagination = normalizeSessionPagination(options);
const rows = sessionsDb.getSessionsByProjectPathPage(
projectPath,
pagination.limit,
pagination.offset,
) as SessionRepositoryRow[];
const total = sessionsDb.countSessionsByProjectPath(projectPath);
return {
sessionsByProvider: bucketSessionRowsByProvider(rows),
total,
hasMore: pagination.offset + rows.length < total,
};
}
// Broadcast progress to all connected WebSocket clients
function broadcastProgress(progress: ProgressUpdate) {
const message = JSON.stringify({
@@ -165,9 +224,10 @@ export async function getProjectsWithSessions(
? row.custom_project_name
: await generateDisplayName(path.basename(projectPath) || projectPath, projectPath);
const sessionsByProvider = buildSessionsByProviderFromDb(projectPath);
const claudeSessionsAll = sessionsByProvider.claude;
const claudeSessions = claudeSessionsAll.slice(0, 5);
const sessionsPage = readProjectSessionsPageByPath(projectPath, {
limit: options.sessionsLimit,
offset: options.sessionsOffset,
});
projects.push({
projectId,
@@ -175,13 +235,13 @@ export async function getProjectsWithSessions(
displayName,
fullPath: projectPath,
isStarred: Boolean(row.isStarred),
sessions: claudeSessions,
cursorSessions: sessionsByProvider.cursor,
codexSessions: sessionsByProvider.codex,
geminiSessions: sessionsByProvider.gemini,
sessions: sessionsPage.sessionsByProvider.claude,
cursorSessions: sessionsPage.sessionsByProvider.cursor,
codexSessions: sessionsPage.sessionsByProvider.codex,
geminiSessions: sessionsPage.sessionsByProvider.gemini,
sessionMeta: {
hasMore: false,
total: claudeSessionsAll.length,
hasMore: sessionsPage.hasMore,
total: sessionsPage.total,
},
});
}
@@ -194,3 +254,32 @@ export async function getProjectsWithSessions(
return projects;
}
/**
* Loads one paginated session slice for a specific project id.
*/
export async function getProjectSessionsPage(
projectId: string,
options: SessionPaginationOptions = {},
): Promise<ProjectSessionsPageApiView> {
const projectRow = projectsDb.getProjectById(projectId);
if (!projectRow) {
throw new AppError(`Project "${projectId}" was not found.`, {
code: 'PROJECT_NOT_FOUND',
statusCode: 404,
});
}
const sessionsPage = readProjectSessionsPageByPath(projectRow.project_path, options);
return {
projectId: projectRow.project_id,
sessions: sessionsPage.sessionsByProvider.claude,
cursorSessions: sessionsPage.sessionsByProvider.cursor,
codexSessions: sessionsPage.sessionsByProvider.codex,
geminiSessions: sessionsPage.sessionsByProvider.gemini,
sessionMeta: {
hasMore: sessionsPage.hasMore,
total: sessionsPage.total,
},
};
}

View File

@@ -70,6 +70,7 @@ type UseSidebarControllerArgs = {
onProjectSelect: (project: Project) => void;
onSessionSelect: (session: ProjectSession) => void;
onSessionDelete?: (sessionId: string) => void;
onLoadMoreSessions?: (projectId: string) => Promise<void> | void;
// `projectId` is the DB-assigned identifier; callbacks use that post-migration.
onProjectDelete?: (projectId: string) => void;
setCurrentProject: (project: Project) => void;
@@ -88,6 +89,7 @@ export function useSidebarController({
onProjectSelect,
onSessionSelect,
onSessionDelete,
onLoadMoreSessions,
onProjectDelete,
setCurrentProject,
setSidebarVisible,
@@ -113,6 +115,7 @@ export function useSidebarController({
const [isSearching, setIsSearching] = useState(false);
const [searchProgress, setSearchProgress] = useState<SearchProgress | null>(null);
const [optimisticStarByProjectId, setOptimisticStarByProjectId] = useState<Map<string, boolean>>(new Map());
const [loadingMoreProjects, setLoadingMoreProjects] = useState<Set<string>>(new Set());
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchSeqRef = useRef(0);
const eventSourceRef = useRef<EventSource | null>(null);
@@ -436,6 +439,31 @@ export function useSidebarController({
const getProjectSessions = useCallback((project: Project) => getAllSessions(project), []);
const loadMoreSessionsForProject = useCallback(async (projectId: string) => {
if (!onLoadMoreSessions || loadingMoreProjects.has(projectId)) {
return;
}
setLoadingMoreProjects((previous) => {
const next = new Set(previous);
next.add(projectId);
return next;
});
try {
await onLoadMoreSessions(projectId);
} catch (error) {
console.error('[Sidebar] Failed to load more sessions:', error);
alert(t('messages.refreshError'));
} finally {
setLoadingMoreProjects((previous) => {
const next = new Set(previous);
next.delete(projectId);
return next;
});
}
}, [loadingMoreProjects, onLoadMoreSessions, t]);
const projectsWithResolvedStarState = useMemo(() => {
if (optimisticStarByProjectId.size === 0) {
return projects;
@@ -661,6 +689,7 @@ export function useSidebarController({
editingSessionName,
searchFilter,
deletingProjects,
loadingMoreProjects,
deleteConfirmation,
sessionDeleteConfirmation,
showVersionModal,
@@ -670,6 +699,7 @@ export function useSidebarController({
toggleStarProject,
isProjectStarred,
getProjectSessions,
loadMoreSessionsForProject,
startEditing,
cancelEditing,
saveProjectName,

View File

@@ -28,6 +28,7 @@ export type SidebarProps = {
onSessionSelect: (session: ProjectSession) => void;
onNewSession: (project: Project) => void;
onSessionDelete?: (sessionId: string) => void;
onLoadMoreSessions?: (projectId: string) => Promise<void> | void;
// `projectId` is the DB identifier; the sidebar hands it back to the parent
// when the delete flow completes.
onProjectDelete?: (projectId: string) => void;

View File

@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useDeviceSettings } from '../../../hooks/useDeviceSettings';
import { useVersionCheck } from '../../../hooks/useVersionCheck';
import { useUiPreferences } from '../../../hooks/useUiPreferences';
@@ -8,6 +9,7 @@ import { useTaskMaster } from '../../../contexts/TaskMasterContext';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import type { Project, LLMProvider } from '../../../types/app';
import type { MCPServerStatus, SidebarProps } from '../types/types';
import SidebarCollapsed from './subcomponents/SidebarCollapsed';
import SidebarContent from './subcomponents/SidebarContent';
import SidebarModals from './subcomponents/SidebarModals';
@@ -26,6 +28,7 @@ function Sidebar({
onSessionSelect,
onNewSession,
onSessionDelete,
onLoadMoreSessions,
onProjectDelete,
isLoading,
loadingProgress,
@@ -75,6 +78,8 @@ function Sidebar({
toggleStarProject,
isProjectStarred,
getProjectSessions,
loadingMoreProjects,
loadMoreSessionsForProject,
startEditing,
cancelEditing,
saveProjectName,
@@ -106,6 +111,7 @@ function Sidebar({
onProjectSelect,
onSessionSelect,
onSessionDelete,
onLoadMoreSessions,
onProjectDelete,
setCurrentProject,
setSidebarVisible: (visible) => setPreference('sidebarVisible', visible),
@@ -148,6 +154,7 @@ function Sidebar({
tasksEnabled,
mcpServerStatus,
getProjectSessions,
loadingMoreProjects,
isProjectStarred,
onEditingNameChange: setEditingName,
onToggleProject: toggleProject,
@@ -161,6 +168,7 @@ function Sidebar({
onDeleteProject: requestProjectDelete,
onSessionSelect: handleSessionClick,
onDeleteSession: showDeleteSessionConfirmation,
onLoadMoreSessions: loadMoreSessionsForProject,
onNewSession,
onEditingSessionNameChange: setEditingSessionName,
onStartEditingSession: (sessionId, initialName) => {

View File

@@ -1,10 +1,12 @@
import { Check, ChevronDown, ChevronRight, Edit3, Folder, FolderOpen, Star, Trash2, X } from 'lucide-react';
import type { TFunction } from 'i18next';
import { Button } from '../../../../shared/view/ui';
import { cn } from '../../../../lib/utils';
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
import type { MCPServerStatus, SessionWithProvider } from '../../types/types';
import { getTaskIndicatorStatus } from '../../utils/utils';
import TaskIndicator from './TaskIndicator';
import SidebarProjectSessions from './SidebarProjectSessions';
@@ -19,6 +21,7 @@ type SidebarProjectItemProps = {
editingName: string;
sessions: SessionWithProvider[];
initialSessionsLoaded: boolean;
isLoadingMoreSessions: boolean;
currentTime: Date;
editingSession: string | null;
editingSessionName: string;
@@ -39,6 +42,7 @@ type SidebarProjectItemProps = {
sessionTitle: string,
provider: LLMProvider,
) => void;
onLoadMoreSessions: (projectId: string) => void;
onNewSession: (project: Project) => void;
onEditingSessionNameChange: (value: string) => void;
onStartEditingSession: (sessionId: string, initialName: string) => void;
@@ -47,7 +51,10 @@ type SidebarProjectItemProps = {
t: TFunction;
};
const getSessionCountDisplay = (sessions: SessionWithProvider[]): string => String(sessions.length);
const getSessionCountDisplay = (project: Project, sessions: SessionWithProvider[]): string => {
const total = Number(project.sessionMeta?.total ?? sessions.length);
return String(total);
};
export default function SidebarProjectItem({
project,
@@ -60,6 +67,7 @@ export default function SidebarProjectItem({
editingName,
sessions,
initialSessionsLoaded,
isLoadingMoreSessions,
currentTime,
editingSession,
editingSessionName,
@@ -75,6 +83,7 @@ export default function SidebarProjectItem({
onDeleteProject,
onSessionSelect,
onDeleteSession,
onLoadMoreSessions,
onNewSession,
onEditingSessionNameChange,
onStartEditingSession,
@@ -86,8 +95,9 @@ export default function SidebarProjectItem({
// after the projectName → projectId migration.
const isSelected = selectedProject?.projectId === project.projectId;
const isEditing = editingProject === project.projectId;
const sessionCountDisplay = getSessionCountDisplay(sessions);
const sessionCountLabel = `${sessionCountDisplay} session${sessions.length === 1 ? '' : 's'}`;
const totalSessionCount = Number(project.sessionMeta?.total ?? sessions.length);
const sessionCountDisplay = getSessionCountDisplay(project, sessions);
const sessionCountLabel = `${sessionCountDisplay} session${totalSessionCount === 1 ? '' : 's'}`;
const taskStatus = getTaskIndicatorStatus(project, mcpServerStatus);
const toggleProject = () => onToggleProject(project.projectId);
@@ -399,6 +409,8 @@ export default function SidebarProjectItem({
sessions={sessions}
selectedSession={selectedSession}
initialSessionsLoaded={initialSessionsLoaded}
hasMoreSessions={Boolean(project.sessionMeta?.hasMore)}
isLoadingMoreSessions={isLoadingMoreSessions}
currentTime={currentTime}
editingSession={editingSession}
editingSessionName={editingSessionName}
@@ -409,6 +421,7 @@ export default function SidebarProjectItem({
onProjectSelect={onProjectSelect}
onSessionSelect={onSessionSelect}
onDeleteSession={onDeleteSession}
onLoadMoreSessions={onLoadMoreSessions}
onNewSession={onNewSession}
t={t}
/>

View File

@@ -1,7 +1,9 @@
import { useEffect } from 'react';
import type { TFunction } from 'i18next';
import type { LoadingProgress, Project, ProjectSession, LLMProvider } from '../../../../types/app';
import type { MCPServerStatus, SessionWithProvider } from '../../types/types';
import SidebarProjectItem from './SidebarProjectItem';
import SidebarProjectsState from './SidebarProjectsState';
@@ -23,6 +25,8 @@ export type SidebarProjectListProps = {
tasksEnabled: boolean;
mcpServerStatus: MCPServerStatus;
getProjectSessions: (project: Project) => SessionWithProvider[];
onLoadMoreSessions: (projectId: string) => void;
loadingMoreProjects: Set<string>;
isProjectStarred: (projectName: string) => boolean;
onEditingNameChange: (value: string) => void;
onToggleProject: (projectName: string) => void;
@@ -65,6 +69,8 @@ export default function SidebarProjectList({
tasksEnabled,
mcpServerStatus,
getProjectSessions,
onLoadMoreSessions,
loadingMoreProjects,
isProjectStarred,
onEditingNameChange,
onToggleProject,
@@ -123,6 +129,7 @@ export default function SidebarProjectList({
editingName={editingName}
sessions={getProjectSessions(project)}
initialSessionsLoaded={initialSessionsLoaded.has(project.projectId)}
isLoadingMoreSessions={loadingMoreProjects.has(project.projectId)}
currentTime={currentTime}
editingSession={editingSession}
editingSessionName={editingSessionName}
@@ -138,6 +145,7 @@ export default function SidebarProjectList({
onDeleteProject={onDeleteProject}
onSessionSelect={onSessionSelect}
onDeleteSession={onDeleteSession}
onLoadMoreSessions={onLoadMoreSessions}
onNewSession={onNewSession}
onEditingSessionNameChange={onEditingSessionNameChange}
onStartEditingSession={onStartEditingSession}

View File

@@ -1,8 +1,10 @@
import { Plus } from 'lucide-react';
import type { TFunction } from 'i18next';
import { Button } from '../../../../shared/view/ui';
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
import type { SessionWithProvider } from '../../types/types';
import SidebarSessionItem from './SidebarSessionItem';
type SidebarProjectSessionsProps = {
@@ -11,6 +13,8 @@ type SidebarProjectSessionsProps = {
sessions: SessionWithProvider[];
selectedSession: ProjectSession | null;
initialSessionsLoaded: boolean;
hasMoreSessions: boolean;
isLoadingMoreSessions: boolean;
currentTime: Date;
editingSession: string | null;
editingSessionName: string;
@@ -26,6 +30,7 @@ type SidebarProjectSessionsProps = {
sessionTitle: string,
provider: LLMProvider,
) => void;
onLoadMoreSessions: (projectId: string) => void;
onNewSession: (project: Project) => void;
t: TFunction;
};
@@ -54,6 +59,8 @@ export default function SidebarProjectSessions({
sessions,
selectedSession,
initialSessionsLoaded,
hasMoreSessions,
isLoadingMoreSessions,
currentTime,
editingSession,
editingSessionName,
@@ -64,6 +71,7 @@ export default function SidebarProjectSessions({
onProjectSelect,
onSessionSelect,
onDeleteSession,
onLoadMoreSessions,
onNewSession,
t,
}: SidebarProjectSessionsProps) {
@@ -105,25 +113,39 @@ export default function SidebarProjectSessions({
<p className="text-xs text-muted-foreground">{t('sessions.noSessions')}</p>
</div>
) : (
sessions.map((session) => (
<SidebarSessionItem
key={session.id}
project={project}
session={session}
selectedSession={selectedSession}
currentTime={currentTime}
editingSession={editingSession}
editingSessionName={editingSessionName}
onEditingSessionNameChange={onEditingSessionNameChange}
onStartEditingSession={onStartEditingSession}
onCancelEditingSession={onCancelEditingSession}
onSaveEditingSession={onSaveEditingSession}
onProjectSelect={onProjectSelect}
onSessionSelect={onSessionSelect}
onDeleteSession={onDeleteSession}
t={t}
/>
))
<>
{sessions.map((session) => (
<SidebarSessionItem
key={session.id}
project={project}
session={session}
selectedSession={selectedSession}
currentTime={currentTime}
editingSession={editingSession}
editingSessionName={editingSessionName}
onEditingSessionNameChange={onEditingSessionNameChange}
onStartEditingSession={onStartEditingSession}
onCancelEditingSession={onCancelEditingSession}
onSaveEditingSession={onSaveEditingSession}
onProjectSelect={onProjectSelect}
onSessionSelect={onSessionSelect}
onDeleteSession={onDeleteSession}
t={t}
/>
))}
{hasMoreSessions && (
<Button
variant="ghost"
size="sm"
className="h-8 w-full justify-center text-xs text-muted-foreground hover:text-foreground"
onClick={() => onLoadMoreSessions(project.projectId)}
disabled={isLoadingMoreSessions}
>
{isLoadingMoreSessions ? t('sessions.loadingSessions') : 'Load more sessions'}
</Button>
)}
</>
)}
</div>
);

View File

@@ -100,6 +100,86 @@ const getProjectSessions = (project: Project): ProjectSession[] => {
];
};
const countLoadedProjectSessions = (project: Project): number => getProjectSessions(project).length;
const mergeSessionProviderLists = (baseSessions: ProjectSession[], additionalSessions: ProjectSession[]): ProjectSession[] => {
const merged = [...baseSessions];
const seenSessionIds = new Set(baseSessions.map((session) => String(session.id)));
for (const session of additionalSessions) {
const sessionId = String(session.id);
if (seenSessionIds.has(sessionId)) {
continue;
}
merged.push(session);
seenSessionIds.add(sessionId);
}
return merged;
};
const mergeExpandedSessionPages = (previousProjects: Project[], incomingProjects: Project[]): Project[] => {
if (previousProjects.length === 0) {
return incomingProjects;
}
const previousByProjectId = new Map(previousProjects.map((project) => [project.projectId, project]));
return incomingProjects.map((incomingProject) => {
const previousProject = previousByProjectId.get(incomingProject.projectId);
if (!previousProject) {
return incomingProject;
}
const previousLoadedCount = countLoadedProjectSessions(previousProject);
const incomingLoadedCount = countLoadedProjectSessions(incomingProject);
if (previousLoadedCount <= incomingLoadedCount) {
return incomingProject;
}
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 ?? []),
};
const totalSessions = Number(incomingProject.sessionMeta?.total ?? previousLoadedCount);
mergedProject.sessionMeta = {
...incomingProject.sessionMeta,
total: totalSessions,
hasMore: countLoadedProjectSessions(mergedProject) < totalSessions,
};
return mergedProject;
});
};
const mergeProjectSessionPage = (
existingProject: Project,
sessionsPage: Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'sessionMeta'>,
): 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 ?? []),
};
const totalSessions = Number(sessionsPage.sessionMeta?.total ?? existingProject.sessionMeta?.total ?? 0);
mergedProject.sessionMeta = {
...existingProject.sessionMeta,
...sessionsPage.sessionMeta,
total: totalSessions,
hasMore: countLoadedProjectSessions(mergedProject) < totalSessions,
};
return mergedProject;
};
const isUpdateAdditive = (
currentProjects: Project[],
updatedProjects: Project[],
@@ -194,7 +274,8 @@ export function useProjectsState({
const projectData = (await response.json()) as Project[];
setProjects((prevProjects) => {
const mergedProjects = mergeTaskMasterCache(projectData, prevProjects);
const projectsWithTaskMaster = mergeTaskMasterCache(projectData, prevProjects);
const mergedProjects = mergeExpandedSessionPages(prevProjects, projectsWithTaskMaster);
if (prevProjects.length === 0) {
return mergedProjects;
@@ -336,7 +417,8 @@ export function useProjectsState({
(selectedSession && activeSessions.has(selectedSession.id)) ||
(activeSessions.size > 0 && Array.from(activeSessions).some((id) => id.startsWith('new-session-')));
const updatedProjects = mergeTaskMasterCache(projectsMessage.projects, projects);
const updatedProjectsWithTaskMaster = mergeTaskMasterCache(projectsMessage.projects, projects);
const updatedProjects = mergeExpandedSessionPages(projects, updatedProjectsWithTaskMaster);
if (
hasActiveSession &&
@@ -522,14 +604,24 @@ export function useProjectsState({
}
setProjects((prevProjects) =>
prevProjects.map((project) => ({
...project,
sessions: project.sessions?.filter((session) => session.id !== sessionIdToDelete) ?? [],
sessionMeta: {
prevProjects.map((project) => {
const updatedProject: Project = {
...project,
sessions: project.sessions?.filter((session) => session.id !== sessionIdToDelete) ?? [],
cursorSessions: project.cursorSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [],
codexSessions: project.codexSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [],
geminiSessions: project.geminiSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [],
};
const totalSessions = Math.max(0, Number(project.sessionMeta?.total ?? 0) - 1);
updatedProject.sessionMeta = {
...project.sessionMeta,
total: Math.max(0, (project.sessionMeta?.total as number | undefined ?? 0) - 1),
},
})),
total: totalSessions,
hasMore: countLoadedProjectSessions(updatedProject) < totalSessions,
};
return updatedProject;
}),
);
},
[navigate, selectedSession?.id],
@@ -539,7 +631,8 @@ export function useProjectsState({
try {
const response = await api.projects();
const freshProjects = (await response.json()) as Project[];
const mergedProjects = mergeTaskMasterCache(freshProjects, projects);
const projectsWithTaskMaster = mergeTaskMasterCache(freshProjects, projects);
const mergedProjects = mergeExpandedSessionPages(projects, projectsWithTaskMaster);
setProjects((prevProjects) =>
projectsHaveChanges(prevProjects, mergedProjects, true) ? mergedProjects : prevProjects,
@@ -582,6 +675,55 @@ export function useProjectsState({
}
}, [projects, selectedProject, selectedSession]);
const loadMoreProjectSessions = useCallback(async (projectId: string) => {
const project = projects.find((candidate) => candidate.projectId === projectId);
if (!project) {
return;
}
const loadedCount = countLoadedProjectSessions(project);
const totalCount = Number(project.sessionMeta?.total ?? 0);
if (totalCount > 0 && loadedCount >= totalCount) {
return;
}
const response = await api.projectSessions(projectId, {
limit: 20,
offset: loadedCount,
});
if (!response.ok) {
const payload = (await response.json().catch(() => ({}))) as { error?: string | { message?: string } };
const errorPayload = payload.error;
const message =
typeof errorPayload === 'string'
? errorPayload
: errorPayload && typeof errorPayload === 'object' && errorPayload.message
? errorPayload.message
: `Failed to load more sessions for project ${projectId}`;
throw new Error(message);
}
const sessionsPage = (await response.json()) as Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'sessionMeta'>;
let mergedProjectForSelection: Project | null = null;
setProjects((previousProjects) =>
previousProjects.map((candidate) => {
if (candidate.projectId !== projectId) {
return candidate;
}
const mergedProject = mergeProjectSessionPage(candidate, sessionsPage);
mergedProjectForSelection = mergedProject;
return mergedProject;
}),
);
if (selectedProject?.projectId === projectId && mergedProjectForSelection) {
setSelectedProject(mergedProjectForSelection);
}
}, [projects, selectedProject?.projectId]);
// `projectId` is the DB identifier passed from the sidebar's delete flow
// after the migration away from folder-derived project names.
const handleProjectDelete = useCallback(
@@ -606,6 +748,7 @@ export function useProjectsState({
onSessionSelect: handleSessionSelect,
onNewSession: handleNewSession,
onSessionDelete: handleSessionDelete,
onLoadMoreSessions: loadMoreProjectSessions,
onProjectDelete: handleProjectDelete,
isLoading: isLoadingProjects,
loadingProgress,
@@ -621,6 +764,7 @@ export function useProjectsState({
handleProjectDelete,
handleProjectSelect,
handleSessionDelete,
loadMoreProjectSessions,
handleSessionSelect,
handleSidebarRefresh,
isLoadingProjects,
@@ -658,6 +802,7 @@ export function useProjectsState({
handleSessionSelect,
handleNewSession,
handleSessionDelete,
loadMoreProjectSessions,
handleProjectDelete,
handleSidebarRefresh,
};

View File

@@ -54,6 +54,12 @@ export const api = {
// After the projectName → projectId migration the path/query identifier is
// the DB-assigned `projectId`; parameter names reflect that for clarity.
projects: () => authenticatedFetch('/api/projects'),
projectSessions: (projectId, { limit = 20, offset = 0 } = {}) => {
const params = new URLSearchParams();
params.set('limit', String(limit));
params.set('offset', String(offset));
return authenticatedFetch(`/api/projects/${encodeURIComponent(projectId)}/sessions?${params.toString()}`);
},
projectTaskmaster: (projectId) =>
authenticatedFetch(`/api/projects/${encodeURIComponent(projectId)}/taskmaster`),
// Unified endpoint for persisted session messages.