diff --git a/web/src/discover/DiscoverPage.test.tsx b/web/src/discover/DiscoverPage.test.tsx index e723e3a..dec24cd 100644 --- a/web/src/discover/DiscoverPage.test.tsx +++ b/web/src/discover/DiscoverPage.test.tsx @@ -531,11 +531,21 @@ describe('DiscoverPage', () => { const server = stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/decline': () => { server.deck = [TIKKA] + server.activeBatch = { batch_id: 'b-j1', job_id: 'j1' } return declined(BOWLS, 'j1') }, - 'GET /api/v1/jobs/j1': () => jsonResponse(200, batchJob('j1', 'failed', [], 'model unavailable')), - 'POST /api/v1/suggestions/batches': () => jsonResponse(202, { batch_id: 'b-j2', job_id: 'j2', trigger: 'manual', status: 'pending' }), + 'GET /api/v1/jobs/j1': () => { + server.activeBatch = null + server.failedBatch = { batch_id: 'b-j1', job_id: 'j1' } + return jsonResponse(200, batchJob('j1', 'failed', [], 'model unavailable')) + }, + 'POST /api/v1/suggestions/batches': () => { + server.failedBatch = null + server.activeBatch = { batch_id: 'b-j2', job_id: 'j2' } + return jsonResponse(202, { batch_id: 'b-j2', job_id: 'j2', trigger: 'manual', status: 'pending' }) + }, 'GET /api/v1/jobs/j2': () => { + server.activeBatch = null server.deck = [TIKKA, ORZO] return jsonResponse(200, batchJob('j2', 'succeeded', ['s3'])) }, @@ -628,6 +638,36 @@ describe('DiscoverPage', () => { expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/batches']) }) + it.each([ + ['once its job has been polled', false], + ['before its job has been polled', true], + ])('drops a failed batch %s once a read of the deck reports a later batch another session finished', async (_, slowPoll) => { + const poll = deferred() + const server = stubServer([BOWLS, TIKKA], { + 'GET /api/v1/jobs/j1': () => (slowPoll ? poll.promise : jsonResponse(200, batchJob('j1', 'failed', [], 'ai_failed'))), + }) + server.failedBatch = { batch_id: 'b-j1', job_id: 'j1' } + const queryClient = createQueryClient() + await renderPage(queryClient) + await screen.findByRole('article', { name: 'Korean beef bowls' }) + if (slowPoll) await waitFor(() => expect(jobPolls(server.fetchMock)).toBe(1)) + else expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't find new side quests. ai_failed") + + // Another session's batch j2 succeeds, superseding j1's failure. + server.failedBatch = null + server.deck = [BOWLS, TIKKA, ORZO] + const reads = deckReads(server.fetchMock) + await act(() => queryClient.invalidateQueries({ queryKey: DECK_KEY })) + await waitFor(() => expect(deckReads(server.fetchMock)).toBe(reads + 1)) + await waitFor(() => expect(screen.getAllByRole('article', { hidden: true })).toHaveLength(3)) + // A poll of j1 still out when the deck moved on does not bring it back. + await act(async () => poll.resolve(jsonResponse(200, batchJob('j1', 'failed', [], 'ai_failed')))) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.queryByText('Finding new side quests…')).not.toBeInTheDocument() + expect(sent(server.fetchMock)).toEqual([]) + }) + it('follows the batch the deck reports running when the page opens, with skeleton cards rather than "All caught up"', async () => { const jobs = [batchJob('j1', 'running'), batchJob('j1', 'succeeded', ['s1'])] const server = stubServer([], { diff --git a/web/src/discover/DiscoverPage.tsx b/web/src/discover/DiscoverPage.tsx index c4fabfb..fd77c50 100644 --- a/web/src/discover/DiscoverPage.tsx +++ b/web/src/discover/DiscoverPage.tsx @@ -100,17 +100,20 @@ export function DiscoverPage() { // decision started or joined, or the deck's active or failed batch, which // may have been started elsewhere or before the page opened. const [batchJobId, setBatchJobId] = useState(null) + const job = useJob(BATCH_JOB_KIND, batchJobId) // A read of the deck reporting a new active or failed batch follows its - // job from that render on; one reporting neither leaves the last job - // followed, so its end and any failure stay shown. + // job from that render on. One reporting neither leaves the last job + // followed, so its end stays shown — unless that job is known to have + // failed, by its poll or the deck's last read: the deck reports the newest + // batch's failure, so a later batch has superseded it, and it goes. const failedJobId = deck.data?.failedBatch?.job_id ?? null const deckJobId = deck.data?.activeBatch?.job_id ?? failedJobId - const [seenDeckJobId, setSeenDeckJobId] = useState(null) - if (deckJobId !== seenDeckJobId) { - setSeenDeckJobId(deckJobId) - if (deckJobId !== null) setBatchJobId(deckJobId) + const [seenRead, setSeenRead] = useState({ at: 0, jobId: null as string | null, failedJobId: null as string | null }) + if (deck.dataUpdatedAt !== seenRead.at) { + setSeenRead({ at: deck.dataUpdatedAt, jobId: deckJobId, failedJobId }) + if (deckJobId !== null && deckJobId !== seenRead.jobId) setBatchJobId(deckJobId) + else if (deckJobId === null && batchJobId !== null && (job.status === 'failed' || batchJobId === seenRead.failedJobId)) setBatchJobId(null) } - const job = useJob(BATCH_JOB_KIND, batchJobId) const top = cards?.[0] const [busy, setBusy] = useState(false)