From 1d531cc2cb9dd4144c1e218be3ea281da346aa1a Mon Sep 17 00:00:00 2001 From: JamesDAdams Date: Thu, 27 Aug 2026 05:57:09 +0200 Subject: [PATCH] feat: add global in-app notification system Add a persisted notification center with an unread badge, header bell, 5-second top-right toasts, and configurable popup/sound toggles. Plugins can emit notifications via registry.notify(). Notifications persist in a new SQLite table and broadcast over WebSocket with fetch/reload parity. - Header bell + unread badge (left of split-view button) - Notification center (list, per-item delete, clear-all) - Toast stacked top-right, auto-dismiss after 5s - Notification settings (popup + sound master toggles) - Plugin notify API (ProviderPluginRegistry.notify) - SQLite notifications table + REST routes - WebSocket events: notifications.new/.deleted/.read/.cleared --- src/provider/index.ts | 8 ++ src/server/db/index.ts | 13 ++ src/server/db/migrations.test.ts | 14 ++ src/server/db/notifications.test.ts | 87 ++++++++++++ src/server/db/notifications.ts | 79 +++++++++++ src/server/index.ts | 36 +++++ src/server/notifications/service.ts | 27 ++++ src/server/providers/plugins/loader.ts | 3 + src/server/providers/plugins/registry.ts | 10 ++ src/server/routes/notifications.ts | 47 +++++++ src/server/routes/plugins.ts | 3 + src/shared/protocol.ts | 10 ++ src/shared/types.ts | 21 +++ web/src/components/layout/Header.tsx | 27 ++++ .../notifications/NotificationCenter.tsx | 128 +++++++++++++++++ .../notifications/NotificationToasts.tsx | 39 +++++ .../settings/NotificationSettings.tsx | 6 + web/src/components/shared/icons/BellIcon.tsx | 17 +++ web/src/components/shared/icons/index.ts | 1 + web/src/lib/sound.test.ts | 1 + web/src/lib/sound.ts | 2 +- web/src/stores/notificationHistory.test.ts | 95 +++++++++++++ web/src/stores/notificationHistory.ts | 133 ++++++++++++++++++ web/src/stores/notifications.ts | 4 + web/src/stores/session/messageHandler.ts | 28 +++- 25 files changed, 837 insertions(+), 2 deletions(-) create mode 100644 src/server/db/notifications.test.ts create mode 100644 src/server/db/notifications.ts create mode 100644 src/server/notifications/service.ts create mode 100644 src/server/routes/notifications.ts create mode 100644 web/src/components/notifications/NotificationCenter.tsx create mode 100644 web/src/components/notifications/NotificationToasts.tsx create mode 100644 web/src/components/shared/icons/BellIcon.tsx create mode 100644 web/src/stores/notificationHistory.test.ts create mode 100644 web/src/stores/notificationHistory.ts diff --git a/src/provider/index.ts b/src/provider/index.ts index d846b161f..a7df538da 100644 --- a/src/provider/index.ts +++ b/src/provider/index.ts @@ -101,10 +101,18 @@ export interface ProviderPluginRuntime { // Plugin Registry (passed to plugins during registration) // ============================================================================ +/** A plugin-emitted in-app notification. */ +export interface PluginNotification { + title: string + body: string +} + export interface ProviderPluginRegistry { registerAuth(adapter: ProviderAuthAdapter): void registerTransport(adapter: ProviderTransportAdapter): void registerPreset(preset: ProviderPreset): void + /** Emit an in-app notification that surfaces in the UI (toast + history). */ + notify(notification: PluginNotification): void readonly runtime: ProviderPluginRuntime } diff --git a/src/server/db/index.ts b/src/server/db/index.ts index 6797f401f..320c146b2 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -439,5 +439,18 @@ function runMigrations(db: Database.Database): void { ) `) + // Global in-app notification history + db.exec(` + CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + body TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'system', + read INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ) + `) + db.exec(`CREATE INDEX IF NOT EXISTS idx_notifications_created ON notifications(created_at)`) + logger.info('Database migrations completed') } diff --git a/src/server/db/migrations.test.ts b/src/server/db/migrations.test.ts index 6b5548772..ae771a3c8 100644 --- a/src/server/db/migrations.test.ts +++ b/src/server/db/migrations.test.ts @@ -304,4 +304,18 @@ describe('db migrations', () => { db.close() }) + + it('creates notifications table on a fresh database', () => { + const config = loadConfig() + config.database.path = dbPath + initDatabase(config) + + const db = new Database(dbPath) + const columns = db.prepare(`PRAGMA table_info(notifications)`).all() as { name: string }[] + const columnNames = columns.map((c) => c.name) + + expect(columnNames).toEqual(expect.arrayContaining(['id', 'title', 'body', 'source', 'read', 'created_at'])) + + db.close() + }) }) diff --git a/src/server/db/notifications.test.ts b/src/server/db/notifications.test.ts new file mode 100644 index 000000000..d6e8a7177 --- /dev/null +++ b/src/server/db/notifications.test.ts @@ -0,0 +1,87 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { loadConfig } from '../config.js' +import { closeDatabase, initDatabase } from '../db/index.js' +import { + createNotification, + listNotifications, + deleteNotification, + clearNotifications, + markNotificationsRead, + countUnreadNotifications, + setNotificationRead, + getNotification, +} from '../db/notifications.js' + +describe('notifications db', () => { + let tmpDir: string + let dbPath: string + + beforeEach(async () => { + closeDatabase() + tmpDir = await mkdtemp(join(tmpdir(), 'openfox-notif-test-')) + dbPath = join(tmpDir, 'test.db') + const config = loadConfig() + config.database.path = dbPath + initDatabase(config) + }) + + afterEach(async () => { + closeDatabase() + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('creates and lists notifications newest first', () => { + createNotification({ title: 'First', body: 'body 1', source: 'plugin' }) + const second = createNotification({ title: 'Second', body: 'body 2' }) + + const all = listNotifications() + expect(all.length).toBe(2) + // Newest first: second (just created) first + expect(all[0]!.id).toBe(second.id) + expect(all[0]!.source).toBe('system') + expect(all[1]!.source).toBe('plugin') + expect(all[0]!.read).toBe(false) + }) + + it('tracks unread count', () => { + createNotification({ title: 'A', body: 'a' }) + const b = createNotification({ title: 'B', body: 'b' }) + expect(countUnreadNotifications()).toBe(2) + + setNotificationRead(b.id, true) + expect(countUnreadNotifications()).toBe(1) + + markNotificationsRead() + expect(countUnreadNotifications()).toBe(0) + }) + + it('deletes a single notification', () => { + const a = createNotification({ title: 'A', body: 'a' }) + createNotification({ title: 'B', body: 'b' }) + deleteNotification(a.id) + + expect(listNotifications().length).toBe(1) + expect(getNotification(a.id)).toBeNull() + }) + + it('clears all notifications', () => { + createNotification({ title: 'A', body: 'a' }) + createNotification({ title: 'B', body: 'b' }) + clearNotifications() + + expect(listNotifications()).toEqual([]) + expect(countUnreadNotifications()).toBe(0) + }) + + it('persists a test notification source', () => { + const n = createNotification({ title: 'Test', body: 'sample', source: 'test' }) + expect(n.source).toBe('test') + + const all = listNotifications() + expect(all[0]!.title).toBe('Test') + expect(all[0]!.source).toBe('test') + }) +}) diff --git a/src/server/db/notifications.ts b/src/server/db/notifications.ts new file mode 100644 index 000000000..cf0d063db --- /dev/null +++ b/src/server/db/notifications.ts @@ -0,0 +1,79 @@ +import { randomBytes } from 'node:crypto' +import type { Notification, NotificationInput } from '../../shared/types.js' +import { getDatabase } from './index.js' + +interface NotificationRow { + id: string + title: string + body: string + source: string + read: number + created_at: string +} + +function toNotification(row: NotificationRow): Notification { + return { + id: row.id, + title: row.title, + body: row.body, + source: row.source, + read: row.read === 1, + createdAt: row.created_at, + } +} + +export function createNotification(input: NotificationInput): Notification { + const db = getDatabase() + const now = new Date().toISOString() + const row: NotificationRow = { + id: randomBytes(8).toString('hex'), + title: input.title, + body: input.body, + source: input.source ?? 'system', + read: 0, + created_at: now, + } + db.prepare( + `INSERT INTO notifications (id, title, body, source, read, created_at) + VALUES (@id, @title, @body, @source, @read, @created_at)`, + ).run(row) + return toNotification(row) +} + +export function listNotifications(): Notification[] { + const db = getDatabase() + const rows = db.prepare('SELECT * FROM notifications ORDER BY created_at DESC').all() as NotificationRow[] + return rows.map(toNotification) +} + +export function getNotification(id: string): Notification | null { + const db = getDatabase() + const row = db.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as NotificationRow | undefined + return row ? toNotification(row) : null +} + +export function deleteNotification(id: string): void { + const db = getDatabase() + db.prepare('DELETE FROM notifications WHERE id = ?').run(id) +} + +export function clearNotifications(): void { + const db = getDatabase() + db.prepare('DELETE FROM notifications').run() +} + +export function markNotificationsRead(): void { + const db = getDatabase() + db.prepare('UPDATE notifications SET read = 1 WHERE read = 0').run() +} + +export function countUnreadNotifications(): number { + const db = getDatabase() + const row = db.prepare('SELECT COUNT(*) AS count FROM notifications WHERE read = 0').get() as { count: number } + return row.count +} + +export function setNotificationRead(id: string, read: boolean): void { + const db = getDatabase() + db.prepare('UPDATE notifications SET read = ? WHERE id = ?').run(read ? 1 : 0, id) +} diff --git a/src/server/index.ts b/src/server/index.ts index 6e9f7f8bd..7b9d1d3dd 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -127,6 +127,11 @@ export async function createServerHandle(config: Config): Promise launch: import('./runner/launch.js').WorkflowLaunchPayload, ) => void = () => {} + // Deferred broadcast for the notifications service. Same rationale as the + // tasks broadcast: the WebSocket server doesn't exist yet when the routes and + // plugin registry are wired, so notifications are buffered through this hook. + let deferNotificationsBroadcast: (message: import('../shared/protocol.js').ServerMessage) => void = () => {} + // Get config directory for loading user items const configDir = getGlobalConfigDir(config.mode ?? 'production') @@ -135,6 +140,17 @@ export async function createServerHandle(config: Config): Promise mode: config.mode === 'development' ? 'development' : 'production', configDirectory: configDir, }) + + // Notifications service: persists rows and broadcasts to all UIs. The + // broadcaster is deferred because the WebSocket server is created later. + const { createNotificationsService } = await import('./notifications/service.js') + const notificationsService = createNotificationsService({ + notify: (notification) => { + deferNotificationsBroadcast(createServerMessage('notifications.new', { notification })) + }, + }) + providerAdapters.setNotify((notification) => notificationsService.notify({ source: 'plugin', ...notification })) + const pluginDiagnostics = await loadProviderPlugins({ registry: providerAdapters, configDirectory: configDir }) for (const diagnostic of pluginDiagnostics) { if (!diagnostic.loaded) logger.warn('Provider plugin failed to load', { ...diagnostic }) @@ -532,6 +548,23 @@ export async function createServerHandle(config: Config): Promise registerTaskRoutes(tasksRouter, tasksService) app.use('/api', tasksRouter) + // Global in-app notification history. Also mounted before the Vite + // middleware; mutations broadcast through the deferred WebSocket sink. + const { registerNotificationRoutes } = await import('./routes/notifications.js') + const notificationsRouter = express.Router() + registerNotificationRoutes(notificationsRouter, { + deleted: (id) => { + deferNotificationsBroadcast(createServerMessage('notifications.deleted', { id })) + }, + read: () => { + deferNotificationsBroadcast(createServerMessage('notifications.read', {})) + }, + cleared: () => { + deferNotificationsBroadcast(createServerMessage('notifications.cleared', {})) + }, + }) + app.use('/api', notificationsRouter) + // Branch management endpoints (project-scoped, repo operations) /** List local git branches */ @@ -3456,6 +3489,9 @@ export async function createServerHandle(config: Config): Promise deferTasksBroadcast = (projectId, payload) => wssExports.broadcastForProject(projectId, '', { type: 'tasks.update', payload }) + // Point the notifications service at the live WebSocket broadcaster now that it exists. + deferNotificationsBroadcast = (message) => wssExports.broadcastAll(message) + // Point the tasks service at the workflow launcher. Task-seeded workflows run // through the same shared launcher as runner.launch (src/server/runner/launch.ts). const { launchWorkflowRun, abortRunnerRun } = await import('./runner/launch.js') diff --git a/src/server/notifications/service.ts b/src/server/notifications/service.ts new file mode 100644 index 000000000..7b40e4da3 --- /dev/null +++ b/src/server/notifications/service.ts @@ -0,0 +1,27 @@ +import type { Notification, NotificationInput } from '../../shared/types.js' +import { createNotification as dbCreateNotification } from '../db/notifications.js' + +export type NotificationsBroadcaster = { + /** Broadcast a newly created notification to all clients. */ + notify(notification: Notification): void +} + +/** + * Global in-app notification service. Persists the notification row and pushes + * it to every connected UI. The broadcaster is injected so the service works + * before the WebSocket server exists (deferred wiring in the server entry) and + * stays testable. + */ +export class NotificationsService { + constructor(private broadcaster: NotificationsBroadcaster) {} + + notify(input: NotificationInput): Notification { + const notification = dbCreateNotification(input) + this.broadcaster.notify(notification) + return notification + } +} + +export function createNotificationsService(broadcaster: NotificationsBroadcaster): NotificationsService { + return new NotificationsService(broadcaster) +} diff --git a/src/server/providers/plugins/loader.ts b/src/server/providers/plugins/loader.ts index 4984a9f6c..3331ff0ea 100644 --- a/src/server/providers/plugins/loader.ts +++ b/src/server/providers/plugins/loader.ts @@ -106,6 +106,9 @@ export async function loadProviderPlugins(options: { options.registry.registerPreset(preset) diagnostic.presets.push(preset.id) }, + notify(notification) { + options.registry.notify(notification) + }, } try { const module = (await import(pathToFileURL(join(packageDir, plugin)).href)) as { diff --git a/src/server/providers/plugins/registry.ts b/src/server/providers/plugins/registry.ts index 5a5bf8bb0..f2f44e412 100644 --- a/src/server/providers/plugins/registry.ts +++ b/src/server/providers/plugins/registry.ts @@ -5,15 +5,25 @@ import type { ProviderPluginRuntime, ProviderPreset, ProviderTransportAdapter, + PluginNotification, } from '../../../provider/index.js' export class ProviderRegistry implements ProviderPluginRegistry { private readonly authAdapters = new Map() private readonly transportAdapters = new Map() private readonly presets = new Map() + private notifyFn: ((notification: PluginNotification) => void) | null = null constructor(readonly runtime: ProviderPluginRuntime) {} + setNotify(fn: (notification: PluginNotification) => void): void { + this.notifyFn = fn + } + + notify(notification: PluginNotification): void { + this.notifyFn?.(notification) + } + registerAuth(adapter: ProviderAuthAdapter): void { this.register(this.authAdapters, adapter.id, adapter, 'auth adapter') } diff --git a/src/server/routes/notifications.ts b/src/server/routes/notifications.ts new file mode 100644 index 000000000..46cc4d159 --- /dev/null +++ b/src/server/routes/notifications.ts @@ -0,0 +1,47 @@ +import { Router, type Request, type Response } from 'express' +import { + listNotifications, + deleteNotification, + clearNotifications, + markNotificationsRead, +} from '../db/notifications.js' + +export interface BroadcastNotifications { + /** A notification was deleted. */ + deleted(id: string): void + /** All notifications were marked as read. */ + read(): void + /** All notifications were cleared. */ + cleared(): void +} + +/** + * REST API for the global in-app notification history. Mutations broadcast + * over WebSocket (see registerNotificationRoutes' broadcaster) so live clients + * stay in sync; responses return the canonical row shape for fetch/reload + * parity. + */ +export function registerNotificationRoutes(router: Router, broadcast: BroadcastNotifications): void { + router.get('/notifications', (_req: Request, res: Response) => { + res.json({ notifications: listNotifications() }) + }) + + router.delete('/notifications', (_req: Request, res: Response) => { + clearNotifications() + broadcast.cleared() + res.status(204).end() + }) + + router.post('/notifications/read-all', (_req: Request, res: Response) => { + markNotificationsRead() + broadcast.read() + res.status(204).end() + }) + + router.delete('/notifications/:id', (req: Request, res: Response) => { + const id = req.params['id'] as string + deleteNotification(id) + broadcast.deleted(id) + res.status(204).end() + }) +} diff --git a/src/server/routes/plugins.ts b/src/server/routes/plugins.ts index a4e6af36e..8fe8af632 100644 --- a/src/server/routes/plugins.ts +++ b/src/server/routes/plugins.ts @@ -161,6 +161,9 @@ export function createPluginRoutes(options: { providerAdapters.registerPreset(preset) diagnostic.presets.push(preset.id) }, + notify(notification) { + providerAdapters.notify(notification) + }, } await mod.register(trackingRegistry) diagnostic.loaded = true diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 436461700..9c9a282d7 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -144,6 +144,11 @@ export type ServerMessageType = | 'git.status' // Branch and diff info, pushed on interval or session load // Project tasks events | 'tasks.update' // A task (or task config) changed; clients owning the project update their boards + // Notifications events + | 'notifications.new' // A new notification was created (broadcast to all clients) + | 'notifications.deleted' // A notification was deleted + | 'notifications.read' // All notifications were marked as read + | 'notifications.cleared' // Notification history was cleared // MCP server events | 'mcp.servers.changed' // MCP server configuration was modified by agent // Other @@ -529,6 +534,11 @@ export interface TasksUpdatePayload { changedTaskId?: string | undefined } +// Payloads for notifications +export interface NotificationsNewPayload { + notification: import('./types.js').Notification +} + // Shared background process types export type BackgroundProcessStatus = 'pending' | 'starting' | 'running' | 'stopping' | 'exited' diff --git a/src/shared/types.ts b/src/shared/types.ts index ce2594acb..dbbcc9316 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -519,6 +519,27 @@ export interface ProjectTaskCounts { done: number } +// ============================================================================ +// Notifications +// ============================================================================ + +export interface Notification { + id: string + title: string + body: string + /** Origin of the notification (plugin id, system, etc). */ + source: string + /** Whether the user has seen the notification center since it arrived. */ + read: boolean + createdAt: string +} + +export interface NotificationInput { + title: string + body: string + source?: string +} + export interface Criterion { id: string description: string // Self-contained contract, includes how to verify diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 3f3075db5..fe26bf88d 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -10,8 +10,12 @@ import { ColumnsIcon, XCloseIcon, ChevronDownIcon, + BellIcon, } from '../shared/icons' import { Link, useLocation } from 'wouter' +import { useNotificationHistoryStore } from '../../stores/notificationHistory' +import { NotificationCenter } from '../notifications/NotificationCenter' +import { NotificationToasts } from '../notifications/NotificationToasts' import { useSessionStore } from '../../stores/session' import { useProjectStore } from '../../stores/project' import { useConfigStore } from '../../stores/config' @@ -42,8 +46,11 @@ export function Header({ onMenuClick, onCriteriaToggle }: HeaderProps) { const [isFullscreen, setIsFullscreen] = useState(!!document.fullscreenElement) const [location, setLocation] = useLocation() const [tasksModalOpen, setTasksModalOpen] = useState(false) + const [notifOpen, setNotifOpen] = useState(false) const runningTaskCount = useTasksStore((state) => state.counts.running) const loadCounts = useTasksStore((state) => state.loadCounts) + const unreadNotifications = useNotificationHistoryStore((state) => state.unreadCount) + const loadNotifications = useNotificationHistoryStore((state) => state.load) const activeProjectId = useTasksStore((state) => state.activeProjectId) const lastAutoLaunch = useTasksStore((state) => state.lastAutoLaunch) const clearAutoLaunch = useTasksStore((state) => state.clearAutoLaunch) @@ -95,6 +102,10 @@ export function Header({ onMenuClick, onCriteriaToggle }: HeaderProps) { } }, [project?.id, activeProjectId, loadCounts]) + useEffect(() => { + void loadNotifications() + }, [loadNotifications]) + useEffect(() => { startAutoRefresh() return () => stopAutoRefresh() @@ -219,6 +230,20 @@ export function Header({ onMenuClick, onCriteriaToggle }: HeaderProps) {
+ + {!isSplit && ( + ) : undefined + } + > +
+ {notifications.length === 0 ? ( +
+ + No notifications +
+ ) : ( +
    + {notifications.map((n) => ( +
  • +
    +
    + {n.title} + {n.source && ( + + {n.source} + + )} +
    + {n.body && ( +

    {n.body}

    + )} + {formatDate(n.createdAt)} +
    + +
  • + ))} +
+ )} +
+ + + {deleteTarget && ( + setDeleteTarget(null)} + onConfirm={() => void handleDelete()} + title="Delete notification?" + message="This notification will be removed from your history." + confirmLabel="Delete" + confirmVariant="danger" + /> + )} + + {confirmClear && ( + setConfirmClear(false)} + onConfirm={() => { + void clearAll() + setConfirmClear(false) + }} + title="Clear all notifications?" + message="Your entire notification history will be deleted." + confirmLabel="Clear all" + confirmVariant="danger" + /> + )} + + ) +} diff --git a/web/src/components/notifications/NotificationToasts.tsx b/web/src/components/notifications/NotificationToasts.tsx new file mode 100644 index 000000000..0f4480610 --- /dev/null +++ b/web/src/components/notifications/NotificationToasts.tsx @@ -0,0 +1,39 @@ +import { useNotificationHistoryStore } from '../../stores/notificationHistory' +import { BellIcon, XCloseIcon } from '../shared/icons' + +export function NotificationToasts() { + const toasts = useNotificationHistoryStore((state) => state.toasts) + const dismissToast = useNotificationHistoryStore((state) => state.dismissToast) + + if (toasts.length === 0) return null + + return ( +
+ {toasts.map((toast) => ( +
+
+ +
+
+
{toast.title}
+ {toast.body && ( +
{toast.body}
+ )} +
+ +
+ ))} +
+ ) +} diff --git a/web/src/components/settings/NotificationSettings.tsx b/web/src/components/settings/NotificationSettings.tsx index 94b6a46ea..83f10fd1f 100644 --- a/web/src/components/settings/NotificationSettings.tsx +++ b/web/src/components/settings/NotificationSettings.tsx @@ -96,6 +96,12 @@ export function NotificationSettings() {

Master Controls

+ update({ ...settings, popupEnabled: v })} + /> + + + + ) +} diff --git a/web/src/components/shared/icons/index.ts b/web/src/components/shared/icons/index.ts index 4d5b2c734..b7db89d32 100644 --- a/web/src/components/shared/icons/index.ts +++ b/web/src/components/shared/icons/index.ts @@ -1,4 +1,5 @@ export { ArrowLeftIcon } from './ArrowLeftIcon' +export { BellIcon } from './BellIcon' export { ArrowRightIcon } from './ArrowRightIcon' export { AttachIcon } from './AttachIcon' export { ArchiveIcon } from './ArchiveIcon' diff --git a/web/src/lib/sound.test.ts b/web/src/lib/sound.test.ts index a1b3f61e2..67b169a41 100644 --- a/web/src/lib/sound.test.ts +++ b/web/src/lib/sound.test.ts @@ -43,6 +43,7 @@ describe('sound integration', () => { settings: { soundEnabled: true, browserNotificationEnabled: false, + popupEnabled: true, events: { complete: { soundEnabled: true, browserNotification: false, customSoundUrl: null }, waiting_for_user: { soundEnabled: true, browserNotification: false, customSoundUrl: null }, diff --git a/web/src/lib/sound.ts b/web/src/lib/sound.ts index 45aca9b24..46ab0e8bc 100644 --- a/web/src/lib/sound.ts +++ b/web/src/lib/sound.ts @@ -60,7 +60,7 @@ function sendBrowserNotification(event: SoundEvent) { }) } -function playEvent(event: SoundEvent, agent?: AgentType) { +export function playEvent(event: SoundEvent, agent?: AgentType) { const { settings } = useNotificationSettingsStore.getState() // Master sound toggle diff --git a/web/src/stores/notificationHistory.test.ts b/web/src/stores/notificationHistory.test.ts new file mode 100644 index 000000000..a36a2d78b --- /dev/null +++ b/web/src/stores/notificationHistory.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi, beforeEach } from 'vitest' + +const { authFetchMock } = vi.hoisted(() => ({ + authFetchMock: vi.fn(async () => ({ ok: true, json: async () => ({}) })), +})) + +vi.mock('../lib/api', () => ({ + authFetch: authFetchMock, +})) + +import { useNotificationHistoryStore } from './notificationHistory' +import type { Notification } from '@shared/types.js' + +function makeNotification(overrides: Partial = {}): Notification { + return { + id: 'n1', + title: 'Hello', + body: 'World', + source: 'plugin', + read: false, + createdAt: '2026-08-27T00:00:00.000Z', + ...overrides, + } +} + +describe('notifications store', () => { + beforeEach(() => { + authFetchMock.mockReset() + useNotificationHistoryStore.setState({ notifications: [], toasts: [], unreadCount: 0, loaded: false }) + }) + + it('adds notification and increments unread count', () => { + useNotificationHistoryStore.getState().addNotification(makeNotification()) + const state = useNotificationHistoryStore.getState() + expect(state.notifications.length).toBe(1) + expect(state.unreadCount).toBe(1) + expect(state.toasts.length).toBe(1) + }) + + it('pushes a toast when adding a notification', () => { + useNotificationHistoryStore.getState().addNotification(makeNotification()) + expect(useNotificationHistoryStore.getState().toasts[0]?.title).toBe('Hello') + }) + + it('deletes a notification and recomputes unread count', async () => { + useNotificationHistoryStore.setState({ + notifications: [makeNotification({ id: 'n1', read: false }), makeNotification({ id: 'n2', read: true })], + unreadCount: 1, + toasts: [{ id: 'n1', title: 'Hello', body: 'World', source: 'plugin', createdAt: '' }], + }) + authFetchMock.mockResolvedValue({ ok: true, json: async () => ({}) }) + await useNotificationHistoryStore.getState().deleteNotification('n1') + const state = useNotificationHistoryStore.getState() + expect(state.notifications).toEqual([expect.objectContaining({ id: 'n2' })]) + expect(state.unreadCount).toBe(0) + expect(state.toasts).toEqual([]) + }) + + it('handles cleared notifications', () => { + useNotificationHistoryStore.setState({ notifications: [makeNotification()], unreadCount: 1, toasts: [] }) + useNotificationHistoryStore.getState().handleCleared() + const state = useNotificationHistoryStore.getState() + expect(state.notifications).toEqual([]) + expect(state.unreadCount).toBe(0) + }) + + it('handles read-all broadcast', () => { + useNotificationHistoryStore.setState({ + notifications: [makeNotification({ id: 'n1', read: false }), makeNotification({ id: 'n2', read: false })], + unreadCount: 2, + }) + useNotificationHistoryStore.getState().handleRead() + const state = useNotificationHistoryStore.getState() + expect(state.unreadCount).toBe(0) + expect(state.notifications.every((n) => n.read)).toBe(true) + }) + + it('marks all read and resets unread count', async () => { + useNotificationHistoryStore.setState({ notifications: [makeNotification()], unreadCount: 1 }) + authFetchMock.mockResolvedValue({ ok: true, json: async () => ({}) }) + await useNotificationHistoryStore.getState().markAllRead() + const state = useNotificationHistoryStore.getState() + expect(state.unreadCount).toBe(0) + expect(state.notifications[0]!.read).toBe(true) + }) + + it('dismisses a toast', () => { + useNotificationHistoryStore.setState({ + toasts: [{ id: 'n1', title: 'Hello', body: 'World', source: 'plugin', createdAt: '' }], + }) + useNotificationHistoryStore.getState().dismissToast('n1') + expect(useNotificationHistoryStore.getState().toasts).toEqual([]) + }) +}) diff --git a/web/src/stores/notificationHistory.ts b/web/src/stores/notificationHistory.ts new file mode 100644 index 000000000..cf9a4e896 --- /dev/null +++ b/web/src/stores/notificationHistory.ts @@ -0,0 +1,133 @@ +import { create } from 'zustand' +import { authFetch } from '../lib/api' +import { useNotificationSettingsStore } from './notifications' +import type { Notification } from '@shared/types.js' + +interface Toast { + id: string + title: string + body: string + source: string + createdAt: string +} + +interface NotificationsState { + notifications: Notification[] + toasts: Toast[] + unreadCount: number + loaded: boolean + + load: () => Promise + addNotification: (notification: Notification) => void + handleDeleted: (id: string) => void + handleRead: () => void + handleCleared: () => void + deleteNotification: (id: string) => Promise + clearAll: () => Promise + markAllRead: () => Promise + dismissToast: (id: string) => void + pushToast: (toast: Toast) => void +} + +export const useNotificationHistoryStore = create((set, get) => ({ + notifications: [], + toasts: [], + unreadCount: 0, + loaded: false, + + load: async () => { + try { + const res = await authFetch('/api/notifications') + if (!res.ok) return + const data = await res.json() + const notifications = (data.notifications ?? []) as Notification[] + set({ + notifications, + unreadCount: notifications.filter((n) => !n.read).length, + loaded: true, + }) + } catch { + set({ loaded: true }) + } + }, + + addNotification: (notification) => { + set((state) => ({ + notifications: [notification, ...state.notifications], + unreadCount: state.unreadCount + 1, + })) + // Show the toast only if the "Show popup" setting is enabled. + if (useNotificationSettingsStore.getState().settings.popupEnabled) { + get().pushToast({ + id: notification.id, + title: notification.title, + body: notification.body, + source: notification.source, + createdAt: notification.createdAt, + }) + } + }, + + handleDeleted: (id) => { + set((state) => removeNotification(state, id)) + }, + + handleRead: () => { + set((state) => ({ + notifications: state.notifications.map((n) => ({ ...n, read: true })), + unreadCount: 0, + })) + }, + + handleCleared: () => { + set({ notifications: [], unreadCount: 0, toasts: [] }) + }, + + deleteNotification: async (id) => { + try { + await authFetch(`/api/notifications/${id}`, { method: 'DELETE' }) + set((state) => removeNotification(state, id)) + } catch { + // Keep stale row; a later refetch reconciles. + } + }, + + clearAll: async () => { + try { + await authFetch('/api/notifications', { method: 'DELETE' }) + set({ notifications: [], unreadCount: 0, toasts: [] }) + } catch { + // ignore + } + }, + + markAllRead: async () => { + try { + await authFetch('/api/notifications/read-all', { method: 'POST' }) + get().handleRead() + } catch { + // ignore + } + }, + + dismissToast: (id) => { + set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })) + }, + + pushToast: (toast) => { + set((state) => ({ toasts: [...state.toasts, toast] })) + // Auto-dismiss after 5 seconds. + setTimeout(() => { + get().dismissToast(toast.id) + }, 5000) + }, +})) + +function removeNotification(state: NotificationsState, id: string): Partial { + const notifications = state.notifications.filter((n) => n.id !== id) + return { + notifications, + unreadCount: notifications.filter((n) => !n.read).length, + toasts: state.toasts.filter((t) => t.id !== id), + } +} diff --git a/web/src/stores/notifications.ts b/web/src/stores/notifications.ts index 9c88ced33..17e5dec83 100644 --- a/web/src/stores/notifications.ts +++ b/web/src/stores/notifications.ts @@ -21,6 +21,8 @@ export interface NotificationSettings { // Master toggles soundEnabled: boolean browserNotificationEnabled: boolean + /** Show the in-app popup toast (top-right) when a notification arrives. */ + popupEnabled: boolean // Per-event config (global defaults) events: Record @@ -76,6 +78,7 @@ const DEFAULT_NEW_MESSAGE_CONFIG: EventNotificationConfig = { export const DEFAULT_SETTINGS: NotificationSettings = { soundEnabled: true, browserNotificationEnabled: false, + popupEnabled: true, events: { complete: { ...DEFAULT_EVENT_CONFIG }, waiting_for_user: { ...DEFAULT_EVENT_CONFIG }, @@ -209,6 +212,7 @@ function mergeWithDefaults(partial: Partial): Notification return { soundEnabled: partial.soundEnabled ?? DEFAULT_SETTINGS.soundEnabled, browserNotificationEnabled: partial.browserNotificationEnabled ?? DEFAULT_SETTINGS.browserNotificationEnabled, + popupEnabled: partial.popupEnabled ?? DEFAULT_SETTINGS.popupEnabled, events: { complete: { ...DEFAULT_EVENT_CONFIG, ...partial.events?.complete }, waiting_for_user: { ...DEFAULT_EVENT_CONFIG, ...partial.events?.waiting_for_user }, diff --git a/web/src/stores/session/messageHandler.ts b/web/src/stores/session/messageHandler.ts index 2b55d86c5..d7fb99ad6 100644 --- a/web/src/stores/session/messageHandler.ts +++ b/web/src/stores/session/messageHandler.ts @@ -33,7 +33,8 @@ import type { import { useDevServerStore } from '../dev-server' import { useBackgroundProcessesStore } from '../background-processes' import { useTasksStore } from '../tasks' -import { playNewMessage } from '../../lib/sound' +import { useNotificationHistoryStore } from '../notificationHistory' +import { playNewMessage, playEvent } from '../../lib/sound' import type { AgentType } from '../notifications' import type { SessionState, PendingQuestion, SessionPane } from './types' import { handleGlobalSoundEffects, resolveAgentType } from './sounds' @@ -1081,6 +1082,31 @@ export function handleServerMessage( break } + case 'notifications.new': { + const payload = message.payload as import('@shared/protocol.js').NotificationsNewPayload + const notification = payload.notification + useNotificationHistoryStore.getState().addNotification(notification) + // Sound (only plays if enabled in notification settings). + playEvent('complete') + break + } + + case 'notifications.deleted': { + const payload = message.payload as { id: string } + useNotificationHistoryStore.getState().handleDeleted(payload.id) + break + } + + case 'notifications.read': { + useNotificationHistoryStore.getState().handleRead() + break + } + + case 'notifications.cleared': { + useNotificationHistoryStore.getState().handleCleared() + break + } + case 'error': { const payload = message.payload as { code: string; message: string } console.error('Server error:', payload)