From ff741827b79c5eb79843ac483bcecf232016e6fe Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 5 Sep 2026 04:44:52 +0100 Subject: [PATCH] fix: guard deferred review receipts by decision locus --- .../paper/review/PaperReviewView.spec.ts | 53 +++++++++++++++++++ .../src/views/paper/PaperReviewView.vue | 10 +++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/frontend/taskdeck-web/src/tests/views/paper/review/PaperReviewView.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/review/PaperReviewView.spec.ts index c22ed2483..5fb706d7b 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/review/PaperReviewView.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/review/PaperReviewView.spec.ts @@ -1654,6 +1654,59 @@ describe('PaperReviewView', () => { ) }) + it('does not record a defer receipt for a proposal left during the await', async () => { + let resolveDefer!: (proposal: Proposal) => void + mocks.deferProposal.mockImplementationOnce( + () => new Promise((resolve) => { resolveDefer = resolve }), + ) + const wrapper = await mountView([ + makeProposal({ id: 'aaa-1', summary: 'First proposal' }), + makeProposal({ id: 'bbb-1', summary: 'Second proposal' }), + ]) + + await wrapper.find('[data-serial="#AAA-"]').trigger('click') + await flushPromises() + await wrapper.find('[data-testid="decision-defer"]').trigger('click') + + // The queue stays interactive while the defer request is in flight. + await wrapper.find('[data-serial="#BBB-"]').trigger('click') + await flushPromises() + // Keep the current explicit selection while removing the route hash; this + // makes a stale receipt visible if the continuation re-anchors itself. + await wrapper.vm.$router.replace('/workspace/review') + await flushPromises() + + resolveDefer(makeProposal({ + id: 'aaa-1', + summary: 'First proposal', + deferredUntil: new Date(Date.now() + 60 * 60_000).toISOString(), + })) + await flushPromises() + + expect(mocks.deferProposal).toHaveBeenCalledWith('aaa-1') + expect(wrapper.get('[data-testid="paper-review-main"]').text()).toContain('Second proposal') + expect(wrapper.find('[data-testid="paper-review-decision-receipt"]').exists()).toBe(false) + }) + + it('records a defer receipt when the current decision locus still matches', async () => { + let resolveDefer!: (proposal: Proposal) => void + mocks.deferProposal.mockImplementationOnce( + () => new Promise((resolve) => { resolveDefer = resolve }), + ) + const wrapper = await mountView([makeProposal({ id: 'matching-defer' })]) + + await wrapper.find('[data-testid="decision-defer"]').trigger('click') + await Promise.resolve() + resolveDefer(makeProposal({ + id: 'matching-defer', + deferredUntil: new Date(Date.now() + 60 * 60_000).toISOString(), + })) + await flushPromises() + + expect(wrapper.get('[data-testid="paper-review-decision-receipt"]').attributes('data-decision')) + .toBe('deferred') + }) + it('removes a snoozed proposal from the visible queue after defer resolves', async () => { const deferred = makeProposal({ id: 'snooze-me', diff --git a/frontend/taskdeck-web/src/views/paper/PaperReviewView.vue b/frontend/taskdeck-web/src/views/paper/PaperReviewView.vue index 95325e267..a72131b26 100644 --- a/frontend/taskdeck-web/src/views/paper/PaperReviewView.vue +++ b/frontend/taskdeck-web/src/views/paper/PaperReviewView.vue @@ -2051,7 +2051,15 @@ async function onDefer() { // deep-linked proposal whose re-defer failed would otherwise vanish (its prior deferredUntil is // still in effect) with no retry path, despite the error toast. if (deferred) { - recordDecisionReceipt(p.id, 'deferred') + // The queue remains interactive while the request is in flight. Only + // surface a receipt when the reviewer is still at the decision locus that + // started this defer; a late response must not pull them back to proposal A + // after they have selected proposal B. Keep deep-link cleanup separate: + // it is tied to the successful server result, not to the visible receipt. + const currentDecisionLocusId = explicitActiveId.value ?? activeProposal.value?.id + if (proposalIdsEqual(currentDecisionLocusId, p.id)) { + recordDecisionReceipt(p.id, 'deferred') + } void clearProposalDeepLink(p.id) } }