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 cfea238630..91f06f1cc6 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,7 +9,12 @@ import { useBackgroundSync } from './use-background-sync' const noop = () => undefined const requestGateway = async () => ({ sessions: [] }) -function render(activeGatewayProfile: string, activeConnectionId: string, refreshSessions: () => Promise) { +function render( + activeGatewayProfile: string, + activeConnectionId: string, + refreshSessions: () => Promise, + gatewayRequest = requestGateway +) { return renderHook( ({ connectionId, profile }: { connectionId: string; profile: string }) => { useBackgroundSync({ @@ -26,7 +31,7 @@ function render(activeGatewayProfile: string, activeConnectionId: string, refres refreshHermesConfig: noop, refreshMessagingSessions: noop, refreshSessions, - requestGateway + requestGateway: gatewayRequest }) }, { initialProps: { connectionId: activeConnectionId, profile: activeGatewayProfile } } @@ -47,6 +52,39 @@ describe('useBackgroundSync profile-scoped session refresh', () => { vi.useRealTimers() }) + it('coalesces change ticks while the live status request is pending', async () => { + $changeEventsAvailable.set(true) + let release!: (value: { sessions: [] }) => void + const pending = new Promise<{ sessions: [] }>(resolve => { release = resolve }) + const request = vi.fn(() => pending) + render('default', 'local', async () => undefined, request) + await act(async () => undefined) + + for (let tick = 1; tick <= 8; tick += 1) { + await act(async () => { $sessionsChangeTick.set(tick) }) + } + + expect(request).toHaveBeenCalledTimes(1) + await act(async () => { release({ sessions: [] }) }) + expect(request).toHaveBeenCalledTimes(2) + }) + + it('discards a queued old-connection refresh when the connection changes', async () => { + let rejectOld!: (reason: Error) => void + const oldRequest = new Promise<{ sessions: [] }>((_, reject) => { rejectOld = reject }) + const request = vi.fn().mockReturnValueOnce(oldRequest).mockResolvedValue({ sessions: [] }) + const hook = render('default', 'first', async () => undefined, request) + await act(async () => { $sessionsChangeTick.set(1) }) + hook.rerender({ connectionId: 'second', profile: 'default' }) + await act(async () => undefined) + expect(request).toHaveBeenCalledTimes(2) + await act(async () => { rejectOld(new Error('old connection closed')) }) + expect(request).toHaveBeenCalledTimes(2) + hook.unmount() + await act(async () => { $sessionsChangeTick.set(2) }) + expect(request).toHaveBeenCalledTimes(2) + }) + it('refreshes the session list after the active gateway profile changes', async () => { const refreshSessions = vi.fn(async () => undefined) const hook = render('default', 'local', refreshSessions) 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 9bd5e47403..5363eeb149 100644 --- a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts +++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts @@ -575,7 +575,6 @@ export function useBackgroundSync({ }: BackgroundSyncParams): void { const changeEventsAvailable = useStore($changeEventsAvailable) const cronChangeTick = useStore($cronChangeTick) - const sessionsChangeTick = useStore($sessionsChangeTick) const activeTranscriptBusy = useStore($busy) const activeTranscriptRefreshPendingRef = useRef(null) // Tile reconcile state (#93942 slice 1): shared sequence guard + per-tile @@ -682,9 +681,16 @@ export function useBackgroundSync({ let cancelled = false let inFlight = false + let refreshPending = false const refreshLiveStatuses = async () => { + if (cancelled) { + return + } + if (inFlight) { + refreshPending = true + return } @@ -701,9 +707,16 @@ export function useBackgroundSync({ // still work as before; leave the current sidebar state untouched. } finally { inFlight = false + + if (refreshPending && !cancelled) { + refreshPending = false + void refreshLiveStatuses() + } } } + const unsubscribe = $sessionsChangeTick.listen(() => void refreshLiveStatuses()) + const dispose = visiblePoll( changeEventsAvailable ? LIVE_SESSION_STATUS_BACKSTOP_INTERVAL_MS : LIVE_SESSION_STATUS_POLL_INTERVAL_MS, () => void refreshLiveStatuses() @@ -713,11 +726,12 @@ export function useBackgroundSync({ return () => { cancelled = true + unsubscribe() dispose() } - // sessionsChangeTick: each sessions.changed broadcast re-seeds immediately - // via the effect re-run (already coalesced to 2s server-side). - }, [activeGatewayProfile, changeEventsAvailable, gatewayState, requestGateway, sessionsChangeTick]) + // Keep the in-flight guard alive across change ticks; a slow response must + // not create a new request (and invalidate the old result) on every tick. + }, [activeConnectionId, activeGatewayProfile, changeEventsAvailable, gatewayState, requestGateway]) // sessions.changed also means the *stored* list may have new rows (a cron // run's session, an inbound messaging turn creating a thread). The full list