diff --git a/client/src/data-provider/SSE/__tests__/queries.test.ts b/client/src/data-provider/SSE/__tests__/queries.test.ts index 1c320d3c98..2afb0e1256 100644 --- a/client/src/data-provider/SSE/__tests__/queries.test.ts +++ b/client/src/data-provider/SSE/__tests__/queries.test.ts @@ -42,6 +42,10 @@ import { genTitleQueryKey, queueTitleGeneration, useActiveJobs, + resetActiveJobsGrace, + getActiveJobsRefetchInterval, + ACTIVE_JOBS_POLL_MS, + ACTIVE_JOBS_SUCCESSOR_GRACE_MS, } from '../queries'; describe('fetchStreamStatus generation protocol advertisement', () => { @@ -84,14 +88,65 @@ describe('useActiveJobs focus behaviour', () => { expect(options.staleTime).toBe(5_000); }); - it('polls only while something is listed', () => { + it('polls while something is listed', () => { (useQuery as jest.Mock).mockClear(); + resetActiveJobsGrace(); useActiveJobs(); const options = (useQuery as jest.Mock).mock.calls[0][0]; - expect(options.refetchInterval({ activeJobIds: ['convo-1'] })).toBe(5_000); - expect(options.refetchInterval({ activeJobIds: [] })).toBe(false); + expect(options.refetchInterval({ activeJobIds: ['convo-1'] })).toBe(ACTIVE_JOBS_POLL_MS); + }); +}); + +describe('useActiveJobs successor grace', () => { + let now = 1_000_000; + + beforeEach(() => { + now = 1_000_000; + jest.spyOn(Date, 'now').mockImplementation(() => now); + resetActiveJobsGrace(); + }); + + afterEach(() => { + (Date.now as jest.Mock).mockRestore?.(); + resetActiveJobsGrace(); + }); + + it('does not poll for an empty list it has never seen fill', () => { + expect(getActiveJobsRefetchInterval({ activeJobIds: [] })).toBe(false); + }); + + it('keeps asking across the handover to a server-started successor', () => { + /** + * The run ends, the client removes its own job optimistically, and the + * backend admits the queued turn (or a background-tool continuation) it + * owns. Going quiet on the empty list is what leaves the pane on the + * previous turn until a reload. + */ + expect(getActiveJobsRefetchInterval({ activeJobIds: ['convo-1'] })).toBe(ACTIVE_JOBS_POLL_MS); + + now += 1_000; + expect(getActiveJobsRefetchInterval({ activeJobIds: [] })).toBe(ACTIVE_JOBS_POLL_MS); + }); + + it('stops once the handover window has passed without a successor', () => { + expect(getActiveJobsRefetchInterval({ activeJobIds: ['convo-1'] })).toBe(ACTIVE_JOBS_POLL_MS); + + now += ACTIVE_JOBS_SUCCESSOR_GRACE_MS + 1; + expect(getActiveJobsRefetchInterval({ activeJobIds: [] })).toBe(false); + }); + + it('renews the window when the successor turns up', () => { + getActiveJobsRefetchInterval({ activeJobIds: ['convo-1'] }); + now += 1_000; + getActiveJobsRefetchInterval({ activeJobIds: [] }); + + now += 1_000; + expect(getActiveJobsRefetchInterval({ activeJobIds: ['convo-1'] })).toBe(ACTIVE_JOBS_POLL_MS); + + now += ACTIVE_JOBS_SUCCESSOR_GRACE_MS - 1; + expect(getActiveJobsRefetchInterval({ activeJobIds: [] })).toBe(ACTIVE_JOBS_POLL_MS); }); }); diff --git a/client/src/data-provider/SSE/queries.ts b/client/src/data-provider/SSE/queries.ts index ca5834c2f1..d533155949 100644 --- a/client/src/data-provider/SSE/queries.ts +++ b/client/src/data-provider/SSE/queries.ts @@ -219,6 +219,40 @@ export function useTitleGeneration(enabled = true) { * - Polls while jobs are active * - Shows generation indicators in conversation list */ +export const ACTIVE_JOBS_POLL_MS = 5_000; +/** + * How long to keep asking after the last job this client knew about ended. + * + * A successor the server admits for itself — a queued turn handed to the + * backend, a background-tool continuation — starts as part of terminalizing + * the run before it. But finishing a run also empties this list, partly + * because the client removes its own job optimistically the moment `FINAL` + * lands. Stopping the poll on an empty list therefore goes quiet at exactly + * the moment a successor appears, and with the tab already focused there is no + * focus refetch to recover: the pane waits for a reload. Keep asking across + * that handover instead. + */ +export const ACTIVE_JOBS_SUCCESSOR_GRACE_MS = 30_000; + +/** Module-scoped rather than per-observer: this is one shared query, and a + * component mounting mid-handover should inherit the grace, not restart it. */ +let lastListedActiveJobsAt = 0; + +/** @internal Test seam — the grace window is wall-clock state. */ +export function resetActiveJobsGrace(): void { + lastListedActiveJobsAt = 0; +} + +export function getActiveJobsRefetchInterval(data?: ActiveJobsResponse): number | false { + if ((data?.activeJobIds?.length ?? 0) > 0) { + lastListedActiveJobsAt = Date.now(); + return ACTIVE_JOBS_POLL_MS; + } + return Date.now() - lastListedActiveJobsAt < ACTIVE_JOBS_SUCCESSOR_GRACE_MS + ? ACTIVE_JOBS_POLL_MS + : false; +} + export function useActiveJobs(enabled = true) { return useQuery({ queryKey: [QueryKeys.activeJobs], @@ -231,7 +265,7 @@ export function useActiveJobs(enabled = true) { * invisible until some unrelated refetch, and returning to the tab is * exactly when a pane needs to know whether its history has moved. */ refetchOnWindowFocus: 'always', - refetchInterval: (data) => ((data?.activeJobIds?.length ?? 0) > 0 ? 5_000 : false), + refetchInterval: getActiveJobsRefetchInterval, retry: false, }); }