diff --git a/apps/dashboard/src/app/app-shell.tsx b/apps/dashboard/src/app/app-shell.tsx
index 04aab3b..0399ef6 100644
--- a/apps/dashboard/src/app/app-shell.tsx
+++ b/apps/dashboard/src/app/app-shell.tsx
@@ -2,12 +2,13 @@ import { useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { navigationItems } from './navigation.js';
import { ShellStatusPanel } from './shell-status-panel.js';
+import { NotificationBell, NotificationProvider, NotificationViewport } from './notifications.js';
export function AppShell() {
const [isNavigationOpen, setIsNavigationOpen] = useState(false);
return (
-
+
Skip to content
+
+
);
}
diff --git a/apps/dashboard/src/app/notifications.css b/apps/dashboard/src/app/notifications.css
new file mode 100644
index 0000000..203362d
--- /dev/null
+++ b/apps/dashboard/src/app/notifications.css
@@ -0,0 +1,57 @@
+.notification-bell {
+ position: relative;
+ display: grid;
+ flex: 0 0 auto;
+ place-items: center;
+ width: 2.5rem;
+ height: 2.5rem;
+ padding: 0;
+ color: #dfe9e3;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: .7rem;
+}
+.notification-bell:hover { color: var(--accent); border-color: #3d5a4c; }
+.notification-bell:focus-visible, .notification-close:focus-visible, .notification-clear:focus-visible, .notification-entry:focus-visible, .notification-toast a:focus-visible, .notification-toast button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.notification-bell svg, .notification-empty svg { width: 1.2rem; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; }
+.notification-badge { position: absolute; top: -.35rem; right: -.35rem; display: grid; place-items: center; min-width: 1.15rem; height: 1.15rem; padding: 0 .25rem; color: #07130c; background: var(--accent); border: 2px solid #0c1511; border-radius: 999px; font-size: .62rem; font-weight: 900; line-height: 1; }
+.brand { justify-content: space-between; }
+.brand > div { margin-right: auto; }
+.notification-backdrop { position: fixed; inset: 0; z-index: 69; width: 100%; padding: 0; background: rgba(0, 0, 0, .52); border: 0; }
+.notification-centre { position: fixed; inset: 0 0 0 auto; z-index: 70; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: min(27rem, 100%); visibility: hidden; background: #0c1511; border-left: 1px solid var(--border); box-shadow: -1.5rem 0 4rem rgba(0, 0, 0, .38); transform: translateX(100%); transition: transform 220ms ease, visibility 220ms; }
+.notification-centre.is-open { visibility: visible; transform: translateX(0); }
+.notification-centre > header { display: flex; align-items: center; justify-content: space-between; min-height: 5.5rem; padding: 1.2rem 1.25rem; border-bottom: 1px solid var(--border); }
+.notification-centre h2 { font-size: 1.35rem; letter-spacing: -.025em; }
+.notification-centre .eyebrow { margin-bottom: .25rem; }
+.notification-close { display: grid; place-items: center; width: 2.35rem; height: 2.35rem; padding: 0; color: var(--muted); background: transparent; border: 1px solid var(--border); border-radius: .6rem; font-size: 1.5rem; line-height: 1; }
+.notification-list { min-height: 0; padding: 0; margin: 0; overflow-y: auto; list-style: none; }
+.notification-list li { border-bottom: 1px solid var(--border); }
+.notification-entry { display: grid; grid-template-columns: .65rem minmax(0, 1fr); gap: .8rem; padding: 1rem 1.25rem; color: inherit; text-decoration: none; }
+a.notification-entry:hover { background: var(--surface); }
+.notification-signal { width: .55rem; height: .55rem; margin-top: .3rem; background: #77a6d8; border-radius: 50%; box-shadow: 0 0 0 .3rem rgba(119, 166, 216, .1); }
+.notification-signal.success { background: var(--accent); box-shadow: 0 0 0 .3rem rgba(117, 214, 157, .1); }
+.notification-signal.warning { background: var(--warning); box-shadow: 0 0 0 .3rem rgba(243, 199, 107, .1); }
+.notification-signal.error { background: var(--danger); box-shadow: 0 0 0 .3rem rgba(255, 138, 132, .1); }
+.notification-copy { display: grid; gap: .28rem; min-width: 0; }
+.notification-copy strong { color: #edf5f0; font-size: .9rem; line-height: 1.3; }
+.notification-copy > span { overflow: hidden; color: var(--muted); font-size: .82rem; line-height: 1.45; text-overflow: ellipsis; }
+.notification-copy time { color: #71857b; font-size: .7rem; }
+.notification-centre > footer { display: flex; justify-content: flex-end; padding: .8rem 1.25rem; border-top: 1px solid var(--border); }
+.notification-clear { padding: .4rem 0; color: var(--muted); background: transparent; border: 0; font-size: .78rem; font-weight: 700; }
+.notification-clear:hover { color: #edf5f0; }
+.notification-empty { display: grid; place-items: center; align-content: center; padding: 2rem; text-align: center; }
+.notification-empty > span { display: grid; place-items: center; width: 3.25rem; height: 3.25rem; margin-bottom: .9rem; color: var(--accent); background: var(--accent-dark); border: 1px solid #28593d; border-radius: 1rem; }
+.notification-empty strong { margin-bottom: .35rem; font-size: 1rem; }
+.notification-empty p { max-width: 18rem; margin: 0; color: var(--muted); font-size: .82rem; line-height: 1.5; }
+.toast-stack { position: fixed; right: 1rem; bottom: 1rem; z-index: 75; display: grid; gap: .65rem; width: min(24rem, calc(100% - 2rem)); pointer-events: none; }
+.notification-toast { position: relative; overflow: hidden; background: #15221c; border: 1px solid #355044; border-radius: .85rem; box-shadow: 0 1rem 3rem rgba(0, 0, 0, .4); pointer-events: auto; animation: notification-arrive 220ms ease-out both; }
+.notification-toast.warning { background: #282112; border-color: #5b4922; }
+.notification-toast.error { background: #2d1715; border-color: #69332e; }
+.notification-toast > a, .notification-toast > div { display: grid; grid-template-columns: .65rem minmax(0, 1fr); gap: .75rem; padding: .9rem 2.75rem .9rem 1rem; color: inherit; text-decoration: none; }
+.notification-toast > a:hover { background: rgba(255, 255, 255, .035); }
+.notification-toast .notification-copy time { display: none; }
+.notification-toast > button { position: absolute; top: .5rem; right: .5rem; width: 1.8rem; height: 1.8rem; padding: 0; color: var(--muted); background: transparent; border: 0; border-radius: .35rem; font-size: 1.2rem; }
+@keyframes notification-arrive { from { opacity: 0; transform: translateY(.75rem) scale(.98); } }
+@media (prefers-reduced-motion: reduce) { .notification-centre, .notification-toast { transition: none; animation: none; } }
+@media (max-width: 47.999rem) { .brand .notification-bell { display: none; } .toast-stack { bottom: .75rem; } }
+@media (min-width: 48rem) { .mobile-header .notification-bell { display: none; } }
diff --git a/apps/dashboard/src/app/notifications.tsx b/apps/dashboard/src/app/notifications.tsx
new file mode 100644
index 0000000..2ad1b09
--- /dev/null
+++ b/apps/dashboard/src/app/notifications.tsx
@@ -0,0 +1,346 @@
+import type { DashboardEvent } from '../../../../packages/api-contracts/src/index.js';
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type PropsWithChildren,
+} from 'react';
+import { Link } from 'react-router-dom';
+import { z } from 'zod';
+import { useDashboardRealtime } from './realtime-provider.js';
+import './notifications.css';
+
+export type NotificationTone = 'info' | 'success' | 'warning' | 'error';
+
+export interface NotificationItem {
+ id: string;
+ title: string;
+ message: string;
+ occurredAt: string;
+ tone: NotificationTone;
+ href?: string;
+ isRead: boolean;
+}
+
+export type NotificationDraft = Omit;
+
+interface NotificationContextValue {
+ isOpen: boolean;
+ notifications: readonly NotificationItem[];
+ toasts: readonly NotificationItem[];
+ unreadCount: number;
+ clearAll(): void;
+ dismissToast(id: string): void;
+ setOpen(isOpen: boolean): void;
+}
+
+interface RealtimeSubscription {
+ subscribe(listener: (event: DashboardEvent) => void): () => void;
+}
+
+const STORAGE_KEY = 'reddit-bot.notifications.v1';
+const MAX_NOTIFICATIONS = 50;
+const NotificationContext = createContext(null);
+const StoredNotificationsSchema = z.array(z.strictObject({
+ id: z.string(),
+ title: z.string(),
+ message: z.string(),
+ occurredAt: z.string(),
+ tone: z.enum(['info', 'success', 'warning', 'error']),
+ href: z.string().optional(),
+ isRead: z.boolean(),
+}));
+
+function notificationStorage(): Storage | undefined {
+ try {
+ return globalThis.localStorage;
+ } catch {
+ return undefined;
+ }
+}
+
+function titleCase(value: string): string {
+ return value.charAt(0).toUpperCase() + value.slice(1).replaceAll('-', ' ');
+}
+
+export function notificationForEvent(event: DashboardEvent): NotificationDraft | null {
+ switch (event.type) {
+ case 'reply.received':
+ return {
+ id: event.id,
+ occurredAt: event.occurredAt,
+ title: `New reply from u/${event.payload.sender}`,
+ message: event.payload.preview?.trim() || 'A new message arrived in this conversation.',
+ tone: 'success',
+ href: `/conversations/${encodeURIComponent(event.payload.conversationId)}`,
+ };
+ case 'message.sent':
+ return {
+ id: event.id,
+ occurredAt: event.occurredAt,
+ title: `Message sent to u/${event.payload.recipient}`,
+ message: 'The message was delivered successfully.',
+ tone: 'success',
+ href: event.payload.conversationId
+ ? `/conversations/${encodeURIComponent(event.payload.conversationId)}`
+ : '/conversations',
+ };
+ case 'message.failed':
+ return {
+ id: event.id,
+ occurredAt: event.occurredAt,
+ title: `Message to u/${event.payload.recipient} failed`,
+ message: event.payload.reason,
+ tone: 'error',
+ href: event.payload.conversationId
+ ? `/conversations/${encodeURIComponent(event.payload.conversationId)}`
+ : '/operations',
+ };
+ case 'bot.status.changed':
+ return {
+ id: event.id,
+ occurredAt: event.occurredAt,
+ title: `Bot ${event.payload.status}`,
+ message: event.payload.reason ?? `The bot is now ${event.payload.status}.`,
+ tone: event.payload.status === 'running' ? 'success' : event.payload.status === 'stopped' ? 'warning' : 'info',
+ href: '/operations',
+ };
+ case 'sync.completed':
+ return {
+ id: event.id,
+ occurredAt: event.occurredAt,
+ title: `${titleCase(event.payload.kind)} sync complete`,
+ message: `${event.payload.processed.toLocaleString()} items processed.`,
+ tone: 'success',
+ href: '/operations',
+ };
+ case 'sync.failed':
+ return {
+ id: event.id,
+ occurredAt: event.occurredAt,
+ title: `${titleCase(event.payload.kind)} sync failed`,
+ message: event.payload.reason,
+ tone: 'error',
+ href: '/operations',
+ };
+ case 'rate-limit.changed':
+ return {
+ id: event.id,
+ occurredAt: event.occurredAt,
+ title: event.payload.limited ? 'Rate limit reached' : 'Rate limit cleared',
+ message: event.payload.reason ?? (event.payload.limited
+ ? 'Sending will resume when the limit resets.'
+ : 'The bot can send messages again.'),
+ tone: event.payload.limited ? 'warning' : 'success',
+ href: '/operations',
+ };
+ case 'reddit.session.changed':
+ if (!['authenticated', 'expired', 'logged-out', 'error'].includes(event.payload.status)) return null;
+ return {
+ id: event.id,
+ occurredAt: event.occurredAt,
+ title: event.payload.status === 'authenticated' ? 'Reddit account connected' : 'Reddit account needs attention',
+ message: event.payload.reason ?? (event.payload.username
+ ? `Signed in as u/${event.payload.username}.`
+ : `Session status changed to ${event.payload.status}.`),
+ tone: event.payload.status === 'authenticated' ? 'success' : 'error',
+ href: '/settings',
+ };
+ case 'conversation.updated':
+ case 'sync.started':
+ case 'sync.progress':
+ case 'log.created':
+ return null;
+ }
+}
+
+function loadNotifications(): NotificationItem[] {
+ const storage = notificationStorage();
+ if (!storage) return [];
+ try {
+ const value: unknown = JSON.parse(storage.getItem(STORAGE_KEY) ?? '[]');
+ const parsed = StoredNotificationsSchema.safeParse(value);
+ return parsed.success ? parsed.data.slice(0, MAX_NOTIFICATIONS) : [];
+ } catch {
+ return [];
+ }
+}
+
+export interface NotificationProviderProps extends PropsWithChildren {
+ realtime?: RealtimeSubscription;
+}
+
+export function NotificationProvider({ children, realtime: realtimeOverride }: NotificationProviderProps) {
+ const dashboardRealtime = useDashboardRealtime();
+ const realtime = realtimeOverride ?? dashboardRealtime;
+ const [isOpen, setIsOpenState] = useState(false);
+ const [notifications, setNotifications] = useState(loadNotifications);
+ const [toasts, setToasts] = useState([]);
+
+ const dismissToast = useCallback((id: string) => {
+ setToasts((current) => current.filter((toast) => toast.id !== id));
+ }, []);
+
+ const setOpen = useCallback((nextOpen: boolean) => {
+ setIsOpenState(nextOpen);
+ if (nextOpen) setNotifications((current) => current.map((item) => ({ ...item, isRead: true })));
+ }, []);
+
+ useEffect(() => {
+ try {
+ notificationStorage()?.setItem(STORAGE_KEY, JSON.stringify(notifications));
+ } catch {
+ // Storage can be unavailable in private or restricted browser contexts.
+ }
+ }, [notifications]);
+
+ useEffect(() => {
+ if (isOpen && notifications.some((item) => !item.isRead)) {
+ setNotifications((current) => current.map((item) => ({ ...item, isRead: true })));
+ }
+ }, [isOpen, notifications]);
+
+ useEffect(() => realtime.subscribe((event) => {
+ const draft = notificationForEvent(event);
+ if (!draft) return;
+ const item = { ...draft, isRead: false };
+ setNotifications((current) => current.some((entry) => entry.id === item.id)
+ ? current
+ : [item, ...current].slice(0, MAX_NOTIFICATIONS));
+ setToasts((current) => current.some((entry) => entry.id === item.id)
+ ? current
+ : [...current, item].slice(-3));
+ }), [realtime]);
+
+ useEffect(() => {
+ if (toasts.length === 0) return undefined;
+ const timers = toasts.map((toast) => globalThis.setTimeout(
+ () => dismissToast(toast.id),
+ toast.tone === 'error' ? 8_000 : 5_000,
+ ));
+ return () => timers.forEach((timer) => globalThis.clearTimeout(timer));
+ }, [dismissToast, toasts]);
+
+ useEffect(() => {
+ function closeOnEscape(event: KeyboardEvent): void {
+ if (event.key === 'Escape') setOpen(false);
+ }
+ globalThis.addEventListener('keydown', closeOnEscape);
+ return () => globalThis.removeEventListener('keydown', closeOnEscape);
+ }, [setOpen]);
+
+ const value = useMemo(() => ({
+ isOpen,
+ notifications,
+ toasts,
+ unreadCount: notifications.filter((item) => !item.isRead).length,
+ clearAll: () => setNotifications([]),
+ dismissToast,
+ setOpen,
+ }), [dismissToast, isOpen, notifications, setOpen, toasts]);
+
+ return {children};
+}
+
+function useNotifications(): NotificationContextValue {
+ const context = useContext(NotificationContext);
+ if (!context) throw new Error('Notifications must be used within NotificationProvider');
+ return context;
+}
+
+function BellIcon() {
+ return (
+
+ );
+}
+
+export function NotificationBell({ className = '' }: { className?: string }) {
+ const { isOpen, setOpen, unreadCount } = useNotifications();
+ return (
+
+ );
+}
+
+function formattedTime(value: string): string {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return '';
+ return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date);
+}
+
+function NotificationContent({ item }: { item: NotificationItem }) {
+ return (
+ <>
+
+
+ {item.title}
+ {item.message}
+
+
+ >
+ );
+}
+
+export function NotificationViewport() {
+ const { clearAll, dismissToast, isOpen, notifications, setOpen, toasts } = useNotifications();
+ return (
+ <>
+ {isOpen ?