diff --git a/frontend/package.json b/frontend/package.json index b7b4dfd48..33d670686 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,7 +28,9 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@tailwindcss/vite": "^4.1.14", - "@tanstack/react-query": "^5.90.5", + "@tanstack/query-sync-storage-persister": "5.101.2", + "@tanstack/react-query": "^5.101.2", + "@tanstack/react-query-persist-client": "5.101.2", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6453076d7..ddb4e713c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,6 +28,7 @@ import { useMobileTabBar } from '@/hooks/useMobileTabBar' import { TTSProvider } from './contexts/TTSContext' import { ThemeProvider, useThemeMode } from './contexts/ThemeContext' import { AuthProvider } from './contexts/AuthContext' +import { QueryCachePersistenceProvider } from './contexts/QueryCachePersistenceContext' import { KeyboardShortcutsProvider } from './contexts/KeyboardShortcutsContext' import { EventProvider, usePermissions, useEventContext } from '@/contexts/EventContext' import { OpenCodeServerProvider, isLocalOpenCodeServer } from '@/contexts/OpenCodeServerContext' @@ -38,6 +39,7 @@ import { SSHHostKeyDialog } from './components/ssh/SSHHostKeyDialog' import { loginLoader, setupLoader, registerLoader, protectedLoader } from './lib/auth-loaders' import { getSwipeBackTarget, isSessionDetailRoute } from '@/lib/navigation' import { onNotificationClick } from '@/lib/serviceWorker' +import { applyPersistedQueryDefaults } from '@/lib/queryPersistence' import { useAuth } from '@/hooks/useAuth' import { useOpenCodeFailureToast } from '@/hooks/useOpenCodeFailureToast' import { useOpenCodeServerActions } from '@/hooks/useOpenCodeServerActions' @@ -55,6 +57,8 @@ const queryClient = new QueryClient({ }, }) +applyPersistedQueryDefaults(queryClient) + function SSHHostKeyDialogWrapper() { const { sshHostKey } = useEventContext() return ( @@ -168,12 +172,14 @@ function ServerScope({ children }: { children: ReactNode }) { function RootLayout() { return ( - - - - - - + + + + + + + + ) } diff --git a/frontend/src/components/navigation/DesktopSessionTree.test.tsx b/frontend/src/components/navigation/DesktopSessionTree.test.tsx index 186e4eba6..864c4a8a3 100644 --- a/frontend/src/components/navigation/DesktopSessionTree.test.tsx +++ b/frontend/src/components/navigation/DesktopSessionTree.test.tsx @@ -89,6 +89,7 @@ vi.mock('@/hooks/useOpenCode', async (importOriginal) => { }) vi.mock('@/hooks/useSessionPins', () => ({ + SESSION_PINS_QUERY_KEY: ['session-pins'], useSessionPins: () => ({ data: sessionPinsData.current }), })) diff --git a/frontend/src/components/navigation/RemoteProjectNav.test.tsx b/frontend/src/components/navigation/RemoteProjectNav.test.tsx index 1e12c8d68..d07ff2a5a 100644 --- a/frontend/src/components/navigation/RemoteProjectNav.test.tsx +++ b/frontend/src/components/navigation/RemoteProjectNav.test.tsx @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { IsRestoringProvider, QueryClient, QueryClientProvider } from '@tanstack/react-query' import type { OpenCodeProject } from '@/api/opencode' import { RemoteProjectNavList } from './RemoteProjectNav' @@ -132,22 +132,25 @@ function createWrapper( queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }), + isRestoring = false, ) { return ({ children }: { children: React.ReactNode }) => ( - - - - {children} - - - } - /> - - + + + + + {children} + + + } + /> + + + ) } @@ -174,7 +177,11 @@ describe('RemoteProjectNavList', () => { mocks.sessions = [createSession('a1', '/projects/alpha', Date.now())] mocks.hasNextPage.current = false mocks.listProjects.mockResolvedValue([alpha, beta]) - mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen], isLoading: false }) + mocks.useOpenCodeServers.mockReturnValue({ + servers: [kitchen], + isLoading: false, + showsInitialLoading: false, + }) mocks.useOpenCodeServerStatuses.mockReturnValue({ statuses: [], statusById: { srv_kitchen: { serverId: 'srv_kitchen', connected: true, version: null, error: null } }, @@ -289,6 +296,7 @@ describe('RemoteProjectNavList', () => { mocks.useOpenCodeServers.mockReturnValue({ servers: [{ ...kitchen, enabled: false }], isLoading: false, + showsInitialLoading: false, }) render( @@ -343,6 +351,62 @@ describe('RemoteProjectNavList', () => { expect(screen.queryByText('Could not load projects')).not.toBeInTheDocument() }) + it('keeps the project list loading while the cache restore holds the query', () => { + mocks.listProjects.mockReturnValue(new Promise(() => {})) + + render(, { + wrapper: createWrapper('/', undefined, true), + }) + + expect(screen.getByText('Loading projects...')).toBeInTheDocument() + expect(screen.queryByText('No projects')).not.toBeInTheDocument() + expect(mocks.listProjects).not.toHaveBeenCalled() + }) + + it('keeps the sidebar loading while the server list is restoring', () => { + mocks.useOpenCodeServers.mockReturnValue({ + servers: [], + isLoading: false, + showsInitialLoading: true, + }) + + render( + , + { wrapper: createWrapper('/') }, + ) + + expect(screen.getByText('Loading projects...')).toBeInTheDocument() + expect(screen.queryByText('Server not found')).not.toBeInTheDocument() + }) + + it('reports an unreachable server list instead of a missing server while paused offline', () => { + mocks.useOpenCodeServers.mockReturnValue({ + servers: [], + isLoading: false, + showsInitialLoading: false, + isPaused: true, + }) + + render( + , + { wrapper: createWrapper('/') }, + ) + + expect(screen.getByText('Servers unavailable')).toBeInTheDocument() + expect(screen.queryByText('Server not found')).not.toBeInTheDocument() + expect(mocks.listProjects).not.toHaveBeenCalled() + }) + it('reports an unreachable server on a failed project load', async () => { mocks.listProjects.mockRejectedValue(new Error('offline')) mocks.useOpenCodeServerStatuses.mockReturnValue({ diff --git a/frontend/src/components/navigation/RemoteProjectNav.tsx b/frontend/src/components/navigation/RemoteProjectNav.tsx index e1c8f764e..1cb679755 100644 --- a/frontend/src/components/navigation/RemoteProjectNav.tsx +++ b/frontend/src/components/navigation/RemoteProjectNav.tsx @@ -360,20 +360,24 @@ export function RemoteProjectNavList({ onOpenProject, onSelectSession, }: RemoteProjectNavListProps) { - const { servers, isLoading: isServersLoading } = useOpenCodeServers() + const { + servers, + showsInitialLoading: isServersLoading, + isPaused: serversPaused, + } = useOpenCodeServers() const { statusById } = useOpenCodeServerStatuses() const server = servers.find((candidate) => candidate.id === serverId) const { directory: activeDirectory } = useActiveRemoteDirectory(serverId) - const { projects, isLoading: projectsLoading, isError: projectsError, hasData: hasProjects } = - useRemoteProjectsByActivity(serverId) + const { projects, showsInitialLoading, showsError } = useRemoteProjectsByActivity(serverId) const trimmedSearch = search.trim() if (isServersLoading) return Loading projects... + if (serversPaused && servers.length === 0) return Servers unavailable if (!server) return Server not found if (!server.enabled) return Server is disabled - if (projectsLoading) return Loading projects... - if (projectsError && !hasProjects) { + if (showsInitialLoading) return Loading projects... + if (showsError) { return ( {statusById[serverId]?.connected === false ? 'Server unreachable' : 'Could not load projects'} diff --git a/frontend/src/components/repo/RepoList.test.tsx b/frontend/src/components/repo/RepoList.test.tsx index 3c0966784..8946c1655 100644 --- a/frontend/src/components/repo/RepoList.test.tsx +++ b/frontend/src/components/repo/RepoList.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { render, screen, within } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { BrowserRouter } from 'react-router-dom' @@ -64,8 +64,12 @@ function createRepo(overrides: Partial = {}): Repo { } } -function renderRepoList(onAddRepo: () => void = vi.fn()) { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) +function renderRepoList( + onAddRepo: () => void = vi.fn(), + options: { queryClient?: QueryClient } = {}, +) { + const queryClient = options.queryClient + ?? new QueryClient({ defaultOptions: { queries: { retry: false } } }) return render( @@ -121,6 +125,26 @@ describe('RepoList', () => { expect(await screen.findByText('No recently accessed repositories')).toBeInTheDocument() }) + it('shows the error state when the first load fails', async () => { + mocks.listRepos.mockRejectedValue(new Error('offline')) + + renderRepoList() + + expect(await screen.findByText(/Failed to load repositories/)).toBeInTheDocument() + }) + + it('keeps cached repositories when the refetch fails', async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + queryClient.setQueryData(['repos'], [createRepo()]) + mocks.listRepos.mockRejectedValue(new Error('offline')) + + renderRepoList(vi.fn(), { queryClient }) + + expect(await screen.findByText('demo-app')).toBeInTheDocument() + await waitFor(() => expect(queryClient.getQueryState(['repos'])?.status).toBe('error')) + expect(screen.queryByText(/Failed to load repositories/)).not.toBeInTheDocument() + }) + it('labels the confirm button "Unlink" for a local repository', async () => { mocks.listRepos.mockResolvedValue([createRepo()]) const user = userEvent.setup() diff --git a/frontend/src/components/repo/RepoList.tsx b/frontend/src/components/repo/RepoList.tsx index 28175a3dd..68f744b2c 100644 --- a/frontend/src/components/repo/RepoList.tsx +++ b/frontend/src/components/repo/RepoList.tsx @@ -162,7 +162,7 @@ export function RepoList({ onAddRepo }: { onAddRepo: () => void }) { const { data: repos, - isLoading, + isPending, error, } = useQuery({ queryKey: ["repos"], @@ -312,7 +312,7 @@ export function RepoList({ onAddRepo }: { onAddRepo: () => void }) { const renderContent = () => { switch (true) { - case isLoading && !repos: + case isPending && !repos: return (
@@ -332,7 +332,7 @@ export function RepoList({ onAddRepo }: { onAddRepo: () => void }) {
) - case !!error: + case !!error && !repos: return (
Failed to load repositories:{" "} diff --git a/frontend/src/components/servers/RemoteProjectList.tsx b/frontend/src/components/servers/RemoteProjectList.tsx index c0f8f14b7..b7b1023dd 100644 --- a/frontend/src/components/servers/RemoteProjectList.tsx +++ b/frontend/src/components/servers/RemoteProjectList.tsx @@ -13,6 +13,7 @@ import { ActivityStateIndicator } from '@/components/activity/ActivityStateIndic import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import { LinkedServerStateCard } from '@/components/servers/LinkedServerStateCard' +import { ServersUnavailableCard } from '@/components/servers/ServersUnavailableCard' import { OpenDirectoryDialog } from '@/components/servers/OpenDirectoryDialog' import { RemoteProjectRowActions } from '@/components/servers/RemoteProjectRowActions' import { RepoCardShell } from '@/components/repo/RepoCardShell' @@ -86,16 +87,22 @@ function RemoteProjectListLoading() { } function RemoteProjectListContent({ serverId }: { serverId: string }) { - const { servers, isLoading: serversLoading } = useOpenCodeServers() + const { + servers, + showsInitialLoading: serversLoading, + isPaused: serversPaused, + refetch: refetchServers, + } = useOpenCodeServers() const { openTab } = useSettingsDialog() const [openDirectoryOpen, setOpenDirectoryOpen] = useState(false) const [searchQuery, setSearchQuery] = useState('') const server = servers.find((candidate) => candidate.id === serverId) + const serversUnavailable = serversPaused && servers.length === 0 const isDisabled = server !== undefined && !server.enabled - const { projects, activityById, isLoading, isError, hasData, error, refetch } = useRemoteProjectsByActivity(serverId) - const showsProjectError = isError && !hasData + const { projects, activityById, showsInitialLoading, showsError, error, refetch } = + useRemoteProjectsByActivity(serverId) const filteredProjects = useMemo(() => { const query = searchQuery.trim().toLowerCase() @@ -111,7 +118,9 @@ function RemoteProjectListContent({ serverId }: { serverId: string }) { return (
- {!serversLoading && !server ? ( + {serversUnavailable ? : null} + + {!serversLoading && !serversUnavailable && !server ? ( ) : null} + {serversLoading ? : null} + {isDisabled ? ( ) : null} - {server && !isDisabled && showsProjectError ? ( + {server && !isDisabled && showsError ? (

Could not load projects

- {error instanceof Error - ? error.message - : 'The server did not respond.'} + {error instanceof Error ? error.message : 'The server did not respond.'}