((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({
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 b10f6acee..8fec70697 100644
--- a/frontend/taskdeck-web/src/tests/views/paper/PaperInboxView.spec.ts
+++ b/frontend/taskdeck-web/src/tests/views/paper/PaperInboxView.spec.ts
@@ -845,6 +845,77 @@ 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).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 () => {
+ 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()).toBe('Inbox · capture surface')
+
+ 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..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
@@ -110,6 +110,44 @@ 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 },
+ })
+
+ // 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)
+ 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 2dd00c7f4..0ccef9d96 100644
--- a/frontend/taskdeck-web/src/views/paper/PaperInboxView.vue
+++ b/frontend/taskdeck-web/src/views/paper/PaperInboxView.vue
@@ -513,15 +513,33 @@ defineExpose({ variant, toggleVariant, setVariant })
+
{{
isArchivedHistory
? $t('inbox.history.eyebrow')
- : $t(
- 'inbox.eyebrow',
- { pending: pendingTriageCount, total: capturedCount },
- capturedCount,
- )
+ : isScopeReplacement
+ ? $t('inbox.eyebrowUncounted')
+ : $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);