fix(desktop): isolate state across source switches
This commit is contained in:
committed by
Teknium
parent
0ca7443824
commit
fa73b45d34
@@ -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', () => ({
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user