mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-30 09:21:33 +00:00
refactor: implement pagination for project sessions loading
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user