diff --git a/apps/dashboard/src/app/notifications.css b/apps/dashboard/src/app/notifications.css index 203362d..1a33a59 100644 --- a/apps/dashboard/src/app/notifications.css +++ b/apps/dashboard/src/app/notifications.css @@ -43,7 +43,7 @@ a.notification-entry:hover { background: var(--surface); } .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; } +.toast-stack { position: fixed; right: 1rem; bottom: 1rem; z-index: 75; display: grid; gap: .65rem; width: min(24rem, calc(100% - 2rem)); max-height: calc(100vh - 2rem); overflow-y: auto; 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; } diff --git a/apps/dashboard/src/app/notifications.tsx b/apps/dashboard/src/app/notifications.tsx index 3d5860d..365e516 100644 --- a/apps/dashboard/src/app/notifications.tsx +++ b/apps/dashboard/src/app/notifications.tsx @@ -168,6 +168,34 @@ export function notificationForEvent(event: DashboardEvent): NotificationDraft | } } +/** + * Toasts interrupt the operator, so reserve them for inbound activity and + * states that need attention. Routine lifecycle and successful sync events + * remain available in the notification centre without producing a pop-up. + */ +export function shouldToastForEvent(event: DashboardEvent): boolean { + switch (event.type) { + case 'reply.received': + case 'media.received': + case 'message.failed': + case 'sync.failed': + return true; + case 'bot.status.changed': + return event.payload.status === 'stopped'; + case 'rate-limit.changed': + return event.payload.limited; + case 'reddit.session.changed': + return ['expired', 'logged-out', 'error'].includes(event.payload.status); + case 'message.sent': + case 'conversation.updated': + case 'sync.started': + case 'sync.progress': + case 'sync.completed': + case 'log.created': + return false; + } +} + function loadNotifications(): NotificationItem[] { const storage = notificationStorage(); if (!storage) return []; @@ -221,20 +249,13 @@ export function NotificationProvider({ children, realtime: realtimeOverride }: N 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)); + if (shouldToastForEvent(event)) { + setToasts((current) => current.some((entry) => entry.id === item.id) + ? current + : [...current, item]); + } }), [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); diff --git a/docs/frontend.md b/docs/frontend.md index 13e6460..62f1f82 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -63,7 +63,7 @@ Rules: - Duplicate/replayed events must be harmless. - A disconnected stream marks data stale but does not blank useful cached content. - Invalid event payloads are contained and repaired with a snapshot refresh. -- User-triggered outcomes may create toasts; durable/history-worthy events also appear in the notification center/activity views. +- The notification centre retains useful lifecycle history, including successful syncs. Sticky toasts are reserved for inbound replies/media and attention-worthy failures or runtime/account states; they remain visible until the operator dismisses them. - Raw technical logs belong in Operations, not as general user notifications. ## Authentication and privacy diff --git a/tests/dashboard/notifications.test.tsx b/tests/dashboard/notifications.test.tsx index 2f4ddd7..5f62366 100644 --- a/tests/dashboard/notifications.test.tsx +++ b/tests/dashboard/notifications.test.tsx @@ -11,6 +11,7 @@ import { NotificationProvider, NotificationViewport, notificationForEvent, + shouldToastForEvent, } from '../../apps/dashboard/src/app/notifications.js'; class FakeRealtime { @@ -28,7 +29,9 @@ class FakeRealtime { afterEach(() => { cleanup(); + globalThis.localStorage.clear(); vi.restoreAllMocks(); + vi.useRealTimers(); }); describe('dashboard notifications', () => { @@ -90,4 +93,57 @@ describe('dashboard notifications', () => { await user.click(within(centre).getByRole('button', { name: 'Clear all' })); expect(within(centre).getByText('All quiet')).toBeInTheDocument(); }); + + it('keeps routine sync completions in the notification centre without showing a toast', async () => { + const realtime = new FakeRealtime(); + const user = userEvent.setup(); + render( + + + + + + , + ); + + const syncEvent: DashboardEvent = { + id: 'sync-1', + occurredAt: '2026-08-09T09:30:00.000Z', + type: 'sync.completed', + payload: { syncId: 'conversation-sync-1', kind: 'conversations', processed: 12 }, + }; + expect(shouldToastForEvent(syncEvent)).toBe(false); + act(() => realtime.emit(syncEvent)); + + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Notifications, 1 unread' })); + expect(within(screen.getByLabelText('Notifications', { selector: 'aside' })).getByText('Conversations sync complete')) + .toBeInTheDocument(); + }); + + it('keeps important event toasts visible until they are dismissed', async () => { + vi.useFakeTimers(); + const realtime = new FakeRealtime(); + render( + + + + + , + ); + + act(() => realtime.emit({ + id: 'media-1', + occurredAt: '2026-08-09T09:30:00.000Z', + type: 'media.received', + payload: { conversationId: 'conversation-1', sender: 'alice', count: 1 }, + })); + expect(screen.getByRole('status')).toHaveTextContent('Image received from u/alice'); + + act(() => vi.advanceTimersByTime(60_000)); + expect(screen.getByRole('status')).toBeInTheDocument(); + + act(() => screen.getByRole('button', { name: 'Dismiss Image received from u/alice' }).click()); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); });