You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The bell icon in the sidebar bottom rail has been a placeholder since the initial app-shell PR (#819). Clicking it does nothing: there is no onClick handler, no notifications store, no drawer component, no unread badge. The design spec has documented the intended behavior since page-structure was defined (#766) but the actual feature has never been built:
Notifications Panel (docs/design/page-structure.md:252-255) Trigger: Bell icon in sidebar bottom + unread badge
Slide-in drawer aggregating system notifications: budget alerts, approval arrivals, agent status changes, system errors. Sources from WS system, approvals, and budget channels.
This issue tracks the full in-dashboard notifications system, which is broader than the drawer alone. Operators need notifications delivered through three in-band channels (drawer, browser, toast), with consistent routing rules, persistence, and an integrated settings surface. The existing NotificationSink protocol tracked in #849 is the out-of-band counterpart (ntfy / Slack / email); this issue is deliberately the in-band twin so the two can be designed to share the same event taxonomy and routing rules.
Current state (what exists, what is missing)
Exists today
Toast queue (web/src/components/ui/toast.tsx, web/src/stores/toast.ts): Zustand-backed ephemeral popup queue with add / dismiss / dismissAll actions, variants (success / info / warning / error), auto-dismiss durations per variant, and persistent (no auto-dismiss) warning/error variants. Used ad-hoc from various stores and hooks for transient feedback.
Global WS notifications hook (web/src/hooks/useGlobalNotifications.ts): mounted in AppLayout, subscribes to the agents channel, dispatches personality.trimmed events to a toast via the agents store. Only one hook path currently produces operator-facing notifications.
Notifications drawer (sidebar bell trigger): no store, no WS subscribers for system / approvals / budget, no drawer component, no unread count, no localStorage persistence, no read/dismiss state, no grouping, no filter, no keyboard shortcut.
Browser notifications: no Notification.requestPermission() flow, no opt-in UX, no fire-when-backgrounded routing, no click-to-focus, no rate limiting.
Cross-channel routing: toasts and the (missing) drawer are not fed by a single source. A WS event that should fan out to multiple surfaces has to be manually wired into each hook and store. There is no shared notification object, no category taxonomy, no severity mapping, no routing table.
Settings UX: no Notifications section in the Settings page; no per-category opt-in/out; no browser permission status display; no "Do Not Disturb" hours; no sound toggle.
Backend event taxonomy: WS channels currently publish raw domain events. There is no "operator notification" envelope with severity, category, title, body, action link. Every consumer has to interpret the raw payload.
Proposed architecture
One event, three surfaces
Every operator notification flows through a single frontend pipeline and is dispatched to up to three in-band surfaces. Which surfaces fire for a given event is determined by the category of the event and the user's per-category routing preferences.
Routing defaults are user-overridable in Settings. A category that is user-disabled in every surface is effectively muted (still logged in the drawer store for history but no visual cue).
Surface 1: Notifications drawer
Trigger: Click the Bell icon in the sidebar rail (already exists, currently a dead button), or keyboard shortcut Shift+N.
Unread badge: Red dot on the bell when there are unread items. Optional numeric count for >5 unread, with "99+" cap.
Shell: Right-side slide-in drawer using the existing Drawer component (web/src/components/ui/drawer.tsx) at w-96.
Header: "Notifications", count of unread, filter chip row (All / Approvals / Budget / System / Agents / Tasks), bulk action menu (Mark all read, Clear all, Clear dismissed).
Body: Virtualised list of NotificationItem rows grouped by day (Today / Yesterday / Earlier). Each row: severity icon, title, description, relative timestamp ("2m ago", live-updating), source channel chip, action button (if actionHref present), per-item menu (Mark read/unread, Dismiss, Snooze 15m).
Grouping: Consecutive items with the same category + same source entity collapse into a group ("3 new approvals from eng dept"). Expand on click.
Empty state: Friendly copy + link to the Settings → Notifications page ("You're all caught up. Tune which events trigger notifications in Settings → Notifications.")
Footer: Link to /settings?namespace=notifications (a deep link, not a tab switch) and a "Clear all" button.
Live updating: The relative timestamps on visible rows tick every second via a shared useNowTicker(1000) hook so "2m ago" becomes "3m ago" without a refresh.
Accessibility:
role="dialog" + focus trap on open (already provided by Drawer).
aria-live="polite" region inside the drawer announcing new items while the drawer is open.
Keyboard nav: Up/Down to move between items, Enter to activate actionHref, Delete/Backspace to dismiss, R to toggle read.
Surface 2: Toast queue (existing, enhanced)
Already implemented: see web/src/components/ui/toast.tsx + web/src/stores/toast.ts.
Enhancements needed:
Shared event source: toasts should be fired by notificationsStore.enqueue(event) rather than direct useToastStore.getState().add(...) calls. The store applies the routing table and calls the toast queue only when the routing says "toast".
Align severity mapping: drawer and toast share the same severity system (info/success/warning/error/critical). Today toast has success/info/warning/error only -- add critical with a distinct red-on-red persistent variant.
Promote toast -> drawer: every toast also lands in the drawer as a read-once item (read=false) so dismissing the toast does not lose the event. This is the central value of the unified pipeline: ephemeral alert + persistent log from a single fire.
Click-through: toasts with an actionHref route the user to the relevant page when clicked (the current toast has no click action).
Rate limit: max N identical toasts per minute (dedupe by NotificationItem.id prefix) to prevent a pathological WS backend from spamming.
Sound (optional, opt-in): a short chime on warning/error/critical toasts when the user has enabled it in Settings.
Surface 3: Browser notifications
Permission flow: The first time a user logs in after this ships, a non-intrusive banner at the top of the dashboard offers to "Enable desktop notifications for critical alerts (approvals, budget, outages)". Clicking "Enable" calls Notification.requestPermission(). Clicking "Not now" hides the banner for the session; "Never" persists the decline in localStorage. The banner is also re-enterable from Settings -> Notifications.
Trigger conditions: Browser notifications fire only when document.hidden === true (tab backgrounded) and the category's routing includes browser and the user has granted permission. The rationale: if the tab is foregrounded the user will see the toast + drawer; the browser notification is for when they have the dashboard open in a background tab or on another desktop.
Click-to-focus: Clicking the OS notification focuses the dashboard tab (window.focus()) and navigates to actionHref if present.
Severity -> Notification options:
critical: requireInteraction: true (stays until clicked), silent: false (plays system chime).
info / success: default auto-dismiss, silent: true.
Icon: SynthOrg logo from /synthorg-icon.png (already in public/).
Rate limit: max 1 browser notification per category per 60s. Bursts are coalesced into a summary notification ("3 new approvals pending").
Fallback on denied permission: If the user declined, the Settings page shows a persistent "Browser notifications blocked" warning with instructions to re-enable in site settings and a "Request again" button that re-runs the permission prompt (browsers only allow this if they have not been permanently blocked).
Service Worker integration (out of scope): Push notifications delivered when the tab is closed require a Service Worker + Push API + backend push server. Defer to a follow-up issue -- the first version only delivers notifications while the tab is open (even if backgrounded).
Settings UX
Add a new Notifications namespace / tab to the Settings page:
Browser permission section: current status (granted / denied / default), "Request permission" button, explanatory copy.
Per-category routing table: one row per category from the taxonomy above, with three checkboxes (Drawer / Toast / Browser) and a severity override dropdown. Changes persist to localStorage (MVP) or a backend user-preferences endpoint (future).
Global toggles: Do Not Disturb mode with optional schedule ("Quiet hours: 20:00 - 08:00"), sound on/off, rate limit override.
History retention: how long the drawer keeps items (24h / 7d / 30d / forever) -- backs the localStorage ring buffer size.
Clear all history button with a confirm dialog.
Integration with backend NotificationSink protocol (#849)
#849 tracks the out-of-band side (ntfy, Slack, email) and this issue tracks the in-band side (drawer, toast, browser). The two should share:
The same routing model: a single user preference determines which sinks (in-band AND out-of-band) fire for a given category. Implementing both side-by-side avoids two different "notification settings" surfaces in the UI.
Backend publish-once, fan-out: the backend publishes a single NotificationEvent to a dedicated WS channel (e.g. notifications) and to every configured out-of-band sink. The dashboard subscribes to that channel for its three in-band surfaces. This is cleaner than having the dashboard infer notifications from raw domain channels and keeps the routing logic on the backend.
Recommendation: do this issue after or alongside#849, and share the NotificationEvent envelope design between them. If #849 ships first with its own ad-hoc envelope, this issue will need a small refactor of that envelope to match the frontend's NotificationItem shape. If they ship together, the envelope can be designed jointly.
Implementation phases
Phase 1 -- MVP (unblocks the dead bell button)
Frontend: useNotificationsStore (Zustand), NotificationItem type, localStorage persistence (ring buffer), basic routing table (hardcoded defaults, no user config yet).
WS subscribers for system, approvals, budget (subset of the taxonomy).
Drawer UI with unread badge on the bell, filter chips, per-item actions.
Toast integration: route existing toast-triggering events through the new store so they land in the drawer too.
Keyboard shortcut Shift+N.
Sidebar bell wired up (replace the dead placeholder).
Phase 2 -- Browser notifications
Permission banner + Settings entry.
Fire-when-backgrounded dispatch.
Click-to-focus + navigation.
Rate limiting and burst coalescing.
Phase 3 -- Settings UX
Full notifications Settings tab (per-category routing, DND, history retention, sound, permission management).
localStorage -> optional backend sync of preferences.
Phase 4 -- Taxonomy completion
Wire up remaining categories from the taxonomy table (agents, tasks, providers).
Service Worker / Push API for notifications delivered when the dashboard is closed (separate follow-up issue).
Backend persistence of read/dismissed state (MVP is localStorage-only -- acceptable for a single-device operator workflow, revisit if multi-device sync becomes a requirement).
In-notification quick actions beyond actionHref navigation (e.g. "Approve from the drawer without navigating"). Approvals have their own dedicated page; the drawer's job is to surface and route, not to replace the primary surface.
Replacing the Toast system -- the existing toast queue is retained; this issue only unifies its event source.
Quick UX fix in the meantime
Until Phase 1 lands, the sidebar Bell button should be visibly disabled with a "Coming soon" tooltip so users stop clicking a dead control. This is a 5-line change to web/src/components/layout/Sidebar.tsx and should land as part of the PR that opens this issue (or the Base UI migration PR that discovered the gap, PR #1074).
References
Design spec: docs/design/page-structure.md:252-255 (Notifications Panel) and docs/design/page-structure.md:396-405 (WS channel table).
feat: in-dashboard notifications system -- drawer + browser notifications + toast unification
Context
The bell icon in the sidebar bottom rail has been a placeholder since the initial app-shell PR (#819). Clicking it does nothing: there is no
onClickhandler, no notifications store, no drawer component, no unread badge. The design spec has documented the intended behavior since page-structure was defined (#766) but the actual feature has never been built:This issue tracks the full in-dashboard notifications system, which is broader than the drawer alone. Operators need notifications delivered through three in-band channels (drawer, browser, toast), with consistent routing rules, persistence, and an integrated settings surface. The existing
NotificationSinkprotocol tracked in #849 is the out-of-band counterpart (ntfy / Slack / email); this issue is deliberately the in-band twin so the two can be designed to share the same event taxonomy and routing rules.Current state (what exists, what is missing)
Exists today
web/src/components/ui/toast.tsx,web/src/stores/toast.ts): Zustand-backed ephemeral popup queue withadd/dismiss/dismissAllactions, variants (success/info/warning/error), auto-dismiss durations per variant, and persistent (no auto-dismiss) warning/error variants. Used ad-hoc from various stores and hooks for transient feedback.web/src/hooks/useGlobalNotifications.ts): mounted inAppLayout, subscribes to theagentschannel, dispatchespersonality.trimmedevents to a toast via the agents store. Only one hook path currently produces operator-facing notifications.NotificationSinkprotocol intended for ntfy / Slack / email adapters. Not yet implemented.Missing
system/approvals/budget, no drawer component, no unread count, no localStorage persistence, no read/dismiss state, no grouping, no filter, no keyboard shortcut.Notification.requestPermission()flow, no opt-in UX, no fire-when-backgrounded routing, no click-to-focus, no rate limiting.Proposed architecture
One event, three surfaces
Every operator notification flows through a single frontend pipeline and is dispatched to up to three in-band surfaces. Which surfaces fire for a given event is determined by the category of the event and the user's per-category routing preferences.
NotificationItem shape
Category taxonomy
approvals.pendingapprovalsapprovals.expiringapprovalsapprovals.decidedapprovalsbudget.thresholdbudgetbudget.exhaustedbudgetsystem.errorsystemsystem.restart_requiredsystemsystem.shutdownsystemagents.personality_trimmedagentsagents.hiredagentsagents.firedagentstasks.failedagents/taskstasks.blockedagents/tasksproviders.downsystem/ pollproviders.degradedsystem/ pollconnection.lostconnection.exhaustedRouting defaults are user-overridable in Settings. A category that is user-disabled in every surface is effectively muted (still logged in the drawer store for history but no visual cue).
Surface 1: Notifications drawer
Shift+N.Drawercomponent (web/src/components/ui/drawer.tsx) atw-96.NotificationItemrows grouped by day (Today / Yesterday / Earlier). Each row: severity icon, title, description, relative timestamp ("2m ago", live-updating), source channel chip, action button (ifactionHrefpresent), per-item menu (Mark read/unread, Dismiss, Snooze 15m)./settings?namespace=notifications(a deep link, not a tab switch) and a "Clear all" button.useNowTicker(1000)hook so "2m ago" becomes "3m ago" without a refresh.role="dialog"+ focus trap on open (already provided byDrawer).aria-live="polite"region inside the drawer announcing new items while the drawer is open.actionHref, Delete/Backspace to dismiss,Rto toggle read.Surface 2: Toast queue (existing, enhanced)
web/src/components/ui/toast.tsx+web/src/stores/toast.ts.notificationsStore.enqueue(event)rather than directuseToastStore.getState().add(...)calls. The store applies the routing table and calls the toast queue only when the routing says "toast".info/success/warning/error/critical). Today toast hassuccess/info/warning/erroronly -- addcriticalwith a distinct red-on-red persistent variant.actionHrefroute the user to the relevant page when clicked (the current toast has no click action).NotificationItem.idprefix) to prevent a pathological WS backend from spamming.warning/error/criticaltoasts when the user has enabled it in Settings.Surface 3: Browser notifications
Notification.requestPermission(). Clicking "Not now" hides the banner for the session; "Never" persists the decline in localStorage. The banner is also re-enterable from Settings -> Notifications.document.hidden === true(tab backgrounded) and the category's routing includesbrowserand the user has granted permission. The rationale: if the tab is foregrounded the user will see the toast + drawer; the browser notification is for when they have the dashboard open in a background tab or on another desktop.window.focus()) and navigates toactionHrefif present.critical:requireInteraction: true(stays until clicked),silent: false(plays system chime).error/warning: default auto-dismiss,silent: false.info/success: default auto-dismiss,silent: true./synthorg-icon.png(already inpublic/).Settings UX
Add a new
Notificationsnamespace / tab to the Settings page:Integration with backend
NotificationSinkprotocol (#849)#849 tracks the out-of-band side (ntfy, Slack, email) and this issue tracks the in-band side (drawer, toast, browser). The two should share:
NotificationSink.send(...)should be consumable by the frontend store with minimal translation.NotificationEventto a dedicated WS channel (e.g.notifications) and to every configured out-of-band sink. The dashboard subscribes to that channel for its three in-band surfaces. This is cleaner than having the dashboard infer notifications from raw domain channels and keeps the routing logic on the backend.Recommendation: do this issue after or alongside #849, and share the
NotificationEventenvelope design between them. If #849 ships first with its own ad-hoc envelope, this issue will need a small refactor of that envelope to match the frontend'sNotificationItemshape. If they ship together, the envelope can be designed jointly.Implementation phases
Phase 1 -- MVP (unblocks the dead bell button)
useNotificationsStore(Zustand),NotificationItemtype, localStorage persistence (ring buffer), basic routing table (hardcoded defaults, no user config yet).system,approvals,budget(subset of the taxonomy).Shift+N.Phase 2 -- Browser notifications
Phase 3 -- Settings UX
Phase 4 -- Taxonomy completion
NotificationEventenvelope and shared routing model.Phase 5 -- Service Worker push (out of scope, separate issue)
Acceptance criteria
system,approvals, andbudgetchannels flow into the drawer with correct category and severity.useToastStore.add(...)calls from WS handlers).Shift+Nopens/closes the drawer.docs/design/page-structure.mdalongside the existing Notifications Panel entry.Non-goals
actionHrefnavigation (e.g. "Approve from the drawer without navigating"). Approvals have their own dedicated page; the drawer's job is to surface and route, not to replace the primary surface.Quick UX fix in the meantime
Until Phase 1 lands, the sidebar Bell button should be visibly disabled with a "Coming soon" tooltip so users stop clicking a dead control. This is a 5-line change to
web/src/components/layout/Sidebar.tsxand should land as part of the PR that opens this issue (or the Base UI migration PR that discovered the gap, PR #1074).References
docs/design/page-structure.md:252-255(Notifications Panel) anddocs/design/page-structure.md:396-405(WS channel table).feat: NotificationSink protocol for out-of-band operator alerts) -- the out-of-band twin of this issue.web/src/components/ui/toast.tsx,web/src/stores/toast.ts,web/src/hooks/useGlobalNotifications.ts.web/src/components/layout/Sidebar.tsx:290-301.