diff --git a/e2e/background-activity.spec.ts b/e2e/background-activity.spec.ts index 8bdeaa9f6..57c6e9b51 100644 --- a/e2e/background-activity.spec.ts +++ b/e2e/background-activity.spec.ts @@ -76,9 +76,10 @@ function createUnregisteredDirectory(name: string): string { return directory } -function waitForDirectorySubscription(page: Page, directory: string, endpoint = '/api/sse/subscribe') { - return page.waitForResponse((response) => - response.url().includes(endpoint) && (response.request().postData() ?? '').includes(directory), +function waitForDirectorySubscription(page: Page, directory: string) { + return page.waitForResponse( + (response) => + response.url().includes('/api/sse/subscribe') && (response.request().postData() ?? '').includes(directory), ) } @@ -195,7 +196,7 @@ test.describe('session background activity', () => { await expect(backgroundIndicator(page)).toBeHidden() }) - test('clears the indicator for a shell that exits while an unregistered session directory is unsubscribed', async ({ + test('keeps an unregistered session directory subscribed while its shell runs and clears the indicator after it exits elsewhere', async ({ page, request, repo, @@ -213,15 +214,20 @@ test.describe('session background activity', () => { await expect(backgroundIndicator(page)).toBeVisible() - const unsubscribed = waitForDirectorySubscription(page, directory, '/api/sse/unsubscribe') + const unsubscribes: string[] = [] + page.on('request', (outgoing) => { + if (outgoing.url().includes('/api/sse/unsubscribe') && (outgoing.postData() ?? '').includes(directory)) { + unsubscribes.push(outgoing.url()) + } + }) + await navigateWithinApp(page, '/') - expect((await unsubscribed).ok()).toBe(true) await removeShell(request, directory, shellId) - await page.waitForTimeout(3_000) await navigateWithinApp(page, `/repos/${repo.id}/sessions/${sessionId}`) await expect(page.getByPlaceholder('Send a message...')).toBeVisible() await expect(backgroundIndicator(page)).toBeHidden({ timeout: 20_000 }) + expect(unsubscribes).toEqual([]) }) test('follows a session moved to another directory without a reload', async ({ page, request, repo }) => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 071b656d9..fccd63160 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -149,6 +149,11 @@ function GlobalShortcuts() { return null } +function SidebarServerRouteSync() { + useSidebarServerRouteSync() + return null +} + function ServerScope({ children }: { children: ReactNode }) { const { serverId } = useParams<{ serverId: string }>() @@ -165,8 +170,6 @@ function AppShell() { const rootRef = useRef(null) const { openSheet, open } = useMobileTabBar() - useSidebarServerRouteSync() - const swipeNav = useSwipeNavigation() const getRouteSwipeBackTarget = useCallback( @@ -236,6 +239,7 @@ function AppShell() { +
diff --git a/frontend/src/components/navigation/LinkedServersNav.test.tsx b/frontend/src/components/navigation/LinkedServersNav.test.tsx index 27d4cc4d3..f6be0c2f0 100644 --- a/frontend/src/components/navigation/LinkedServersNav.test.tsx +++ b/frontend/src/components/navigation/LinkedServersNav.test.tsx @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ useOpenCodeServerStatuses: vi.fn(), useServerActivityState: vi.fn(), useLocalActivityState: vi.fn(), + useAuth: vi.fn(), })) vi.mock('@/hooks/useOpenCodeServers', () => ({ @@ -19,6 +20,10 @@ vi.mock('@/hooks/useOpenCodeServers', () => ({ useOpenCodeServerStatuses: mocks.useOpenCodeServerStatuses, })) +vi.mock('@/hooks/useAuth', () => ({ + useAuth: mocks.useAuth, +})) + vi.mock('@/hooks/useActiveWork', () => ({ useServerActivityState: mocks.useServerActivityState, useLocalActivityState: mocks.useLocalActivityState, @@ -71,6 +76,7 @@ describe('LinkedServersNav', () => { localStorage.clear() useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) setServers([]) + mocks.useAuth.mockReturnValue({ isAuthenticated: true }) mocks.useServerActivityState.mockReturnValue(null) mocks.useLocalActivityState.mockReturnValue(null) mocks.useOpenCodeServerStatuses.mockReturnValue({ diff --git a/frontend/src/hooks/useSidebarServerSelection.test.tsx b/frontend/src/hooks/useSidebarServerSelection.test.tsx index 7d0ce93ee..c017366ed 100644 --- a/frontend/src/hooks/useSidebarServerSelection.test.tsx +++ b/frontend/src/hooks/useSidebarServerSelection.test.tsx @@ -7,6 +7,7 @@ import { SIDEBAR_SERVER_STORAGE_KEY, useSidebarServerStore } from '@/stores/side const mocks = vi.hoisted(() => ({ useOpenCodeServers: vi.fn(), + useAuth: vi.fn(), })) vi.mock('@/hooks/useOpenCodeServers', () => ({ @@ -14,6 +15,10 @@ vi.mock('@/hooks/useOpenCodeServers', () => ({ useOpenCodeServerStatuses: () => ({ statuses: [], statusById: {}, isLoading: false }), })) +vi.mock('@/hooks/useAuth', () => ({ + useAuth: mocks.useAuth, +})) + const kitchen = { id: 'srv_kitchen', name: 'Kitchen', @@ -85,6 +90,7 @@ function renderHarness(initialEntry = '/', options?: { syncRoute?: boolean }) { function resetSelection() { localStorage.clear() useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) + mocks.useAuth.mockReturnValue({ isAuthenticated: true }) mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen, office], isLoading: false, @@ -215,6 +221,14 @@ describe('useSidebarServerRouteSync', () => { ) }) + it('does not load the server list while unauthenticated', () => { + mocks.useAuth.mockReturnValue({ isAuthenticated: false }) + + renderHarness('/') + + expect(mocks.useOpenCodeServers).toHaveBeenCalledWith({ enabled: false }) + }) + it('follows a linked server route with no sidebar consumer mounted', async () => { render( diff --git a/frontend/src/hooks/useSidebarServerSelection.ts b/frontend/src/hooks/useSidebarServerSelection.ts index e0e18e8ac..688d3db7b 100644 --- a/frontend/src/hooks/useSidebarServerSelection.ts +++ b/frontend/src/hooks/useSidebarServerSelection.ts @@ -1,6 +1,7 @@ import { useEffect } from 'react' import { useLocation, useMatch } from 'react-router-dom' import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import { useAuth } from '@/hooks/useAuth' import { useOpenCodeServers } from '@/hooks/useOpenCodeServers' import { isAssistantPath, parseRepoRoute } from '@/lib/navigation' import { useSidebarServerStore } from '@/stores/sidebarServerStore' @@ -13,12 +14,14 @@ function isLocalRoute(pathname: string): boolean { * Keeps the sidebar server selection in step with the location and the linked server list: a linked * server route selects that server, a local repo or assistant route selects local, and a selected * server that is missing or disabled in a successfully loaded list falls back to local. Mounted once - * by the app shell so the selection follows the route even when no sidebar consumer is rendered. + * inside `AuthProvider` by the app shell so the selection follows the route even when no sidebar + * consumer is rendered; the server list is only loaded once authenticated. */ export function useSidebarServerRouteSync(): void { const { pathname } = useLocation() const routeServerId = useMatch('/servers/:serverId/*')?.params.serverId - const { servers, isSuccess } = useOpenCodeServers() + const { isAuthenticated } = useAuth() + const { servers, isSuccess } = useOpenCodeServers({ enabled: isAuthenticated }) const serverId = useSidebarServerStore((state) => state.serverId) const selectServer = useSidebarServerStore((state) => state.selectServer)