From 7c521e73e76c68d6433d6c922c6c4aa81a752bba Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 4 Sep 2026 23:41:34 +0100 Subject: [PATCH 1/3] fix(inbox): report whether a capture list load applied its response captureStore.fetchItems returns without writing anything when its request id has been superseded, and it does so by resolving rather than throwing, in both the success path and the catch. useInboxOrchestrator inferred "applied" from resolution alone and cleared isScopeReplacement even when the store had dropped the response, which un-hid the retained old-scope rows under the new scope's label. fetchItems now returns true when it wrote the response into items and false when it dropped it as superseded. A failure that is still the latest request still throws, so failure and supersession stay distinguishable. The value is additive: call sites that ignore it are unchanged. loadInboxInternal clears isScopeReplacement only on an applied response. The request-id and scope-key checks stay: they guard a stale caller, while the new flag guards a dropped response. Two orchestrator specs resolved the deferred load with undefined, so they passed under both contracts. They now resolve true, and two new specs cover the dropped response and the next applied one. Refs #2501 --- .../src/composables/useInboxOrchestrator.ts | 12 ++++- .../taskdeck-web/src/store/captureStore.ts | 27 ++++++++-- .../composables/useInboxOrchestrator.spec.ts | 50 +++++++++++++++++-- .../src/tests/store/captureStore.spec.ts | 43 ++++++++++++++-- 4 files changed, 117 insertions(+), 15 deletions(-) diff --git a/frontend/taskdeck-web/src/composables/useInboxOrchestrator.ts b/frontend/taskdeck-web/src/composables/useInboxOrchestrator.ts index 3de0279b2..92cdb2de2 100644 --- a/frontend/taskdeck-web/src/composables/useInboxOrchestrator.ts +++ b/frontend/taskdeck-web/src/composables/useInboxOrchestrator.ts @@ -360,7 +360,15 @@ export function useInboxOrchestrator(options: { } inboxLoadPerf.start() try { - await captureStore.fetchItems({ + // `applied` is the store reporting that THIS call's response was written + // into `items` (#2501). `fetchItems` resolves without writing anything + // when its own request id has been superseded, so resolution alone is not + // evidence the new scope's rows arrived. Clearing the flag on resolution + // alone un-hid the retained OLD-scope rows under the NEW scope's label — + // the exact state this flag exists to prevent. The request-id and + // scope-key checks below stay: they guard against a stale caller, while + // `applied` guards against a dropped response. + const applied = await captureStore.fetchItems({ limit: 200, ...(activeBoardId.value ? { boardId: activeBoardId.value } : {}), }) @@ -368,7 +376,7 @@ export function useInboxOrchestrator(options: { boardId: activeBoardId.value, archived: isArchivedHistory.value, }) - if (requestId === latestInboxLoadRequestId && requestScopeKey === currentScopeKey) { + if (applied && requestId === latestInboxLoadRequestId && requestScopeKey === currentScopeKey) { isScopeReplacement.value = false } } catch { diff --git a/frontend/taskdeck-web/src/store/captureStore.ts b/frontend/taskdeck-web/src/store/captureStore.ts index 63c30c8a3..3d2a09a9c 100644 --- a/frontend/taskdeck-web/src/store/captureStore.ts +++ b/frontend/taskdeck-web/src/store/captureStore.ts @@ -188,7 +188,24 @@ export const useCaptureStore = defineStore('capture', () => { }) } - async function fetchItems(query?: CaptureListQuery) { + /** + * Load the Inbox list, and REPORT whether this call's response was applied + * (#2501). + * + * A superseded call resolves without writing anything — twice over, once in + * the success path and once in the catch — so resolution alone never meant + * "the rows on screen are now this call's rows". A caller that inferred + * success from resolution therefore acted on a response the store had + * dropped; `useInboxOrchestrator` cleared its scope-replacement flag that + * way, un-hiding the retained OLD-scope rows under the NEW scope's label. + * + * `true` means this response was written into `items`. `false` means it was + * dropped as superseded and the caller's assumptions about `items` are + * unchanged. A failure that is still the latest request throws, as before, so + * failure and supersession stay distinguishable. The value is additive: + * existing `await fetchItems(...)` call sites that ignore it are unaffected. + */ + async function fetchItems(query?: CaptureListQuery): Promise { const requestId = ++latestListLoadRequestId if (isDemoMode) { loadingList.value = true @@ -196,23 +213,25 @@ export const useCaptureStore = defineStore('capture', () => { if (requestId === latestListLoadRequestId) { items.value = buildDemoCaptureItems() loadingList.value = false + return true } - return + return false } try { loadingList.value = true listError.value = null const loadedItems = await captureApi.listItems(query) - if (requestId !== latestListLoadRequestId) return + if (requestId !== latestListLoadRequestId) return false // 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 + return true } catch (e: unknown) { - if (requestId !== latestListLoadRequestId) return + if (requestId !== latestListLoadRequestId) return false const message = getErrorDisplay(e, 'Failed to load inbox items').message listError.value = message toast.error(message) diff --git a/frontend/taskdeck-web/src/tests/composables/useInboxOrchestrator.spec.ts b/frontend/taskdeck-web/src/tests/composables/useInboxOrchestrator.spec.ts index e7ea76b6a..63f8e6a6e 100644 --- a/frontend/taskdeck-web/src/tests/composables/useInboxOrchestrator.spec.ts +++ b/frontend/taskdeck-web/src/tests/composables/useInboxOrchestrator.spec.ts @@ -156,6 +156,9 @@ describe('useInboxOrchestrator', () => { ], }) mockCaptureStore.pollBatchTriageCompletion.mockReset().mockReturnValue(vi.fn()) + // The store reports whether it APPLIED the response (#2501). The default is + // the ordinary case: the response was the latest and was written. + mockCaptureStore.fetchItems.mockReset().mockResolvedValue(true) }) describe('batch selection', () => { @@ -437,7 +440,10 @@ describe('useInboxOrchestrator', () => { }) it('marks a route-scope list load as a replacement until it succeeds', async () => { - const pendingLoad = deferred() + // `true` is the store reporting an APPLIED response (#2501). This test + // used to resolve `undefined`, which passed under both the old contract + // and the new one and so proved nothing about which was in force. + const pendingLoad = deferred() mockCaptureStore.fetchItems.mockReturnValueOnce(pendingLoad.promise) const orch = createOrchestrator() @@ -446,7 +452,7 @@ describe('useInboxOrchestrator', () => { expect(orch.isScopeReplacement.value).toBe(true) - pendingLoad.resolve(undefined) + pendingLoad.resolve(true) await flushAsyncWork() expect(orch.isScopeReplacement.value).toBe(false) @@ -804,19 +810,53 @@ describe('useInboxOrchestrator', () => { }) it('clears the scope-replacement state only after the latest scoped load succeeds', async () => { - const pendingLoad = deferred() + const pendingLoad = deferred() mockCaptureStore.fetchItems.mockReturnValueOnce(pendingLoad.promise) const orch = createOrchestrator() const load = orch.loadInboxForScopeReplacement() expect(orch.isScopeReplacement.value).toBe(true) - pendingLoad.resolve() + // `true` is the store saying it WROTE this response into `items`. + pendingLoad.resolve(true) await load expect(orch.isScopeReplacement.value).toBe(false) }) + /** + * #2501 MEDIUM-1: `fetchItems` returns without writing anything when its + * request id has been superseded, and it does so by resolving, not by + * throwing. Resolution alone therefore does not mean the new scope's rows + * arrived, and treating it that way un-hid the retained OLD-scope rows + * under the NEW scope's chip. Only an applied response clears the flag. + */ + it('keeps the scope-replacement state when the store drops a superseded response', async () => { + const pendingLoad = deferred() + mockCaptureStore.fetchItems.mockReturnValueOnce(pendingLoad.promise) + const orch = createOrchestrator() + + const load = orch.loadInboxForScopeReplacement() + expect(orch.isScopeReplacement.value).toBe(true) + + pendingLoad.resolve(false) + await load + + expect(orch.isScopeReplacement.value).toBe(true) + }) + + it('clears the scope-replacement state on the next applied response', async () => { + mockCaptureStore.fetchItems.mockResolvedValueOnce(false).mockResolvedValueOnce(true) + const orch = createOrchestrator() + + await orch.loadInboxForScopeReplacement() + expect(orch.isScopeReplacement.value).toBe(true) + + await orch.loadInbox() + + expect(orch.isScopeReplacement.value).toBe(false) + }) + it('keeps the scope-replacement state after failure so retained rows stay hidden', async () => { mockCaptureStore.fetchItems.mockRejectedValueOnce(new Error('scope load failed')) const orch = createOrchestrator() @@ -829,7 +869,7 @@ describe('useInboxOrchestrator', () => { it('lets a successful retry clear a failed scope replacement', async () => { mockCaptureStore.fetchItems .mockRejectedValueOnce(new Error('scope load failed')) - .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(true) const orch = createOrchestrator() await orch.loadInboxForScopeReplacement() diff --git a/frontend/taskdeck-web/src/tests/store/captureStore.spec.ts b/frontend/taskdeck-web/src/tests/store/captureStore.spec.ts index be3240381..3077263bb 100644 --- a/frontend/taskdeck-web/src/tests/store/captureStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/captureStore.spec.ts @@ -69,7 +69,9 @@ describe('captureStore', () => { }, ]) - await store.fetchItems({ limit: 100 }) + // The return value is the store reporting that it WROTE this response + // (#2501). Callers cannot infer it from resolution alone. + await expect(store.fetchItems({ limit: 100 })).resolves.toBe(true) expect(store.items).toHaveLength(1) expect(captureApi.listItems).toHaveBeenCalledWith({ limit: 100 }) @@ -92,13 +94,17 @@ describe('captureStore', () => { id: 'all-capture', userId: 'u1', boardId: null, status: 'New', source: 'Typed', textExcerpt: 'visible after clearing scope', createdAt: new Date().toISOString(), processedAt: null, }]) - await unfilteredLoad + // The latest load applied its response. + await expect(unfilteredLoad).resolves.toBe(true) resolveScoped([{ id: 'scoped-capture', userId: 'u1', boardId: 'board-7', status: 'New', source: 'Typed', textExcerpt: 'late scoped result', createdAt: new Date().toISOString(), processedAt: null, }]) - await scopedLoad + // The superseded one RESOLVES, but it wrote nothing, and it says so + // (#2501). Resolution alone used to be indistinguishable from success, so a + // caller could not tell a dropped response from an applied one. + await expect(scopedLoad).resolves.toBe(false) expect(store.items.map((item) => item.id)).toEqual(['all-capture']) }) @@ -133,12 +139,41 @@ describe('captureStore', () => { id: 'late-scoped', userId: 'u1', boardId: 'board-7', status: 'New', source: 'Typed', textExcerpt: 'obsolete scoped response', createdAt: new Date().toISOString(), processedAt: null, }]) - await scopedLoad + // Superseded by the (failed) unfiltered load, so it applied nothing (#2501). + await expect(scopedLoad).resolves.toBe(false) expect(store.loadingList).toBe(false) expect(store.listError).toBe('Failed to load inbox items') expect(store.items.map((item) => item.id)).toEqual(['retained-scoped']) }) + it('reports a superseded FAILURE as unapplied rather than surfacing it', async () => { + const store = useCaptureStore() + let rejectScoped!: (reason?: unknown) => void + let resolveUnfiltered!: (value: any[]) => void + const scopedResponse = new Promise((_resolve, reject) => { rejectScoped = reject }) + const unfilteredResponse = new Promise((resolve) => { resolveUnfiltered = resolve }) + vi.mocked(captureApi.listItems) + .mockReturnValueOnce(scopedResponse as never) + .mockReturnValueOnce(unfilteredResponse as never) + + const scopedLoad = store.fetchItems({ boardId: 'board-7', limit: 200 }) + const unfilteredLoad = store.fetchItems({ limit: 200 }) + + resolveUnfiltered([{ + id: 'all-capture', userId: 'u1', boardId: null, status: 'New', source: 'Typed', + textExcerpt: 'the scope the user is actually on', createdAt: new Date().toISOString(), processedAt: null, + }]) + await expect(unfilteredLoad).resolves.toBe(true) + + rejectScoped(new Error('obsolete scoped failure')) + // A failure belonging to a scope the user has already left is deliberately + // neither thrown nor recorded — but it is not silent either: the call + // reports that it applied nothing (#2501). + await expect(scopedLoad).resolves.toBe(false) + expect(store.listError).toBeNull() + expect(store.items.map((item) => item.id)).toEqual(['all-capture']) + }) + it('loads and caches capture details', async () => { const store = useCaptureStore() vi.mocked(captureApi.getItem).mockResolvedValue({ From c3258bef43472cc33fa4859ed9512ab0ba493396 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Fri, 4 Sep 2026 23:42:07 +0100 Subject: [PATCH 2/3] fix(inbox): tell the truth about a list load in flight Two presentation defects with the same cause: the Inbox showed nothing, or the wrong thing, about a load that was running. The eyebrow. useInboxCounts runs unconditionally over captureStore.items, so while a scope replacement was in flight the eyebrow published counts computed from the retained old-scope rows next to the chip that already named the new scope. The triage table hides those same rows for exactly that reason. The eyebrow now renders a count-free key while isScopeReplacement is true and returns to the counted form when the response is applied. The PaperScopeDisclosure chip is untouched. The table. A same-scope refresh keeps the retained rows mounted, visible and usable, so aria-busy on the section was the only sign a load was running, which is nothing for a sighted user. The header's count line now carries a refreshing note while such a load runs. Row actions stay enabled: the rows are still the right rows for this scope, and disabling them was declined. inbox.eyebrowLoading and inbox.refreshing are added to en, it and es. Neither carries an interpolation or plural forms. Refs #2501 Refs #2022 --- frontend/taskdeck-web/src/locales/en/inbox.ts | 10 +++++ frontend/taskdeck-web/src/locales/es/inbox.ts | 8 ++++ frontend/taskdeck-web/src/locales/it/inbox.ts | 8 ++++ .../tests/views/paper/PaperInboxView.spec.ts | 42 +++++++++++++++++++ .../paper/inbox/PaperTriageTable.spec.ts | 35 ++++++++++++++++ .../src/views/paper/PaperInboxView.vue | 20 ++++++--- .../views/paper/inbox/PaperTriageTable.vue | 27 ++++++++++++ 7 files changed, 145 insertions(+), 5 deletions(-) diff --git a/frontend/taskdeck-web/src/locales/en/inbox.ts b/frontend/taskdeck-web/src/locales/en/inbox.ts index 05fdc4b6d..818411920 100644 --- a/frontend/taskdeck-web/src/locales/en/inbox.ts +++ b/frontend/taskdeck-web/src/locales/en/inbox.ts @@ -25,6 +25,12 @@ export default { eyebrow: 'Inbox · capture surface · {pending} awaiting triage · {total} captured | Inbox · capture surface · {pending} awaiting triage · {total} captured', + // Shown INSTEAD of `eyebrow` while a scope replacement is loading (#2501). + // The rows those counts would be computed from belong to the scope the user + // just left, so the eyebrow carries no count at all rather than a count about + // somewhere else. Deliberately not a plural message: it has nothing to agree + // with, which is the whole point. + eyebrowLoading: 'Inbox · capture surface · loading captures…', // Rendered as `{lead} {emphasis}` — the space before the emphasis // comes from the template, so `lead` must not carry a trailing space. title: { @@ -128,6 +134,10 @@ export default { empty: { scoped: 'No captures in {scope}. Show all captures to restore the full Inbox.', }, + // Appended to the capture-count line while a SAME-scope list load runs over + // rows that stay visible and usable (#2501). Lowercase because it follows a + // "·" separator inside that line. + refreshing: 'refreshing…', variantToggle: { label: 'Capture variant', }, diff --git a/frontend/taskdeck-web/src/locales/es/inbox.ts b/frontend/taskdeck-web/src/locales/es/inbox.ts index e0f9391ac..0bc4d080b 100644 --- a/frontend/taskdeck-web/src/locales/es/inbox.ts +++ b/frontend/taskdeck-web/src/locales/es/inbox.ts @@ -18,6 +18,10 @@ export default { eyebrow: 'Inbox · superficie de captura · {pending} por clasificar · {total} capturada | Inbox · superficie de captura · {pending} por clasificar · {total} capturadas', + // Se muestra EN LUGAR de `eyebrow` mientras se sustituye el ámbito (#2501): + // los recuentos serían del ámbito que el usuario acaba de dejar. Sin plural: + // no hay ningún número con el que concordar. + eyebrowLoading: 'Inbox · superficie de captura · cargando las capturas…', title: { lead: '¿Qué tienes en mente,', emphasis: 'en dos palabras?', @@ -97,6 +101,10 @@ export default { empty: { scoped: 'No hay capturas en {scope}. Muestra todas las capturas para restaurar el Inbox completo.', }, + // Se añade a la línea del recuento durante una recarga en el MISMO ámbito, + // con las filas todavía visibles y utilizables (#2501). En minúscula: va + // detrás de un separador "·". + refreshing: 'actualizando…', variantToggle: { label: 'Variante de captura', }, diff --git a/frontend/taskdeck-web/src/locales/it/inbox.ts b/frontend/taskdeck-web/src/locales/it/inbox.ts index 235d42a9b..44818e540 100644 --- a/frontend/taskdeck-web/src/locales/it/inbox.ts +++ b/frontend/taskdeck-web/src/locales/it/inbox.ts @@ -16,6 +16,10 @@ export default { eyebrow: 'Inbox · superficie di cattura · {pending} da smistare · {total} catturato | Inbox · superficie di cattura · {pending} da smistare · {total} catturati', + // Mostrato AL POSTO di `eyebrow` durante la sostituzione dell'ambito (#2501): + // i conteggi apparterrebbero all'ambito appena lasciato. Nessun plurale: non + // c'è alcun numero con cui concordare. + eyebrowLoading: 'Inbox · superficie di cattura · caricamento delle catture…', title: { lead: 'Cosa hai in mente,', emphasis: 'in breve?', @@ -95,6 +99,10 @@ export default { empty: { scoped: 'Nessuna cattura in {scope}. Mostra tutte le catture per ripristinare l’Inbox completo.', }, + // Aggiunto alla riga del conteggio durante un aggiornamento nello STESSO + // ambito, con le righe ancora visibili e utilizzabili (#2501). Minuscolo: + // segue un separatore "·". + refreshing: 'aggiornamento…', variantToggle: { label: 'Variante di cattura', }, diff --git a/frontend/taskdeck-web/src/tests/views/paper/PaperInboxView.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/PaperInboxView.spec.ts index 6539d7133..e724ab01d 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/PaperInboxView.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/PaperInboxView.spec.ts @@ -824,6 +824,48 @@ describe('PaperInboxView', () => { expect(table.text()).not.toContain('1 item') }) + /** + * #2501: during a scope replacement the rows still in `items` belong to the + * OLD scope — the table hides them for exactly that reason — while the scope + * chip already names the NEW one. `useInboxCounts` counts whatever is in + * `items`, so the eyebrow published old-scope numbers beside a new-scope + * label. It drops the counts instead of relabelling them. + */ + it('drops the eyebrow counts while a scope replacement is in flight', () => { + orchestratorState.items.value = [ + captureRow('old-scope-1', 'New'), + captureRow('old-scope-2', 'Converted'), + ] as CaptureItemSummary[] + orchestratorState.isScopeReplacement.value = true + + const wrapper = mount(PaperInboxView) + const eyebrow = wrapper.find('[data-testid="paper-inbox-eyebrow"]').text() + + expect(eyebrow).not.toContain('awaiting triage') + expect(eyebrow).not.toContain('captured') + expect(eyebrow).not.toMatch(/\d/) + expect(eyebrow).toContain('Inbox · capture surface') + expect(eyebrow).toContain('loading captures') + }) + + it('publishes the eyebrow counts again once the replacement resolves', async () => { + orchestratorState.items.value = [ + captureRow('new-scope-1', 'New'), + captureRow('new-scope-2', 'New'), + ] as CaptureItemSummary[] + orchestratorState.isScopeReplacement.value = true + + const wrapper = mount(PaperInboxView) + expect(wrapper.find('[data-testid="paper-inbox-eyebrow"]').text()).toContain('loading captures') + + orchestratorState.isScopeReplacement.value = false + await wrapper.vm.$nextTick() + + const eyebrow = wrapper.find('[data-testid="paper-inbox-eyebrow"]').text() + expect(eyebrow).toContain('2 awaiting triage') + expect(eyebrow).toContain('2 captured') + }) + it('guards nib submissions while capture creation is in flight', async () => { let resolveCreate: (value: unknown) => void = () => undefined mockCaptureStore.createItem.mockReturnValueOnce(new Promise((resolve) => { diff --git a/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperTriageTable.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperTriageTable.spec.ts index aa15496e0..85f76a4ad 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperTriageTable.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperTriageTable.spec.ts @@ -110,6 +110,41 @@ describe('PaperTriageTable', () => { expect(wrapper.text()).not.toContain('2 items') }) + /** + * #2501 (LOW): a SAME-scope refresh keeps the retained rows mounted, visible + * and interactive — deliberately, because they are still the right rows for + * the scope on screen. Until now `aria-busy` was the only sign that a load + * was running at all, which is nothing for a sighted user. Row actions stay + * enabled: disabling them during a background refresh was declined as a + * product-posture change. + */ + it('shows a visible refreshing note while a same-scope load runs over retained rows', () => { + const wrapper = mount(PaperTriageTable, { + props: { items: makeItems(), loadingList: true, scopeReplacement: false }, + }) + + expect(wrapper.get('[data-testid="paper-triage-refreshing"]').text()).toContain('refreshing') + expect(wrapper.get('.paper-triage').attributes('aria-busy')).toBe('true') + expect(wrapper.get('.paper-triage__list').attributes('style')).toBeUndefined() + expect(wrapper.findAll('.paper-triage__row')).toHaveLength(2) + expect(wrapper.text()).toContain('2 items') + expect(wrapper.findAll('button[data-action="edit"]')[0]!.attributes('disabled')).toBeUndefined() + }) + + it('shows no refreshing note when no list load is running', () => { + const wrapper = mount(PaperTriageTable, { props: { items: makeItems() } }) + + expect(wrapper.find('[data-testid="paper-triage-refreshing"]').exists()).toBe(false) + }) + + it('shows no refreshing note during a scope replacement, which hides the rows instead', () => { + const wrapper = mount(PaperTriageTable, { + props: { items: makeItems(), loadingList: true, scopeReplacement: true }, + }) + + expect(wrapper.find('[data-testid="paper-triage-refreshing"]').exists()).toBe(false) + }) + it('prioritizes an error and hides retained rows and their count after replacement fails', () => { const wrapper = mount(PaperTriageTable, { props: { diff --git a/frontend/taskdeck-web/src/views/paper/PaperInboxView.vue b/frontend/taskdeck-web/src/views/paper/PaperInboxView.vue index d9c13e469..b98ebb5bb 100644 --- a/frontend/taskdeck-web/src/views/paper/PaperInboxView.vue +++ b/frontend/taskdeck-web/src/views/paper/PaperInboxView.vue @@ -504,15 +504,25 @@ defineExpose({ variant, toggleVariant, setVariant }) +
{{ isArchivedHistory ? $t('inbox.history.eyebrow') - : $t( - 'inbox.eyebrow', - { pending: pendingTriageCount, total: capturedCount }, - capturedCount, - ) + : isScopeReplacement + ? $t('inbox.eyebrowLoading') + : $t( + 'inbox.eyebrow', + { pending: pendingTriageCount, total: capturedCount }, + capturedCount, + ) }}

