Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 19 additions & 13 deletions frontend/taskdeck-web/src/composables/useReviewProposals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,14 +618,16 @@ export function useReviewProposals() {
const queueAnnouncementKey = computed(() => awaitingProposalIds.value.join('\n'))

/**
* The board scope of a queue read, as one comparable value. Lower-cased
* because the scope is the BOARD, not the casing the query string happened to
* carry — the same rule `matchesActiveBoardFilter` applies — and an empty
* filter is the unscoped queue, exactly as `boardId: activeBoardFilter.value
* || undefined` sends it.
* The read identity of a queue, as one comparable value. Lower-cased because
* the scope is the BOARD, not the casing the query string happened to carry —
* the same rule `matchesActiveBoardFilter` applies — and the history mode is
* part of the rendered read even though both modes share the board filter.
* An empty filter is the unscoped live queue, exactly as
* `boardId: activeBoardFilter.value || undefined` sends it.
*/
function queueScopeOf(boardId: string | null | undefined): string | null {
return boardId ? boardId.toLowerCase() : null
const boardScope = boardId ? boardId.toLowerCase() : '<unscoped>'
return `${boardScope}:${isArchivedHistory.value ? 'archived' : 'live'}`
}

/**
Expand All @@ -635,8 +637,8 @@ export function useReviewProposals() {
const landedQueueScope = ref<string | null | undefined>(undefined)

/**
* Whether a queue read has landed for the board scope currently on screen
* (#2599 item 1). This is what the two skins' announcement gates need, and
* Whether a queue read has landed for the board-and-mode scope currently on
* screen (#2599 item 1). This is what the two skins' announcement gates need, and
* `!proposalsLoading` was the wrong approximation of it.
*
* An explicit `loadProposals` raises `proposalsLoading` WITHOUT clearing
Expand All @@ -651,8 +653,8 @@ export function useReviewProposals() {
* is not a count of the queue on screen:
* - before the first read lands (the #2593 skeleton gate — the count is 0
* because nothing has been read, not because nothing awaits review);
* - after a board-filter change, until the new scope's read lands: the rows
* still rendered belong to the previous board;
* - after a board-filter or history-mode change, until the new scope's read
* lands: the rows still rendered belong to the previous read identity;
* - for a scope no read has landed for, including one whose only read
* failed: the entry load, or the first read after a filter change.
*
Expand All @@ -667,7 +669,9 @@ export function useReviewProposals() {
* degraded and refused disclosures (#2214).
*
* A same-scope reload keeps it settled, because the queue it is about is
* still the one being counted. `queueAccessRevoked` keeps its own separate
* still the one being counted. A live-to-archived or archived-to-live
* transition is a new read identity, even on the same board.
* `queueAccessRevoked` keeps its own separate
* gate: a revocation is a different fact with a different remedy.
*/
const queueScopeLoaded = computed(
Expand Down Expand Up @@ -1251,6 +1255,7 @@ export function useReviewProposals() {
// The scope this read is ASKING about. A late answer -- success or 403 --
// describes the board it queried, never whichever board is on screen now.
const requestedBoardId = activeBoardFilter.value || null
const requestedHistoryMode = isArchivedHistory.value
// A hash target is part of the question too. Hash navigation does not start
// a queue load, so it needs its own snapshot to stop an old by-id answer from
// inserting or marking unavailable whichever proposal is selected next.
Expand All @@ -1264,7 +1269,8 @@ export function useReviewProposals() {
const isSupersededQueueRead = () =>
observedLoadId !== latestProposalLoadRequestId ||
proposalsLoading.value ||
(activeBoardFilter.value || null) !== requestedBoardId
(activeBoardFilter.value || null) !== requestedBoardId ||
isArchivedHistory.value !== requestedHistoryMode
const isSupersededCompositeRead = () =>
isSupersededQueueRead() || route.hash !== requestedHash
// The list and optional by-id request form one composite read. Every guard
Expand Down Expand Up @@ -1644,7 +1650,7 @@ export function useReviewProposals() {
)

watch(
() => activeBoardFilter.value,
() => [activeBoardFilter.value, isArchivedHistory.value],
() => { loadProposals().catch(() => {}) },
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,11 @@ function localIsStaleProposal(status: string, createdAtMs: number, nowMs: number
}

function watcherForCurrentSourceValue(expected: unknown) {
const watcher = watchers.find(([source]) => typeof source === 'function' && (source as () => unknown)() === expected)
const watcher = watchers.find(([source]) => {
if (typeof source !== 'function') return false
const current = (source as () => unknown)()
return Array.isArray(current) ? current[0] === expected : current === expected
})
expect(watcher).toBeDefined()
return watcher!
}
Expand Down Expand Up @@ -1510,6 +1514,86 @@ describe('useReviewProposals', () => {
await watcherForCurrentSourceValue('board-filter-probe')[1]()
expect(mockAutomationApi.getProposals).toHaveBeenCalled()
})

it('history-mode watcher reloads the same board as a new read identity', async () => {
mockRoute.query = { boardId: 'board-history' }
const rp = useReviewProposals()
mockAutomationApi.getProposals.mockResolvedValueOnce([
makeProposal({ id: 'live', boardId: 'board-history' }),
])
await rp.loadProposals()
expect(rp.queueScopeLoaded.value).toBe(true)

mockRoute.query = { boardId: 'board-history', history: 'archived' }
expect(rp.isArchivedHistory.value).toBe(true)
expect(rp.queueScopeLoaded.value).toBe(false)

const watcher = watchers.find(([source]) => {
if (typeof source !== 'function') return false
const value = (source as () => unknown)()
return Array.isArray(value) && value[0] === 'board-history' && value[1] === true
})
expect(watcher).toBeDefined()

mockAutomationApi.getProposals.mockResolvedValueOnce([
makeProposal({ id: 'archived', boardId: 'board-history', status: 'Applied' }),
])
await watcher![1]()

expect(mockAutomationApi.getProposals).toHaveBeenLastCalledWith({
limit: 200,
boardId: 'board-history',
})
expect(rp.proposals.value.map((proposal: any) => proposal.id)).toEqual(['archived'])
expect(rp.queueScopeLoaded.value).toBe(true)

mockRoute.query = { boardId: 'board-history' }
expect(rp.isArchivedHistory.value).toBe(false)
expect(rp.queueScopeLoaded.value).toBe(false)
const reverseWatcher = watchers.find(([source]) => {
if (typeof source !== 'function') return false
const value = (source as () => unknown)()
return Array.isArray(value) && value[0] === 'board-history' && value[1] === false
})
expect(reverseWatcher).toBeDefined()

mockAutomationApi.getProposals.mockResolvedValueOnce([
makeProposal({ id: 'live-again', boardId: 'board-history' }),
])
await reverseWatcher![1]()
expect(rp.proposals.value.map((proposal: any) => proposal.id)).toEqual(['live-again'])
expect(rp.queueScopeLoaded.value).toBe(true)
})

it('discards a late live response after a same-board history transition', async () => {
mockRoute.query = { boardId: 'board-history' }
let resolveLive!: (proposals: ReturnType<typeof makeProposal>[]) => void
mockAutomationApi.getProposals.mockReturnValueOnce(
new Promise((resolve) => {
resolveLive = resolve
}),
)
const rp = useReviewProposals()
const liveLoad = rp.loadProposalsWithOutcome()

mockRoute.query = { boardId: 'board-history', history: 'archived' }
const historyWatcher = watchers.find(([source]) => {
if (typeof source !== 'function') return false
const value = (source as () => unknown)()
return Array.isArray(value) && value[0] === 'board-history' && value[1] === true
})
expect(historyWatcher).toBeDefined()
mockAutomationApi.getProposals.mockResolvedValueOnce([
makeProposal({ id: 'archived', boardId: 'board-history', status: 'Applied' }),
])

await historyWatcher![1]()
expect(rp.proposals.value.map((proposal: any) => proposal.id)).toEqual(['archived'])

resolveLive([makeProposal({ id: 'late-live', boardId: 'board-history' })])
await expect(liveLoad).resolves.toBe('superseded')
expect(rp.proposals.value.map((proposal: any) => proposal.id)).toEqual(['archived'])
})
})

// #2194 - with Review open, a proposal created server-side never appeared:
Expand Down
Loading