feat(notifications): unify notification slices, dismiss action, stats panel, unified Notifications page (#876)

This commit is contained in:
Mega Mind
2026-04-24 11:58:11 -07:00
committed by GitHub
parent 95504d4b09
commit 1d864efbeb
17 changed files with 322 additions and 193 deletions
+4 -1
View File
@@ -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
@@ -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 (
<button
onClick={() => {
if (isUnread) onMarkRead(n.id);
}}
className={`w-full text-left p-3 border-b border-stone-100 hover:bg-stone-50 transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1 ${
<div
className={`w-full p-3 border-b border-stone-100 hover:bg-stone-50 transition-colors duration-150 ${
isUnread ? 'bg-primary-50/30' : 'bg-white'
}`}>
<div className="flex items-start gap-3">
@@ -71,7 +69,11 @@ const NotificationCard = ({ notification: n, onMarkRead }: Props) => {
)}
</div>
<div className="flex-1 min-w-0">
<button
onClick={() => {
if (isUnread) onMarkRead(n.id);
}}
className="flex-1 min-w-0 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1 rounded-sm">
{/* Header row: provider badge + timestamp */}
<div className="flex items-center gap-2 mb-1">
<span
@@ -103,9 +105,24 @@ const NotificationCard = ({ notification: n, onMarkRead }: Props) => {
{/* Body preview */}
{n.body && <p className="text-xs text-stone-500 mt-0.5 line-clamp-2">{n.body}</p>}
</div>
</button>
{onDismiss && (
<button
onClick={() => onDismiss(n.id)}
className="mt-0.5 ml-1 flex-shrink-0 p-0.5 rounded hover:bg-stone-200 text-stone-400 hover:text-stone-600 transition-colors"
aria-label="Dismiss notification">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
</button>
</div>
);
};
@@ -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<string | undefined>(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);
}}
/>
))}
</div>
@@ -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<NotificationStats | null>(null);
const [settings, setSettings] = useState<
Record<
string,
@@ -27,6 +30,12 @@ const NotificationRoutingPanel = () => {
const [loadedProviders, setLoadedProviders] = useState<Record<string, boolean>>({});
const [loadErrors, setLoadErrors] = useState<Record<string, string>>({});
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 = () => {
/>
<div className="p-4 space-y-4">
{stats && (
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-stone-100">
<p className="text-sm font-medium text-stone-900">Pipeline stats</p>
</div>
<div className="grid grid-cols-3 divide-x divide-stone-100">
{[
{ label: 'Total', value: stats.total },
{ label: 'Unread', value: stats.unread },
{ label: 'Unscored', value: stats.unscored },
].map(({ label, value }) => (
<div key={label} className="px-4 py-3 text-center">
<p className="text-lg font-semibold text-stone-900">{value}</p>
<p className="text-xs text-stone-500">{label}</p>
</div>
))}
</div>
</div>
)}
{/* Info card */}
<div className="p-4 bg-blue-50 border border-blue-200 rounded-xl">
<div className="flex items-start space-x-3">
@@ -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);
});
});
});
+3 -18
View File
@@ -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,
+9 -2
View File
@@ -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 (
<div className="p-4 pt-6">
<div className="p-4 pt-6 space-y-4">
{/* Integration notifications — from connected accounts, scored by local AI */}
<div className="max-w-2xl mx-auto bg-white rounded-2xl shadow-soft border border-stone-200 overflow-hidden min-h-[200px]">
<NotificationCenter />
</div>
{/* Core-bridge notifications — system events */}
<div className="max-w-2xl mx-auto bg-white rounded-2xl shadow-soft border border-stone-200 overflow-hidden">
<div className="flex items-center justify-between border-b border-stone-100 px-4 py-3">
<div>
<h1 className="text-lg font-semibold text-stone-900">Notifications</h1>
<h1 className="text-lg font-semibold text-stone-900">System Events</h1>
<p className="text-xs text-stone-500">
{unread > 0 ? `${unread} unread` : 'All caught up'}
</p>
+1 -8
View File
@@ -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<void> {
}
type NotificationIngestResult = { id: string; skipped?: false } | { skipped: true; reason: string };
type NotificationStats = {
total: number;
unread: number;
unscored: number;
by_provider: Record<string, number>;
by_action: Record<string, number>;
};
/**
* Ingest a new notification via the core RPC pipeline.
+2 -2
View File
@@ -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,
@@ -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<IntegrationNotification> = {}
@@ -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);
});
});
});
-2
View File
@@ -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 => {
+66 -2
View File
@@ -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<boolean>) {
state.integrationLoading = action.payload;
},
setIntegrationError(state, action: PayloadAction<string | null>) {
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<string>) {
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<string>) {
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<IntegrationNotification>) {
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;
-62
View File
@@ -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<boolean>) {
state.loading = _action.payload;
},
setNotificationsError(state, action: PayloadAction<string | null>) {
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<string>) {
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<IntegrationNotification>) {
// 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;
+8
View File
@@ -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<string, number>;
by_action: Record<string, number>;
}
+8 -6
View File
@@ -77,10 +77,10 @@ function getPlatformCapabilities(): Record<string, unknown>[] {
}
/** 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<string, unknown> = {
runner: 'local',
@@ -93,8 +93,10 @@ export const config: Options.Testrunner & Record<string, unknown> = {
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'],
+26 -5
View File
@@ -149,11 +149,17 @@ pub fn event_to_notification(event: &DomainEvent) -> Option<CoreNotificationEven
id: format!("notification-triaged:{}:{}:{}", id, action, latency_ms),
category: CoreNotificationCategory::Agents,
title: format!("High-priority {} notification", provider),
body: format!(
"Action: {} (score: {:.0}%). Routed to orchestrator.",
action,
importance_score * 100.0
),
body: if action == "escalate" {
format!(
"Action: escalate (score: {:.0}%). Routed to orchestrator.",
importance_score * 100.0
)
} else {
format!(
"Action: react (score: {:.0}%). Routed for follow-up.",
importance_score * 100.0
)
},
deep_link: Some("/notifications".into()),
timestamp_ms: ts,
})
@@ -316,6 +322,21 @@ mod tests {
assert!(n.deep_link.as_deref() == Some("/notifications"));
}
#[test]
fn notification_triaged_react_uses_follow_up_copy() {
let ev = DomainEvent::NotificationTriaged {
id: "n2".into(),
provider: "discord".into(),
action: "react".into(),
importance_score: 0.7,
latency_ms: 120,
routed: true,
};
let n = event_to_notification(&ev).expect("should produce notification");
assert_eq!(n.category, CoreNotificationCategory::Agents);
assert!(n.body.contains("Routed for follow-up"));
}
#[test]
fn notification_triaged_drop_is_silent() {
let ev = DomainEvent::NotificationTriaged {
+11
View File
@@ -683,6 +683,17 @@ mod tests {
assert!(insert_if_not_recent(&config, &fresh_same_content).unwrap());
}
#[test]
fn exists_recent_rejects_expired_notification() {
let dir = TempDir::new().unwrap();
let config = test_config(&dir);
let mut n = sample_notification("old1", "slack");
n.received_at = Utc::now() - chrono::Duration::seconds(120);
insert(&config, &n).unwrap();
assert!(!exists_recent(&config, "slack", None, "Test notification", "Test body").unwrap());
}
#[test]
fn settings_roundtrip_defaults_and_upsert() {
let dir = TempDir::new().unwrap();