Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 12 additions & 6 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand All @@ -55,6 +57,8 @@ const queryClient = new QueryClient({
},
})

applyPersistedQueryDefaults(queryClient)

function SSHHostKeyDialogWrapper() {
const { sshHostKey } = useEventContext()
return (
Expand Down Expand Up @@ -168,12 +172,14 @@ function ServerScope({ children }: { children: ReactNode }) {
function RootLayout() {
return (
<AuthProvider>
<ThemeProvider>
<TTSProvider>
<ThemedToaster />
<Outlet />
</TTSProvider>
</ThemeProvider>
<QueryCachePersistenceProvider>
<ThemeProvider>
<TTSProvider>
<ThemedToaster />
<Outlet />
</TTSProvider>
</ThemeProvider>
</QueryCachePersistenceProvider>
</AuthProvider>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ vi.mock('@/hooks/useOpenCode', async (importOriginal) => {
})

vi.mock('@/hooks/useSessionPins', () => ({
SESSION_PINS_QUERY_KEY: ['session-pins'],
useSessionPins: () => ({ data: sessionPinsData.current }),
}))

Expand Down
94 changes: 79 additions & 15 deletions frontend/src/components/navigation/RemoteProjectNav.test.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -132,22 +132,25 @@ function createWrapper(
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
}),
isRestoring = false,
) {
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route
path="*"
element={
<>
{children}
<LocationSpy />
</>
}
/>
</Routes>
</MemoryRouter>
<IsRestoringProvider value={isRestoring}>
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route
path="*"
element={
<>
{children}
<LocationSpy />
</>
}
/>
</Routes>
</MemoryRouter>
</IsRestoringProvider>
</QueryClientProvider>
)
}
Expand All @@ -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 } },
Expand Down Expand Up @@ -289,6 +296,7 @@ describe('RemoteProjectNavList', () => {
mocks.useOpenCodeServers.mockReturnValue({
servers: [{ ...kitchen, enabled: false }],
isLoading: false,
showsInitialLoading: false,
})

render(
Expand Down Expand Up @@ -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(<RemoteListHarness serverId="srv_kitchen" />, {
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(
<RemoteProjectNavList
serverId="srv_kitchen"
isVisible
onOpenProject={vi.fn()}
onSelectSession={vi.fn()}
/>,
{ 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(
<RemoteProjectNavList
serverId="srv_kitchen"
isVisible
onOpenProject={vi.fn()}
onSelectSession={vi.fn()}
/>,
{ 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({
Expand Down
14 changes: 9 additions & 5 deletions frontend/src/components/navigation/RemoteProjectNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <SessionNavStatus>Loading projects...</SessionNavStatus>
if (serversPaused && servers.length === 0) return <SessionNavStatus>Servers unavailable</SessionNavStatus>
if (!server) return <SessionNavStatus>Server not found</SessionNavStatus>
if (!server.enabled) return <SessionNavStatus>Server is disabled</SessionNavStatus>
if (projectsLoading) return <SessionNavStatus>Loading projects...</SessionNavStatus>
if (projectsError && !hasProjects) {
if (showsInitialLoading) return <SessionNavStatus>Loading projects...</SessionNavStatus>
if (showsError) {
return (
<SessionNavStatus>
{statusById[serverId]?.connected === false ? 'Server unreachable' : 'Could not load projects'}
Expand Down
30 changes: 27 additions & 3 deletions frontend/src/components/repo/RepoList.test.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -64,8 +64,12 @@ function createRepo(overrides: Partial<Repo> = {}): 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(
<QueryClientProvider client={queryClient}>
Expand Down Expand Up @@ -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()
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/repo/RepoList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export function RepoList({ onAddRepo }: { onAddRepo: () => void }) {

const {
data: repos,
isLoading,
isPending,
error,
} = useQuery({
queryKey: ["repos"],
Expand Down Expand Up @@ -312,7 +312,7 @@ export function RepoList({ onAddRepo }: { onAddRepo: () => void }) {

const renderContent = () => {
switch (true) {
case isLoading && !repos:
case isPending && !repos:
return (
<div className="px-0 md:px-4 md:pt-4 h-full flex flex-col">
<div className="px-2 md:px-0">
Expand All @@ -332,7 +332,7 @@ export function RepoList({ onAddRepo }: { onAddRepo: () => void }) {
</div>
)

case !!error:
case !!error && !repos:
return (
<div className="text-center p-8 text-destructive">
Failed to load repositories:{" "}
Expand Down
29 changes: 19 additions & 10 deletions frontend/src/components/servers/RemoteProjectList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand All @@ -111,7 +118,9 @@ function RemoteProjectListContent({ serverId }: { serverId: string }) {

return (
<div className="flex h-full flex-col px-0 md:px-4 md:pt-4">
{!serversLoading && !server ? (
{serversUnavailable ? <ServersUnavailableCard onRetry={refetchServers} /> : null}

{!serversLoading && !serversUnavailable && !server ? (
<LinkedServerStateCard
icon={Server}
title="Server not found"
Expand All @@ -121,6 +130,8 @@ function RemoteProjectListContent({ serverId }: { serverId: string }) {
/>
) : null}

{serversLoading ? <RemoteProjectListLoading /> : null}

{isDisabled ? (
<LinkedServerStateCard
icon={Server}
Expand All @@ -131,15 +142,13 @@ function RemoteProjectListContent({ serverId }: { serverId: string }) {
/>
) : null}

{server && !isDisabled && showsProjectError ? (
{server && !isDisabled && showsError ? (
<Card className="flex flex-col items-center gap-3 p-6 text-center">
<Server className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">Could not load projects</p>
<p className="text-sm text-muted-foreground">
{error instanceof Error
? error.message
: 'The server did not respond.'}
{error instanceof Error ? error.message : 'The server did not respond.'}
</p>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
Expand All @@ -148,8 +157,8 @@ function RemoteProjectListContent({ serverId }: { serverId: string }) {
</Card>
) : null}

{server && !isDisabled && !showsProjectError ? (
isLoading ? (
{server && !isDisabled && !showsError ? (
showsInitialLoading ? (
<RemoteProjectListLoading />
) : projects.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center p-12 text-center">
Expand Down
Loading