Skip to content

feat: in-dashboard notifications system -- drawer + browser notifications + toast unification #1078

@Aureliolo

Description

@Aureliolo

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 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.
  • Out-of-band sink protocol (feat: NotificationSink protocol for out-of-band operator alerts #849, open): backend NotificationSink protocol intended for ntfy / Slack / email adapters. Not yet implemented.

Missing

  • 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.

┌─────────────────────┐     ┌──────────────────────┐
│ WS channels         │     │ Frontend events      │
│  - system           │     │  - WS handshake fail │
│  - approvals        ├────>│  - auth error        │
│  - budget           │     │  - local action      │
│  - agents           │     │    confirmation      │
└──────────┬──────────┘     └──────────┬───────────┘
           │                           │
           v                           v
     ┌─────────────────────────────────────┐
     │ notificationsStore.enqueue(event)   │
     │  - assigns category + severity      │
     │  - applies user routing preferences │
     │  - dedupes + rate-limits            │
     └──────────┬──────────────────────────┘
                │
    ┌───────────┼──────────────────────────┐
    v           v                          v
┌────────┐  ┌────────┐              ┌─────────────┐
│ Toast  │  │ Drawer │              │ Browser     │
│ queue  │  │ store  │              │ Notification│
└────────┘  └────────┘              │ API         │
                                    └─────────────┘

NotificationItem shape

interface NotificationItem {
  id: string                          // stable dedupe key (e.g. `approval:${approvalId}:pending`)
  category: NotificationCategory      // see taxonomy below
  severity: 'info' | 'success' | 'warning' | 'error' | 'critical'
  title: string
  description?: string
  timestamp: string                   // ISO 8601
  source: 'ws' | 'local' | 'poll'
  channel?: WsChannel                 // which WS channel emitted it (for filter UI)
  actionHref?: string                 // click-through route (e.g. `/approvals/abc`)
  actionLabel?: string                // CTA button text
  read: boolean                       // user has opened drawer and seen it
  dismissed: boolean                  // user explicitly dismissed
  metadata?: Record<string, unknown>  // category-specific payload passthrough
}

Category taxonomy

Category Source Default routing Severity default Example
approvals.pending WS approvals drawer + toast + browser warning New approval request awaiting operator decision
approvals.expiring WS approvals drawer + browser warning Approval within 5 min of timeout
approvals.decided WS approvals drawer only info An approval was decided by another operator
budget.threshold WS budget drawer + toast + browser warning 80%/90% budget threshold crossed
budget.exhausted WS budget drawer + toast + browser critical Budget hard cap reached
system.error WS system drawer + toast error Backend error surfaced from engine
system.restart_required WS system drawer + toast (persistent) warning Setting change requires restart
system.shutdown WS system drawer + toast + browser critical Graceful shutdown initiated
agents.personality_trimmed WS agents toast only info Existing behavior (#1070)
agents.hired WS agents drawer only info New agent added by HR
agents.fired WS agents drawer only info Agent terminated
tasks.failed WS agents / tasks drawer + toast error Critical task failure
tasks.blocked WS agents / tasks drawer warning Task blocked on dependency or review
providers.down WS system / poll drawer + toast + browser error LLM provider health probe failed
providers.degraded WS system / poll drawer + toast warning Provider latency or error-rate spike
connection.lost frontend (WS store) toast (existing) warning Dashboard WebSocket disconnected
connection.exhausted frontend (WS store) drawer + toast + browser error Reconnect budget exhausted

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).
    • error / warning: default auto-dismiss, silent: false.
    • 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 event taxonomy (categories above). Whatever envelope feat: NotificationSink protocol for out-of-band operator alerts #849 chooses for NotificationSink.send(...) should be consumable by the frontend store with minimal translation.
  • 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

Phase 5 -- Service Worker push (out of scope, separate issue)

  • Service Worker + Push API + backend push server for notifications delivered when the dashboard tab is closed entirely.

Acceptance criteria

  • Clicking the sidebar Bell icon opens a right-side drawer.
  • The drawer shows a feed of notification items grouped by day.
  • The bell icon shows an unread count badge when there are unread items.
  • New WS events on system, approvals, and budget channels flow into the drawer with correct category and severity.
  • Dismissed / read state persists across page reloads via localStorage.
  • The existing toast queue is fed by the same store (no direct useToastStore.add(...) calls from WS handlers).
  • Shift+N opens/closes the drawer.
  • The Settings page has a Notifications section exposing per-category routing and a browser-permission control.
  • Browser notifications fire only when the tab is backgrounded and the category is opted in.
  • Clicking a browser notification focuses the dashboard tab and navigates to the relevant page.
  • Browser notifications are rate limited (max 1 per category per 60s) with burst coalescing.
  • The notifications Settings section shows browser permission status and offers a re-request button.
  • The category taxonomy is documented in docs/design/page-structure.md alongside the existing Notifications Panel entry.
  • The envelope + routing model is aligned with feat: NotificationSink protocol for out-of-band operator alerts #849 (coordinate before merge).

Non-goals

  • 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    prio:mediumShould do, but not blockingscope:medium1-3 days of workscope:webVue 3 dashboardspec:human-interactionDESIGN_SPEC Section 13 - Human Interaction Layertype:featureNew feature implementationv0.6Minor version v0.6

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions