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
20 changes: 13 additions & 7 deletions e2e/background-activity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
}

Expand Down Expand Up @@ -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,
Expand All @@ -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 }) => {
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ function GlobalShortcuts() {
return null
}

function SidebarServerRouteSync() {
useSidebarServerRouteSync()
return null
}

function ServerScope({ children }: { children: ReactNode }) {
const { serverId } = useParams<{ serverId: string }>()

Expand All @@ -165,8 +170,6 @@ function AppShell() {
const rootRef = useRef<HTMLDivElement>(null)
const { openSheet, open } = useMobileTabBar()

useSidebarServerRouteSync()

const swipeNav = useSwipeNavigation()

const getRouteSwipeBackTarget = useCallback(
Expand Down Expand Up @@ -236,6 +239,7 @@ function AppShell() {
<RepoWorktreesProvider>
<KeyboardShortcutsProvider>
<GlobalShortcuts />
<SidebarServerRouteSync />
<div className="flex h-dvh w-full min-w-0 flex-col">
<PwaUpdatePrompt />
<div ref={rootRef} className="flex flex-1 min-h-0 w-full min-w-0">
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/components/navigation/LinkedServersNav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,18 @@ const mocks = vi.hoisted(() => ({
useOpenCodeServerStatuses: vi.fn(),
useServerActivityState: vi.fn(),
useLocalActivityState: vi.fn(),
useAuth: vi.fn(),
}))

vi.mock('@/hooks/useOpenCodeServers', () => ({
useOpenCodeServers: mocks.useOpenCodeServers,
useOpenCodeServerStatuses: mocks.useOpenCodeServerStatuses,
}))

vi.mock('@/hooks/useAuth', () => ({
useAuth: mocks.useAuth,
}))

vi.mock('@/hooks/useActiveWork', () => ({
useServerActivityState: mocks.useServerActivityState,
useLocalActivityState: mocks.useLocalActivityState,
Expand Down Expand Up @@ -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({
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/hooks/useSidebarServerSelection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@ import { SIDEBAR_SERVER_STORAGE_KEY, useSidebarServerStore } from '@/stores/side

const mocks = vi.hoisted(() => ({
useOpenCodeServers: vi.fn(),
useAuth: vi.fn(),
}))

vi.mock('@/hooks/useOpenCodeServers', () => ({
useOpenCodeServers: mocks.useOpenCodeServers,
useOpenCodeServerStatuses: () => ({ statuses: [], statusById: {}, isLoading: false }),
}))

vi.mock('@/hooks/useAuth', () => ({
useAuth: mocks.useAuth,
}))

const kitchen = {
id: 'srv_kitchen',
name: 'Kitchen',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
<MemoryRouter initialEntries={['/']}>
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/hooks/useSidebarServerSelection.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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)

Expand Down
Loading