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

@@ -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.