feat: @agor/stores package + Electrobun hardening (WIP)
- packages/stores/: theme, notifications, health stores extracted - Electrobun hardening: durable event sequencing, file conflict detection, push-based updates, backpressure guards (partial, agents still running)
This commit is contained in:
parent
5836fb7d80
commit
5e1fd62ed9
13 changed files with 855 additions and 665 deletions
170
packages/stores/notifications.svelte.ts
Normal file
170
packages/stores/notifications.svelte.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
// 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<Toast[]>([]);
|
||||
let notificationHistory = $state<HistoryNotification[]>([]);
|
||||
|
||||
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<ToastType, number[]>();
|
||||
|
||||
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 = [];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue