()
+ return { ...actual, getSession: mocks.getSession, listProjects: mocks.listProjects }
+})
+
+vi.mock('@/hooks/useOpenCodeServers', () => ({
+ useOpenCodeServers: () => ({
+ servers: [{ id: 'srv_kitchen', name: 'Kitchen', enabled: true }],
+ isLoading: false,
+ }),
+}))
+
+vi.mock('@/hooks/useSidebarRepoGroups', () => ({
+ useNavigableRepos: () => ({ repos: [], isLoading: false, refetch: vi.fn() }),
+}))
+
+function renderScope(entry: string, seed?: (queryClient: QueryClient) => void) {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ seed?.(queryClient)
+ const wrapper = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ )
+ return renderHook(() => useSessionPickerRouteScope(true), { wrapper })
+}
+
+const kitchenProject = {
+ id: 'p1',
+ name: 'App',
+ canonical: '/home/me/app',
+ time: { created: 1, updated: 2 },
+ sandboxes: [],
+}
+
+describe('useSessionPickerRouteScope', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ useSidebarServerStore.setState({ serverId: 'srv_kitchen' })
+ mocks.listProjects.mockResolvedValue([])
+ })
+
+ it('resolves a session subdirectory to the canonical directory of its project', async () => {
+ mocks.getSession.mockResolvedValue({
+ id: 'ses_remote',
+ location: { directory: '/home/me/app/packages/web' },
+ })
+ mocks.listProjects.mockResolvedValue([kitchenProject])
+
+ const { result } = renderScope('/servers/srv_kitchen/sessions/ses_remote')
+
+ await waitFor(() => expect(result.current.remote?.projectDirectory).toBe('/home/me/app'))
+ expect(result.current.remote?.isPending).toBe(false)
+ expect(result.current.remote?.serverName).toBe('Kitchen')
+ expect(result.current.activeSessionID).toBe('ses_remote')
+ })
+
+ it('falls back to the session directory when no project owns it', async () => {
+ mocks.getSession.mockResolvedValue({
+ id: 'ses_remote',
+ location: { directory: '/home/me/other' },
+ })
+
+ const { result } = renderScope('/servers/srv_kitchen/sessions/ses_remote')
+
+ await waitFor(() => expect(result.current.remote?.projectDirectory).toBe('/home/me/other'))
+ expect(result.current.remote?.isPending).toBe(false)
+ })
+
+ it('reports the scope as pending while the session directory resolves', async () => {
+ mocks.getSession.mockReturnValue(new Promise(() => undefined))
+
+ const { result } = renderScope('/servers/srv_kitchen/sessions/ses_remote')
+
+ await waitFor(() => expect(result.current.remote?.isPending).toBe(true))
+ expect(result.current.remote?.projectDirectory).toBeUndefined()
+ })
+
+ it('reuses the session the open page already loaded', async () => {
+ mocks.getSession.mockReturnValue(new Promise(() => undefined))
+ mocks.listProjects.mockResolvedValue([kitchenProject])
+
+ const { result } = renderScope('/servers/srv_kitchen/sessions/ses_remote', (queryClient) => {
+ queryClient.setQueryData(
+ ['opencode', 'session', 'ses_remote', '/home/me/app/packages/web', 'srv_kitchen'],
+ { id: 'ses_remote', location: { directory: '/home/me/app/packages/web' } },
+ )
+ })
+
+ await waitFor(() => expect(result.current.remote?.projectDirectory).toBe('/home/me/app'))
+ expect(result.current.remote?.isPending).toBe(false)
+ expect(mocks.getSession).not.toHaveBeenCalled()
+ })
+
+ it('scopes to the whole server on a route without a session', () => {
+ const { result } = renderScope('/')
+
+ expect(result.current.remote?.projectDirectory).toBeUndefined()
+ expect(result.current.remote?.isPending).toBe(false)
+ expect(result.current.activeSessionID).toBeUndefined()
+ })
+
+ it('resolves no linked-server scope for the local server', () => {
+ useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID })
+
+ const { result } = renderScope('/repos/5/sessions/ses_local')
+
+ expect(result.current.remote).toBeUndefined()
+ expect(mocks.getSession).not.toHaveBeenCalled()
+ expect(mocks.listProjects).not.toHaveBeenCalled()
+ })
+})
diff --git a/frontend/src/hooks/useSessionPickerRouteScope.ts b/frontend/src/hooks/useSessionPickerRouteScope.ts
index 24dc2a660..81dc07260 100644
--- a/frontend/src/hooks/useSessionPickerRouteScope.ts
+++ b/frontend/src/hooks/useSessionPickerRouteScope.ts
@@ -2,7 +2,7 @@ import { useLocation } from 'react-router-dom'
import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas'
import type { Repo } from '@/api/types'
import type { RemotePickerScope } from '@/components/session/SessionPickerDialog'
-import { parseRepoRoute, parseServerSessionRoute } from '@/lib/navigation'
+import { parseActivePickerSession, parseRepoRoute } from '@/lib/navigation'
import { useNavigableRepos } from '@/hooks/useSidebarRepoGroups'
import { useOpenCodeServers } from '@/hooks/useOpenCodeServers'
import { useSidebarServerSelection } from '@/hooks/useSidebarServerSelection'
@@ -11,7 +11,6 @@ import { useActiveRemoteDirectory } from '@/hooks/useActiveRemoteDirectory'
export interface SessionPickerRouteScope {
currentRepo: Repo | undefined
activeSessionID: string | undefined
- activeServerId: string | undefined
remote: RemotePickerScope | undefined
}
@@ -24,25 +23,23 @@ export function useSessionPickerRouteScope(enabled: boolean): SessionPickerRoute
const isRemote = selectedServerId !== LOCAL_OPENCODE_SERVER_ID
const repoRoute = parseRepoRoute(pathname)
- const serverSessionRoute = parseServerSessionRoute(pathname)
const currentRepo = isRemote || repoRoute.repoId === null
? undefined
: repos.find((candidate) => candidate.id === repoRoute.repoId)
- const activeSessionID = repoRoute.sessionId ?? serverSessionRoute?.sessionId ?? undefined
- const activeServerId = repoRoute.sessionId ? LOCAL_OPENCODE_SERVER_ID : serverSessionRoute?.serverId
+ const activeSessionID = parseActivePickerSession(pathname).sessionId
if (!isRemote) {
- return { currentRepo, activeSessionID, activeServerId, remote: undefined }
+ return { currentRepo, activeSessionID, remote: undefined }
}
return {
currentRepo,
activeSessionID,
- activeServerId,
remote: {
serverId: selectedServerId,
serverName: servers.find((server) => server.id === selectedServerId)?.name,
- projectDirectory: activeRemoteDirectory ?? undefined,
+ projectDirectory: activeRemoteDirectory.directory ?? undefined,
+ isPending: activeRemoteDirectory.isPending,
},
}
}
diff --git a/frontend/src/lib/navigation.test.ts b/frontend/src/lib/navigation.test.ts
index 1cb0940a6..dc0d64c2f 100644
--- a/frontend/src/lib/navigation.test.ts
+++ b/frontend/src/lib/navigation.test.ts
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
-import { getSessionListPath, getSwipeBackTarget, getAssistantPath, getAssistantSessionListPath, isAssistantPath, getPathWithReturnTo, getReturnToPath, getRepoPath, getServerProjectPath, getServerSessionPath, getSessionPath, isSessionDetailRoute, parseRepoRoute, parseServerProjectRoute, parseServerSessionRoute } from './navigation';
+import { getSessionListPath, getSwipeBackTarget, getAssistantPath, getAssistantSessionListPath, isAssistantPath, getPathWithReturnTo, getReturnToPath, getRepoPath, getServerProjectPath, getServerSessionPath, getSessionPath, isSessionDetailRoute, parseActivePickerSession, parseRepoRoute, parseServerProjectRoute, parseServerSessionRoute } from './navigation';
+import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas';
describe('getAssistantPath', () => {
it('returns /assistant', () => {
@@ -191,6 +192,27 @@ describe('parseServerSessionRoute', () => {
});
});
+describe('parseActivePickerSession', () => {
+ it('reads a local session route on the local server', () => {
+ expect(parseActivePickerSession('/repos/5/sessions/ses_1')).toEqual({
+ sessionId: 'ses_1',
+ serverId: LOCAL_OPENCODE_SERVER_ID,
+ });
+ });
+
+ it('reads a linked-server session route on its own server', () => {
+ expect(parseActivePickerSession('/servers/srv_kitchen/sessions/ses_1')).toEqual({
+ sessionId: 'ses_1',
+ serverId: 'srv_kitchen',
+ });
+ });
+
+ it('returns no session outside a session route', () => {
+ expect(parseActivePickerSession('/repos/5')).toEqual({});
+ expect(parseActivePickerSession('/servers/srv_kitchen/projects')).toEqual({});
+ });
+});
+
describe('return target helpers', () => {
it('adds encoded returnTo params for internal paths', () => {
expect(getPathWithReturnTo('/repos/5/schedules', '/repos/5/sessions/abc?assistant=1')).toBe(
diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts
index 20aeb54fd..9226190cd 100644
--- a/frontend/src/lib/navigation.ts
+++ b/frontend/src/lib/navigation.ts
@@ -65,6 +65,22 @@ export function parseServerSessionRoute(pathname: string): { serverId: string; s
return { serverId, sessionId };
}
+/**
+ * Reads the session route in view, whether it belongs to the local server or a linked one.
+ *
+ * @param pathname Pathname of the location to read.
+ * @returns The routed session and its server; both are omitted when the location is not a session route.
+ */
+export function parseActivePickerSession(pathname: string): { sessionId?: string; serverId?: string } {
+ const repoRoute = parseRepoRoute(pathname);
+ if (repoRoute.sessionId) {
+ return { sessionId: repoRoute.sessionId, serverId: LOCAL_OPENCODE_SERVER_ID };
+ }
+ const serverSessionRoute = parseServerSessionRoute(pathname);
+ if (!serverSessionRoute) return {};
+ return { sessionId: serverSessionRoute.sessionId, serverId: serverSessionRoute.serverId };
+}
+
export function isSessionDetailRoute(pathname: string): boolean {
const normalizedPathname = pathname.replace(/\/+$/, '');
return /^\/repos\/[^/]+\/sessions\/[^/]+$/.test(normalizedPathname) || parseServerSessionRoute(normalizedPathname) !== null;
diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx
index 9c0ba24a9..8ee72d4bf 100644
--- a/frontend/src/pages/SessionDetail.tsx
+++ b/frontend/src/pages/SessionDetail.tsx
@@ -49,7 +49,7 @@ import { ProjectActionsMenu } from "@/components/repo/ProjectActionsMenu";
import { RepoActionsDialog } from "@/components/repo/RepoActionsDialog";
import { ResetPermissionsDialog } from "@/components/repo/ResetPermissionsDialog";
import { RepoSkillsDialog } from "@/components/repo/RepoSkillsDialog";
-import { compactSession, forkSession, listSessionMessages, remoteProjectName } from "@/api/opencode";
+import { compactSession, findRemoteProjectForDirectory, forkSession, listSessionMessages, remoteProjectName } from "@/api/opencode";
import { useOpenCodeServerId, isLocalOpenCodeServer } from "@/contexts/OpenCodeServerContext";
import { useRemoteProjects } from "@/hooks/useRemoteProjects";
import { useSwipeNavigation } from "@/contexts/SwipeNavigationContext";
@@ -221,9 +221,7 @@ export function SessionDetail() {
const remoteProject = useMemo(() => {
if (!isRemoteServer || !sessionDirectory) return undefined;
- return remoteProjects.find(
- (entry) => sessionDirectory === entry.canonical || sessionDirectory.startsWith(`${entry.canonical}/`),
- );
+ return findRemoteProjectForDirectory(remoteProjects, sessionDirectory);
}, [isRemoteServer, remoteProjects, sessionDirectory]);
const remoteProjectDirectory = isRemoteServer && sessionDirectory
@@ -555,7 +553,7 @@ export function SessionDetail() {
const pickerNavigation = useSessionPickerNavigation({
mode: 'push',
onClose: () => setSessionsDialogOpen(false),
- onDeleteActiveSession: handleCloseSession,
+ onDeleteActiveSession: isRemoteServer ? handleCloseSession : undefined,
})
const handleOpenModelDialog = useCallback(() => {
@@ -1051,7 +1049,12 @@ export function SessionDetail() {
activeSessionID={sessionId || undefined}
remote={
isRemoteServer
- ? { serverId, serverName: linkedServer?.name, projectDirectory: remoteProjectDirectory }
+ ? {
+ serverId,
+ serverName: linkedServer?.name,
+ projectDirectory: remoteProjectDirectory,
+ isPending: sessionLoading,
+ }
: undefined
}
{...pickerNavigation}
diff --git a/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx b/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx
index 053bfd6a3..3079727d7 100644
--- a/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx
+++ b/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx
@@ -540,6 +540,7 @@ describe('SessionDetail remote server mode', () => {
serverId: 'srv_kitchen',
serverName: 'Kitchen',
projectDirectory: '/home/me/app',
+ isPending: false,
},
}),
)
From de4a9bcf1a5664612e19576901831a877a1cab26 Mon Sep 17 00:00:00 2001
From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Date: Sat, 24 Oct 2026 00:14:38 -0400
Subject: [PATCH 10/14] fix(frontend): stretch the session picker tabs across
the full width
---
frontend/src/components/session/SessionPickerDialog.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/src/components/session/SessionPickerDialog.tsx b/frontend/src/components/session/SessionPickerDialog.tsx
index 780475150..9e428d226 100644
--- a/frontend/src/components/session/SessionPickerDialog.tsx
+++ b/frontend/src/components/session/SessionPickerDialog.tsx
@@ -1362,7 +1362,7 @@ function SessionPickerPanelBody({
>
{renderTitle?.(SESSION_PICKER_TAB_LABELS[activeTab])}
-
+
{SESSION_PICKER_TAB_LABELS.active}
Date: Sat, 24 Oct 2026 00:32:13 -0400
Subject: [PATCH 11/14] fix: order linked-server projects by session activity
and stop waking idle projects
OpenCode stamps project time.active whenever a location boots, and directory-scoped requests boot it, so the Manager's reconnect made every idle linked-server project look recently active. Remote projects are now ordered and labelled by their newest session's update time (one global session list per server), falling back to time.updated. Pending permission, form and shell lists, and the backend replay, only run for linked-server directories with running sessions, tracked pending work or the viewed route. The hard-coded status dot on remote project cards is removed.
---
.../src/services/opencode-event-routing.ts | 27 ++++
backend/src/services/remote-server-events.ts | 39 ++++++
.../services/remote-server-events.test.ts | 57 +++++++++
frontend/src/api/opencode.test.ts | 76 ++++++++---
frontend/src/api/opencode.ts | 62 +++++++--
.../navigation/RemoteProjectNav.test.tsx | 33 ++++-
.../navigation/RemoteProjectNav.tsx | 4 +-
.../components/servers/RemoteProjectList.tsx | 19 +--
.../__tests__/RemoteProjectList.test.tsx | 70 +++++++++--
.../session/SessionPickerDialog.test.tsx | 17 +++
frontend/src/contexts/EventContext.test.tsx | 109 +++++++++++++++-
frontend/src/contexts/EventContext.tsx | 118 ++++++++++++++----
frontend/src/hooks/useRemoteProjects.test.tsx | 115 ++++++++++++++++-
frontend/src/hooks/useRemoteProjects.ts | 60 ++++++++-
14 files changed, 719 insertions(+), 87 deletions(-)
diff --git a/backend/src/services/opencode-event-routing.ts b/backend/src/services/opencode-event-routing.ts
index 877e2b949..bed6f3d70 100644
--- a/backend/src/services/opencode-event-routing.ts
+++ b/backend/src/services/opencode-event-routing.ts
@@ -52,6 +52,33 @@ export function planSessionStatusReplay(
return { idle, busy }
}
+/**
+ * Records the activity state an event implies for its session, so that directory-scoped work can be
+ * limited to the sessions that are actually running.
+ *
+ * @param busySessions Sessions currently running on the server, updated in place.
+ * @param event Event observed on the server stream.
+ */
+export function applySessionActivity(busySessions: Set, event: V2Event): void {
+ switch (event.type) {
+ case 'session.status': {
+ const { sessionID, status } = event.data
+ if (status.type === 'idle') busySessions.delete(sessionID)
+ else busySessions.add(sessionID)
+ return
+ }
+ case 'session.execution.started':
+ busySessions.add(event.data.sessionID)
+ return
+ case 'session.idle':
+ case 'session.execution.succeeded':
+ case 'session.execution.failed':
+ case 'session.execution.interrupted':
+ case 'session.deleted':
+ busySessions.delete(event.data.sessionID)
+ }
+}
+
export function createReplayEventBuilder(idPrefix: string): ReplayEventBuilder {
let count = 0
return (type, directory, data) => {
diff --git a/backend/src/services/remote-server-events.ts b/backend/src/services/remote-server-events.ts
index 91dc01e3b..c81fbcf7c 100644
--- a/backend/src/services/remote-server-events.ts
+++ b/backend/src/services/remote-server-events.ts
@@ -14,6 +14,7 @@ import type {
OpenCodeServerRegistry,
} from './opencode-server-registry'
import {
+ applySessionActivity,
collectPendingReplayEvents,
createAuthorizedEventSourceInit,
createReplayEventBuilder,
@@ -63,6 +64,7 @@ interface RemoteServerStream {
versionRequested: boolean
versionProbeToken: number
sessionDirectories: Map
+ busySessions: Set
subagentSessions: Set
}
@@ -152,6 +154,9 @@ export class RemoteServerEventHub {
const key = buildServerDirectoryKey(serverId, directory)
if (!this.aggregator.hasClientDirectory(clientId, key)) return
+ const stream = this.streams.get(serverId)
+ if (!stream || !this.hasActiveSession(stream, directory)) return
+
let api: OpenCodeApi
try {
api = this.registry.getClient(serverId).api
@@ -172,6 +177,13 @@ export class RemoteServerEventHub {
this.emitReplayEventsToClient(clientId, serverId, directory, events.forms)
}
+ private hasActiveSession(stream: RemoteServerStream, directory: string): boolean {
+ for (const sessionID of stream.busySessions) {
+ if (stream.sessionDirectories.get(sessionID) === directory) return true
+ }
+ return false
+ }
+
private emitReplayEventsToClient(
clientId: string,
serverId: string,
@@ -281,6 +293,7 @@ export class RemoteServerEventHub {
try {
const { event, resolvedDirectory, payloadJson } = routed
const sessionID = sessionIDFromEvent(event)
+ if (sessionID) applySessionActivity(stream.busySessions, event)
if (sessionID && event.type === 'session.deleted') {
stream.subagentSessions.delete(sessionID)
} else if (event.type === 'session.created' && event.data.parentID) {
@@ -352,12 +365,16 @@ export class RemoteServerEventHub {
if (!this.isStreamCurrent(serverId, stream, source)) return
+ await this.resolveActiveSessionDirectories(api, serverId, stream, Object.keys(active ?? {}))
+ if (!this.isStreamCurrent(serverId, stream, source)) return
+
const plan = planSessionStatusReplay(active, stream.sessionDirectories)
let replayed = 0
for (const { sessionID, directory } of plan.idle) {
const event = this.buildReplayEvent('session.status', directory, { sessionID, status: { type: 'idle' } })
this.aggregator.deliverRemoteEnvelope(serverId, directory, JSON.stringify(event))
+ stream.busySessions.delete(sessionID)
replayed++
}
@@ -365,6 +382,7 @@ export class RemoteServerEventHub {
const directory = stream.sessionDirectories.get(sessionID) ?? null
const event = this.buildReplayEvent('session.status', directory ?? '', { sessionID, status: { type: 'busy' } })
this.aggregator.deliverRemoteEnvelope(serverId, directory, JSON.stringify(event))
+ stream.busySessions.add(sessionID)
replayed++
}
@@ -373,6 +391,26 @@ export class RemoteServerEventHub {
}
}
+ private async resolveActiveSessionDirectories(
+ api: OpenCodeApi,
+ serverId: string,
+ stream: RemoteServerStream,
+ sessionIDs: string[],
+ ): Promise {
+ const unresolved = sessionIDs.filter((sessionID) => !stream.sessionDirectories.has(sessionID))
+ if (unresolved.length === 0) return
+
+ await Promise.allSettled(unresolved.map(async (sessionID) => {
+ try {
+ const session = await api.session.get({ sessionID })
+ const directory = session.location.directory
+ if (directory) stream.sessionDirectories.set(sessionID, directory)
+ } catch (error) {
+ logger.warn(`remote status replay: failed to resolve the directory of ${sessionID} on ${serverId}: ${String(error)}`)
+ }
+ }))
+ }
+
private async refreshVersion(serverId: string): Promise {
const stream = this.streams.get(serverId)
if (!stream || stream.versionRequested) return
@@ -413,6 +451,7 @@ export class RemoteServerEventHub {
versionRequested: false,
versionProbeToken: 0,
sessionDirectories: new Map(),
+ busySessions: new Set(),
subagentSessions: new Set(),
}
this.streams.set(serverId, stream)
diff --git a/backend/test/services/remote-server-events.test.ts b/backend/test/services/remote-server-events.test.ts
index cfe464484..97248a439 100644
--- a/backend/test/services/remote-server-events.test.ts
+++ b/backend/test/services/remote-server-events.test.ts
@@ -104,6 +104,7 @@ interface ApiFixture {
permissions?: Record
forms?: Record
active?: Record
+ sessionDirectories?: Record
}
function makeApi(fixture: ApiFixture = {}): OpenCodeApi {
@@ -126,6 +127,9 @@ function makeApi(fixture: ApiFixture = {}): OpenCodeApi {
},
session: {
active: vi.fn(async () => fixture.active ?? {}),
+ get: vi.fn(async (input: { sessionID: string }) => ({
+ location: { directory: fixture.sessionDirectories?.[input.sessionID] ?? '' },
+ })),
},
} as unknown as OpenCodeApi
}
@@ -779,8 +783,13 @@ describe('RemoteServerEventHub pending action replay', () => {
sseAggregator.shutdown()
})
+ function trackSessionStatus(sessionID: string, directory: string, status: string): void {
+ sources.sources[0]!.message(remoteEvent('session.status', { sessionID, status: { type: status } }, directory))
+ }
+
it('replays pending remote actions to the subscribing client only', async () => {
hub.start()
+ trackSessionStatus('ses-1', '/repo', 'busy')
const client = createCapturingClient()
const other = createCapturingClient()
sseAggregator.addClient('replay-a', client.callback, client.writeFrame, ['srv_a|/repo'])
@@ -802,11 +811,57 @@ describe('RemoteServerEventHub pending action replay', () => {
expect(other.frames).toHaveLength(0)
})
+ it('skips the replay for a linked directory with no active session', async () => {
+ hub.start()
+ const client = createCapturingClient()
+ sseAggregator.addClient('idle', client.callback, client.writeFrame, ['srv_a|/repo'])
+
+ await flushPromises()
+
+ expect(fake.api.permission.request.list).not.toHaveBeenCalled()
+ expect(fake.api.form.list).not.toHaveBeenCalled()
+ expect(client.frames).toHaveLength(0)
+ })
+
+ it('stops replaying for a linked directory once its session goes idle', async () => {
+ hub.start()
+ trackSessionStatus('ses-1', '/repo', 'busy')
+ trackSessionStatus('ses-1', '/repo', 'idle')
+ const client = createCapturingClient()
+ sseAggregator.addClient('settled', client.callback, client.writeFrame, ['srv_a|/repo'])
+
+ await flushPromises()
+
+ expect(fake.api.permission.request.list).not.toHaveBeenCalled()
+ expect(client.frames).toHaveLength(0)
+ })
+
+ it('resolves the directory of an active session before gating the replay', async () => {
+ fake = createRegistry([SERVER_A], {
+ active: { 'ses-1': { type: 'running' } },
+ sessionDirectories: { 'ses-1': '/repo' },
+ permissions: { '/repo': [{ id: 'perm-1', sessionID: 'ses-1', action: 'shell', resources: ['ls'] }] },
+ })
+ hub = new RemoteServerEventHub(fake.registry, sseAggregator, { eventSourceFactory: sources.factory })
+ hub.start()
+ sources.sources[0]!.open()
+ await flushPromises()
+
+ const client = createCapturingClient()
+ sseAggregator.addClient('resolved', client.callback, client.writeFrame, ['srv_a|/repo'])
+ await flushPromises()
+
+ expect(fake.api.session.get).toHaveBeenCalledWith({ sessionID: 'ses-1' })
+ expect(fake.api.permission.request.list).toHaveBeenCalledWith({ location: { directory: '/repo' } })
+ expect(client.frames.map(parseFrame).map(frame => frame.payload.type)).toEqual(['permission.asked'])
+ })
+
it('does not use the local pending action fetcher for remote keys', async () => {
const localApi = makeApi({ permissions: { '/repo': [{ id: 'local-perm' }] } })
sseAggregator.setPendingActionsFetcher({ api: localApi })
hub.start()
+ trackSessionStatus('ses-1', '/repo', 'busy')
const client = createCapturingClient()
sseAggregator.addClient('mixed', client.callback, client.writeFrame, ['/repo', 'srv_a|/repo'])
@@ -820,6 +875,7 @@ describe('RemoteServerEventHub pending action replay', () => {
it('replays remote actions when a client subscribes later', async () => {
hub.start()
+ trackSessionStatus('ses-1', '/repo', 'busy')
const client = createCapturingClient()
sseAggregator.addClient('late', client.callback, client.writeFrame, [])
@@ -838,6 +894,7 @@ describe('RemoteServerEventHub pending action replay', () => {
vi.mocked(fake.api.form.list).mockResolvedValue({ location: { directory: '/repo' }, data: [] })
hub.start()
+ trackSessionStatus('ses-1', '/repo', 'busy')
const client = createCapturingClient()
sseAggregator.addClient('unsubscribed', client.callback, client.writeFrame, ['srv_a|/repo'])
sseAggregator.removeDirectories('unsubscribed', ['srv_a|/repo'])
diff --git a/frontend/src/api/opencode.test.ts b/frontend/src/api/opencode.test.ts
index 7b0e91ce9..5ce3e3c50 100644
--- a/frontend/src/api/opencode.test.ts
+++ b/frontend/src/api/opencode.test.ts
@@ -21,7 +21,7 @@ import {
listPendingPermissions,
listSessionMessages,
listSessionPage,
- projectActivityTimestamp,
+ rankProjectsBySessionActivity,
readSessionSnapshot,
remoteProjectName,
renameSession,
@@ -30,7 +30,7 @@ import {
runCommand,
runShell,
sendPrompt,
- sortProjectsByActivity,
+ sortProjectsByUpdatedAt,
stageRevert,
switchSessionAgent,
switchSessionModel,
@@ -765,26 +765,23 @@ const project = (
time: { created: number; updated: number; active?: number },
) => ({ id, name, canonical, time, sandboxes: [] })
-describe('project helpers', () => {
- it('prefers the activity time over the update time', () => {
- const withActivity = project('p1', 'Active', '/work/active', { created: 1, updated: 10, active: 900 })
-
- expect(projectActivityTimestamp(withActivity)).toBe(900)
- })
-
- it('falls back to the update time when the project was never active', () => {
- const neverActive = project('p1', 'Idle', '/work/idle', { created: 1, updated: 10, active: 0 })
-
- expect(projectActivityTimestamp(neverActive)).toBe(10)
- })
+const session = (id: string, directory: string, updated: number) => ({
+ id,
+ projectID: 'prj',
+ cost: 0,
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+ time: { created: updated - 1000, updated },
+ location: { directory },
+})
- it('orders projects by activity, most recent first, without mutating the input', () => {
- const stale = project('p_stale', 'Stale', '/work/stale', { created: 1, updated: 500, active: 100 })
- const recent = project('p_recent', 'Recent', '/work/recent', { created: 1, updated: 100, active: 900 })
+describe('project helpers', () => {
+ it('orders projects by their update time, most recent first, without mutating the input', () => {
+ const stale = project('p_stale', 'Stale', '/work/stale', { created: 1, updated: 100 })
+ const recent = project('p_recent', 'Recent', '/work/recent', { created: 1, updated: 900 })
const middle = project('p_middle', 'Middle', '/work/middle', { created: 1, updated: 700 })
const projects = [stale, recent, middle]
- expect(sortProjectsByActivity(projects).map((entry) => entry.id)).toEqual([
+ expect(sortProjectsByUpdatedAt(projects).map((entry) => entry.id)).toEqual([
'p_recent',
'p_middle',
'p_stale',
@@ -792,6 +789,49 @@ describe('project helpers', () => {
expect(projects.map((entry) => entry.id)).toEqual(['p_stale', 'p_recent', 'p_middle'])
})
+ it('labels a project with its newest session time and ignores the project activity time', () => {
+ const projects = [project('p1', 'Alpha', '/work/alpha', { created: 1, updated: 10, active: 900 })]
+
+ const [entry] = rankProjectsBySessionActivity(projects, [
+ session('ses_old', '/work/alpha', 300),
+ session('ses_new', '/work/alpha', 500),
+ ])
+
+ expect(entry.project.id).toBe('p1')
+ expect(entry.activityAt).toBe(500)
+ })
+
+ it('credits a session in a nested directory to the project that owns it', () => {
+ const projects = [project('p1', 'Alpha', '/work/alpha', { created: 1, updated: 10 })]
+
+ const [entry] = rankProjectsBySessionActivity(projects, [
+ session('ses_nested', '/work/alpha/packages/web', 400),
+ ])
+
+ expect(entry.activityAt).toBe(400)
+ })
+
+ it('orders session-backed projects first, then the rest by update time', () => {
+ const projects = [
+ project('p_idle', 'Idle', '/work/idle', { created: 1, updated: 950, active: 999 }),
+ project('p_worked', 'Worked', '/work/worked', { created: 1, updated: 20, active: 999 }),
+ project('p_quiet', 'Quiet', '/work/quiet', { created: 1, updated: 600 }),
+ ]
+
+ const ranked = rankProjectsBySessionActivity(projects, [session('ses_1', '/work/worked', 300)])
+
+ expect(ranked.map((entry) => entry.project.id)).toEqual(['p_worked', 'p_idle', 'p_quiet'])
+ expect(ranked.map((entry) => entry.activityAt)).toEqual([300, 950, 600])
+ })
+
+ it('ignores sessions that belong to no listed project', () => {
+ const projects = [project('p1', 'Alpha', '/work/alpha', { created: 1, updated: 10 })]
+
+ const [entry] = rankProjectsBySessionActivity(projects, [session('ses_1', '/srv/other', 900)])
+
+ expect(entry.activityAt).toBe(10)
+ })
+
it('names a project from its trimmed title', () => {
expect(remoteProjectName(project('p1', ' Alpha ', '/work/alpha', { created: 1, updated: 2 }))).toBe('Alpha')
})
diff --git a/frontend/src/api/opencode.ts b/frontend/src/api/opencode.ts
index af21b06e1..bbefa3874 100644
--- a/frontend/src/api/opencode.ts
+++ b/frontend/src/api/opencode.ts
@@ -38,24 +38,66 @@ export type OpenCodeLocationInfo = Awaited>[number]
+/** A project paired with the timestamp that represents its most recent activity. */
+export interface RemoteProjectActivity {
+ project: OpenCodeProject
+ activityAt: number
+}
+
/**
- * Returns the timestamp that represents a project's most recent activity.
+ * Orders projects from the most to the least recently updated.
+ *
+ * A project's own update time covers metadata edits and registration, not work, so this is the
+ * ordering used when no session evidence is available.
*
- * @param project Project whose activity time is read.
- * @returns The activity time, falling back to the last update when the project was never active.
+ * @param projects Projects to order.
+ * @returns A new array with the projects sorted by update time; the input is left untouched.
*/
-export function projectActivityTimestamp(project: OpenCodeProject): number {
- return project.time.active || project.time.updated
+export function sortProjectsByUpdatedAt(projects: OpenCodeProject[]): OpenCodeProject[] {
+ return [...projects].sort((a, b) => b.time.updated - a.time.updated)
}
/**
- * Orders projects from the most to the least recently active.
+ * Derives each project's activity time from the newest session of the server that belongs to it and
+ * orders projects by that time.
*
- * @param projects Projects to order.
- * @returns A new array with the projects sorted by activity; the input is left untouched.
+ * Session update times record work; a project's own update time only records metadata edits and
+ * registration, so projects without a session in the given page fall back to it and sort after the
+ * session-backed ones. `time.active` is deliberately unused: OpenCode stamps it on every location
+ * boot, including boots caused by other clients and idle evictions.
+ *
+ * @param projects Projects of one server.
+ * @param sessions Sessions of that server, as returned by a first session page.
+ * @returns The projects with their activity time, most recently active first.
*/
-export function sortProjectsByActivity(projects: OpenCodeProject[]): OpenCodeProject[] {
- return [...projects].sort((a, b) => projectActivityTimestamp(b) - projectActivityTimestamp(a))
+export function rankProjectsBySessionActivity(
+ projects: OpenCodeProject[],
+ sessions: SessionInfo[],
+): RemoteProjectActivity[] {
+ const sessionActivityByProjectId = new Map()
+ for (const session of sessions) {
+ const project = findRemoteProjectForDirectory(projects, session.location.directory)
+ if (!project) continue
+ const updated = session.time.updated
+ const current = sessionActivityByProjectId.get(project.id)
+ if (current === undefined || updated > current) {
+ sessionActivityByProjectId.set(project.id, updated)
+ }
+ }
+
+ const sessionActivity = (project: OpenCodeProject) => sessionActivityByProjectId.get(project.id)
+
+ return projects
+ .map((project) => ({
+ project,
+ activityAt: sessionActivity(project) ?? project.time.updated,
+ }))
+ .sort((a, b) => {
+ const aHasSession = sessionActivity(a.project) !== undefined
+ const bHasSession = sessionActivity(b.project) !== undefined
+ if (aHasSession !== bHasSession) return aHasSession ? -1 : 1
+ return b.activityAt - a.activityAt
+ })
}
/**
diff --git a/frontend/src/components/navigation/RemoteProjectNav.test.tsx b/frontend/src/components/navigation/RemoteProjectNav.test.tsx
index c9b329514..1e12c8d68 100644
--- a/frontend/src/components/navigation/RemoteProjectNav.test.tsx
+++ b/frontend/src/components/navigation/RemoteProjectNav.test.tsx
@@ -21,7 +21,10 @@ const mocks = vi.hoisted(() => ({
useOpenCodeServerStatuses: vi.fn(),
useSessionsAcrossDirectories: vi.fn(),
useCreateSession: vi.fn(),
- sessionsCalls: [] as Array<{ directories: string[]; options?: { serverId?: string } }>,
+ sessionsCalls: [] as Array<{
+ directories: string[]
+ options?: { serverId?: string; allDirectories?: boolean; limit?: number }
+ }>,
createCalls: [] as Array<{ directory?: string; serverId?: string }>,
sessions: [] as SessionFixture[],
hasNextPage: { current: false },
@@ -43,11 +46,13 @@ vi.mock('@/hooks/useOpenCode', async (importOriginal) => {
...actual,
useSessionsAcrossDirectories: (
directories: string[],
- options?: { serverId?: string },
+ options?: { serverId?: string; allDirectories?: boolean; limit?: number },
) => {
mocks.sessionsCalls.push({ directories, options })
return {
- data: mocks.sessions.filter((session) => directories.includes(session.location.directory)),
+ data: options?.allDirectories
+ ? mocks.sessions
+ : mocks.sessions.filter((session) => directories.includes(session.location.directory)),
isLoading: false,
isError: false,
hasNextPage: mocks.hasNextPage.current,
@@ -194,6 +199,28 @@ describe('RemoteProjectNavList', () => {
expect(mocks.listProjects).toHaveBeenCalledWith('srv_kitchen')
})
+ it('orders projects by their newest session and ignores the project activity time', async () => {
+ mocks.listProjects.mockResolvedValue([
+ createProject('p1', 'Alpha', '/projects/alpha', { created: 1, updated: 900, active: 999 }),
+ createProject('p2', 'Beta', '/projects/beta', { created: 1, updated: 100, active: 1 }),
+ ])
+ mocks.sessions = [createSession('b1', '/projects/beta', 500)]
+
+ render(, { wrapper: createWrapper('/') })
+
+ await screen.findByText('Beta')
+ const toggles = screen
+ .getAllByRole('button', { name: /^Show sessions in / })
+ .map((button) => button.getAttribute('aria-label'))
+ expect(toggles).toEqual(['Show sessions in Beta', 'Show sessions in Alpha'])
+ expect(
+ mocks.sessionsCalls.filter((call) => call.options?.allDirectories === true),
+ ).toHaveLength(1)
+ expect(
+ mocks.sessionsCalls.find((call) => call.options?.allDirectories === true)?.options,
+ ).toMatchObject({ serverId: 'srv_kitchen', limit: 100 })
+ })
+
it('expands the project of the current project route and loads its sessions', async () => {
render(, {
wrapper: createWrapper('/servers/srv_kitchen/projects?directory=%2Fprojects%2Falpha'),
diff --git a/frontend/src/components/navigation/RemoteProjectNav.tsx b/frontend/src/components/navigation/RemoteProjectNav.tsx
index de19ff0a2..e1c8f764e 100644
--- a/frontend/src/components/navigation/RemoteProjectNav.tsx
+++ b/frontend/src/components/navigation/RemoteProjectNav.tsx
@@ -5,7 +5,7 @@ import type { Session } from '@/api/types'
import { ActivityStateIndicator } from '@/components/activity/ActivityStateIndicator'
import { useSessionsAcrossDirectories } from '@/hooks/useOpenCode'
import { useActiveRemoteDirectory } from '@/hooks/useActiveRemoteDirectory'
-import { useRemoteProjects } from '@/hooks/useRemoteProjects'
+import { useRemoteProjectsByActivity } from '@/hooks/useRemoteProjects'
import { useOpenCodeServers, useOpenCodeServerStatuses } from '@/hooks/useOpenCodeServers'
import { useProjectActivityState } from '@/hooks/useActiveWork'
import { useSessionSearch } from '@/hooks/useSessionSearch'
@@ -366,7 +366,7 @@ export function RemoteProjectNavList({
const { directory: activeDirectory } = useActiveRemoteDirectory(serverId)
const { projects, isLoading: projectsLoading, isError: projectsError, hasData: hasProjects } =
- useRemoteProjects(serverId)
+ useRemoteProjectsByActivity(serverId)
const trimmedSearch = search.trim()
if (isServersLoading) return Loading projects...
diff --git a/frontend/src/components/servers/RemoteProjectList.tsx b/frontend/src/components/servers/RemoteProjectList.tsx
index b8468000a..c0f8f14b7 100644
--- a/frontend/src/components/servers/RemoteProjectList.tsx
+++ b/frontend/src/components/servers/RemoteProjectList.tsx
@@ -1,9 +1,9 @@
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Folder, FolderGit2, FolderPlus, GitBranch, Search, Server } from 'lucide-react'
-import { projectActivityTimestamp, remoteProjectName, type OpenCodeProject } from '@/api/opencode'
+import { remoteProjectName, type OpenCodeProject } from '@/api/opencode'
import { useOpenCodeServers } from '@/hooks/useOpenCodeServers'
-import { useRemoteProjects } from '@/hooks/useRemoteProjects'
+import { useRemoteProjectsByActivity } from '@/hooks/useRemoteProjects'
import { useSettingsDialog } from '@/hooks/useSettingsDialog'
import { useProjectActivityState } from '@/hooks/useActiveWork'
import { getServerProjectPath } from '@/lib/navigation'
@@ -19,15 +19,19 @@ import { RepoCardShell } from '@/components/repo/RepoCardShell'
import { RepoCardSkeleton } from '@/components/repo/RepoCardSkeleton'
import { RepoSearchInput } from '@/components/repo/RepoSearchInput'
-function RemoteProjectCard({ project, serverId }: { project: OpenCodeProject; serverId: string }) {
+interface RemoteProjectCardProps {
+ project: OpenCodeProject
+ serverId: string
+ activityAt: number
+}
+
+function RemoteProjectCard({ project, serverId, activityAt }: RemoteProjectCardProps) {
const navigate = useNavigate()
const activityState = useProjectActivityState(serverId, project.canonical)
- const activeAt = projectActivityTimestamp(project)
return (
}
actions={}
meta={
@@ -40,7 +44,7 @@ function RemoteProjectCard({ project, serverId }: { project: OpenCodeProject; se
{project.canonical}
}
- activityLabel={activeAt > 0 ? formatShortRelativeTime(new Date(activeAt)) : undefined}
+ activityLabel={activityAt > 0 ? formatShortRelativeTime(new Date(activityAt)) : undefined}
isInteractive
onClick={() => navigate(getServerProjectPath(serverId, project.canonical))}
/>
@@ -90,7 +94,7 @@ function RemoteProjectListContent({ serverId }: { serverId: string }) {
const server = servers.find((candidate) => candidate.id === serverId)
const isDisabled = server !== undefined && !server.enabled
- const { projects, isLoading, isError, hasData, error, refetch } = useRemoteProjects(serverId)
+ const { projects, activityById, isLoading, isError, hasData, error, refetch } = useRemoteProjectsByActivity(serverId)
const showsProjectError = isError && !hasData
const filteredProjects = useMemo(() => {
@@ -185,6 +189,7 @@ function RemoteProjectListContent({ serverId }: { serverId: string }) {
key={project.id}
project={project}
serverId={serverId}
+ activityAt={activityById.get(project.id) ?? 0}
/>
))}
diff --git a/frontend/src/components/servers/__tests__/RemoteProjectList.test.tsx b/frontend/src/components/servers/__tests__/RemoteProjectList.test.tsx
index f9899bbc4..9492e0aa0 100644
--- a/frontend/src/components/servers/__tests__/RemoteProjectList.test.tsx
+++ b/frontend/src/components/servers/__tests__/RemoteProjectList.test.tsx
@@ -7,6 +7,7 @@ import { RemoteProjectList } from '../RemoteProjectList'
const mocks = vi.hoisted(() => ({
listProjects: vi.fn(),
+ listSessionPage: vi.fn(),
useCreateSession: vi.fn(),
useOpenCodeServers: vi.fn(),
useSettingsDialog: vi.fn(),
@@ -17,12 +18,13 @@ const mocks = vi.hoisted(() => ({
vi.mock('@/api/opencode', async (importOriginal) => {
const actual = await importOriginal()
- return { ...actual, listProjects: mocks.listProjects }
+ return { ...actual, listProjects: mocks.listProjects, listSessionPage: mocks.listSessionPage }
})
-vi.mock('@/hooks/useOpenCode', () => ({
- useCreateSession: mocks.useCreateSession,
-}))
+vi.mock('@/hooks/useOpenCode', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, useCreateSession: mocks.useCreateSession }
+})
vi.mock('@/hooks/useOpenCodeServers', () => ({
useOpenCodeServers: mocks.useOpenCodeServers,
@@ -110,6 +112,17 @@ const otherProject = {
sandboxes: [],
}
+function createSession(directory: string, updated: number) {
+ return {
+ id: `ses_${directory}`,
+ projectID: 'prj_1',
+ cost: 0,
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+ time: { created: updated - 1000, updated },
+ location: { directory },
+ }
+}
+
function createQueryClient() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
@@ -133,6 +146,7 @@ describe('RemoteProjectList', () => {
vi.clearAllMocks()
mocks.isMobile = true
mocks.listProjects.mockResolvedValue([project])
+ mocks.listSessionPage.mockResolvedValue({ items: [] })
mocks.useCreateSession.mockReturnValue({ mutateAsync: mocks.createSession, isPending: false })
mocks.useOpenCodeServers.mockReturnValue({ servers: [server], isLoading: false })
mocks.useSettingsDialog.mockReturnValue({ openTab: mocks.openTab })
@@ -148,34 +162,70 @@ describe('RemoteProjectList', () => {
expect(mocks.listProjects).toHaveBeenCalledWith('srv_kitchen')
})
- it('labels a row with its project activity time', async () => {
+ it('labels a row with its newest session time instead of the project activity time', async () => {
+ const now = Date.now()
+ mocks.listProjects.mockResolvedValue([
+ {
+ ...project,
+ time: { created: now - 10_800_000, updated: now - 10_800_000, active: now },
+ },
+ ])
+ mocks.listSessionPage.mockResolvedValue({
+ items: [createSession('/home/me/app', now - 300_000)],
+ })
+
+ renderProjectList()
+
+ await screen.findByText('App')
+ expect(await screen.findByText('5m ago')).toBeInTheDocument()
+ })
+
+ it('labels a row with the update time when the project has no session in the fetched page', async () => {
const now = Date.now()
mocks.listProjects.mockResolvedValue([
{
...project,
- time: { created: now - 10_800_000, updated: now - 10_800_000, active: now - 300_000 },
+ time: { created: now - 10_800_000, updated: now - 300_000, active: now },
},
])
renderProjectList()
await screen.findByText('App')
- expect(screen.getByText('5m ago')).toBeInTheDocument()
+ expect(await screen.findByText('5m ago')).toBeInTheDocument()
})
- it('labels a row with the update time when the project has no activity time', async () => {
+ it('orders projects by their newest session, not by the project activity time', async () => {
const now = Date.now()
mocks.listProjects.mockResolvedValue([
{
...project,
- time: { created: now - 10_800_000, updated: now - 300_000 },
+ time: { created: now - 10_800_000, updated: now - 10_800_000, active: now },
+ },
+ {
+ ...otherProject,
+ time: { created: now - 10_800_000, updated: now - 10_800_000, active: now - 3_600_000 },
},
])
+ mocks.listSessionPage.mockResolvedValue({
+ items: [createSession('/srv/other', now - 120_000)],
+ })
renderProjectList()
await screen.findByText('App')
- expect(screen.getByText('5m ago')).toBeInTheDocument()
+ await waitFor(() => {
+ expect(screen.getByText('Other')).toBeInTheDocument()
+ })
+ const names = screen.getAllByRole('heading', { level: 3 }).map((heading) => heading.textContent)
+ expect(names).toEqual(['Other', 'App'])
+ })
+
+ it('shows no status dot on remote project cards', async () => {
+ const { container } = renderProjectList()
+
+ await screen.findByText('App')
+ expect(container.querySelector('.bg-success')).toBeNull()
})
it('opens the remote project page when a project card is clicked', async () => {
diff --git a/frontend/src/components/session/SessionPickerDialog.test.tsx b/frontend/src/components/session/SessionPickerDialog.test.tsx
index b4365a004..2e733730d 100644
--- a/frontend/src/components/session/SessionPickerDialog.test.tsx
+++ b/frontend/src/components/session/SessionPickerDialog.test.tsx
@@ -112,6 +112,12 @@ vi.mock('@/hooks/useSessionPins', () => ({
useToggleSessionPin: () => ({ mutate: mocks.togglePinMock }),
}))
+vi.mock('@/components/navigation/RemoteProjectNav', () => ({
+ RemoteProjectNavList: ({ serverId }: { serverId: string }) => (
+
+ ),
+}))
+
vi.mock('@/hooks/useSidebarRepoGroups', () => ({
useNavigableRepos: () => ({ repos: mocks.repos, isLoading: false, refetch: vi.fn() }),
}))
@@ -1031,6 +1037,17 @@ describe('SessionPickerDialog', () => {
)
})
+ it('renders the linked server projects tab through the shared activity-ordered project list', async () => {
+ renderPicker({
+ remote: { serverId: 'srv_kitchen', serverName: 'Kitchen', projectDirectory: '/home/me/app' },
+ })
+
+ openProjectsTab()
+
+ const nav = await screen.findByTestId('remote-project-nav')
+ expect(nav).toHaveAttribute('data-server', 'srv_kitchen')
+ })
+
it('queries the linked server sessions without a host-supplied server provider', async () => {
renderPicker({
remote: { serverId: 'srv-1', serverName: 'Kitchen', projectDirectory: '/home/me/app' },
diff --git a/frontend/src/contexts/EventContext.test.tsx b/frontend/src/contexts/EventContext.test.tsx
index d6a331c6b..8d1294b17 100644
--- a/frontend/src/contexts/EventContext.test.tsx
+++ b/frontend/src/contexts/EventContext.test.tsx
@@ -2946,17 +2946,28 @@ describe('EventProvider permissions and forms', () => {
await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled())
+ const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1]
+ const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void
+ const onResync = lastSubscribeCall[0].onResync as
+ | ((directories: string[], serverId?: string) => void)
+ | undefined
+
+ mocks.listActiveSessions.mockImplementation((serverId?: string) =>
+ Promise.resolve(serverId === 'srv_remote' ? { 'remote-session': { type: 'running' } } : {}),
+ )
+ act(() => {
+ onEvent({ type: 'session.status', data: { sessionID: 'remote-session', status: { type: 'busy' } }, directory: '/remote/repo', serverId: 'srv_remote' })
+ })
+ await act(async () => {
+ await Promise.resolve()
+ })
+
mocks.listActiveSessions.mockClear()
mocks.listPendingPermissions.mockClear()
mocks.listPendingForms.mockClear()
mocks.listShells.mockClear()
invalidateQueries.mockClear()
- const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1]
- const onResync = lastSubscribeCall[0].onResync as
- | ((directories: string[], serverId?: string) => void)
- | undefined
-
await act(async () => {
onResync?.(['/repo', 'srv_remote|/remote/repo'], 'srv_remote')
})
@@ -2978,6 +2989,93 @@ describe('EventProvider permissions and forms', () => {
expect(invalidateQueries).not.toHaveBeenCalledWith({ queryKey: ['opencode', 'commands'] })
})
+ it('reconciles only linked directories with active work on a remote resync', async () => {
+ mocks.servers = [{ id: 'srv_remote', enabled: true }]
+ mocks.listProjects.mockResolvedValue([
+ { id: 'proj-busy', canonical: '/remote/busy' },
+ { id: 'proj-idle', canonical: '/remote/idle' },
+ { id: 'proj-other', canonical: '/remote/other' },
+ ])
+ mocks.listActiveSessions.mockImplementation((serverId?: string) =>
+ Promise.resolve(serverId === 'srv_remote' ? { 'remote-busy': { type: 'running' } } : {}),
+ )
+
+ render(, { wrapper: createWrapper() })
+
+ await waitFor(() => expect(mocks.listProjects).toHaveBeenCalledWith('srv_remote'))
+ await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled())
+
+ const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1]
+ const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void
+ const onResync = lastSubscribeCall[0].onResync as
+ | ((directories: string[], serverId?: string) => void)
+ | undefined
+
+ act(() => {
+ onEvent({ type: 'session.status', data: { sessionID: 'remote-busy', status: { type: 'busy' } }, directory: '/remote/busy', serverId: 'srv_remote' })
+ })
+ await act(async () => {
+ await Promise.resolve()
+ })
+
+ mocks.listPendingPermissions.mockClear()
+ mocks.listPendingForms.mockClear()
+ mocks.listShells.mockClear()
+
+ await act(async () => {
+ onResync?.(
+ ['srv_remote|/remote/busy', 'srv_remote|/remote/idle', 'srv_remote|/remote/other'],
+ 'srv_remote',
+ )
+ })
+
+ await waitFor(() => {
+ expect(mocks.listPendingPermissions).toHaveBeenCalledWith('/remote/busy', 'srv_remote')
+ expect(mocks.listPendingForms).toHaveBeenCalledWith('/remote/busy', 'srv_remote')
+ expect(mocks.listShells).toHaveBeenCalledWith('/remote/busy', 'srv_remote')
+ })
+ expect(mocks.listPendingPermissions).not.toHaveBeenCalledWith('/remote/idle', 'srv_remote')
+ expect(mocks.listPendingPermissions).not.toHaveBeenCalledWith('/remote/other', 'srv_remote')
+ expect(mocks.listShells).not.toHaveBeenCalledWith('/remote/idle', 'srv_remote')
+ })
+
+ it('reconciles the viewed linked directory even when its session is idle', async () => {
+ mocks.servers = [{ id: 'srv_remote', enabled: true }]
+ mocks.listActiveSessions.mockResolvedValue({})
+
+ render(, {
+ wrapper: createWrapper(createTestQueryClient(), '/servers/srv_remote/sessions/remote-viewed'),
+ })
+
+ await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled())
+
+ const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1]
+ const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void
+ const onResync = lastSubscribeCall[0].onResync as
+ | ((directories: string[], serverId?: string) => void)
+ | undefined
+
+ act(() => {
+ onEvent({ type: 'session.status', data: { sessionID: 'remote-viewed', status: { type: 'idle' } }, directory: '/remote/viewed', serverId: 'srv_remote' })
+ })
+ await act(async () => {
+ await Promise.resolve()
+ })
+
+ mocks.listPendingPermissions.mockClear()
+ mocks.listShells.mockClear()
+
+ await act(async () => {
+ onResync?.(['srv_remote|/remote/viewed', 'srv_remote|/remote/idle'], 'srv_remote')
+ })
+
+ await waitFor(() => {
+ expect(mocks.listPendingPermissions).toHaveBeenCalledWith('/remote/viewed', 'srv_remote')
+ expect(mocks.listShells).toHaveBeenCalledWith('/remote/viewed', 'srv_remote')
+ })
+ expect(mocks.listPendingPermissions).not.toHaveBeenCalledWith('/remote/idle', 'srv_remote')
+ })
+
it('clears a stale remote busy status on a remote resync', async () => {
mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }])
mocks.listActiveSessions.mockResolvedValue({})
@@ -3201,6 +3299,7 @@ describe('EventProvider permissions and forms', () => {
act(() => {
useSessionStatus.getState().setSessionDirectory('remote-session', 'srv_remote|/remote/repo')
+ useSessionStatus.getState().setStatus('remote-session', { type: 'busy' })
})
mocks.servers = [{ id: 'srv_remote', enabled: true }]
diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx
index e8aefc183..9e38d4b08 100644
--- a/frontend/src/contexts/EventContext.tsx
+++ b/frontend/src/contexts/EventContext.tsx
@@ -24,7 +24,7 @@ import type { PermissionResponse, SSHHostKeyRequest, Repo } from '@/api/types'
import { showToast } from '@/lib/toast'
import { openCodeEventStream, type EventStreamHealthState } from '@/lib/opencode-event-stream'
import { addToSessionKeyedState, removeFromSessionKeyedState } from '@/lib/sessionKeyedState'
-import { getServerSessionPath, getSessionPath, parseRepoRoute, parseServerSessionRoute } from '@/lib/navigation'
+import { getServerSessionPath, getSessionPath, parseRepoRoute, parseServerProjectRoute, parseServerSessionRoute } from '@/lib/navigation'
import { serverScopedKey } from '@/lib/serverScope'
import { repoMatchesDirectory } from '@/components/navigation/sidebar-session-tree'
import { changeWalkthroughQueryKey } from '@/hooks/useChangeWalkthrough'
@@ -374,6 +374,8 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
const reposRef = useRef(null)
const ancestryInFlightRef = useRef>(new Set())
const forgottenSessionIDsRef = useRef(new Set())
+ const reconciledActiveDirectoriesRef = useRef(new Set())
+ const viewedRouteRef = useRef({ pathname: location.pathname, search: location.search })
const { isAuthenticated } = useAuth()
const { data: repos } = useQuery({
@@ -434,6 +436,10 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
formsBySessionRef.current = formsBySession
}, [formsBySession])
+ useEffect(() => {
+ viewedRouteRef.current = { pathname: location.pathname, search: location.search }
+ }, [location.pathname, location.search])
+
const viewedSessionId = useMemo(
() => parseServerSessionRoute(location.pathname)?.sessionId ?? parseRepoRoute(location.pathname).sessionId,
[location.pathname],
@@ -699,8 +705,52 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
navigateToSession(currentPermission.sessionID, currentPermission.serverId)
}, [currentPermission, navigateToSession])
- const reconcilePendingActionsForDirectories = useCallback(async (directories: string[]) => {
- await Promise.all(directories.map(async (key) => {
+ const resolveViewedRemoteDirectoryKey = useCallback((): string | null => {
+ const { pathname, search } = viewedRouteRef.current
+ const projectRoute = parseServerProjectRoute(pathname, search)
+ if (projectRoute) return buildServerDirectoryKey(projectRoute.serverId, projectRoute.directory)
+ const sessionRoute = parseServerSessionRoute(pathname)
+ if (!sessionRoute) return null
+ const remembered = useSessionStatus.getState().directories.get(sessionRoute.sessionId)
+ if (remembered) return remembered
+ const cached = findCachedSessionDirectory(sessionRoute.sessionId)
+ return cached ? buildServerDirectoryKey(sessionRoute.serverId, cached) : null
+ }, [findCachedSessionDirectory])
+
+ const collectActiveRemoteDirectoryKeys = useCallback((): Set => {
+ const state = useSessionStatus.getState()
+ const keys = new Set()
+ const addKey = (key: string | null | undefined) => {
+ if (!key) return
+ if (isLocalOpenCodeServer(parseServerDirectoryKey(key).serverId)) return
+ keys.add(key)
+ }
+ for (const key of selectActiveSessionDirectoriesKey(state).split('\n')) addKey(key)
+ for (const bySession of [permissionsBySessionRef.current, formsBySessionRef.current]) {
+ for (const [sessionID, items] of Object.entries(bySession)) {
+ if (items.length === 0) continue
+ addKey(state.directories.get(sessionID))
+ }
+ }
+ addKey(resolveViewedRemoteDirectoryKey())
+ return keys
+ }, [resolveViewedRemoteDirectoryKey])
+
+ /**
+ * Limits directory-scoped work on a linked server to the directories that can hold pending work,
+ * because every directory-scoped request boots that server's location and stamps project activity.
+ * Local directories are always kept.
+ */
+ const filterTrackedDirectories = useCallback((directories: string[]): string[] => {
+ const activeRemoteDirectories = collectActiveRemoteDirectoryKeys()
+ return directories.filter((key) => {
+ if (isLocalOpenCodeServer(parseServerDirectoryKey(key).serverId)) return true
+ return activeRemoteDirectories.has(key)
+ })
+ }, [collectActiveRemoteDirectoryKeys])
+
+ const reconcilePendingActionsForDirectories = useCallback(async (trackedDirectories: string[]) => {
+ await Promise.all(filterTrackedDirectories(trackedDirectories).map(async (key) => {
const { serverId, directory } = parseServerDirectoryKey(key)
try {
const [pendingPermissions, pendingForms] = await Promise.all([
@@ -718,7 +768,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
}
}
}))
- }, [reconcilePermissionsForDirectory, reconcileFormsForDirectory, prunePendingActions])
+ }, [reconcilePermissionsForDirectory, reconcileFormsForDirectory, prunePendingActions, filterTrackedDirectories])
const collectTrackedDirectories = useCallback((): string[] => {
const directories = new Set()
@@ -815,8 +865,8 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
await Promise.allSettled(sessionIDs.map((sessionID) => resolveUp(sessionID)))
}, [])
- const syncRunningShells = useCallback(async (directories: string[]) => {
- await Promise.all(directories.map(async (key) => {
+ const syncRunningShells = useCallback(async (trackedDirectories: string[]) => {
+ await Promise.all(filterTrackedDirectories(trackedDirectories).map(async (key) => {
const { serverId, directory } = parseServerDirectoryKey(key)
const token = useSessionStatus.getState().beginShellSnapshot()
try {
@@ -835,7 +885,20 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
}
}
}))
- }, [resolveSessionAncestry])
+ }, [resolveSessionAncestry, filterTrackedDirectories])
+
+ useEffect(() => {
+ const activeDirectories = activeSessionDirectoriesKey ? activeSessionDirectoriesKey.split('\n') : []
+ const alreadyReconciled = reconciledActiveDirectoriesRef.current
+ const newlyActive = activeDirectories.filter((key) => !alreadyReconciled.has(key))
+ reconciledActiveDirectoriesRef.current = new Set(activeDirectories)
+ const remoteDirectories = newlyActive.filter(
+ (key) => !isLocalOpenCodeServer(parseServerDirectoryKey(key).serverId),
+ )
+ if (remoteDirectories.length === 0) return
+ void reconcilePendingActionsForDirectories(remoteDirectories)
+ void syncRunningShells(remoteDirectories)
+ }, [activeSessionDirectoriesKey, reconcilePendingActionsForDirectories, syncRunningShells])
const fetchInitialPendingData = useCallback(async () => {
const reposToUse = reposRef.current
@@ -913,15 +976,29 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
}
}, [resolveSessionAncestry])
- const reconcileRemoteServersOnce = useCallback((serversToReconcile: Array<{ id: string }>) => {
+ const reconcileRemoteDirectoryActivity = useCallback(async (
+ serverId: string,
+ extraDirectories: string[] = [],
+ ) => {
+ await reconcileRemoteSessionStatuses(serverId)
+ const directories = [...new Set([...collectTrackedDirectories(), ...extraDirectories])].filter(
+ (key) => parseServerDirectoryKey(key).serverId === serverId,
+ )
+ await Promise.all([
+ reconcilePendingActionsForServer(serverId, directories),
+ syncRunningShells(directories),
+ ])
+ }, [reconcileRemoteSessionStatuses, reconcilePendingActionsForServer, collectTrackedDirectories, syncRunningShells])
+
+ const reconcileRemoteServersOnce = useCallback(async (serversToReconcile: Array<{ id: string }>) => {
const reconciled = reconciledRemoteServerIdsRef.current
- for (const server of serversToReconcile) {
- if (reconciled.has(server.id)) continue
+ const newServers = serversToReconcile.filter((server) => {
+ if (reconciled.has(server.id)) return false
reconciled.add(server.id)
- void reconcileRemoteSessionStatuses(server.id)
- void reconcilePendingActionsForServer(server.id)
- }
- }, [reconcileRemoteSessionStatuses, reconcilePendingActionsForServer])
+ return true
+ })
+ await Promise.all(newServers.map((server) => reconcileRemoteDirectoryActivity(server.id)))
+ }, [reconcileRemoteDirectoryActivity])
useEffect(() => {
const enabledIds = new Set(enabledRemoteServers.map((server) => server.id))
@@ -929,7 +1006,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
if (!enabledIds.has(serverId)) reconciledRemoteServerIdsRef.current.delete(serverId)
}
if (!streamConnectedRef.current) return
- reconcileRemoteServersOnce(enabledRemoteServers)
+ void reconcileRemoteServersOnce(enabledRemoteServers)
}, [enabledRemoteServers, reconcileRemoteServersOnce])
const stopStatusPoll = useCallback(() => {
@@ -1152,7 +1229,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
initialFetchDoneRef.current = false
void reconcilePendingActionsForServer(LOCAL_OPENCODE_SERVER_ID)
fetchInitialSessionStatuses()
- reconcileRemoteServersOnce(enabledRemoteServersRef.current)
+ void reconcileRemoteServersOnce(enabledRemoteServersRef.current)
void syncRunningShells(collectTrackedDirectories())
invalidateChildSessionCaches(queryClient)
invalidateQueryKeysDebounced(queryClient, LOCATION_CATALOG_QUERY_KEYS)
@@ -1168,12 +1245,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
const handleResync = (subscribedDirectories: string[], serverId?: string) => {
if (serverId && !isLocalOpenCodeServer(serverId)) {
- const directories = subscribedDirectories.filter(
- (key) => parseServerDirectoryKey(key).serverId === serverId,
- )
- void reconcilePendingActionsForServer(serverId, subscribedDirectories)
- void reconcileRemoteSessionStatuses(serverId)
- void syncRunningShells(directories)
+ void reconcileRemoteDirectoryActivity(serverId, subscribedDirectories)
invalidateSessionListCachesDebounced(queryClient, undefined, serverId)
invalidateQueryKeysDebounced(
queryClient,
@@ -1212,7 +1284,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
subscription.dispose()
subscriptionRef.current = null
}
- }, [isAuthenticated, addPermission, removePermission, addForm, removeForm, forgetPendingActionsForSession, rememberSessionDirectory, fetchInitialSessionStatuses, startStatusPoll, stopStatusPoll, queryClient, handleHealthChange, reconcilePendingActionsForDirectories, reconcilePendingActionsForServer, collectTrackedDirectories, syncRunningShells, resolveSessionAncestry, reconcileRemoteSessionStatuses, reconcileRemoteServersOnce, invalidateRemoteStatusSyncs])
+ }, [isAuthenticated, addPermission, removePermission, addForm, removeForm, forgetPendingActionsForSession, rememberSessionDirectory, fetchInitialSessionStatuses, startStatusPoll, stopStatusPoll, queryClient, handleHealthChange, reconcilePendingActionsForDirectories, reconcilePendingActionsForServer, collectTrackedDirectories, syncRunningShells, resolveSessionAncestry, reconcileRemoteServersOnce, reconcileRemoteDirectoryActivity, invalidateRemoteStatusSyncs])
useEffect(() => {
reposRef.current = repos
diff --git a/frontend/src/hooks/useRemoteProjects.test.tsx b/frontend/src/hooks/useRemoteProjects.test.tsx
index 9b63e5586..741e61a7a 100644
--- a/frontend/src/hooks/useRemoteProjects.test.tsx
+++ b/frontend/src/hooks/useRemoteProjects.test.tsx
@@ -3,16 +3,17 @@ import { renderHook, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { OpenCodeProject } from '@/api/opencode'
-import { useRemoteProjects } from './useRemoteProjects'
+import { useRemoteProjects, useRemoteProjectsByActivity } from './useRemoteProjects'
const mocks = vi.hoisted(() => ({
listProjects: vi.fn(),
+ listSessionPage: vi.fn(),
useOpenCodeServers: vi.fn(),
}))
vi.mock('@/api/opencode', async (importOriginal) => {
const actual = await importOriginal()
- return { ...actual, listProjects: mocks.listProjects }
+ return { ...actual, listProjects: mocks.listProjects, listSessionPage: mocks.listSessionPage }
})
vi.mock('@/hooks/useOpenCodeServers', () => ({
@@ -37,6 +38,17 @@ function createProject(
return { id, name: id, canonical: `/work/${id}`, time, sandboxes: [] }
}
+function createSession(id: string, directory: string, updated: number) {
+ return {
+ id,
+ projectID: 'prj_1',
+ cost: 0,
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+ time: { created: updated - 1000, updated },
+ location: { directory },
+ }
+}
+
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return ({ children }: { children: ReactNode }) => (
@@ -49,10 +61,11 @@ describe('useRemoteProjects', () => {
vi.clearAllMocks()
mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen], isLoading: false })
mocks.listProjects.mockResolvedValue([])
+ mocks.listSessionPage.mockResolvedValue({ items: [] })
})
- it('lists the projects of the linked server, most recently active first', async () => {
- const stale = createProject('stale', { created: 1, updated: 500, active: 100 })
+ it('lists the projects of the linked server, most recently updated first', async () => {
+ const stale = createProject('stale', { created: 1, updated: 900, active: 100 })
const recent = createProject('recent', { created: 1, updated: 100, active: 900 })
mocks.listProjects.mockResolvedValue([stale, recent])
@@ -61,10 +74,21 @@ describe('useRemoteProjects', () => {
})
await waitFor(() => expect(result.current.projects).toHaveLength(2))
- expect(result.current.projects.map((project) => project.id)).toEqual(['recent', 'stale'])
+ expect(result.current.projects.map((project) => project.id)).toEqual(['stale', 'recent'])
expect(mocks.listProjects).toHaveBeenCalledWith('srv_kitchen')
})
+ it('does not fetch sessions for the canonical project lookup', async () => {
+ mocks.listProjects.mockResolvedValue([createProject('app', { created: 1, updated: 2 })])
+
+ const { result } = renderHook(() => useRemoteProjects('srv_kitchen'), {
+ wrapper: createWrapper(),
+ })
+
+ await waitFor(() => expect(result.current.projects).toHaveLength(1))
+ expect(mocks.listSessionPage).not.toHaveBeenCalled()
+ })
+
it('stays idle while the linked server is disabled', () => {
mocks.useOpenCodeServers.mockReturnValue({
servers: [{ ...kitchen, enabled: false }],
@@ -107,3 +131,84 @@ describe('useRemoteProjects', () => {
expect(result.current.error?.message).toBe('offline')
})
})
+
+describe('useRemoteProjectsByActivity', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen], isLoading: false })
+ mocks.listSessionPage.mockResolvedValue({ items: [] })
+ })
+
+ it('orders and dates projects by their newest session', async () => {
+ mocks.listProjects.mockResolvedValue([
+ createProject('idle', { created: 1, updated: 900, active: 999 }),
+ createProject('worked', { created: 1, updated: 100, active: 999 }),
+ ])
+ mocks.listSessionPage.mockResolvedValue({
+ items: [createSession('ses_1', '/work/worked', 500)],
+ })
+
+ const { result } = renderHook(() => useRemoteProjectsByActivity('srv_kitchen'), {
+ wrapper: createWrapper(),
+ })
+
+ await waitFor(() => expect(result.current.activityById.get('worked')).toBe(500))
+ expect(result.current.projects.map((project) => project.id)).toEqual(['worked', 'idle'])
+ expect(result.current.activityById.get('idle')).toBe(900)
+ })
+
+ it('sorts a project without a session after the session-backed ones', async () => {
+ mocks.listProjects.mockResolvedValue([
+ createProject('quiet', { created: 1, updated: 950 }),
+ createProject('worked', { created: 1, updated: 100 }),
+ ])
+ mocks.listSessionPage.mockResolvedValue({
+ items: [createSession('ses_1', '/work/worked', 300)],
+ })
+
+ const { result } = renderHook(() => useRemoteProjectsByActivity('srv_kitchen'), {
+ wrapper: createWrapper(),
+ })
+
+ await waitFor(() => expect(result.current.activityById.get('worked')).toBe(300))
+ expect(result.current.projects.map((project) => project.id)).toEqual(['worked', 'quiet'])
+ })
+
+ it('fetches one session page per server, newest first and across every directory', async () => {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ )
+ mocks.listProjects.mockResolvedValue([
+ createProject('alpha', { created: 1, updated: 10 }),
+ createProject('beta', { created: 1, updated: 20 }),
+ createProject('gamma', { created: 1, updated: 30 }),
+ ])
+ mocks.listSessionPage.mockResolvedValue({ items: [] })
+
+ const { result } = renderHook(() => useRemoteProjectsByActivity('srv_kitchen'), { wrapper })
+
+ await waitFor(() => expect(result.current.projects).toHaveLength(3))
+ await waitFor(() => expect(mocks.listSessionPage).toHaveBeenCalledTimes(1))
+
+ expect(mocks.listSessionPage).toHaveBeenCalledWith(
+ { limit: 100, order: 'desc' },
+ 'srv_kitchen',
+ )
+ })
+
+ it('stays idle while the linked server is disabled', () => {
+ mocks.useOpenCodeServers.mockReturnValue({
+ servers: [{ ...kitchen, enabled: false }],
+ isLoading: false,
+ })
+
+ const { result } = renderHook(() => useRemoteProjectsByActivity('srv_kitchen'), {
+ wrapper: createWrapper(),
+ })
+
+ expect(result.current.projects).toEqual([])
+ expect(mocks.listProjects).not.toHaveBeenCalled()
+ expect(mocks.listSessionPage).not.toHaveBeenCalled()
+ })
+})
diff --git a/frontend/src/hooks/useRemoteProjects.ts b/frontend/src/hooks/useRemoteProjects.ts
index 94929ffdd..fc81bb542 100644
--- a/frontend/src/hooks/useRemoteProjects.ts
+++ b/frontend/src/hooks/useRemoteProjects.ts
@@ -1,9 +1,12 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
-import { listProjects, sortProjectsByActivity, type OpenCodeProject } from '@/api/opencode'
+import { listProjects, rankProjectsBySessionActivity, sortProjectsByUpdatedAt, type OpenCodeProject } from '@/api/opencode'
import { useOpenCodeServers } from '@/hooks/useOpenCodeServers'
+import { useSessionsAcrossDirectories } from '@/hooks/useOpenCode'
import { serverScopedKey } from '@/lib/serverScope'
+const REMOTE_PROJECT_ACTIVITY_SESSION_LIMIT = 100
+
export interface UseRemoteProjectsOptions {
/** Caller-side gate that applies on top of the server's own enabled flag. */
enabled?: boolean
@@ -18,6 +21,11 @@ export interface UseRemoteProjectsResult {
refetch: () => void
}
+export interface UseRemoteProjectsByActivityResult extends UseRemoteProjectsResult {
+ /** Activity time per project id, for surfaces that label rows with it. */
+ activityById: ReadonlyMap
+}
+
/**
* Builds the query options for one linked server's project list so every reader shares the same
* cache entry and retry policy.
@@ -34,11 +42,14 @@ export function remoteProjectsQueryOptions(serverId: string) {
}
/**
- * Loads the projects of one linked server, most recently active first, while that server is enabled.
+ * Loads the projects of one linked server, most recently updated first, while that server is enabled.
+ *
+ * This is the canonical lookup every caller uses to resolve a directory to its project, so it never
+ * fetches sessions.
*
* @param serverId Linked server whose projects are listed.
* @param options Caller-side gate for when the projects may be fetched.
- * @returns The activity-sorted projects plus the query state callers render.
+ * @returns The update-sorted projects plus the query state callers render.
*/
export function useRemoteProjects(
serverId: string,
@@ -53,7 +64,7 @@ export function useRemoteProjects(
})
const projects = useMemo(
- () => sortProjectsByActivity(projectsQuery.data ?? []),
+ () => sortProjectsByUpdatedAt(projectsQuery.data ?? []),
[projectsQuery.data],
)
@@ -66,3 +77,44 @@ export function useRemoteProjects(
refetch: projectsQuery.refetch,
}
}
+
+/**
+ * Loads the projects of one linked server with the activity time shown and sorted by list surfaces.
+ *
+ * A single first page of the server's sessions, newest first and across every directory, is enough to
+ * date each project; the page is requested only once the projects are known, so canonical lookups
+ * through {@link useRemoteProjects} never pay for it.
+ *
+ * @param serverId Linked server whose projects are listed.
+ * @param options Caller-side gate for when the projects and their sessions may be fetched.
+ * @returns The activity-sorted projects, their activity times by project id, and the query state.
+ */
+export function useRemoteProjectsByActivity(
+ serverId: string,
+ options: UseRemoteProjectsOptions = {},
+): UseRemoteProjectsByActivityResult {
+ const projectsResult = useRemoteProjects(serverId, options)
+ const { data: sessions } = useSessionsAcrossDirectories([], {
+ serverId,
+ allDirectories: projectsResult.hasData,
+ limit: REMOTE_PROJECT_ACTIVITY_SESSION_LIMIT,
+ })
+
+ const activity = useMemo(
+ () => rankProjectsBySessionActivity(projectsResult.projects, sessions),
+ [projectsResult.projects, sessions],
+ )
+
+ const projects = useMemo(() => activity.map((entry) => entry.project), [activity])
+
+ const activityById = useMemo(
+ () => new Map(activity.map((entry) => [entry.project.id, entry.activityAt])),
+ [activity],
+ )
+
+ return {
+ ...projectsResult,
+ projects,
+ activityById,
+ }
+}
From adb251081fcf4d4071cff722e35ef673d36ca39d Mon Sep 17 00:00:00 2001
From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Date: Sat, 24 Oct 2026 10:37:41 -0400
Subject: [PATCH 12/14] fix(frontend): resolve the deepest owning remote
project and refresh VCS info after branch changes
findRemoteProjectForDirectory now returns the longest matching canonical path, so nested linked-server projects attribute sessions to the innermost project. Switching, creating and renaming a local branch invalidate that directory's VCS info query, so branch labels refresh immediately.
---
frontend/src/api/opencode.test.ts | 31 ++++++++++++++++
frontend/src/api/opencode.ts | 13 ++++---
.../source-control/BranchesTab.test.tsx | 36 +++++++++++++++++--
.../components/source-control/BranchesTab.tsx | 3 +-
frontend/src/hooks/useGit.test.tsx | 8 +++++
frontend/src/hooks/useGit.ts | 17 +++++----
frontend/src/hooks/useVcsInfo.ts | 4 +--
frontend/src/lib/queryInvalidation.ts | 31 ++++++++++++++++
8 files changed, 128 insertions(+), 15 deletions(-)
diff --git a/frontend/src/api/opencode.test.ts b/frontend/src/api/opencode.test.ts
index 5ce3e3c50..a75180a54 100644
--- a/frontend/src/api/opencode.test.ts
+++ b/frontend/src/api/opencode.test.ts
@@ -851,6 +851,37 @@ describe('project helpers', () => {
expect(findRemoteProjectForDirectory(projects, '/work/beta/packages/web')?.id).toBe('p2')
})
+ it('picks the deepest project when one canonical directory contains another', () => {
+ const projects = [
+ project('p_parent', 'Repo', '/work/repo', { created: 1, updated: 2 }),
+ project('p_child', 'App', '/work/repo/packages/app', { created: 1, updated: 3 }),
+ ]
+
+ expect(findRemoteProjectForDirectory(projects, '/work/repo')?.id).toBe('p_parent')
+ expect(findRemoteProjectForDirectory(projects, '/work/repo/packages/app')?.id).toBe('p_child')
+ expect(findRemoteProjectForDirectory(projects, '/work/repo/packages/app/src')?.id).toBe('p_child')
+ })
+
+ it('credits a session in a nested project to the deepest project', () => {
+ const projects = [
+ project('p_parent', 'Repo', '/work/repo', { created: 1, updated: 20 }),
+ project('p_child', 'App', '/work/repo/packages/app', { created: 1, updated: 10 }),
+ ]
+
+ const ranked = rankProjectsBySessionActivity(projects, [
+ session('ses_nested', '/work/repo/packages/app/src', 400),
+ ])
+
+ expect(ranked.find((entry) => entry.project.id === 'p_child')?.activityAt).toBe(400)
+ expect(ranked.find((entry) => entry.project.id === 'p_parent')?.activityAt).toBe(20)
+ })
+
+ it('returns no project when no canonical directory contains the directory', () => {
+ const projects = [project('p1', 'Alpha', '/work/alpha', { created: 1, updated: 2 })]
+
+ expect(findRemoteProjectForDirectory(projects, '/srv/elsewhere')).toBeUndefined()
+ })
+
it('does not match a directory that only shares a prefix with a canonical directory', () => {
const projects = [project('p1', 'Alpha', '/work/alpha', { created: 1, updated: 2 })]
diff --git a/frontend/src/api/opencode.ts b/frontend/src/api/opencode.ts
index bbefa3874..40bbabf9f 100644
--- a/frontend/src/api/opencode.ts
+++ b/frontend/src/api/opencode.ts
@@ -112,7 +112,7 @@ export function remoteProjectName(project: OpenCodeProject): string {
/**
* Finds the project that owns a directory, matching its canonical directory and anything nested
- * inside it.
+ * inside it. Nested projects share a canonical prefix, so the deepest match owns the directory.
*
* @param projects Projects of the server that owns the directory.
* @param directory Directory whose owning project is resolved.
@@ -122,9 +122,14 @@ export function findRemoteProjectForDirectory(
projects: OpenCodeProject[],
directory: string,
): OpenCodeProject | undefined {
- return projects.find(
- (project) => directory === project.canonical || directory.startsWith(`${project.canonical}/`),
- )
+ let owner: OpenCodeProject | undefined
+ for (const project of projects) {
+ if (directory !== project.canonical && !directory.startsWith(`${project.canonical}/`)) continue
+ if (!owner || project.canonical.length > owner.canonical.length) {
+ owner = project
+ }
+ }
+ return owner
}
export type VcsInfo = Awaited>['data']
diff --git a/frontend/src/components/source-control/BranchesTab.test.tsx b/frontend/src/components/source-control/BranchesTab.test.tsx
index d1fa416ed..971b1682e 100644
--- a/frontend/src/components/source-control/BranchesTab.test.tsx
+++ b/frontend/src/components/source-control/BranchesTab.test.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { render, screen } from '@testing-library/react'
+import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BranchesTab } from './BranchesTab'
@@ -39,7 +39,8 @@ vi.mock('@/components/repo/CreateWorktreeDialog', () => ({
CreateWorktreeDialog: () => null,
}))
-import { listBranches, getRepo } from '@/api/repos'
+import { listBranches, getRepo, switchBranch } from '@/api/repos'
+import { fetchGitStatus } from '@/api/git'
const createWrapper = () => {
const queryClient = new QueryClient({
@@ -89,4 +90,35 @@ describe('BranchesTab', () => {
expect(await screen.findByText('origin/feature')).toBeInTheDocument()
})
+
+ it('invalidates the vcs info of the switched repository', async () => {
+ vi.mocked(switchBranch).mockResolvedValue({
+ id: 1,
+ fullPath: '/abs/repos/my-repo',
+ currentBranch: 'feature',
+ } as never)
+ vi.mocked(fetchGitStatus).mockResolvedValue({
+ branch: 'feature',
+ ahead: 0,
+ behind: 0,
+ files: [],
+ hasChanges: false,
+ operation: null,
+ })
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ })
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
+ const user = userEvent.setup()
+ render(, {
+ wrapper: ({ children }) => {children},
+ })
+
+ await user.click(await screen.findByRole('button', { name: /^feature/ }))
+
+ await waitFor(() => expect(invalidateSpy).toHaveBeenCalledWith({
+ queryKey: ['opencode', 'vcs', '/abs/repos/my-repo', 'info'],
+ exact: true,
+ }))
+ })
})
diff --git a/frontend/src/components/source-control/BranchesTab.tsx b/frontend/src/components/source-control/BranchesTab.tsx
index 1a1853577..73370345d 100644
--- a/frontend/src/components/source-control/BranchesTab.tsx
+++ b/frontend/src/components/source-control/BranchesTab.tsx
@@ -14,7 +14,7 @@ import { useGit } from '@/hooks/useGit'
import { GIT_UI_COLORS } from '@/lib/git-status-styles'
import { CreateWorktreeDialog } from '@/components/repo/CreateWorktreeDialog'
import { IntegrateBranchDialog } from '@/components/source-control/IntegrateBranchDialog'
-import { invalidateRepoGitCaches, setRepoGitStatusCaches } from '@/lib/queryInvalidation'
+import { invalidateRepoGitCaches, invalidateVcsInfoCache, setRepoGitStatusCaches } from '@/lib/queryInvalidation'
interface BranchesTabProps {
repoId: number
@@ -72,6 +72,7 @@ export function BranchesTab({ repoId, currentBranch }: BranchesTabProps) {
queryClient.setQueryData(['repo', repoId], updatedRepo)
setRepoGitStatusCaches(queryClient, repoId, status)
invalidateRepoGitCaches(queryClient, repoId, { invalidateStatus: false })
+ invalidateVcsInfoCache(queryClient, updatedRepo.fullPath)
refetch()
showToast.success(`Switched to branch: ${updatedRepo.currentBranch}`)
},
diff --git a/frontend/src/hooks/useGit.test.tsx b/frontend/src/hooks/useGit.test.tsx
index 7725e1839..fa16da662 100644
--- a/frontend/src/hooks/useGit.test.tsx
+++ b/frontend/src/hooks/useGit.test.tsx
@@ -66,6 +66,7 @@ vi.mock('@tanstack/react-query', async () => {
invalidateQueries: (...args: unknown[]) => unknown
setQueryData: (...args: unknown[]) => unknown
setQueriesData: (...args: unknown[]) => unknown
+ getQueryData: (...args: unknown[]) => unknown
} | null
return {
invalidateQueries: (...args: unknown[]) => {
@@ -80,6 +81,7 @@ vi.mock('@tanstack/react-query', async () => {
mockSetQueriesData(...args)
return real?.setQueriesData(...args)
},
+ getQueryData: (...args: unknown[]) => real?.getQueryData(...args),
}
})
}
@@ -335,6 +337,8 @@ describe('useGit', () => {
describe('renameBranch mutation', () => {
it('calls correct API and updates the status cache on success', async () => {
+ const realClient = mockRealQueryClient.current as QueryClient
+ realClient.setQueryData(['repo', 1], { fullPath: '/abs/repos/my-repo' })
const { result } = renderHook(() => useGit(1), { wrapper: createWrapper() })
await waitFor(() => {
@@ -343,6 +347,10 @@ describe('useGit', () => {
expect(gitApi.gitRenameBranch).toHaveBeenCalledWith(1, { from: 'main', to: 'renamed' })
expectTargetedStatusCacheUpdate()
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({
+ queryKey: ['opencode', 'vcs', '/abs/repos/my-repo', 'info'],
+ exact: true,
+ })
expect(toast.showToast.success).toHaveBeenCalledWith('Branch renamed')
})
diff --git a/frontend/src/hooks/useGit.ts b/frontend/src/hooks/useGit.ts
index 8f5e4987a..cbd06981d 100644
--- a/frontend/src/hooks/useGit.ts
+++ b/frontend/src/hooks/useGit.ts
@@ -3,9 +3,10 @@ import type { QueryClient } from '@tanstack/react-query'
import { gitFetch, gitPull, gitPush, gitCommit, gitStageFiles, gitUnstageFiles, gitDiscardFiles, fetchGitLog, fetchGitDiff, gitReset, gitRenameBranch, gitDeleteBranch, gitStashPush, gitStashApply, gitStashDrop, gitContinueOperation, gitAbortOperation, gitGenerateCommitMessage, gitIntegrateBranch, getApiErrorMessage, fetchGitStatus } from '@/api/git'
import { createBranch } from '@/api/repos'
import type { DeleteBranchRequest, DeleteBranchResult, IntegrateBranchRequest, RenameBranchRequest, StashPushRequest } from '@opencode-manager/shared'
+import type { Repo } from '@/api/types'
import type { GitStatusResponse } from '@/types/git'
import { showToast } from '@/lib/toast'
-import { invalidateRepoGitCaches, setRepoGitStatusCaches } from '@/lib/queryInvalidation'
+import { invalidateRepoGitCaches, invalidateVcsInfoCache, setRepoGitStatusCaches } from '@/lib/queryInvalidation'
interface RepoGitMutationConfig {
mutationFn: (repoId: number, variables: TVariables) => Promise
@@ -160,12 +161,13 @@ export function useGit(repoId: number | undefined, onError?: (error: unknown) =>
const createBranchMutation = useMutation({
mutationFn: async (branchName: string) => {
if (!repoId) throw new Error('No repo ID')
- await createBranch(repoId, branchName)
- return fetchGitStatus(repoId)
+ const createdRepo = await createBranch(repoId, branchName)
+ return { createdRepo, status: await fetchGitStatus(repoId) }
},
- onSuccess: (data) => {
- if (repoId) setRepoGitStatusCaches(queryClient, repoId, data)
+ onSuccess: ({ createdRepo, status }) => {
+ if (repoId) setRepoGitStatusCaches(queryClient, repoId, status)
invalidateRepoGitCaches(queryClient, repoId, { invalidateStatus: false })
+ invalidateVcsInfoCache(queryClient, createdRepo.fullPath)
showToast.success('Branch created')
},
onError: handleError,
@@ -191,7 +193,10 @@ export function useGit(repoId: number | undefined, onError?: (error: unknown) =>
{
mutationFn: gitRenameBranch,
statusFromData: (data) => data,
- invalidate: (client, id) => invalidateRepoGitCaches(client, id, { invalidateStatus: false }),
+ invalidate: (client, id) => {
+ invalidateRepoGitCaches(client, id, { invalidateStatus: false })
+ invalidateVcsInfoCache(client, client.getQueryData(['repo', id])?.fullPath)
+ },
successMessage: 'Branch renamed',
},
)
diff --git a/frontend/src/hooks/useVcsInfo.ts b/frontend/src/hooks/useVcsInfo.ts
index 8238e9df6..b1d27ce74 100644
--- a/frontend/src/hooks/useVcsInfo.ts
+++ b/frontend/src/hooks/useVcsInfo.ts
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { getVcsInfo } from '@/api/opencode'
-import { serverScopedKey } from '@/lib/serverScope'
+import { vcsInfoQueryKey } from '@/lib/queryInvalidation'
export interface UseVcsInfoOptions {
enabled?: boolean
@@ -14,7 +14,7 @@ export function useVcsInfo(
const { enabled = true } = options
return useQuery({
- queryKey: [...serverScopedKey(['opencode', 'vcs', directory ?? ''], serverId), 'info'],
+ queryKey: vcsInfoQueryKey(directory, serverId),
queryFn: () => getVcsInfo(directory ?? '', serverId),
enabled: enabled && Boolean(directory),
staleTime: 30_000,
diff --git a/frontend/src/lib/queryInvalidation.ts b/frontend/src/lib/queryInvalidation.ts
index 501666c5d..764bb3232 100644
--- a/frontend/src/lib/queryInvalidation.ts
+++ b/frontend/src/lib/queryInvalidation.ts
@@ -1,5 +1,6 @@
import type { QueryClient } from '@tanstack/react-query'
import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas'
+import { serverScopedKey } from '@/lib/serverScope'
import type { GitStatusResponse } from '@/types/git'
export function sessionTranscriptQueryKey(sessionID: string | null | undefined) {
@@ -14,6 +15,36 @@ export function invalidateShellCaches(queryClient: QueryClient) {
queryClient.invalidateQueries({ queryKey: ['opencode', 'shells'] })
}
+/**
+ * Query key of the VCS info cached for one directory on one server.
+ *
+ * @param directory Directory the info describes; a missing directory keeps the placeholder key.
+ * @param serverId Server that owns the directory; defaults to the local one.
+ */
+export function vcsInfoQueryKey(
+ directory: string | null | undefined,
+ serverId: string = LOCAL_OPENCODE_SERVER_ID,
+) {
+ return [...serverScopedKey(['opencode', 'vcs', directory ?? ''], serverId), 'info']
+}
+
+/**
+ * Invalidates the VCS info of one directory so a branch change reaches every surface that shows the
+ * branch, while the status and diff caches of the same directory stay untouched.
+ *
+ * @param queryClient Client that owns the cache.
+ * @param directory Directory whose info is invalidated; a missing directory is ignored.
+ * @param serverId Server that owns the directory; defaults to the local one.
+ */
+export function invalidateVcsInfoCache(
+ queryClient: QueryClient,
+ directory: string | null | undefined,
+ serverId: string = LOCAL_OPENCODE_SERVER_ID,
+) {
+ if (!directory) return
+ queryClient.invalidateQueries({ queryKey: vcsInfoQueryKey(directory, serverId), exact: true })
+}
+
export function childSessionReconciliationQueryKey(sessionID: string | null | undefined) {
return ['opencode', 'session-reconcile', sessionID]
}
From 80e7f78a9bae19aebc1698d2bf5c2dfcd590fc5f Mon Sep 17 00:00:00 2001
From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Date: Sat, 24 Oct 2026 19:15:00 -0400
Subject: [PATCH 13/14] test(frontend): wait for the picker's open focus before
pressing Enter on the scope tab
The dialog moves focus to the search input on the next animation frame after opening. The test focused the sessions tab before that frame, so Enter sometimes landed on the search input and the scope never toggled. It now waits for the open focus, asserts the tab has focus, and checks the persisted scope.
---
.../components/session/SessionPickerDialog.test.tsx | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/frontend/src/components/session/SessionPickerDialog.test.tsx b/frontend/src/components/session/SessionPickerDialog.test.tsx
index 2e733730d..eedba48f8 100644
--- a/frontend/src/components/session/SessionPickerDialog.test.tsx
+++ b/frontend/src/components/session/SessionPickerDialog.test.tsx
@@ -357,12 +357,18 @@ describe('SessionPickerDialog', () => {
const user = userEvent.setup()
renderPicker()
+ await waitFor(() => expect(combobox()).toHaveFocus())
+
+ const sessionsTab = screen.getByRole('tab', { name: 'This project' })
act(() => {
- screen.getByRole('tab', { name: 'This project' }).focus()
+ sessionsTab.focus()
})
+ expect(sessionsTab).toHaveFocus()
+
await user.keyboard('{Enter}')
- expect(screen.getByRole('tab', { name: 'All sessions', selected: true })).toBeInTheDocument()
+ expect(await screen.findByRole('tab', { name: 'All sessions', selected: true })).toBeInTheDocument()
+ expect(localStorage.getItem('oc:session-picker:all-projects')).toBe('true')
})
it('labels the sessions tab Sessions and lists every project without a current repo', () => {
From 92c2d49e5c902305f560dd25986b42cdb9ee5581 Mon Sep 17 00:00:00 2001
From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Date: Sat, 24 Oct 2026 19:15:00 -0400
Subject: [PATCH 14/14] fix(backend): classify clone auth failures before
not-found and isolate server tests from host PIDs
Clone error mapping matched a bare '404' anywhere in git's stderr, so an authentication failure on a URL whose port contained 404 was reported as repository not found. 401 and 404 are now matched as HTTP statuses and authentication is checked first. The single-server tests stub process.kill so they never probe or signal real host processes; PID 1234 existing on a Linux runner made them fail.
---
backend/src/services/repo.ts | 30 ++++++++++++++-----
.../services/opencode-single-server.test.ts | 21 +++++++++++++
backend/test/services/repo-git.test.ts | 29 ++++++++++++++++--
3 files changed, 71 insertions(+), 9 deletions(-)
diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts
index ed36f9c55..d409604e8 100644
--- a/backend/src/services/repo.ts
+++ b/backend/src/services/repo.ts
@@ -37,6 +37,9 @@ export class RepoInputError extends Error {
}
}
+const HTTP_AUTH_STATUS_PATTERN = /\b(?:http|status|error):?\s+401\b/
+const HTTP_NOT_FOUND_STATUS_PATTERN = /\b(?:http|status|error):?\s+404\b/
+
function notFoundOrDeniedMessage(repoUrl: string): string {
return `Repository not found or access denied: ${repoUrl}. Check the URL and add credentials in Settings > Git Credentials if it is private.`
}
@@ -52,7 +55,26 @@ function enhanceCloneError(repoUrl: string, originalMessage: string): RepoInputE
return new RepoInputError(`Repository not reachable: ${repoUrl}. Check the URL and your network connection.`)
}
- if (message.includes('repository not found') || message.includes('not found') || message.includes('does not exist') || message.includes('404')) {
+ if (
+ message.includes('authentication failed') ||
+ message.includes('could not authenticate') ||
+ message.includes('invalid credentials') ||
+ message.includes('invalid username or password') ||
+ message.includes('could not read username') ||
+ message.includes('could not read password') ||
+ HTTP_AUTH_STATUS_PATTERN.test(message)
+ ) {
+ return isGitHubHttpsUrl(repoUrl)
+ ? new RepoInputError(notFoundOrDeniedMessage(repoUrl))
+ : new RepoInputError(`Authentication failed for ${repoUrl}. Check your credentials in Settings > Git Credentials.`)
+ }
+
+ if (
+ message.includes('repository not found') ||
+ message.includes('not found') ||
+ message.includes('does not exist') ||
+ HTTP_NOT_FOUND_STATUS_PATTERN.test(message)
+ ) {
return new RepoInputError(notFoundOrDeniedMessage(repoUrl))
}
@@ -62,12 +84,6 @@ function enhanceCloneError(repoUrl: string, originalMessage: string): RepoInputE
: new RepoInputError(notFoundOrDeniedMessage(repoUrl))
}
- if (message.includes('authentication failed') || message.includes('could not authenticate') || message.includes('invalid credentials') || message.includes('could not read username')) {
- return isGitHubHttpsUrl(repoUrl)
- ? new RepoInputError(notFoundOrDeniedMessage(repoUrl))
- : new RepoInputError(`Authentication failed for ${repoUrl}. Check your credentials in Settings > Git Credentials.`)
- }
-
if (message.includes('not a git repository') || message.includes('fatal:')) {
return new RepoInputError(notFoundOrDeniedMessage(repoUrl))
}
diff --git a/backend/test/services/opencode-single-server.test.ts b/backend/test/services/opencode-single-server.test.ts
index 7aa36fec2..d09288fc2 100644
--- a/backend/test/services/opencode-single-server.test.ts
+++ b/backend/test/services/opencode-single-server.test.ts
@@ -17,6 +17,15 @@ const spawnMock = vi.hoisted(() => vi.fn(() => ({
const spawnSyncMock = vi.hoisted(() => vi.fn())
+const processKillMock = vi.hoisted(() => vi.fn((_pid: number, signal?: number | NodeJS.Signals) => {
+ if (signal === 0) {
+ const error = new Error('kill ESRCH') as NodeJS.ErrnoException
+ error.code = 'ESRCH'
+ throw error
+ }
+ return true
+}))
+
const readFileSyncMock = vi.hoisted(() => vi.fn())
vi.mock('bun:sqlite', () => ({
@@ -186,6 +195,16 @@ const routeVersionProbeThroughExecSyncStub = () => {
beforeEach(routeVersionProbeThroughExecSyncStub)
+const originalProcessKill = process.kill
+
+beforeEach(() => {
+ process.kill = processKillMock as unknown as typeof process.kill
+})
+
+afterEach(() => {
+ process.kill = originalProcessKill
+})
+
// Reset singleton before any tests run to clear any polluted state from previous test files
beforeAll(async () => {
const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server')
@@ -2823,6 +2842,7 @@ describe('OpenCodeServerManager - server auth', () => {
await manager.start()
await manager.stop()
+ expect(processKillMock).toHaveBeenCalledWith(1234, 'SIGTERM')
const rmMock = fs.rm as unknown as ReturnType
expect(rmMock).toHaveBeenCalledWith('/test/workspace/.opencode/state/opencode-server-child.json', { force: true })
} finally {
@@ -2869,6 +2889,7 @@ describe('OpenCodeServerManager - server auth', () => {
await secondManager.start()
expect(spawnMock).toHaveBeenCalledTimes(2)
+ expect(processKillMock).toHaveBeenCalledWith(1234, 'SIGKILL')
} finally {
readFileMock.mockReset()
Object.defineProperty(ENV.SERVER, 'NODE_ENV', { value: originalNodeEnv, configurable: true, writable: true })
diff --git a/backend/test/services/repo-git.test.ts b/backend/test/services/repo-git.test.ts
index 5948bad60..93f154dee 100644
--- a/backend/test/services/repo-git.test.ts
+++ b/backend/test/services/repo-git.test.ts
@@ -88,6 +88,14 @@ describe('repo service real git', () => {
})
}
+ function createTlsCloneGitAuth(): GitAuthService {
+ return createGitAuthService({
+ GIT_TERMINAL_PROMPT: '0',
+ GIT_CONFIG_NOSYSTEM: '1',
+ GIT_CONFIG_GLOBAL: '/dev/null',
+ })
+ }
+
describe('initLocalRepo', () => {
it('initializes a relative repo and marks it ready', async () => {
const { initLocalRepo } = await import('../../src/services/repo')
@@ -756,7 +764,7 @@ describe('repo service real git', () => {
const url = `https://127.0.0.1:${port}/404/repo-missing.git`
process.env.GIT_SSL_NO_VERIFY = 'true'
try {
- await expect(cloneRepo(db, gitAuth, url, { directoryName: name })).rejects.toThrow(/Repository not found/)
+ await expect(cloneRepo(db, createTlsCloneGitAuth(), url, { directoryName: name })).rejects.toThrow(/Repository not found/)
} finally {
delete process.env.GIT_SSL_NO_VERIFY
}
@@ -773,7 +781,24 @@ describe('repo service real git', () => {
const url = `https://user:pass@127.0.0.1:${port}/repo-auth.git`
process.env.GIT_SSL_NO_VERIFY = 'true'
try {
- await expect(cloneRepo(db, gitAuth, url, { directoryName: name })).rejects.toThrow(/Authentication failed/)
+ await expect(cloneRepo(db, createTlsCloneGitAuth(), url, { directoryName: name })).rejects.toThrow(/Authentication failed/)
+ } finally {
+ delete process.env.GIT_SSL_NO_VERIFY
+ }
+ })
+
+ expect(getRepoByLocalPath(db, name)).toBeNull()
+ })
+
+ it('maps an authentication clone failure whose URL contains a 404 sequence', async () => {
+ const { cloneRepo } = await import('../../src/services/repo')
+ const name = uniqueName('clone-auth-404')
+
+ await withTlsServer(401, async (port) => {
+ const url = `https://user:pass@127.0.0.1:${port}/404/repo-auth.git`
+ process.env.GIT_SSL_NO_VERIFY = 'true'
+ try {
+ await expect(cloneRepo(db, createTlsCloneGitAuth(), url, { directoryName: name })).rejects.toThrow(/Authentication failed/)
} finally {
delete process.env.GIT_SSL_NO_VERIFY
}