// Notification store — ephemeral toasts + persistent notification history import { getBackend } from '../../src/lib/backend/backend'; // --- Toast types (existing) --- export type ToastType = 'info' | 'success' | 'warning' | 'error'; export interface Toast { id: string; type: ToastType; message: string; timestamp: number; } // --- Notification history types (new) --- export type NotificationType = | 'agent_complete' | 'agent_error' | 'task_review' | 'wake_event' | 'conflict' | 'system'; export interface HistoryNotification { id: string; title: string; body: string; type: NotificationType; timestamp: number; read: boolean; projectId?: string; } // --- State --- let toasts = $state([]); let notificationHistory = $state([]); const MAX_TOASTS = 5; const TOAST_DURATION_MS = 4000; const MAX_HISTORY = 100; // --- Rate limiting (prevents toast flood from hot paths) --- const RATE_LIMIT_WINDOW_MS = 30_000; const RATE_LIMIT_MAX_PER_TYPE = 3; const recentToasts = new Map(); function isRateLimited(type: ToastType): boolean { const now = Date.now(); const timestamps = recentToasts.get(type) ?? []; const recent = timestamps.filter(t => now - t < RATE_LIMIT_WINDOW_MS); recentToasts.set(type, recent); if (recent.length >= RATE_LIMIT_MAX_PER_TYPE) return true; recent.push(now); return false; } // --- Toast API (preserved from original) --- export function getNotifications(): Toast[] { return toasts; } export function notify(type: ToastType, message: string): string { if (isRateLimited(type)) return ''; const id = crypto.randomUUID(); toasts.push({ id, type, message, timestamp: Date.now() }); // Cap visible toasts if (toasts.length > MAX_TOASTS) { toasts = toasts.slice(-MAX_TOASTS); } // Auto-dismiss setTimeout(() => dismissNotification(id), TOAST_DURATION_MS); return id; } export function dismissNotification(id: string): void { toasts = toasts.filter(n => n.id !== id); } // --- Notification History API (new) --- /** Map NotificationType to a toast type for the ephemeral toast */ function notificationTypeToToast(type: NotificationType): ToastType { switch (type) { case 'agent_complete': return 'success'; case 'agent_error': return 'error'; case 'task_review': return 'info'; case 'wake_event': return 'info'; case 'conflict': return 'warning'; case 'system': return 'info'; } } /** Map NotificationType to OS notification urgency */ function notificationUrgency(type: NotificationType): 'low' | 'normal' | 'critical' { switch (type) { case 'agent_error': return 'critical'; case 'conflict': return 'normal'; case 'system': return 'normal'; default: return 'low'; } } /** * Add a notification to history, show a toast, and send an OS desktop notification. */ export function addNotification( title: string, body: string, type: NotificationType, projectId?: string, ): string { const id = crypto.randomUUID(); // Add to history notificationHistory.push({ id, title, body, type, timestamp: Date.now(), read: false, projectId, }); // Cap history if (notificationHistory.length > MAX_HISTORY) { notificationHistory = notificationHistory.slice(-MAX_HISTORY); } // Show ephemeral toast const toastType = notificationTypeToToast(type); notify(toastType, `${title}: ${body}`); // Send OS desktop notification (fire-and-forget) try { getBackend().sendDesktopNotification(title, body, notificationUrgency(type)); } catch { /* backend not ready */ } return id; } export function getNotificationHistory(): HistoryNotification[] { return notificationHistory; } export function getUnreadCount(): number { return notificationHistory.filter(n => !n.read).length; } export function markRead(id: string): void { const entry = notificationHistory.find(n => n.id === id); if (entry) entry.read = true; } export function markAllRead(): void { for (const entry of notificationHistory) { entry.read = true; } } export function clearHistory(): void { notificationHistory = []; }