From 1d864efbeb1936e948fe73d9acedd6210d95d45c Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Sat, 25 Apr 2026 00:28:11 +0530 Subject: [PATCH] feat(notifications): unify notification slices, dismiss action, stats panel, unified Notifications page (#876) --- .github/workflows/e2e-agent-review.yml | 5 +- .../notifications/NotificationCard.tsx | 35 +++-- .../notifications/NotificationCenter.tsx | 47 +++++-- .../panels/NotificationRoutingPanel.tsx | 29 ++++ .../lib/webviewNotifications/service.test.ts | 28 ++-- app/src/lib/webviewNotifications/service.ts | 21 +-- app/src/pages/Notifications.tsx | 11 +- app/src/services/notificationService.ts | 9 +- app/src/services/webviewAccountService.ts | 4 +- .../__tests__/notificationsSlice.test.ts | 130 +++++++++++------- app/src/store/index.ts | 2 - app/src/store/notificationSlice.ts | 68 ++++++++- app/src/store/notificationsSlice.ts | 62 --------- app/src/types/notifications.ts | 8 ++ app/test/wdio.conf.ts | 14 +- src/openhuman/notifications/bus.rs | 31 ++++- src/openhuman/notifications/store.rs | 11 ++ 17 files changed, 322 insertions(+), 193 deletions(-) delete mode 100644 app/src/store/notificationsSlice.ts diff --git a/.github/workflows/e2e-agent-review.yml b/.github/workflows/e2e-agent-review.yml index c4295ea7c..98f67f2cf 100644 --- a/.github/workflows/e2e-agent-review.yml +++ b/.github/workflows/e2e-agent-review.yml @@ -106,7 +106,10 @@ jobs: export RUST_BACKTRACE=1 cd app mkdir -p test/e2e/artifacts - bash scripts/e2e-run-spec.sh test/e2e/specs/agent-review.spec.ts agent-review + if ! bash scripts/e2e-run-spec.sh test/e2e/specs/agent-review.spec.ts agent-review; then + echo "First agent-review run failed; retrying once..." + bash scripts/e2e-run-spec.sh test/e2e/specs/agent-review.spec.ts agent-review-retry + fi - name: Upload E2E artifacts if: always() && steps.gate.outputs.present == 'true' uses: actions/upload-artifact@v4 diff --git a/app/src/components/notifications/NotificationCard.tsx b/app/src/components/notifications/NotificationCard.tsx index 2b9ac1a73..01a03d668 100644 --- a/app/src/components/notifications/NotificationCard.tsx +++ b/app/src/components/notifications/NotificationCard.tsx @@ -50,17 +50,15 @@ function scoreBadgeClass(score: number): string { interface Props { notification: IntegrationNotification; onMarkRead: (id: string) => void; + onDismiss?: (id: string) => void; } -const NotificationCard = ({ notification: n, onMarkRead }: Props) => { +const NotificationCard = ({ notification: n, onMarkRead, onDismiss }: Props) => { const isUnread = n.status === 'unread'; return ( - + {onDismiss && ( + + )} - + ); }; diff --git a/app/src/components/notifications/NotificationCenter.tsx b/app/src/components/notifications/NotificationCenter.tsx index 963c4430d..d5674998c 100644 --- a/app/src/components/notifications/NotificationCenter.tsx +++ b/app/src/components/notifications/NotificationCenter.tsx @@ -1,13 +1,18 @@ import { useEffect, useState } from 'react'; -import { fetchNotifications, markNotificationRead } from '../../services/notificationService'; +import { + dismissNotification, + fetchNotifications, + markNotificationRead, +} from '../../services/notificationService'; import { useAppDispatch, useAppSelector } from '../../store/hooks'; import { - markRead as markReadAction, - setNotifications, - setNotificationsError, - setNotificationsLoading, -} from '../../store/notificationsSlice'; + dismissIntegrationNotification, + markIntegrationRead, + setIntegrationError, + setIntegrationLoading, + setIntegrationNotifications, +} from '../../store/notificationSlice'; import NotificationCard from './NotificationCard'; // ───────────────────────────────────────────────────────────────────────────── @@ -16,7 +21,11 @@ import NotificationCard from './NotificationCard'; const NotificationCenter = () => { const dispatch = useAppDispatch(); - const { items, loading, error } = useAppSelector(s => s.integrationNotifications); + const { + integrationItems: items, + integrationLoading: loading, + integrationError: error, + } = useAppSelector(s => s.notifications); const [selectedProvider, setSelectedProvider] = useState(undefined); // All providers seen across unfiltered loads — kept separate so the filter // pill row doesn't collapse when a provider filter is active. @@ -26,11 +35,11 @@ const NotificationCenter = () => { useEffect(() => { let cancelled = false; const load = async () => { - dispatch(setNotificationsLoading(true)); + dispatch(setIntegrationLoading(true)); try { const result = await fetchNotifications({ provider: selectedProvider, limit: 100 }); if (!cancelled) { - dispatch(setNotifications(result)); + dispatch(setIntegrationNotifications(result)); // Accumulate providers only from unfiltered loads so the pill row // stays stable when a filter is active. if (!selectedProvider) { @@ -41,9 +50,7 @@ const NotificationCenter = () => { } catch (err) { if (!cancelled) { dispatch( - setNotificationsError( - err instanceof Error ? err.message : 'Failed to load notifications' - ) + setIntegrationError(err instanceof Error ? err.message : 'Failed to load notifications') ); } } @@ -55,7 +62,7 @@ const NotificationCenter = () => { }, [dispatch, selectedProvider]); const handleMarkRead = async (id: string) => { - dispatch(markReadAction(id)); + dispatch(markIntegrationRead(id)); try { await markNotificationRead(id); } catch { @@ -63,13 +70,22 @@ const NotificationCenter = () => { } }; + const handleDismiss = async (id: string) => { + dispatch(dismissIntegrationNotification(id)); + try { + await dismissNotification(id); + } catch { + // Optimistic update applied; failure is silent. + } + }; + // Unread count scoped to the currently displayed (filtered) items. const filteredUnreadCount = items.filter(n => n.status === 'unread').length; const handleMarkAllRead = async () => { const unreadIds = items.filter(n => n.status === 'unread').map(n => n.id); for (const id of unreadIds) { - dispatch(markReadAction(id)); + dispatch(markIntegrationRead(id)); try { await markNotificationRead(id); } catch { @@ -172,6 +188,9 @@ const NotificationCenter = () => { onMarkRead={id => { void handleMarkRead(id); }} + onDismiss={id => { + void handleDismiss(id); + }} /> ))} diff --git a/app/src/components/settings/panels/NotificationRoutingPanel.tsx b/app/src/components/settings/panels/NotificationRoutingPanel.tsx index 66d6bb3a0..e1e4d8c04 100644 --- a/app/src/components/settings/panels/NotificationRoutingPanel.tsx +++ b/app/src/components/settings/panels/NotificationRoutingPanel.tsx @@ -1,9 +1,11 @@ import { useEffect, useState } from 'react'; import { + fetchNotificationStats, getNotificationSettings, setNotificationSettings, } from '../../../services/notificationService'; +import type { NotificationStats } from '../../../types/notifications'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; @@ -18,6 +20,7 @@ const PROVIDERS = ['gmail', 'slack', 'discord', 'whatsapp']; const NotificationRoutingPanel = () => { const { navigateBack, breadcrumbs } = useSettingsNavigation(); const providers = PROVIDERS; + const [stats, setStats] = useState(null); const [settings, setSettings] = useState< Record< string, @@ -27,6 +30,12 @@ const NotificationRoutingPanel = () => { const [loadedProviders, setLoadedProviders] = useState>({}); const [loadErrors, setLoadErrors] = useState>({}); + useEffect(() => { + void fetchNotificationStats() + .then(s => setStats(s)) + .catch(err => console.warn('[settings][notification-routing] stats load failed', err)); + }, []); + useEffect(() => { void Promise.allSettled( providers.map(async provider => { @@ -101,6 +110,26 @@ const NotificationRoutingPanel = () => { />
+ {stats && ( +
+
+

Pipeline stats

+
+
+ {[ + { label: 'Total', value: stats.total }, + { label: 'Unread', value: stats.unread }, + { label: 'Unscored', value: stats.unscored }, + ].map(({ label, value }) => ( +
+

{value}

+

{label}

+
+ ))} +
+
+ )} + {/* Info card */}
diff --git a/app/src/lib/webviewNotifications/service.test.ts b/app/src/lib/webviewNotifications/service.test.ts index 217df11b5..59cb2c510 100644 --- a/app/src/lib/webviewNotifications/service.test.ts +++ b/app/src/lib/webviewNotifications/service.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ingestNotification } from '../../services/notificationService'; import { store } from '../../store'; import { addAccount } from '../../store/accountsSlice'; -import { setNotifications } from '../../store/notificationsSlice'; +import { setIntegrationNotifications } from '../../store/notificationSlice'; import { __handleFiredForTests, __resetForTests, handleNotificationClick } from './service'; vi.mock('../../services/notificationService', () => ({ @@ -44,7 +44,7 @@ describe('webviewNotifications service', () => { __resetForTests(); ingestNotificationMock.mockReset(); ingestNotificationMock.mockResolvedValue({ skipped: true, reason: 'test-default' }); - store.dispatch(setNotifications({ items: [], unread_count: 0 })); + store.dispatch(setIntegrationNotifications({ items: [], unread_count: 0 })); store.dispatch(addAccount(sampleAccount)); }); @@ -77,29 +77,29 @@ describe('webviewNotifications service', () => { ingestNotificationMock.mockResolvedValue({ id: 'notif-1', skipped: false }); __handleFiredForTests(makeFiredPayload({ title: 'Hello', body: 'World', tag: 'message' })); - await Promise.resolve(); - - const items = store.getState().integrationNotifications.items; - expect(items.some(item => item.id === 'notif-1')).toBe(true); + await vi.waitFor(() => { + const items = store.getState().notifications.integrationItems; + expect(items.some(item => item.id === 'notif-1')).toBe(true); + }); }); it('ingest skipped does not add integration notification', async () => { ingestNotificationMock.mockResolvedValue({ skipped: true, reason: 'duplicate' }); __handleFiredForTests(makeFiredPayload({ title: 'Hello', body: 'World', tag: 'message' })); - await Promise.resolve(); - - const items = store.getState().integrationNotifications.items; - expect(items).toHaveLength(0); + await vi.waitFor(() => { + const items = store.getState().notifications.integrationItems; + expect(items).toHaveLength(0); + }); }); it('ingest error does not add integration notification', async () => { ingestNotificationMock.mockRejectedValue(new Error('network down')); __handleFiredForTests(makeFiredPayload({ title: 'Hello', body: 'World', tag: 'message' })); - await Promise.resolve(); - - const items = store.getState().integrationNotifications.items; - expect(items).toHaveLength(0); + await vi.waitFor(() => { + const items = store.getState().notifications.integrationItems; + expect(items).toHaveLength(0); + }); }); }); diff --git a/app/src/lib/webviewNotifications/service.ts b/app/src/lib/webviewNotifications/service.ts index 291e0137f..98b69913b 100644 --- a/app/src/lib/webviewNotifications/service.ts +++ b/app/src/lib/webviewNotifications/service.ts @@ -8,8 +8,7 @@ import { focusAccountFromNotification, noteWebviewNotificationFired, } from '../../store/accountsSlice'; -import { notificationReceived } from '../../store/notificationSlice'; -import { addNotification } from '../../store/notificationsSlice'; +import { addIntegrationNotification } from '../../store/notificationSlice'; import { WEBVIEW_NOTIFICATION_FIRED_EVENT, type WebviewNotificationFired } from './types'; const log = debug('webview-notifications'); @@ -68,7 +67,7 @@ export function handleNotificationClick(accountId: string): void { } function handleFired(payload: WebviewNotificationFired): void { - const { account_id: accountId, provider, title, body, tag } = payload; + const { account_id: accountId, provider, title, body } = payload; const redactedAccountId = redactAccountId(accountId); log( 'fired account=%s provider=%s title_chars=%d body_chars=%d', @@ -78,20 +77,6 @@ function handleFired(payload: WebviewNotificationFired): void { body.length ); store.dispatch(noteWebviewNotificationFired({ accountId })); - const now = Date.now(); - store.dispatch( - notificationReceived({ - id: `${accountId}:${tag ?? ''}:${now}`, - category: 'messages', - title, - body, - timestamp: now, - read: false, - accountId, - provider, - deepLink: `/accounts/${accountId}`, - }) - ); // Mirror into the core triage pipeline — fire-and-forget. log( @@ -110,7 +95,7 @@ function handleFired(payload: WebviewNotificationFired): void { if (!result.skipped) { log('[notification_intel] ingest created id=%s', result.id); store.dispatch( - addNotification({ + addIntegrationNotification({ id: result.id, provider, account_id: accountId, diff --git a/app/src/pages/Notifications.tsx b/app/src/pages/Notifications.tsx index eb4704c44..a724c9ebd 100644 --- a/app/src/pages/Notifications.tsx +++ b/app/src/pages/Notifications.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; +import NotificationCenter from '../components/notifications/NotificationCenter'; import { useAppDispatch, useAppSelector } from '../store/hooks'; import { clearAll, @@ -41,11 +42,17 @@ const Notifications = () => { }; return ( -
+
+ {/* Integration notifications — from connected accounts, scored by local AI */} +
+ +
+ + {/* Core-bridge notifications — system events */}
-

Notifications

+

System Events

{unread > 0 ? `${unread} unread` : 'All caught up'}

diff --git a/app/src/services/notificationService.ts b/app/src/services/notificationService.ts index b646925f8..3cd965c7e 100644 --- a/app/src/services/notificationService.ts +++ b/app/src/services/notificationService.ts @@ -1,6 +1,6 @@ import debug from 'debug'; -import type { IntegrationNotification } from '../types/notifications'; +import type { IntegrationNotification, NotificationStats } from '../types/notifications'; import { callCoreRpc } from './coreRpcClient'; const log = debug('notifications'); @@ -48,13 +48,6 @@ export async function markNotificationRead(id: string): Promise { } type NotificationIngestResult = { id: string; skipped?: false } | { skipped: true; reason: string }; -type NotificationStats = { - total: number; - unread: number; - unscored: number; - by_provider: Record; - by_action: Record; -}; /** * Ingest a new notification via the core RPC pipeline. diff --git a/app/src/services/webviewAccountService.ts b/app/src/services/webviewAccountService.ts index d04e34645..5cb7c3283 100644 --- a/app/src/services/webviewAccountService.ts +++ b/app/src/services/webviewAccountService.ts @@ -9,7 +9,7 @@ import { setAccountStatus, setActiveAccount, } from '../store/accountsSlice'; -import { addNotification } from '../store/notificationsSlice'; +import { addIntegrationNotification } from '../store/notificationSlice'; import { fetchRespondQueue } from '../store/providerSurfaceSlice'; import type { AccountProvider, IngestedMessage } from '../types/accounts'; import { threadApi } from './api/threadApi'; @@ -214,7 +214,7 @@ function handleRecipeEvent(evt: RecipeEventPayload) { .then(result => { if (result.skipped) return; store.dispatch( - addNotification({ + addIntegrationNotification({ id: result.id, provider: evt.provider, account_id: accountId, diff --git a/app/src/store/__tests__/notificationsSlice.test.ts b/app/src/store/__tests__/notificationsSlice.test.ts index 935c9bdc7..9857f15f6 100644 --- a/app/src/store/__tests__/notificationsSlice.test.ts +++ b/app/src/store/__tests__/notificationsSlice.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from 'vitest'; import type { IntegrationNotification } from '../../types/notifications'; -import notificationsReducer, { - addNotification, - markRead, - setNotifications, -} from '../notificationsSlice'; +import notificationReducer, { + addIntegrationNotification, + dismissIntegrationNotification, + markIntegrationRead, + setIntegrationNotifications, +} from '../notificationSlice'; const makeNotification = ( overrides: Partial = {} @@ -20,88 +21,121 @@ const makeNotification = ( ...overrides, }); -const initialState = { items: [], unreadCount: 0, loading: false, error: null }; +const baseState = notificationReducer(undefined, { type: '@@init' }); +const initialState = { + ...baseState, + integrationItems: [], + integrationUnreadCount: 0, + integrationLoading: false, + integrationError: null, +}; -describe('notificationsSlice', () => { - describe('setNotifications', () => { +describe('notificationSlice — integration notifications', () => { + describe('setIntegrationNotifications', () => { it('replaces items and unread count', () => { const n1 = makeNotification({ id: 'n-1', status: 'unread' }); const n2 = makeNotification({ id: 'n-2', status: 'read' }); - const state = notificationsReducer( + const state = notificationReducer( initialState, - setNotifications({ items: [n1, n2], unread_count: 1 }) + setIntegrationNotifications({ items: [n1, n2], unread_count: 1 }) ); - expect(state.items).toHaveLength(2); - expect(state.unreadCount).toBe(1); - expect(state.loading).toBe(false); - expect(state.error).toBeNull(); + expect(state.integrationItems).toHaveLength(2); + expect(state.integrationUnreadCount).toBe(1); + expect(state.integrationLoading).toBe(false); + expect(state.integrationError).toBeNull(); }); }); - describe('markRead', () => { - it('marks an unread notification as read and decrements unreadCount', () => { + describe('markIntegrationRead', () => { + it('marks an unread notification as read and decrements integrationUnreadCount', () => { const n = makeNotification({ id: 'n-1', status: 'unread' }); - const loaded = notificationsReducer( + const loaded = notificationReducer( initialState, - setNotifications({ items: [n], unread_count: 1 }) + setIntegrationNotifications({ items: [n], unread_count: 1 }) ); - const state = notificationsReducer(loaded, markRead('n-1')); - expect(state.items[0].status).toBe('read'); - expect(state.unreadCount).toBe(0); + const state = notificationReducer(loaded, markIntegrationRead('n-1')); + expect(state.integrationItems[0].status).toBe('read'); + expect(state.integrationUnreadCount).toBe(0); }); - it('does not decrement unreadCount below zero', () => { + it('does not decrement integrationUnreadCount below zero', () => { const n = makeNotification({ id: 'n-1', status: 'read' }); - const loaded = notificationsReducer( + const loaded = notificationReducer( initialState, - setNotifications({ items: [n], unread_count: 0 }) + setIntegrationNotifications({ items: [n], unread_count: 0 }) ); - const state = notificationsReducer(loaded, markRead('n-1')); - expect(state.unreadCount).toBe(0); + const state = notificationReducer(loaded, markIntegrationRead('n-1')); + expect(state.integrationUnreadCount).toBe(0); }); it('is a no-op for an already-read notification', () => { const n = makeNotification({ id: 'n-1', status: 'read' }); - const loaded = notificationsReducer( + const loaded = notificationReducer( initialState, - setNotifications({ items: [n], unread_count: 0 }) + setIntegrationNotifications({ items: [n], unread_count: 0 }) ); - const state = notificationsReducer(loaded, markRead('n-1')); - expect(state.unreadCount).toBe(0); - expect(state.items[0].status).toBe('read'); + const state = notificationReducer(loaded, markIntegrationRead('n-1')); + expect(state.integrationUnreadCount).toBe(0); + expect(state.integrationItems[0].status).toBe('read'); }); }); - describe('addNotification', () => { - it('prepends a new unread notification and increments unreadCount', () => { + describe('addIntegrationNotification', () => { + it('prepends a new unread notification and increments integrationUnreadCount', () => { const n1 = makeNotification({ id: 'n-1' }); - const loaded = notificationsReducer( + const loaded = notificationReducer( initialState, - setNotifications({ items: [n1], unread_count: 1 }) + setIntegrationNotifications({ items: [n1], unread_count: 1 }) ); const n2 = makeNotification({ id: 'n-2', title: 'Second' }); - const state = notificationsReducer(loaded, addNotification(n2)); - expect(state.items[0].id).toBe('n-2'); - expect(state.items).toHaveLength(2); - expect(state.unreadCount).toBe(2); + const state = notificationReducer(loaded, addIntegrationNotification(n2)); + expect(state.integrationItems[0].id).toBe('n-2'); + expect(state.integrationItems).toHaveLength(2); + expect(state.integrationUnreadCount).toBe(2); }); it('does not add a duplicate notification', () => { const n = makeNotification({ id: 'n-1' }); - const loaded = notificationsReducer( + const loaded = notificationReducer( initialState, - setNotifications({ items: [n], unread_count: 1 }) + setIntegrationNotifications({ items: [n], unread_count: 1 }) ); - const state = notificationsReducer(loaded, addNotification(n)); - expect(state.items).toHaveLength(1); - expect(state.unreadCount).toBe(1); + const state = notificationReducer(loaded, addIntegrationNotification(n)); + expect(state.integrationItems).toHaveLength(1); + expect(state.integrationUnreadCount).toBe(1); }); - it('does not increment unreadCount for a read notification', () => { + it('does not increment integrationUnreadCount for a read notification', () => { const n = makeNotification({ id: 'n-2', status: 'read' }); - const state = notificationsReducer(initialState, addNotification(n)); - expect(state.unreadCount).toBe(0); - expect(state.items).toHaveLength(1); + const state = notificationReducer(initialState, addIntegrationNotification(n)); + expect(state.integrationUnreadCount).toBe(0); + expect(state.integrationItems).toHaveLength(1); + }); + }); + + describe('dismissIntegrationNotification', () => { + it('marks unread notification as dismissed and decrements unread count', () => { + const n = makeNotification({ id: 'n-1', status: 'unread' }); + const loaded = notificationReducer( + initialState, + setIntegrationNotifications({ items: [n], unread_count: 1 }) + ); + + const state = notificationReducer(loaded, dismissIntegrationNotification('n-1')); + expect(state.integrationItems[0].status).toBe('dismissed'); + expect(state.integrationUnreadCount).toBe(0); + }); + + it('does not decrement unread count below zero', () => { + const n = makeNotification({ id: 'n-1', status: 'dismissed' }); + const loaded = notificationReducer( + initialState, + setIntegrationNotifications({ items: [n], unread_count: 0 }) + ); + + const state = notificationReducer(loaded, dismissIntegrationNotification('n-1')); + expect(state.integrationItems[0].status).toBe('dismissed'); + expect(state.integrationUnreadCount).toBe(0); }); }); }); diff --git a/app/src/store/index.ts b/app/src/store/index.ts index 2c5890149..d6091f980 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -17,7 +17,6 @@ import accountsReducer from './accountsSlice'; import channelConnectionsReducer from './channelConnectionsSlice'; import chatRuntimeReducer from './chatRuntimeSlice'; import notificationReducer from './notificationSlice'; -import notificationsReducer from './notificationsSlice'; import providerSurfacesReducer from './providerSurfaceSlice'; import socketReducer from './socketSlice'; import threadReducer from './threadSlice'; @@ -56,7 +55,6 @@ export const store = configureStore({ channelConnections: persistedChannelConnectionsReducer, accounts: persistedAccountsReducer, notifications: persistedNotificationReducer, - integrationNotifications: notificationsReducer, providerSurfaces: providerSurfacesReducer, }, middleware: getDefaultMiddleware => { diff --git a/app/src/store/notificationSlice.ts b/app/src/store/notificationSlice.ts index 27958b7d2..48bb456dc 100644 --- a/app/src/store/notificationSlice.ts +++ b/app/src/store/notificationSlice.ts @@ -1,5 +1,7 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import type { IntegrationNotification } from '../types/notifications'; + export type NotificationCategory = 'messages' | 'agents' | 'skills' | 'system'; export interface NotificationItem { @@ -24,6 +26,10 @@ export interface NotificationPreferences { export interface NotificationState { items: NotificationItem[]; preferences: NotificationPreferences; + integrationItems: IntegrationNotification[]; + integrationUnreadCount: number; + integrationLoading: boolean; + integrationError: string | null; } const MAX_ITEMS = 200; @@ -31,6 +37,10 @@ const MAX_ITEMS = 200; const initialState: NotificationState = { items: [], preferences: { messages: true, agents: true, skills: true, system: true }, + integrationItems: [], + integrationUnreadCount: 0, + integrationLoading: false, + integrationError: null, }; const notificationSlice = createSlice({ @@ -61,13 +71,67 @@ const notificationSlice = createSlice({ ) { state.preferences[action.payload.category] = action.payload.enabled; }, + setIntegrationLoading(state, action: PayloadAction) { + state.integrationLoading = action.payload; + }, + setIntegrationError(state, action: PayloadAction) { + state.integrationError = action.payload; + state.integrationLoading = false; + }, + setIntegrationNotifications( + state, + action: PayloadAction<{ items: IntegrationNotification[]; unread_count: number }> + ) { + state.integrationItems = action.payload.items; + state.integrationUnreadCount = action.payload.unread_count; + state.integrationLoading = false; + state.integrationError = null; + }, + markIntegrationRead(state, action: PayloadAction) { + const n = state.integrationItems.find(i => i.id === action.payload); + if (n && n.status === 'unread') { + n.status = 'read'; + state.integrationUnreadCount = Math.max(0, state.integrationUnreadCount - 1); + } + }, + dismissIntegrationNotification(state, action: PayloadAction) { + const n = state.integrationItems.find(i => i.id === action.payload); + if (n) { + const wasUnread = n.status === 'unread'; + n.status = 'dismissed'; + if (wasUnread) { + state.integrationUnreadCount = Math.max(0, state.integrationUnreadCount - 1); + } + } + }, + addIntegrationNotification(state, action: PayloadAction) { + const exists = state.integrationItems.some(i => i.id === action.payload.id); + if (!exists) { + state.integrationItems.unshift(action.payload); + if (action.payload.status === 'unread') { + state.integrationUnreadCount += 1; + } + } + }, }, }); export const selectUnreadCount = (items: NotificationItem[]): number => items.reduce((n, i) => (i.read ? n : n + 1), 0); -export const { notificationReceived, markRead, markAllRead, clearAll, setPreference } = - notificationSlice.actions; +export const { + notificationReceived, + markRead, + markAllRead, + clearAll, + setPreference, + setIntegrationLoading, + setIntegrationError, + setIntegrationNotifications, + markIntegrationRead, + dismissIntegrationNotification, + addIntegrationNotification, +} = notificationSlice.actions; +export { notificationSlice }; export default notificationSlice.reducer; diff --git a/app/src/store/notificationsSlice.ts b/app/src/store/notificationsSlice.ts deleted file mode 100644 index 0fc4c2609..000000000 --- a/app/src/store/notificationsSlice.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; - -import type { IntegrationNotification } from '../types/notifications'; - -interface NotificationsState { - items: IntegrationNotification[]; - unreadCount: number; - loading: boolean; - error: string | null; -} - -const initialState: NotificationsState = { items: [], unreadCount: 0, loading: false, error: null }; - -export const notificationsSlice = createSlice({ - name: 'notifications', - initialState, - reducers: { - setNotificationsLoading(state, _action: PayloadAction) { - state.loading = _action.payload; - }, - setNotificationsError(state, action: PayloadAction) { - state.error = action.payload; - state.loading = false; - }, - setNotifications( - state, - action: PayloadAction<{ items: IntegrationNotification[]; unread_count: number }> - ) { - state.items = action.payload.items; - state.unreadCount = action.payload.unread_count; - state.loading = false; - state.error = null; - }, - markRead(state, action: PayloadAction) { - const n = state.items.find(i => i.id === action.payload); - if (n && n.status === 'unread') { - n.status = 'read'; - state.unreadCount = Math.max(0, state.unreadCount - 1); - } - }, - addNotification(state, action: PayloadAction) { - // Prepend so newest appears first; avoid duplicates by id. - const exists = state.items.some(i => i.id === action.payload.id); - if (!exists) { - state.items.unshift(action.payload); - if (action.payload.status === 'unread') { - state.unreadCount += 1; - } - } - }, - }, -}); - -export const { - setNotificationsLoading, - setNotificationsError, - setNotifications, - markRead, - addNotification, -} = notificationsSlice.actions; - -export default notificationsSlice.reducer; diff --git a/app/src/types/notifications.ts b/app/src/types/notifications.ts index 9e5fc6c20..34233ddb2 100644 --- a/app/src/types/notifications.ts +++ b/app/src/types/notifications.ts @@ -30,3 +30,11 @@ export interface NotificationSettings { importance_threshold: number; route_to_orchestrator: boolean; } + +export interface NotificationStats { + total: number; + unread: number; + unscored: number; + by_provider: Record; + by_action: Record; +} diff --git a/app/test/wdio.conf.ts b/app/test/wdio.conf.ts index ee24949a0..b9e713590 100644 --- a/app/test/wdio.conf.ts +++ b/app/test/wdio.conf.ts @@ -77,10 +77,10 @@ function getPlatformCapabilities(): Record[] { } /** Port for the automation driver: tauri-driver (4444) or Appium (4723). */ -const driverPort = - process.platform === 'linux' - ? parseInt(process.env.TAURI_DRIVER_PORT || '4444', 10) - : parseInt(process.env.APPIUM_PORT || '4723', 10); +const isLinuxDriver = process.platform === 'linux'; +const driverPort = isLinuxDriver + ? parseInt(process.env.TAURI_DRIVER_PORT || '4444', 10) + : parseInt(process.env.APPIUM_PORT || '4723', 10); export const config: Options.Testrunner & Record = { runner: 'local', @@ -93,8 +93,10 @@ export const config: Options.Testrunner & Record = { logLevel: 'warn', bail: 0, waitforTimeout: 10_000, - connectionRetryTimeout: 120_000, - connectionRetryCount: 3, + // Linux tauri-driver can take longer to establish the initial session on + // loaded CI runners; keep macOS defaults while giving Linux more headroom. + connectionRetryTimeout: isLinuxDriver ? 240_000 : 120_000, + connectionRetryCount: isLinuxDriver ? 5 : 3, // No appium/tauri-driver service — driver is started externally via scripts. framework: 'mocha', reporters: ['spec'], diff --git a/src/openhuman/notifications/bus.rs b/src/openhuman/notifications/bus.rs index df255ad59..7684b6562 100644 --- a/src/openhuman/notifications/bus.rs +++ b/src/openhuman/notifications/bus.rs @@ -149,11 +149,17 @@ pub fn event_to_notification(event: &DomainEvent) -> Option