Loading sessions...
;
@@ -275,6 +285,7 @@ export const SessionList = ({
Date: Sat, 12 Sep 2026 22:49:10 -0400
Subject: [PATCH 2/2] fix(session): stop automatic pagination after next-page
errors
---
.../components/session/SessionList.test.tsx | 222 ++++++++++++++++--
.../src/components/session/SessionList.tsx | 31 ++-
frontend/src/hooks/useOpenCode.ts | 1 +
3 files changed, 230 insertions(+), 24 deletions(-)
diff --git a/frontend/src/components/session/SessionList.test.tsx b/frontend/src/components/session/SessionList.test.tsx
index 113eda532..13e8123c0 100644
--- a/frontend/src/components/session/SessionList.test.tsx
+++ b/frontend/src/components/session/SessionList.test.tsx
@@ -1,9 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor, within, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import type { ReactNode } from 'react'
import { SessionList } from './SessionList'
-const { createSessionMock, deleteSessionMock, sessionsData, createSessionState, fetchNextPageMock, hasNextPageRef, isFetchingNextPageRef, lastSessionsHookArgs, sessionPinsData, togglePinMock } = vi.hoisted(() => ({
+const { createSessionMock, deleteSessionMock, sessionsData, createSessionState, fetchNextPageMock, hasNextPageRef, isFetchingNextPageRef, isFetchNextPageErrorRef, useRealSessionsHookRef, lastSessionsHookArgs, sessionPinsData, togglePinMock } = vi.hoisted(() => ({
createSessionMock: vi.fn(),
deleteSessionMock: vi.fn(),
sessionsData: [] as Array<{ id: string; title: string; directory: string; workspaceID?: string; parentID?: string; time: { updated: number } }>,
@@ -11,30 +13,39 @@ const { createSessionMock, deleteSessionMock, sessionsData, createSessionState,
fetchNextPageMock: vi.fn(),
hasNextPageRef: { current: false },
isFetchingNextPageRef: { current: false },
+ isFetchNextPageErrorRef: { current: false },
+ useRealSessionsHookRef: { current: false },
lastSessionsHookArgs: { current: undefined as { opcodeUrl: string; directories: string[]; options?: { search?: string; limit?: number } } | undefined },
sessionPinsData: [] as Array<{ sessionId: string; directory: string; pinnedAt: number }>,
togglePinMock: vi.fn(),
}))
-vi.mock('@/hooks/useOpenCode', () => ({
- useSessionsAcrossDirectories: (opcodeUrl: string, directories: string[], options?: { search?: string; limit?: number }) => {
- lastSessionsHookArgs.current = { opcodeUrl, directories, options }
- // Simulate server-side search: return empty data when a search query is active
- const data = options?.search ? [] : sessionsData
- return {
- data,
- isLoading: false,
- fetchNextPage: fetchNextPageMock,
- hasNextPage: hasNextPageRef.current,
- isFetchingNextPage: isFetchingNextPageRef.current,
- }
- },
- useCreateSession: (_opcodeUrl: string, directory?: string) => {
- createSessionState.directory = directory
- return { mutate: createSessionMock }
- },
- useDeleteSession: () => ({ mutateAsync: deleteSessionMock, isPending: false }),
-}))
+vi.mock('@/hooks/useOpenCode', async (importOriginal) => {
+ const actual = await importOriginal
()
+ return {
+ ...actual,
+ useSessionsAcrossDirectories: (opcodeUrl: string, directories: string[], options?: { search?: string; limit?: number }) => {
+ if (useRealSessionsHookRef.current) {
+ return actual.useSessionsAcrossDirectories(opcodeUrl, directories, options)
+ }
+ lastSessionsHookArgs.current = { opcodeUrl, directories, options }
+ const data = options?.search ? [] : sessionsData
+ return {
+ data,
+ isLoading: false,
+ fetchNextPage: fetchNextPageMock,
+ hasNextPage: hasNextPageRef.current,
+ isFetchingNextPage: isFetchingNextPageRef.current,
+ isFetchNextPageError: isFetchNextPageErrorRef.current,
+ }
+ },
+ useCreateSession: (_opcodeUrl: string, directory?: string) => {
+ createSessionState.directory = directory
+ return { mutate: createSessionMock }
+ },
+ useDeleteSession: () => ({ mutateAsync: deleteSessionMock, isPending: false }),
+ }
+})
vi.mock('@/hooks/useSessionPins', () => ({
useSessionPins: () => ({ data: sessionPinsData }),
@@ -55,6 +66,8 @@ describe('SessionList', () => {
fetchNextPageMock.mockReset()
hasNextPageRef.current = false
isFetchingNextPageRef.current = false
+ isFetchNextPageErrorRef.current = false
+ useRealSessionsHookRef.current = false
lastSessionsHookArgs.current = undefined
sessionPinsData.splice(0, sessionPinsData.length)
togglePinMock.mockReset()
@@ -366,4 +379,173 @@ describe('SessionList', () => {
expect(togglePinMock).toHaveBeenCalledWith({ sessionId: 'ses_x', directory: '/w/a', pinned: true })
})
+
+ it('shows a retry action and stops automatic pagination while a next-page error is active', async () => {
+ hasNextPageRef.current = true
+ isFetchNextPageErrorRef.current = true
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Failed to load more sessions.')).toBeTruthy()
+ expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy()
+
+ const scrollContainer = screen.getByRole('region', { name: 'Sessions' })
+ Object.defineProperty(scrollContainer, 'scrollHeight', { value: 1000, configurable: true })
+ Object.defineProperty(scrollContainer, 'clientHeight', { value: 500, configurable: true })
+ Object.defineProperty(scrollContainer, 'scrollTop', { value: 300, configurable: true })
+ fireEvent.scroll(scrollContainer)
+
+ await waitFor(() => {
+ expect(fetchNextPageMock).not.toHaveBeenCalled()
+ })
+ })
+
+ it('retries the next page when the retry action is clicked after an error', async () => {
+ hasNextPageRef.current = true
+ isFetchNextPageErrorRef.current = true
+ const user = userEvent.setup()
+
+ render(
+ ,
+ )
+
+ await user.click(screen.getByRole('button', { name: 'Retry' }))
+
+ expect(fetchNextPageMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('shows the retry action instead of the loading state when the first page is empty and the next page failed', () => {
+ sessionsData.splice(0, sessionsData.length)
+ hasNextPageRef.current = true
+ isFetchNextPageErrorRef.current = true
+
+ render(
+ ,
+ )
+
+ expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy()
+ expect(screen.queryByText('Loading sessions...')).toBeNull()
+ })
+
+ it('stops automatic pagination after a persistent next-page error and appends older roots on explicit retry with real React Query', async () => {
+ const fetchMock = vi.fn()
+ vi.stubGlobal('fetch', fetchMock)
+
+ const pageOneItems = [
+ ...Array.from({ length: 4 }, (_, index) => ({
+ id: `root${index}`,
+ projectID: 'proj_1',
+ title: `root session ${index}`,
+ time: { created: 100 + index, updated: 100 + index },
+ })),
+ ...Array.from({ length: 21 }, (_, index) => ({
+ id: `child${index}`,
+ projectID: 'proj_1',
+ parentID: `root${index}`,
+ title: `child session ${index}`,
+ time: { created: index, updated: index },
+ })),
+ ]
+
+ let cursorAttempts = 0
+ let recovered = false
+ let resolveRecovery: ((response: Response) => void) | undefined
+ fetchMock.mockImplementation((input: RequestInfo | URL) => {
+ const url = String(input)
+ if (url.includes('cursor=cursor_next')) {
+ cursorAttempts += 1
+ if (!recovered) {
+ return Promise.resolve(new Response(JSON.stringify({ error: 'persistent failure' }), {
+ status: 500,
+ headers: { 'Content-Type': 'application/json' },
+ }))
+ }
+ return new Promise((resolve) => {
+ resolveRecovery = resolve
+ })
+ }
+ return Promise.resolve(new Response(JSON.stringify({
+ items: pageOneItems,
+ cursor: { next: 'cursor_next' },
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }))
+ })
+
+ useRealSessionsHookRef.current = true
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: 1, retryDelay: 0 }, mutations: { retry: false } },
+ })
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ )
+
+ let unmount: (() => void) | undefined
+ try {
+ const rendered = render(
+ ,
+ { wrapper },
+ )
+ unmount = rendered.unmount
+
+ await waitFor(() => {
+ expect(screen.getByText('root session 0')).toBeTruthy()
+ })
+
+ await waitFor(() => {
+ expect(screen.getByText('Failed to load more sessions.')).toBeTruthy()
+ })
+ expect(cursorAttempts).toBe(2)
+
+ const scrollContainer = screen.getByRole('region', { name: 'Sessions' })
+ fireEvent.scroll(scrollContainer)
+ fireEvent.scroll(scrollContainer)
+
+ await new Promise((resolve) => setTimeout(resolve, 20))
+ expect(cursorAttempts).toBe(2)
+
+ recovered = true
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
+
+ await waitFor(() => {
+ expect(resolveRecovery).toBeDefined()
+ })
+ expect(screen.getByRole('button', { name: 'Retry' })).toBeDisabled()
+ expect(screen.getByText('Loading more sessions...')).toBeTruthy()
+ expect(screen.getByText('root session 0')).toBeTruthy()
+ expect(screen.queryByText('child session 0')).toBeNull()
+
+ resolveRecovery?.(new Response(JSON.stringify({
+ items: [{ id: 'older_root', projectID: 'proj_1', title: 'older root session', time: { created: 1, updated: 1 } }],
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }))
+
+ await waitFor(() => {
+ expect(screen.getByText('older root session')).toBeTruthy()
+ })
+ expect(cursorAttempts).toBe(3)
+ expect(screen.getByText('root session 0')).toBeTruthy()
+ expect(screen.queryByText('child session 0')).toBeNull()
+ expect(screen.queryByText('Failed to load more sessions.')).toBeNull()
+ } finally {
+ unmount?.()
+ queryClient.clear()
+ vi.unstubAllGlobals()
+ }
+ })
})
diff --git a/frontend/src/components/session/SessionList.tsx b/frontend/src/components/session/SessionList.tsx
index dd0b7ae7f..f68589332 100644
--- a/frontend/src/components/session/SessionList.tsx
+++ b/frontend/src/components/session/SessionList.tsx
@@ -41,7 +41,7 @@ export const SessionList = ({
buildSessionKey(session.directory ?? primaryDirectory, session.id),
[primaryDirectory]);
const [searchQuery, setSearchQuery] = useState("");
- const { data: sessions, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useSessionsAcrossDirectories(opcodeUrl, directoriesList, { search: searchQuery, limit: 25 });
+ const { data: sessions, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage, isFetchNextPageError } = useSessionsAcrossDirectories(opcodeUrl, directoriesList, { search: searchQuery, limit: 25 });
const deleteSession = useDeleteSession(opcodeUrl, directoriesList);
const createSession = useCreateSession(opcodeUrl, sessionCreateDirectory, (newSession) => {
onSelectSession(newSession.id);
@@ -89,12 +89,16 @@ export const SessionList = ({
togglePin.mutate({ sessionId: session.id, directory, pinned: !pinnedKeys.has(key) });
};
+ const handleRetryNextPage = useCallback(() => {
+ void fetchNextPage();
+ }, [fetchNextPage]);
+
const handleSessionsScroll = useCallback((event: React.UIEvent) => {
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
- if (scrollHeight - scrollTop - clientHeight <= 240 && hasNextPage && !isFetchingNextPage) {
+ if (scrollHeight - scrollTop - clientHeight <= 240 && hasNextPage && !isFetchingNextPage && !isFetchNextPageError) {
void fetchNextPage();
}
- }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
+ }, [hasNextPage, isFetchingNextPage, isFetchNextPageError, fetchNextPage]);
useEffect(() => {
const sessionList = sessionListRef.current;
@@ -106,16 +110,27 @@ export const SessionList = ({
&& isNearBottom
&& hasNextPage
&& !isFetchingNextPage
+ && !isFetchNextPageError
) {
void fetchNextPage();
}
- }, [isLoading, filteredSessions, hasNextPage, isFetchingNextPage, fetchNextPage]);
+ }, [isLoading, filteredSessions, hasNextPage, isFetchingNextPage, isFetchNextPageError, fetchNextPage]);
if (isLoading) {
return Loading sessions...
;
}
if (!sessions || sessions.length === 0) {
+ if (isFetchNextPageError) {
+ return (
+
+
Failed to load sessions.
+
+
+ );
+ }
if (hasNextPage || isFetchingNextPage) {
return Loading sessions...
;
}
@@ -323,6 +338,14 @@ export const SessionList = ({
{olderSessions.map((session) => renderSessionCard(session, false))}
>
)}
+ {isFetchNextPageError && (
+
+
Failed to load more sessions.
+
+
+ )}
{isFetchingNextPage && (
Loading more sessions...
diff --git a/frontend/src/hooks/useOpenCode.ts b/frontend/src/hooks/useOpenCode.ts
index d6e9babd2..2c5bf1a96 100644
--- a/frontend/src/hooks/useOpenCode.ts
+++ b/frontend/src/hooks/useOpenCode.ts
@@ -124,6 +124,7 @@ export const useSessionsAcrossDirectories = (
fetchNextPage: query.fetchNextPage,
hasNextPage: query.hasNextPage,
isFetchingNextPage: query.isFetchingNextPage,
+ isFetchNextPageError: query.isFetchNextPageError,
error: query.error,
};
};