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
4 changes: 2 additions & 2 deletions docs/memory/feature-flows/events-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ As a **platform user**, I want to see all notifications from my agents in one pl
**Template Structure**:
- Header with title and refresh button (lines 8-26)
- Filter controls panel (lines 29-117): agent, type, priority, status dropdowns + show dismissed checkbox
- Stats cards grid (lines 120-137): pending (red), acknowledged (green), total, agents count (blue)
- Stats cards grid: pending (red — the global count from `GET /api/notifications/count`, independent of the list filter and page, #3449), acknowledged (green), shown (the rows rendered below), agents count (blue)
- Bulk actions bar (lines 140-164): shown when items selected
- Notifications list (lines 167-320): cards with checkbox, icon, content, actions
- Load more button (lines 311-319): pagination
Expand Down Expand Up @@ -61,7 +61,7 @@ As a **platform user**, I want to see all notifications from my agents in one pl
| State | Type | Description |
|-------|------|-------------|
| `notifications` | Array | Loaded notifications |
| `pendingCount` | Number | Count of pending notifications (for badge) |
| `pendingCount` | Number | Global pending count (badges, Operations header). Server figure from `fetchPendingCount()`; never derived from the loaded list (#3449) |
| `loading` | Boolean | Loading state |
| `error` | String | Error message |
| `totalCount` | Number | Total notification count from API |
Expand Down
10 changes: 6 additions & 4 deletions src/frontend/src/components/operator/NotificationsPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -93,17 +93,19 @@

<!-- Stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<!-- #3449: Pending is the global count; the other cards describe the rows
loaded below, so the third is "Shown", never a "Total". -->
<div data-testid="notif-stat-pending" class="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<div class="text-3xl font-bold text-status-danger-600 dark:text-status-danger-400">{{ notificationsStore.pendingCount }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Pending</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<div class="text-3xl font-bold text-status-success-700 dark:text-status-success-400">{{ acknowledgedCount }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Acknowledged</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<div class="text-3xl font-bold text-gray-900 dark:text-white">{{ notificationsStore.totalCount }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Total</div>
<div data-testid="notif-stat-shown" class="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<div class="text-3xl font-bold text-gray-900 dark:text-white">{{ displayedNotifications.length }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Shown</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400">{{ Object.keys(notificationsStore.agentCounts).length }}</div>
Expand Down
22 changes: 14 additions & 8 deletions src/frontend/src/stores/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import { readStoredToken } from '../utils/platformSession'
export const useNotificationsStore = defineStore('notifications', () => {
// State
const notifications = ref([])
// #3449: the GLOBAL pending count — every accessible agent, every page. It is
// a server figure (fetchPendingCount) and is never derived from
// `notifications`, which is one filtered, page-capped slice: the Operations
// header, the tab badge and the NavBar badge all read this.
const pendingCount = ref(0)
const loading = ref(false)
const error = ref(null)
Expand Down Expand Up @@ -110,9 +114,11 @@ export const useNotificationsStore = defineStore('notifications', () => {
totalCount.value = response.data.count || 0
hasMore.value = notifications.value.length < totalCount.value

// Update pending count from filtered results
updatePendingCount()
hasLoaded.value = true
// #3449: the list never writes pendingCount. Re-read the true count
// beside a first-page load so the badge and the rows are of one moment;
// not awaited — the list must not wait on, or fail with, the badge.
if (offset === 0) fetchPendingCount()
} catch (err) {
console.error('Failed to fetch notifications:', err)
error.value = err.response?.data?.detail || 'Failed to load notifications'
Expand All @@ -135,10 +141,6 @@ export const useNotificationsStore = defineStore('notifications', () => {
}
}

function updatePendingCount() {
pendingCount.value = notifications.value.filter(n => n.status === 'pending').length
}

async function acknowledgeNotification(notificationId) {
try {
const token = readStoredToken()
Expand All @@ -148,11 +150,13 @@ export const useNotificationsStore = defineStore('notifications', () => {

// Update local state
const notification = notifications.value.find(n => n.id === notificationId)
const wasPending = !notification || notification.status === 'pending'
if (notification) {
notification.status = 'acknowledged'
notification.acknowledged_at = new Date().toISOString()
}
pendingCount.value = Math.max(0, pendingCount.value - 1)
// #3449: only a row that WAS pending leaves the pending count.
if (wasPending) pendingCount.value = Math.max(0, pendingCount.value - 1)

return true
} catch (err) {
Expand All @@ -170,6 +174,7 @@ export const useNotificationsStore = defineStore('notifications', () => {

// Update local state
const notification = notifications.value.find(n => n.id === notificationId)
const wasPending = !notification || notification.status === 'pending'
if (notification) {
notification.status = 'dismissed'
}
Expand All @@ -179,7 +184,8 @@ export const useNotificationsStore = defineStore('notifications', () => {
notifications.value = notifications.value.filter(n => n.id !== notificationId)
}

pendingCount.value = Math.max(0, pendingCount.value - 1)
// #3449: dismissing an acknowledged row removes no pending item.
if (wasPending) pendingCount.value = Math.max(0, pendingCount.value - 1)

return true
} catch (err) {
Expand Down
173 changes: 173 additions & 0 deletions src/frontend/tests/unit/notificationsPendingCount.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// @vitest-environment jsdom
/**
* #3449 — the global pending-notification count is independent of the list.
*
* `pendingCount` backs the Operations header, the Notifications tab badge and
* the NavBar badge. It used to be recomputed from whatever rows the
* Notifications tab had loaded, so a status / agent filter (or simply the page
* limit) rewrote all three. It is a server figure (`GET
* /api/notifications/count`) — the list never writes it — and the panel's
* fourth card states what it is: the rows shown, not a total.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'

vi.mock('axios', () => {
const inst = { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), defaults: { headers: { common: {} } } }
return { default: inst }
})
vi.mock('@/api', () => {
const inst = { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() }
return { default: inst }
})
vi.mock('@/utils/platformSession', () => ({ readStoredToken: () => 'test-token' }))

import axios from 'axios'
import { useNotificationsStore } from '@/stores/notifications'
import NotificationsPanel from '@/components/operator/NotificationsPanel.vue'

const GLOBAL_PENDING = 502

function rows(n, status, agent = 'agent-a') {
return Array.from({ length: n }, (_, i) => ({
id: `${status}-${agent}-${i}`,
agent_name: agent,
notification_type: 'info',
title: `Notification ${i}`,
priority: 'normal',
status,
created_at: '2026-10-09T08:00:00Z',
message: null,
metadata: null,
}))
}

// The backend as the store sees it: a true pending count, and a list endpoint
// that honours the status filter and caps the page at `limit`.
function serve({ pending = GLOBAL_PENDING, acknowledged = 34 } = {}) {
axios.get.mockImplementation(async (url) => {
if (url.startsWith('/api/notifications/count')) {
return { data: { status: 'pending', count: pending } }
}
const q = new URLSearchParams(url.split('?')[1] || '')
const limit = Number(q.get('limit') || 50)
const status = q.get('status')
const all = status === 'acknowledged'
? rows(acknowledged, 'acknowledged')
: rows(pending, 'pending')
const page = all.slice(0, limit)
return { data: { count: page.length, notifications: page } }
})
axios.post.mockResolvedValue({ data: {} })
}

beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
serve()
})

describe('store: pendingCount is the server count, never the loaded list', () => {
it('a status filter on the list leaves the global pending count alone', async () => {
const store = useNotificationsStore()
await store.fetchPendingCount()
expect(store.pendingCount).toBe(GLOBAL_PENDING)

await store.fetchNotifications({ status: 'acknowledged' })
await flushPromises()

expect(store.notifications).toHaveLength(34)
expect(store.pendingCount).toBe(GLOBAL_PENDING)
})

it('the page limit does not cap the global pending count', async () => {
const store = useNotificationsStore()
await store.fetchPendingCount()

await store.fetchNotifications() // default filter: status=pending, limit=50
await flushPromises()

expect(store.notifications).toHaveLength(50)
expect(store.pendingCount).toBe(GLOBAL_PENDING)
})

it('an agent filter leaves the global pending count alone', async () => {
const store = useNotificationsStore()
await store.fetchPendingCount()

axios.get.mockImplementation(async (url) => (
url.startsWith('/api/notifications/count')
? { data: { status: 'pending', count: GLOBAL_PENDING } }
: { data: { count: 2, notifications: rows(2, 'pending', 'agent-b') } }
))
await store.fetchNotifications({ agentName: 'agent-b' })
await flushPromises()

expect(store.notifications).toHaveLength(2)
expect(store.pendingCount).toBe(GLOBAL_PENDING)
})

it('a list fetch refreshes the count from the count endpoint', async () => {
const store = useNotificationsStore()
await store.fetchNotifications({ status: 'acknowledged' })
await flushPromises()

expect(axios.get.mock.calls.some(([u]) => u.startsWith('/api/notifications/count'))).toBe(true)
expect(store.pendingCount).toBe(GLOBAL_PENDING)
})

it('acknowledging a pending row takes one off; dismissing an acknowledged row does not', async () => {
const store = useNotificationsStore()
await store.fetchNotifications()
await flushPromises()
await store.acknowledgeNotification(store.notifications[0].id)
expect(store.pendingCount).toBe(GLOBAL_PENDING - 1)

// The row is acknowledged now — dismissing it removes no pending item.
await store.dismissNotification(store.notifications[0].id)
expect(store.pendingCount).toBe(GLOBAL_PENDING - 1)

await store.dismissNotification(store.notifications[0].id)
expect(store.pendingCount).toBe(GLOBAL_PENDING - 2)
})

it('a live pending event counts even when the list filter hides it', async () => {
const store = useNotificationsStore()
store.setFilters({ status: 'acknowledged' })
await flushPromises()
expect(store.pendingCount).toBe(GLOBAL_PENDING)

store.addNotification({ id: 'live-1', agent_name: 'agent-a', status: 'pending', priority: 'normal' })

expect(store.notifications.some(n => n.id === 'live-1')).toBe(false)
expect(store.pendingCount).toBe(GLOBAL_PENDING + 1)
})
})

describe('NotificationsPanel: the cards say what they count', () => {
function cards(wrapper) {
const out = {}
wrapper.findAll('[data-testid^="notif-stat-"]').forEach((el) => {
out[el.attributes('data-testid').replace('notif-stat-', '')] = el.text()
})
return out
}

it('Pending stays the global figure under a filter, and the row card is "Shown", not "Total"', async () => {
const wrapper = mount(NotificationsPanel, { global: { stubs: { teleport: true } } })
await flushPromises()

expect(cards(wrapper).pending).toBe(`${GLOBAL_PENDING}Pending`)
expect(cards(wrapper).shown).toBe('50Shown')

// Agent · Type · Priority · Status — the Status select is the fourth.
await wrapper.findAll('select')[3].setValue('acknowledged')
await flushPromises()

const after = cards(wrapper)
expect(after.pending).toBe(`${GLOBAL_PENDING}Pending`)
expect(after.shown).toBe('34Shown')
expect(wrapper.text()).not.toContain('Total')
})
})
Loading