From fa73b45d34caee8ab32e63380c0f5d3c070374f6 Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:05:17 +0200 Subject: [PATCH] fix(desktop): isolate state across source switches --- apps/desktop/src/app/chat/sidebar/index.tsx | 4 +- .../contrib/hooks/use-background-sync.test.ts | 1 + .../hooks/use-background-sync.test.tsx | 27 +++++++-- .../app/contrib/hooks/use-background-sync.ts | 4 +- .../desktop/src/app/contrib/surfaces.test.tsx | 1 + apps/desktop/src/app/contrib/surfaces.tsx | 6 +- apps/desktop/src/app/contrib/wiring.tsx | 26 +++++---- .../desktop/src/app/cron/cron-actions.test.ts | 6 ++ apps/desktop/src/app/cron/cron-actions.ts | 10 +++- .../hooks/use-session-list-actions.test.tsx | 57 +++++++++++++++++++ .../session/hooks/use-session-list-actions.ts | 16 +++++- .../shell/hooks/use-status-snapshot.test.ts | 41 +++++++++++++ .../app/shell/hooks/use-status-snapshot.ts | 14 ++++- apps/desktop/src/store/gateway-switch.ts | 3 + apps/desktop/src/store/projects.test.ts | 29 ++++++++++ apps/desktop/src/store/projects.ts | 22 +++++-- 16 files changed, 236 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 82ac170dcb..822f27439e 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -30,6 +30,7 @@ import { resolveProfileColor } from '@/lib/profile-color' import { sessionMatchesSearch } from '@/lib/session-search' import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source' import { cn } from '@/lib/utils' +import { $activeConnectionId } from '@/store/connections' import { $cronJobs } from '@/store/cron' import { $bindings } from '@/store/keybinds' import { @@ -377,6 +378,7 @@ export function ChatSidebar({ const profiles = useStore($profiles) const profileColors = useStore($profileColors) const profileScope = useStore($profileScope) + const activeConnectionId = useStore($activeConnectionId) // Toggle the persisted read-state watermark from a row menu. The row's own // `unread` prop mirrors what the dot paints; flip it and let the backend @@ -742,7 +744,7 @@ export function ChatSidebar({ const warm = window.setTimeout(() => void refreshProjectTree(), PROJECT_TREE_WARM_MS) return () => window.clearTimeout(warm) - }, [worktreeGroupingActive, showAllProfiles, profileScope, gatewayReady]) + }, [activeConnectionId, worktreeGroupingActive, showAllProfiles, profileScope, gatewayReady]) // Sessions the branch join can't answer for get one look at their own // transcript — a `gh pr create` in there names the PR outright. Backfills diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts b/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts index 1b70bb29bd..bef4635b70 100644 --- a/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts +++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts @@ -83,6 +83,7 @@ function useSyncHarness({ refreshActiveTranscript: () => Promise }) { useBackgroundSync({ + activeConnectionId: 'local', activeGatewayProfile: 'default', activeIsMessaging, activeSessionId, diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.test.tsx b/apps/desktop/src/app/contrib/hooks/use-background-sync.test.tsx index c11a40bc15..cfea238630 100644 --- a/apps/desktop/src/app/contrib/hooks/use-background-sync.test.tsx +++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.test.tsx @@ -9,16 +9,18 @@ import { useBackgroundSync } from './use-background-sync' const noop = () => undefined const requestGateway = async () => ({ sessions: [] }) -function render(activeGatewayProfile: string, refreshSessions: () => Promise) { +function render(activeGatewayProfile: string, activeConnectionId: string, refreshSessions: () => Promise) { return renderHook( - ({ profile }: { profile: string }) => { + ({ connectionId, profile }: { connectionId: string; profile: string }) => { useBackgroundSync({ + activeConnectionId: connectionId, activeGatewayProfile: profile, activeIsMessaging: false, activeSessionId: null, + activeStoredSessionId: null, freshDraftReady: false, gatewayState: 'open', - refreshActiveMessagingTranscript: noop, + refreshActiveTranscript: noop, refreshCronJobs: noop, refreshCurrentModel: noop, refreshHermesConfig: noop, @@ -27,7 +29,7 @@ function render(activeGatewayProfile: string, refreshSessions: () => Promise { it('refreshes the session list after the active gateway profile changes', async () => { const refreshSessions = vi.fn(async () => undefined) - const hook = render('default', refreshSessions) + const hook = render('default', 'local', refreshSessions) await act(async () => undefined) expect(refreshSessions).toHaveBeenCalledTimes(1) refreshSessions.mockClear() - hook.rerender({ profile: 'nova' }) + hook.rerender({ connectionId: 'local', profile: 'nova' }) + + await act(async () => undefined) + expect(refreshSessions).toHaveBeenCalledTimes(1) + }) + + it('refreshes the session list when the backend changes but the profile name does not', async () => { + const refreshSessions = vi.fn(async () => undefined) + const hook = render('default', 'work', refreshSessions) + + await act(async () => undefined) + refreshSessions.mockClear() + + hook.rerender({ connectionId: 'homelab', profile: 'default' }) await act(async () => undefined) expect(refreshSessions).toHaveBeenCalledTimes(1) diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts index 0db1ef4deb..b1c51e0966 100644 --- a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts +++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts @@ -284,6 +284,7 @@ export function resetLiveRuntimeTracking(): void { } interface BackgroundSyncParams { + activeConnectionId: null | string activeGatewayProfile: string activeIsMessaging: boolean activeSessionId: null | string @@ -351,6 +352,7 @@ function visiblePoll(intervalMs: number, tick: () => void): () => void { * All the "the desktop websocket won't tell us, so poll" logic in one place. */ export function useBackgroundSync({ + activeConnectionId, activeGatewayProfile, activeIsMessaging, activeSessionId, @@ -441,7 +443,7 @@ export function useBackgroundSync({ }) .catch(() => undefined) } - }, [activeGatewayProfile, gatewayState, refreshCurrentModel, refreshSessions, requestGateway]) + }, [activeConnectionId, activeGatewayProfile, gatewayState, refreshCurrentModel, refreshSessions, requestGateway]) // A reconnect loses renderer-only working/attention atoms while the backend // keeps the actual turns alive. Re-seed from the gateway's in-memory session diff --git a/apps/desktop/src/app/contrib/surfaces.test.tsx b/apps/desktop/src/app/contrib/surfaces.test.tsx index 472bf8cbea..361590226c 100644 --- a/apps/desktop/src/app/contrib/surfaces.test.tsx +++ b/apps/desktop/src/app/contrib/surfaces.test.tsx @@ -11,6 +11,7 @@ import { ChatRoutesSurface } from './surfaces' import type { WiringActions } from './types' vi.mock('@/contrib/react/use-contributions', () => ({ useContributions: vi.fn() })) +vi.mock('@/store/connections', () => ({ $activeConnectionId: atom('local') })) vi.mock('@/store/gateway', () => ({ $gateway: atom(null) })) vi.mock('@/store/profile', () => ({ $activeGatewayProfile: atom('default') })) vi.mock('@/store/session', () => ({ diff --git a/apps/desktop/src/app/contrib/surfaces.tsx b/apps/desktop/src/app/contrib/surfaces.tsx index abd3fb04d0..d2108a751f 100644 --- a/apps/desktop/src/app/contrib/surfaces.tsx +++ b/apps/desktop/src/app/contrib/surfaces.tsx @@ -13,6 +13,7 @@ import { Navigate, Route, Routes, useParams } from 'react-router' import { ContribBoundary, ContribRender } from '@/contrib/react/boundary' import { useContributions } from '@/contrib/react/use-contributions' +import { $activeConnectionId } from '@/store/connections' import { $gateway } from '@/store/gateway' import { $activeGatewayProfile } from '@/store/profile' import { $freshDraftReady, $gatewayState } from '@/store/session' @@ -77,9 +78,12 @@ export const StatusbarSurface = memo(function StatusbarSurface({ chatOpen: boolean commandCenterOpen: boolean }) { + const activeConnectionId = useStore($activeConnectionId) + const activeGatewayProfile = useStore($activeGatewayProfile) const gatewayState = useStore($gatewayState) const freshDraftReady = useStore($freshDraftReady) - const { inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, actions.requestGateway) + const gatewayScope = `${activeConnectionId ?? ''}\0${activeGatewayProfile}` + const { inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, actions.requestGateway, gatewayScope) const extraLeftItems = useStatusbarContributions('left') const extraRightItems = useStatusbarContributions('right') diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index bdea85b421..3aa1d34f45 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -34,6 +34,7 @@ import { playWakeSound } from '@/lib/wake-sound' import { $billingSettingsRequest } from '@/store/billing-block' import { $desktopBoot } from '@/store/boot' import { requestVoiceConversationStart } from '@/store/composer' +import { $activeConnectionId } from '@/store/connections' import { $cronReviewRequest, setCronFocusJobId } from '@/store/cron' import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout' import { $previewTarget } from '@/store/preview' @@ -212,6 +213,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { const selectedStoredSessionId = useStore($selectedStoredSessionId) const messagingSessions = useStore($messagingSessions) const sessions = useStore($sessions) + const activeConnectionId = useStore($activeConnectionId) const activeGatewayProfile = useStore($activeGatewayProfile) const profileScope = useStore($profileScope) const boot = useStore($desktopBoot) @@ -491,25 +493,28 @@ export function ContribWiring({ children }: { children: ReactNode }) { startFreshSessionDraft() }, [freshSessionRequest, startFreshSessionDraft]) - // Swapping the live gateway to another profile must re-pull that profile's - // global model + active-profile pill (both are nanostores — the blanket - // invalidateQueries on swap doesn't touch them). - const lastGatewayProfileRef = useRef(activeGatewayProfile) + // Swapping the live gateway to another source or profile must re-pull that + // source's model/config/profile state. Two sources commonly both expose a + // `default` profile, so profile alone is not a sufficient identity. + const gatewayScope = `${activeConnectionId ?? ''}\0${activeGatewayProfile}` + const lastGatewayScopeRef = useRef(gatewayScope) // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { - if (activeGatewayProfile === lastGatewayProfileRef.current) { + if (gatewayScope === lastGatewayScopeRef.current) { return } - lastGatewayProfileRef.current = activeGatewayProfile - // Force: the new profile has its own defaults, so reseed the selector even - // if the composer already shows values from the previous profile. Both - // refreshes carry an intent token so a picker click made in flight wins. + lastGatewayScopeRef.current = gatewayScope + // Force: the new source/profile pair has its own defaults, so reseed the + // selector even if the composer already shows values from the previous + // backend. These refreshes carry intent tokens so an in-flight picker + // click still wins. void refreshCurrentModel(true) void refreshHermesConfig(true) void refreshActiveProfile() - }, [activeGatewayProfile, refreshCurrentModel, refreshHermesConfig]) + resetProjectTreeState() + }, [gatewayScope, refreshCurrentModel, refreshHermesConfig]) // New session anchored to a workspace. Seeds cwd + branch from the clicked // workspace; an explicit worktree path also drills the sidebar into that @@ -776,6 +781,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { // Keep app data live while the gateway is open (on-connect reseed + the // cron / messaging / transcript visibility polls + fresh-draft reseed). useBackgroundSync({ + activeConnectionId, activeGatewayProfile, activeIsMessaging, activeSessionId, diff --git a/apps/desktop/src/app/cron/cron-actions.test.ts b/apps/desktop/src/app/cron/cron-actions.test.ts index 654f65b6b7..d79246a69a 100644 --- a/apps/desktop/src/app/cron/cron-actions.test.ts +++ b/apps/desktop/src/app/cron/cron-actions.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const getCronJobs = vi.fn() +const getApiRequestConnection = vi.fn<() => null | string>(() => null) const triggerCronJob = vi.fn() vi.mock('@/hermes', () => ({ + getApiRequestConnection: () => getApiRequestConnection(), getCronJobs: (...args: unknown[]) => getCronJobs(...args), triggerCronJob: (...args: unknown[]) => triggerCronJob(...args) })) @@ -24,6 +26,8 @@ function deferred() { describe('triggerAndRefreshCronJobs', () => { beforeEach(() => { + getApiRequestConnection.mockReset() + getApiRequestConnection.mockReturnValue(null) getCronJobs.mockReset() triggerCronJob.mockReset() }) @@ -106,6 +110,8 @@ describe('triggerAndRefreshCronJobs', () => { describe('mutateAndRefreshCronJobs', () => { beforeEach(() => { + getApiRequestConnection.mockReset() + getApiRequestConnection.mockReturnValue(null) getCronJobs.mockReset() }) diff --git a/apps/desktop/src/app/cron/cron-actions.ts b/apps/desktop/src/app/cron/cron-actions.ts index 7fafe13c59..e49c541f64 100644 --- a/apps/desktop/src/app/cron/cron-actions.ts +++ b/apps/desktop/src/app/cron/cron-actions.ts @@ -1,4 +1,4 @@ -import { type CronJob, getCronJobs, triggerCronJob } from '@/hermes' +import { type CronJob, getApiRequestConnection, getCronJobs, triggerCronJob } from '@/hermes' import { beginCronJobsAction, beginCronJobsRequest, @@ -18,6 +18,10 @@ export interface CronMutationRefreshResult extends CronTriggerRefreshResult { value: T | null } +function cronRequestScope(profile: string): string { + return `${getApiRequestConnection() ?? ''}\u0000${profile}` +} + async function refreshForGeneration(profile: string, request: CronJobsRequest): Promise { try { const jobs = await getCronJobs(profile) @@ -37,14 +41,14 @@ async function refreshForGeneration(profile: string, request: CronJobsRequest): } export function refreshCronJobs(profile: string): Promise { - return refreshForGeneration(profile, beginCronJobsRequest(profile)) + return refreshForGeneration(profile, beginCronJobsRequest(cronRequestScope(profile))) } export async function mutateAndRefreshCronJobs( profile: string, mutate: () => Promise ): Promise> { - const scopeToken = beginCronJobsAction(profile) + const scopeToken = beginCronJobsAction(cronRequestScope(profile)) let value: T try { diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx index b2360bc50b..fe8ef38fe7 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx @@ -60,6 +60,7 @@ const sidebar = ( const listSidebarSessions = vi.fn() const listAllProfileSessions = vi.fn() const getCronJobs = vi.fn() +const gatewayScope = vi.hoisted(() => ({ epoch: 0 })) interface Deferred { promise: Promise @@ -84,6 +85,11 @@ vi.mock('@/hermes', async importOriginal => ({ listSidebarSessions: (...args: unknown[]) => listSidebarSessions(...args) })) +vi.mock('@/store/gateway', async importOriginal => ({ + ...(await importOriginal>()), + gatewayActivationEpoch: () => gatewayScope.epoch +})) + // The refresh only reads the optimistic tombstone set; stub it so we don't pull // the whole projects store (gateway / fs / git) into this hook's test. const removed = vi.hoisted(() => ({ ids: new Set() })) @@ -93,6 +99,7 @@ vi.mock('@/store/projects', () => ({ })) beforeEach(() => { + gatewayScope.epoch = 0 getCronJobs.mockReset() getCronJobs.mockResolvedValue([]) listSidebarSessions.mockReset() @@ -249,6 +256,33 @@ describe('refreshSessions identity + loading hygiene', () => { off() expect(loadingStates).toEqual([false, true, false]) }) + + it('clears initial loading after a failed source activation advances the gateway epoch', async () => { + const pending = deferred() + listSidebarSessions.mockReturnValue(pending.promise) + const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' })) + + let refresh!: Promise + + act(() => { + refresh = result.current.refreshSessions() + }) + + expect($sessionsLoading.get()).toBe(true) + + // A source dial owns a new activation epoch even when it fails and leaves + // the previous source active. Its in-flight session response is stale, but + // it still owns the initial loading state and must release that state. + gatewayScope.epoch += 1 + + await act(async () => { + pending.resolve(sidebar({ sessions: [row('stale')] })) + await refresh + }) + + expect($sessions.get()).toEqual([]) + expect($sessionsLoading.get()).toBe(false) + }) }) describe('refreshSessions batches slices into one request', () => { @@ -392,6 +426,29 @@ describe('refreshSessions batches slices into one request', () => { expect($messagingSessions.get().map(session => session.id)).toEqual(['personal-signal']) }) + it('ignores an in-flight response after the source changes with the same profile', async () => { + const work = deferred() + const personal = deferred() + + listSidebarSessions.mockReturnValueOnce(work.promise).mockReturnValueOnce(personal.promise) + const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' })) + const workRefresh = result.current.refreshSessions() + + gatewayScope.epoch += 1 + const personalRefresh = result.current.refreshSessions() + + await act(async () => { + personal.resolve(sidebar({ sessions: [row('personal-session')] }, [], [row('personal-chat', { source: 'telegram' })])) + await personalRefresh + + work.resolve(sidebar({ sessions: [row('work-session')] }, [], [row('work-chat', { source: 'signal' })])) + await workRefresh + }) + + expect($sessions.get().map(session => session.id)).toEqual(['personal-session']) + expect($messagingSessions.get().map(session => session.id)).toEqual(['personal-chat']) + }) + it('scopes the cron-jobs fetch to the active profile', async () => { listSidebarSessions.mockResolvedValue(sidebar({ sessions: [] })) diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts index a3416032f2..9b3c2bef82 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts @@ -8,6 +8,7 @@ import { MESSAGING_SESSION_SOURCE_IDS, normalizeSessionSource } from '@/lib/session-source' +import { gatewayActivationEpoch } from '@/store/gateway' import { $pinnedSessionIds, $sessionsLimit, @@ -112,6 +113,7 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg /** Refresh the active profile's messaging-platform sidebar slice. */ const refreshMessagingSessions = useCallback(async () => { const sessionProfile = sidebarProfileForScope(profileScope) + const activationEpoch = gatewayActivationEpoch() // A callback captured before a profile switch may still be queued by an // event subscription. Do not let it start a request against the old scope. @@ -129,7 +131,8 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg if ( refreshMessagingSessionsRequestRef.current !== requestId || - sidebarProfileForScope(profileScopeRef.current) !== sessionProfile + sidebarProfileForScope(profileScopeRef.current) !== sessionProfile || + gatewayActivationEpoch() !== activationEpoch ) { return } @@ -151,6 +154,7 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg const loadMoreMessagingForPlatform = useCallback( async (platform: string) => { const sessionProfile = sidebarProfileForScope(profileScope) + const activationEpoch = gatewayActivationEpoch() if (sidebarProfileForScope(profileScopeRef.current) !== sessionProfile) { return @@ -184,7 +188,8 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg if ( loadMoreMessagingRequestRef.current[requestKey] !== requestId || - sidebarProfileForScope(profileScopeRef.current) !== sessionProfile + sidebarProfileForScope(profileScopeRef.current) !== sessionProfile || + gatewayActivationEpoch() !== activationEpoch ) { return } @@ -221,6 +226,7 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg /** Refresh every sidebar session slice without committing an obsolete profile response. */ const refreshSessions = useCallback(async () => { const sessionProfile = sidebarProfileForScope(profileScope) + const activationEpoch = gatewayActivationEpoch() if (sidebarProfileForScope(profileScopeRef.current) !== sessionProfile) { return @@ -265,7 +271,8 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg if ( refreshSessionsRequestRef.current === requestId && - sidebarProfileForScope(profileScopeRef.current) === sessionProfile + sidebarProfileForScope(profileScopeRef.current) === sessionProfile && + gatewayActivationEpoch() === activationEpoch ) { const recents = result.recents @@ -325,6 +332,9 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg setMessagingTruncated(result.messaging.sessions.length >= MESSAGING_SECTION_LIMIT) } } finally { + // The request id is enough here: a newer refresh owns its own loading + // state, while a failed source activation still needs the old request to + // clear the spinner even though it advanced the gateway epoch. if (showLoading && refreshSessionsRequestRef.current === requestId) { setSessionsLoading(false) } diff --git a/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts b/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts index ce3db7e829..547fef6876 100644 --- a/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts +++ b/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts @@ -155,6 +155,47 @@ describe('useStatusSnapshot', () => { expect(result.current.inferenceStatus).toBeNull() }) + it('refreshes readiness by source and ignores the previous backend response', async () => { + const workRuntime = deferred() + const workSetup = deferred() + const homeRuntime = deferred() + const homeSetup = deferred() + let source = 'work' + + const requestGateway = vi.fn((method: string) => { + if (source === 'work') { + return method === 'setup.runtime_check' ? workRuntime.promise : workSetup.promise + } + + return method === 'setup.runtime_check' ? homeRuntime.promise : homeSetup.promise + }) as unknown as GatewayRequester + + const { rerender, result } = renderHook(({ scope }) => useStatusSnapshot('open', requestGateway, scope), { + initialProps: { scope: 'work\0default' } + }) + + await flushAsync() + source = 'home' + rerender({ scope: 'home\0default' }) + await flushAsync() + + expect(result.current.inferenceStatus).toBeNull() + + await act(async () => { + homeRuntime.resolve({ ok: true }) + homeSetup.resolve({ provider_configured: true }) + await vi.advanceTimersByTimeAsync(0) + }) + expect(result.current.inferenceStatus).toMatchObject({ ready: true, source: 'runtime_check' }) + + await act(async () => { + workRuntime.resolve({ error: 'stale backend', ok: false }) + workSetup.resolve({ provider_configured: false }) + await vi.advanceTimersByTimeAsync(0) + }) + expect(result.current.inferenceStatus).toMatchObject({ ready: true, source: 'runtime_check' }) + }) + it('waits for a slow refresh to settle before scheduling another one', async () => { const setup = deferred() const runtime = deferred() diff --git a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts index d9391dae20..5fdc639177 100644 --- a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts +++ b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts @@ -11,7 +11,11 @@ const REFRESH_MS = 60_000 type GatewayRequester = (method: string, params?: Record) => Promise -export function useStatusSnapshot(gatewayState: string | undefined, requestGateway: GatewayRequester) { +export function useStatusSnapshot( + gatewayState: string | undefined, + requestGateway: GatewayRequester, + gatewayScope = '' +) { const [statusSnapshot, setStatusSnapshot] = useState(null) const [inferenceStatus, setInferenceStatus] = useState(null) @@ -19,6 +23,12 @@ export function useStatusSnapshot(gatewayState: string | undefined, requestGatew let cancelled = false let timer: number | undefined + // Status and inference readiness belong to one backend. A source switch + // can keep gatewayState="open" throughout, so clear the previous source's + // snapshot and start a fresh scoped request explicitly. + setStatusSnapshot(null) + setInferenceStatus(null) + // A closed/connecting gateway cannot have an authoritative live-runtime // result. Clear readiness before starting the REST status leg so a hung // getStatus() cannot leave a stale "ready" state visible after disconnect. @@ -102,7 +112,7 @@ export function useStatusSnapshot(gatewayState: string | undefined, requestGatew window.clearTimeout(timer) } } - }, [gatewayState, requestGateway]) + }, [gatewayScope, gatewayState, requestGateway]) return { inferenceStatus, statusSnapshot } } diff --git a/apps/desktop/src/store/gateway-switch.ts b/apps/desktop/src/store/gateway-switch.ts index 48e81381ca..d166071f79 100644 --- a/apps/desktop/src/store/gateway-switch.ts +++ b/apps/desktop/src/store/gateway-switch.ts @@ -4,6 +4,7 @@ import { resetLiveRuntimeTracking } from '@/app/contrib/hooks/use-background-syn import { resetSidebarBatchCapability } from '@/hermes' import { invalidateProfileScopedQueries } from '@/lib/query-client' import { clearArtifactRegistry } from '@/store/artifacts' +import { invalidateCronJobsRequests, setCronJobs } from '@/store/cron' import { resetSessionsLimit } from '@/store/layout' import { resetLiveSync } from '@/store/live-sync' import { invalidateProfileListFetches } from '@/store/profile' @@ -61,6 +62,8 @@ export function wipeSessionListsForGatewaySwitch(): void { setSessionProfilesTruncated({}) setSessionProfilesUsage({}) setCronSessions([]) + invalidateCronJobsRequests() + setCronJobs([]) setMessagingSessions([]) setMessagingPlatformTotals({}) setMessagingTruncated(false) diff --git a/apps/desktop/src/store/projects.test.ts b/apps/desktop/src/store/projects.test.ts index c3332c2288..d91882032d 100644 --- a/apps/desktop/src/store/projects.test.ts +++ b/apps/desktop/src/store/projects.test.ts @@ -8,6 +8,7 @@ import { $currentCwd, $selectedStoredSessionId, $sessions, applyConfiguredDefaul import { $activeProjectId, + $projects, $projectScope, $projectsRpcAvailable, $projectTree, @@ -400,6 +401,34 @@ describe('projects RPC capability', () => { expect($projectsRpcAvailable.get()).toBe(false) }) + it('does not publish a late project list from the previous source', async () => { + let resolveA: ((value: unknown) => void) | undefined + + const responseA = new Promise(resolve => { + resolveA = resolve + }) + + const gatewayA = { connectionState: 'open', request: vi.fn(() => responseA) } + + const gatewayB = { + connectionState: 'open', + request: vi.fn().mockResolvedValue({ active_id: null, projects: [{ id: 'source-b', name: 'Source B' }] }) + } + + let current = gatewayA + + activeGateway.mockImplementation(() => current as never) + const pendingA = refreshProjects() + + current = gatewayB + await refreshProjects() + + resolveA?.({ active_id: null, projects: [{ id: 'source-a', name: 'Source A' }] }) + await pendingA + + expect($projects.get().map(project => project.id)).toEqual(['source-b']) + }) + it('blocks opening the create dialog once the backend is known stale', () => { $projectsRpcAvailable.set(false) diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index c0c1a9cfce..9af7255c6f 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -6,7 +6,7 @@ import { type SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups' import type { HermesGitBaseBranch, HermesGitBranch } from '@/global' -import { getHermesConfig, type HermesGateway } from '@/hermes' +import { getHermesConfig, hermesApi, type HermesGateway } from '@/hermes' import { translateNow } from '@/i18n' import { desktopDefaultCwd, isDesktopFsRemoteMode, selectDesktopPaths, writeDesktopFileText } from '@/lib/desktop-fs' import { desktopGit, isGitEndpointMissingError } from '@/lib/desktop-git' @@ -374,14 +374,28 @@ function applyPayload(payload: ProjectsPayload): void { $activeProjectId.set(payload.active_id ?? null) } +let projectsRefreshGeneration = 0 + // Pull the full project list + active pointer. Best-effort: a failure (gateway // not up yet) leaves the cached atoms intact so the sidebar doesn't flicker. export async function refreshProjects(): Promise { + const generation = ++projectsRefreshGeneration + let gateway: HermesGateway | null = null + try { - applyPayload(await gatewayRequest('projects.list')) + gateway = (await activeProjectsContext()).gateway + const payload = await gatewayRequestOn(gateway, 'projects.list') + + if (generation !== projectsRefreshGeneration || activeGateway() !== gateway) { + return + } + + applyPayload(payload) markProjectsRpcSuccess() } catch (err) { - markProjectsRpcFailure(err) + if (generation === projectsRefreshGeneration && (!gateway || activeGateway() === gateway)) { + markProjectsRpcFailure(err) + } // Backend may not be ready; keep the last known list. } } @@ -475,7 +489,7 @@ async function refreshProjectTreeAcrossProfiles(): Promise { $projectTreeLoading.set(true) try { - const res = await window.hermesDesktop.api({ + const res = await hermesApi({ path: `/api/profiles/projects/tree?preview_limit=${PROJECT_TREE_PREVIEW_LIMIT}`, timeoutMs: PROJECT_TREE_REQUEST_TIMEOUT_MS })