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

@@ -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,
},
};
}