diff --git a/frontend/taskdeck-web/src/views/paper/inbox/PaperTriageTable.vue b/frontend/taskdeck-web/src/views/paper/inbox/PaperTriageTable.vue index 66592c437..0031095c2 100644 --- a/frontend/taskdeck-web/src/views/paper/inbox/PaperTriageTable.vue +++ b/frontend/taskdeck-web/src/views/paper/inbox/PaperTriageTable.vue @@ -223,6 +223,17 @@ function boardPickReasonId(item: CaptureItemSummary): string { } const hasItems = computed(() => props.items.length > 0) + +/** + * A list load running over rows that stay on screen (#2501). + * + * A scope replacement is excluded: those rows belong to the scope being left, + * so they are hidden and the "Loading…" empty state speaks for them instead. + * A failed load is excluded too — the error takes the header's place. + */ +const isBackgroundRefresh = computed( + () => props.loadingList && !props.scopeReplacement && !props.listError && hasItems.value, +) const hasMutationInFlight = computed( () => props.actionBusyItemId !== null && props.actionBusyItemId !== undefined, ) @@ -542,6 +553,17 @@ function recordedOr(value: string | null | undefined): string {

{{ readOnly ? t('inbox.history.tableTitle') : "Today's captures" }}

{{ hasItems ? `${items.length} item${items.length === 1 ? '' : 's'} · most recent first` : 'No captures yet' }} + +  · {{ t('inbox.refreshing') }} @@ -884,6 +906,11 @@ function recordedOr(value: string | null | undefined): string { justify-content: space-between; margin-bottom: 12px; } +/* The same-scope refresh note, set apart from the count it follows (#2501). */ +.paper-triage__refreshing { + font-style: italic; + opacity: 0.75; +} .paper-triage__title { margin: 0; font-family: var(--serif); From fef363c02a7c2ccfef5b211546409d1e55c54dcf Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 5 Sep 2026 02:33:22 +0100 Subject: [PATCH 3/3] fix(inbox): stop the replacement eyebrow claiming a load that has stopped Review of PR #2584 (MEDIUM, introduced by the PR itself). The count-free eyebrow branched on isScopeReplacement alone, but that flag is deliberately sticky across failure: loadInboxInternal swallows the throw so the retained rows stay hidden rather than being shown as the new scope. With the API unreachable on mount, or a 500 on a scope switch, the header read "Inbox - capture surface - loading captures..." permanently, directly above the table's own "Failed to load inbox items" and its Retry button. The eyebrow now makes no claim about the load at all. inbox.eyebrowLoading becomes inbox.eyebrowUncounted in en, it and es, and reads just "Inbox - capture surface". Loading, error and retry belong to the table, which already states all three; this line's only job is refusing to publish a count it cannot stand behind. Also from review: the refreshing-note spec asserted toContain('refreshing'), which a missing catalog key would satisfy by rendering the key itself. It now asserts the rendered English. And the demo branch's unreachable return false keeps its guard with a one-line note saying why it cannot currently fail. Refs #2501 --- frontend/taskdeck-web/src/locales/en/inbox.ts | 19 ++++++---- frontend/taskdeck-web/src/locales/es/inbox.ts | 5 +-- frontend/taskdeck-web/src/locales/it/inbox.ts | 5 +-- .../taskdeck-web/src/store/captureStore.ts | 4 +++ .../tests/views/paper/PaperInboxView.spec.ts | 35 +++++++++++++++++-- .../paper/inbox/PaperTriageTable.spec.ts | 5 ++- .../src/views/paper/PaperInboxView.vue | 12 +++++-- 7 files changed, 69 insertions(+), 16 deletions(-) diff --git a/frontend/taskdeck-web/src/locales/en/inbox.ts b/frontend/taskdeck-web/src/locales/en/inbox.ts index 818411920..986e61338 100644 --- a/frontend/taskdeck-web/src/locales/en/inbox.ts +++ b/frontend/taskdeck-web/src/locales/en/inbox.ts @@ -25,12 +25,19 @@ export default { eyebrow: 'Inbox · capture surface · {pending} awaiting triage · {total} captured | Inbox · capture surface · {pending} awaiting triage · {total} captured', - // Shown INSTEAD of `eyebrow` while a scope replacement is loading (#2501). - // The rows those counts would be computed from belong to the scope the user - // just left, so the eyebrow carries no count at all rather than a count about - // somewhere else. Deliberately not a plural message: it has nothing to agree - // with, which is the whole point. - eyebrowLoading: 'Inbox · capture surface · loading captures…', + // Shown INSTEAD of `eyebrow` during a scope replacement (#2501). The rows + // those counts would be computed from belong to the scope the user just left, + // so the eyebrow carries no count at all rather than a count about somewhere + // else. Deliberately not a plural message: it has nothing to agree with, + // which is the whole point. + // + // It also makes NO claim about the load. `isScopeReplacement` is sticky + // across failure by design — the orchestrator swallows the throw so the + // retained rows stay hidden — so a "loading…" word here would sit above the + // table's own error and Retry, permanently, describing a load that had + // already stopped. The table owns loading, error and retry; this line owns + // only the refusal to publish a count it cannot stand behind. + eyebrowUncounted: 'Inbox · capture surface', // Rendered as `{lead} {emphasis}` — the space before the emphasis // comes from the template, so `lead` must not carry a trailing space. title: { diff --git a/frontend/taskdeck-web/src/locales/es/inbox.ts b/frontend/taskdeck-web/src/locales/es/inbox.ts index 0bc4d080b..27b423cff 100644 --- a/frontend/taskdeck-web/src/locales/es/inbox.ts +++ b/frontend/taskdeck-web/src/locales/es/inbox.ts @@ -20,8 +20,9 @@ export default { 'Inbox · superficie de captura · {pending} por clasificar · {total} capturada | Inbox · superficie de captura · {pending} por clasificar · {total} capturadas', // Se muestra EN LUGAR de `eyebrow` mientras se sustituye el ámbito (#2501): // los recuentos serían del ámbito que el usuario acaba de dejar. Sin plural: - // no hay ningún número con el que concordar. - eyebrowLoading: 'Inbox · superficie de captura · cargando las capturas…', + // no hay ningún número con el que concordar. Y sin ninguna palabra sobre la + // carga: la tabla es la dueña del estado de carga, del error y del reintento. + eyebrowUncounted: 'Inbox · superficie de captura', title: { lead: '¿Qué tienes en mente,', emphasis: 'en dos palabras?', diff --git a/frontend/taskdeck-web/src/locales/it/inbox.ts b/frontend/taskdeck-web/src/locales/it/inbox.ts index 44818e540..6895f94b9 100644 --- a/frontend/taskdeck-web/src/locales/it/inbox.ts +++ b/frontend/taskdeck-web/src/locales/it/inbox.ts @@ -18,8 +18,9 @@ export default { 'Inbox · superficie di cattura · {pending} da smistare · {total} catturato | Inbox · superficie di cattura · {pending} da smistare · {total} catturati', // Mostrato AL POSTO di `eyebrow` durante la sostituzione dell'ambito (#2501): // i conteggi apparterrebbero all'ambito appena lasciato. Nessun plurale: non - // c'è alcun numero con cui concordare. - eyebrowLoading: 'Inbox · superficie di cattura · caricamento delle catture…', + // c'è alcun numero con cui concordare. E nessuna parola sul caricamento: la + // tabella possiede stato di caricamento, errore e riprova. + eyebrowUncounted: 'Inbox · superficie di cattura', title: { lead: 'Cosa hai in mente,', emphasis: 'in breve?', diff --git a/frontend/taskdeck-web/src/store/captureStore.ts b/frontend/taskdeck-web/src/store/captureStore.ts index 3d2a09a9c..d1b50cae1 100644 --- a/frontend/taskdeck-web/src/store/captureStore.ts +++ b/frontend/taskdeck-web/src/store/captureStore.ts @@ -210,6 +210,10 @@ export const useCaptureStore = defineStore('capture', () => { if (isDemoMode) { loadingList.value = true listError.value = null + // The guard is pre-existing and cannot currently fail: nothing awaits + // between the id bump above and this check, so no other call can have + // superseded this one. It is kept, with its `false` arm, so the branch + // stays correct and total if the demo path ever becomes genuinely async. if (requestId === latestListLoadRequestId) { items.value = buildDemoCaptureItems() loadingList.value = false diff --git a/frontend/taskdeck-web/src/tests/views/paper/PaperInboxView.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/PaperInboxView.spec.ts index e724ab01d..c96c41763 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/PaperInboxView.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/PaperInboxView.spec.ts @@ -844,8 +844,37 @@ describe('PaperInboxView', () => { expect(eyebrow).not.toContain('awaiting triage') expect(eyebrow).not.toContain('captured') expect(eyebrow).not.toMatch(/\d/) - expect(eyebrow).toContain('Inbox · capture surface') - expect(eyebrow).toContain('loading captures') + expect(eyebrow).toBe('Inbox · capture surface') + }) + + /** + * `isScopeReplacement` is deliberately STICKY across failure — the + * orchestrator swallows the throw so the retained rows stay hidden rather + * than being presented as the new scope. So a failed replacement leaves the + * flag true indefinitely, and an eyebrow that said "loading captures…" on + * that flag alone would claim a load that had already stopped, permanently, + * directly above the table's own error and Retry. The replacement eyebrow + * therefore makes NO claim about the load at all; the table owns that state. + */ + it('makes no loading claim in the eyebrow when the replacement has failed', () => { + orchestratorState.items.value = [ + captureRow('old-scope-1', 'New'), + captureRow('old-scope-2', 'Converted'), + ] as CaptureItemSummary[] + orchestratorState.isScopeReplacement.value = true + mockCaptureStore.loadingList = false + mockCaptureStore.listError = 'Failed to load inbox items' + + const wrapper = mount(PaperInboxView) + const eyebrow = wrapper.find('[data-testid="paper-inbox-eyebrow"]').text() + + expect(eyebrow).not.toMatch(/\d/) + expect(eyebrow).not.toContain('loading captures') + expect(eyebrow.toLowerCase()).not.toContain('loading') + expect(eyebrow).toBe('Inbox · capture surface') + // The table still says what actually happened. + const table = wrapper.findComponent({ name: 'PaperTriageTable' }) + expect(table.find('[role="alert"]').text()).toContain('Failed to load inbox items') }) it('publishes the eyebrow counts again once the replacement resolves', async () => { @@ -856,7 +885,7 @@ describe('PaperInboxView', () => { orchestratorState.isScopeReplacement.value = true const wrapper = mount(PaperInboxView) - expect(wrapper.find('[data-testid="paper-inbox-eyebrow"]').text()).toContain('loading captures') + expect(wrapper.find('[data-testid="paper-inbox-eyebrow"]').text()).toBe('Inbox · capture surface') orchestratorState.isScopeReplacement.value = false await wrapper.vm.$nextTick() diff --git a/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperTriageTable.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperTriageTable.spec.ts index 85f76a4ad..b489f50a0 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperTriageTable.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperTriageTable.spec.ts @@ -123,7 +123,10 @@ describe('PaperTriageTable', () => { props: { items: makeItems(), loadingList: true, scopeReplacement: false }, }) - expect(wrapper.get('[data-testid="paper-triage-refreshing"]').text()).toContain('refreshing') + // The rendered English, not `toContain('refreshing')`: a missing catalog key + // renders the key itself, `inbox.refreshing`, which contains that substring + // and would have passed. + expect(wrapper.get('[data-testid="paper-triage-refreshing"]').text()).toBe('· refreshing…') expect(wrapper.get('.paper-triage').attributes('aria-busy')).toBe('true') expect(wrapper.get('.paper-triage__list').attributes('style')).toBeUndefined() expect(wrapper.findAll('.paper-triage__row')).toHaveLength(2) diff --git a/frontend/taskdeck-web/src/views/paper/PaperInboxView.vue b/frontend/taskdeck-web/src/views/paper/PaperInboxView.vue index b98ebb5bb..0fca9c480 100644 --- a/frontend/taskdeck-web/src/views/paper/PaperInboxView.vue +++ b/frontend/taskdeck-web/src/views/paper/PaperInboxView.vue @@ -505,19 +505,27 @@ defineExpose({ variant, toggleVariant, setVariant }) with the total ("1 catturato" vs "2 catturati"), so the count has to reach the catalog as a choice and not only as an interpolation. -->
{{ isArchivedHistory ? $t('inbox.history.eyebrow') : isScopeReplacement - ? $t('inbox.eyebrowLoading') + ? $t('inbox.eyebrowUncounted') : $t( 'inbox.eyebrow', { pending: pendingTriageCount, total: capturedCount },