mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
feat(notifications): unify notification slices, dismiss action, stats panel, unified Notifications page (#876)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user