diff --git a/frontend/taskdeck-web/src/store/captureStore.ts b/frontend/taskdeck-web/src/store/captureStore.ts index 2b89388ee..9828ffdba 100644 --- a/frontend/taskdeck-web/src/store/captureStore.ts +++ b/frontend/taskdeck-web/src/store/captureStore.ts @@ -148,7 +148,6 @@ export const useCaptureStore = defineStore('capture', () => { async function fetchItems(query?: CaptureListQuery) { const requestId = ++latestListLoadRequestId - const observedListWriteGeneration = latestListWriteGeneration if (isDemoMode) { loadingList.value = true listError.value = null @@ -164,7 +163,11 @@ export const useCaptureStore = defineStore('capture', () => { listError.value = null const loadedItems = await captureApi.listItems(query) if (requestId !== latestListLoadRequestId) return - if (observedListWriteGeneration !== latestListWriteGeneration) return + // This is the explicit/user-facing list load. A successful mutation may + // finish while a scope replacement is in flight, but that must not make + // the newer scope response disappear. The request id still gives the + // usual latest-load-wins ordering; background batch polls keep the write + // generation guard in their own reader below. items.value = loadedItems } catch (e: unknown) { if (requestId !== latestListLoadRequestId) return @@ -439,10 +442,13 @@ export const useCaptureStore = defineStore('capture', () => { actionBusyItemId.value = itemId actionError.value = null const triageResult = await captureApi.enqueueTriage(itemId, boardId) - recordCaptureWrite(itemId, true) const existingDetail = detailById.value[itemId] const existingSummary = items.value.find((item) => item.id === itemId) + // An uncached item has no summary to protect. Avoid invalidating an + // explicit list load solely because the detail generation advanced. + const syncSummary = Boolean(existingDetail || existingSummary) + recordCaptureWrite(itemId, syncSummary) let optimisticDetail: CaptureItem | null = null if (existingDetail) { optimisticDetail = { @@ -699,6 +705,12 @@ export const useCaptureStore = defineStore('capture', () => { throw e } + // Record every successful batch write before any reconciliation read. An + // older detail poll must not restore the pre-batch status/disposition. + for (const item of result.results) { + if (item.success) recordCaptureWrite(item.itemId, true) + } + if (result.succeeded > 0) { toast.success(`${result.succeeded} of ${result.total} items processed`) } diff --git a/frontend/taskdeck-web/src/tests/store/captureStore.spec.ts b/frontend/taskdeck-web/src/tests/store/captureStore.spec.ts index d56ab9d21..e4f66e796 100644 --- a/frontend/taskdeck-web/src/tests/store/captureStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/captureStore.spec.ts @@ -1413,6 +1413,43 @@ describe('captureStore', () => { } }) + it('commits an explicit list load after a concurrent keep write', async () => { + const store = useCaptureStore() + const createdAt = new Date().toISOString() + store.items = [{ + id: 'board-a-item', userId: 'u1', boardId: 'board-a', status: 'New', source: 'Typed', + textExcerpt: 'board A', createdAt, processedAt: null, + }] + + let resolveKeep!: (value: unknown) => void + let resolveList!: (value: unknown[]) => void + vi.mocked(captureApi.keepItem).mockReturnValueOnce( + new Promise((resolve) => { resolveKeep = resolve }) as never, + ) + vi.mocked(captureApi.listItems).mockReturnValueOnce( + new Promise((resolve) => { resolveList = resolve }) as never, + ) + + const keep = store.keepItem('board-a-item') + const scopedLoad = store.fetchItems({ boardId: 'board-b' }) + + resolveKeep({ + id: 'board-a-item', userId: 'u1', boardId: 'board-a', status: 'New', source: 'Typed', + textExcerpt: 'board A', rawText: 'board A', createdAt, processedAt: null, retryCount: 0, + disposition: { kind: 'Kept', at: createdAt, byUserId: 'u1', boardId: 'board-a' }, + }) + await keep + + resolveList([{ + id: 'board-b-item', userId: 'u1', boardId: 'board-b', status: 'New', source: 'Typed', + textExcerpt: 'board B', createdAt, processedAt: null, + }]) + await scopedLoad + + expect(store.items.map((item) => item.id)).toEqual(['board-b-item']) + expect(store.detailById['board-a-item']?.disposition?.kind).toBe('Kept') + }) + it('keeps a successful suggestion update when an older detail read lands later', async () => { const store = useCaptureStore() const createdAt = new Date().toISOString() @@ -1519,6 +1556,80 @@ describe('captureStore', () => { vi.useRealTimers() } }) + + it('does not invalidate an explicit list load for an uncached triage item', async () => { + const store = useCaptureStore() + let resolveList!: (value: unknown[]) => void + vi.mocked(captureApi.listItems).mockReturnValueOnce( + new Promise((resolve) => { resolveList = resolve }) as never, + ) + vi.mocked(captureApi.enqueueTriage).mockResolvedValue({ + status: 'Triaging', alreadyTriaging: false, + } as never) + vi.mocked(captureApi.getItem).mockRejectedValue(new Error('detail unavailable')) + + const listLoad = store.fetchItems({ limit: 200 }) + await store.triageItem('uncached-capture') + resolveList([{ + id: 'fresh-capture', userId: 'u1', boardId: null, status: 'New', source: 'Typed', + textExcerpt: 'fresh list row', createdAt: new Date().toISOString(), processedAt: null, + }]) + await listLoad + + expect(store.items.map((item) => item.id)).toEqual(['fresh-capture']) + }) + + it('does not let a pre-batch detail poll restore the old status', async () => { + vi.useFakeTimers() + try { + const store = useCaptureStore() + const createdAt = new Date().toISOString() + const staleDetail = { + id: 'batch-capture', userId: 'u1', boardId: null, status: 'Triaging', source: 'Typed', + textExcerpt: 'batch this', rawText: 'batch this', createdAt, processedAt: null, + retryCount: 0, provenance: null, + } + store.items = [{ + id: 'batch-capture', userId: 'u1', boardId: null, status: 'Triaging', source: 'Typed', + textExcerpt: 'batch this', createdAt, processedAt: null, + }] + store.detailById['batch-capture'] = staleDetail as never + + let resolvePoll!: (value: unknown) => void + vi.mocked(captureApi.getItem) + .mockReturnValueOnce(new Promise((resolve) => { resolvePoll = resolve }) as never) + .mockResolvedValue({ + ...staleDetail, + status: 'Ignored', + disposition: { kind: 'Ignored', at: createdAt, byUserId: 'u1', boardId: null }, + } as never) + vi.mocked(captureApi.batchTriage).mockResolvedValue({ + total: 1, + succeeded: 1, + failed: 0, + results: [{ itemId: 'batch-capture', success: true }], + }) + vi.mocked(captureApi.listItems).mockResolvedValue([{ + id: 'batch-capture', userId: 'u1', boardId: null, status: 'Ignored', source: 'Typed', + textExcerpt: 'batch this', createdAt, processedAt: createdAt, errorMessage: null, + disposition: { kind: 'Ignored', at: createdAt, byUserId: 'u1', boardId: null }, + }] as never) + + const stop = store.pollTriageCompletion('batch-capture') + await vi.advanceTimersByTimeAsync(2_000) + expect(captureApi.getItem).toHaveBeenCalledTimes(1) + + await store.batchTriage(['batch-capture'], 'ignore') + resolvePoll(staleDetail) + await Promise.resolve() + await Promise.resolve() + + expect(store.detailById['batch-capture']?.status).toBe('Ignored') + stop() + } finally { + vi.useRealTimers() + } + }) }) function degradedDetail(status: string, errorMessage: string | null) {