fix(desktop): isolate state across source switches

This commit is contained in:
David Dudok de Wit
2026-08-17 11:05:17 +02:00
committed by Teknium
parent 0ca7443824
commit fa73b45d34
16 changed files with 236 additions and 31 deletions
+3 -1
View File
@@ -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
@@ -83,6 +83,7 @@ function useSyncHarness({
refreshActiveTranscript: () => Promise<void>
}) {
useBackgroundSync({
activeConnectionId: 'local',
activeGatewayProfile: 'default',
activeIsMessaging,
activeSessionId,
@@ -9,16 +9,18 @@ import { useBackgroundSync } from './use-background-sync'
const noop = () => undefined
const requestGateway = async () => ({ sessions: [] })
function render(activeGatewayProfile: string, refreshSessions: () => Promise<void>) {
function render(activeGatewayProfile: string, activeConnectionId: string, refreshSessions: () => Promise<void>) {
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<voi
requestGateway
})
},
{ initialProps: { profile: activeGatewayProfile } }
{ initialProps: { connectionId: activeConnectionId, profile: activeGatewayProfile } }
)
}
@@ -47,13 +49,26 @@ describe('useBackgroundSync profile-scoped session refresh', () => {
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)
@@ -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
@@ -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<unknown>(null) }))
vi.mock('@/store/profile', () => ({ $activeGatewayProfile: atom('default') }))
vi.mock('@/store/session', () => ({
+5 -1
View File
@@ -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')
+16 -10
View File
@@ -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,
@@ -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<T>() {
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()
})
+7 -3
View File
@@ -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<T> extends CronTriggerRefreshResult {
value: T | null
}
function cronRequestScope(profile: string): string {
return `${getApiRequestConnection() ?? ''}\u0000${profile}`
}
async function refreshForGeneration(profile: string, request: CronJobsRequest): Promise<CronTriggerRefreshResult> {
try {
const jobs = await getCronJobs(profile)
@@ -37,14 +41,14 @@ async function refreshForGeneration(profile: string, request: CronJobsRequest):
}
export function refreshCronJobs(profile: string): Promise<CronTriggerRefreshResult> {
return refreshForGeneration(profile, beginCronJobsRequest(profile))
return refreshForGeneration(profile, beginCronJobsRequest(cronRequestScope(profile)))
}
export async function mutateAndRefreshCronJobs<T>(
profile: string,
mutate: () => Promise<T>
): Promise<CronMutationRefreshResult<T>> {
const scopeToken = beginCronJobsAction(profile)
const scopeToken = beginCronJobsAction(cronRequestScope(profile))
let value: T
try {
@@ -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<T> {
promise: Promise<T>
@@ -84,6 +85,11 @@ vi.mock('@/hermes', async importOriginal => ({
listSidebarSessions: (...args: unknown[]) => listSidebarSessions(...args)
}))
vi.mock('@/store/gateway', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
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<string>() }))
@@ -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<SidebarSessionsResponse>()
listSidebarSessions.mockReturnValue(pending.promise)
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
let refresh!: Promise<void>
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<SidebarSessionsResponse>()
const personal = deferred<SidebarSessionsResponse>()
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: [] }))
@@ -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)
}
@@ -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<unknown>()
const workSetup = deferred<unknown>()
const homeRuntime = deferred<unknown>()
const homeSetup = deferred<unknown>()
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<unknown>()
const runtime = deferred<unknown>()
@@ -11,7 +11,11 @@ const REFRESH_MS = 60_000
type GatewayRequester = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
export function useStatusSnapshot(gatewayState: string | undefined, requestGateway: GatewayRequester) {
export function useStatusSnapshot(
gatewayState: string | undefined,
requestGateway: GatewayRequester,
gatewayScope = ''
) {
const [statusSnapshot, setStatusSnapshot] = useState<StatusResponse | null>(null)
const [inferenceStatus, setInferenceStatus] = useState<RuntimeReadinessResult | null>(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 }
}
+3
View File
@@ -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)
+29
View File
@@ -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)
+18 -4
View File
@@ -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<void> {
const generation = ++projectsRefreshGeneration
let gateway: HermesGateway | null = null
try {
applyPayload(await gatewayRequest<ProjectsPayload>('projects.list'))
gateway = (await activeProjectsContext()).gateway
const payload = await gatewayRequestOn<ProjectsPayload>(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<void> {
$projectTreeLoading.set(true)
try {
const res = await window.hermesDesktop.api<ProjectTreePayload>({
const res = await hermesApi<ProjectTreePayload>({
path: `/api/profiles/projects/tree?preview_limit=${PROJECT_TREE_PREVIEW_LIMIT}`,
timeoutMs: PROJECT_TREE_REQUEST_TIMEOUT_MS
})