Skip to content

feat(notifications): cross-room, cross-tab notification center - #537

Merged
juliarvalenti merged 2 commits into
mainfrom
claude/youthful-mccarthy-pn20c5
Aug 15, 2026
Merged

feat(notifications): cross-room, cross-tab notification center#537
juliarvalenti merged 2 commits into
mainfrom
claude/youthful-mccarthy-pn20c5

Conversation

@juliarvalenti

@juliarvalenti juliarvalenti commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Replaces the never-merged per-room audio ping (#532) with a real notifications system, scoped per #536: cross-room, cross-tab, dismissible, with an inbox instead of just a chime. Validated live against a running SLIM node (via mycelium l9 send from #534): cross-room delivery, consensus classification, inbox/badge/title, and the audio ping all confirmed.

Changes

Backend

  • New GET /notifications/stream: aggregates every room's activity (plus room_created/room_deleted) into one SSE connection, growing/shrinking its room subscription set live off the same app-channel frames the sidebar consumes. No producer changes — every room-channel payload already carries room_name.
  • Fixes the classification bug from Notifications system: cross-room, cross-tab, dismissible (beyond the in-room audio ping) #536: parseEvent only unwrapped l9_exchange, so live consensus (l9_commit) and knowledge (l9_knowledge) fell through to the unhandled-type fallback — consensus never rendered and never pinged, despite being the headline "Needs me" case. Both now unwrap; consensus renders as a real system notice and feeds NegotiationView.

Frontend

  • lib/notifications.ts — classifies a stream frame into mention/direct/consensus/knowledge/join, scoped to the acting-as principal; isAdmitted for scope ("Needs me" vs "Everything"), per-room mute, DND; localStorage persistence; BroadcastChannel cross-tab sync; Web Locks leader election so only one tab plays audio / raises a desktop notification.
  • lib/audio-ping.ts — dependency-free synthesized two-tone chime + gesture unlock.
  • components/notifications-provider.tsx — root-mounted provider owning the single global SSE subscription, unread state, and the tab-title badge.
  • notification-bell.tsx / notification-settings.tsx — header bell/inbox (read, dismiss, mute, mark-all-read, clear) + settings (scope, sound + volume, desktop notifications, DND).
  • Ping gate is window-focus, not tab-visibility. Gating on document.visibilityState only fired when you switched tabs / minimized; a window behind another app stayed "visible", so alt-tab-to-another-app silently dropped the ping. Now gates on !document.hasFocus() — fires whenever this tab isn't the one you're looking at (other tab, minimized, or other app), matching the intent.

Client-side only for now (no server-side notification ledger), per the issue.

Testing

  • Backend: pytest (incl. new test_notifications_stream.py), ruff/format/ty.
  • Frontend: vitest (incl. notifications.test.ts, notification-bell.test.tsx), tsc, build (confirms /api/notifications/stream registers as its own streaming route).
  • Live: cross-room consensus fired into a room the user wasn't viewing → inbox + badge + title updated, and the audio ping fired with the tab backgrounded.

Known follow-ups (non-blocking)

  • electLeader isn't idempotent, so React Strict Mode double-elects in dev (one held + one pending lock from the same tab). Cosmetic; single lock holder still correct.
  • A stale background leader tab that never received a user gesture holds leadership but can't unlock audio → silent. Rare; worth a re-elect-if-can't-play pass later.

Related

Relates to #536. Supersedes #532 (recommend closing without merging its per-room ping).

claude and others added 2 commits August 15, 2026 01:01
Replaces the never-merged per-room audio ping (#532) with a real
notifications system, as scoped in #536.

Backend:
- New GET /notifications/stream aggregates every room's activity (plus
  room create/delete) into one SSE connection, so the client doesn't have to
  open a per-room stream for every room the user participates in. It follows
  rooms created/deleted after connecting by watching the same app-channel
  frames the sidebar already consumes.
- Fix the real classification bug flagged in #536: parseEvent only unwrapped
  l9_exchange, so live consensus (l9_commit) and knowledge (l9_knowledge)
  fell through to the unhandled-type console.warn fallback. Both now unwrap
  correctly — consensus renders as a real "Consensus"/"Timeout" notice (and
  feeds NegotiationView's convergence state) instead of never firing.

Frontend:
- lib/notifications.ts: classifies a wire frame into mention/direct/
  consensus/knowledge/join, scoped to the acting-as principal; a settings
  predicate (scope, per-room mute, global DND); localStorage persistence;
  BroadcastChannel cross-tab sync for read/dismiss/clear/settings; Web
  Locks-based leader election so only one open tab pings/desktop-notifies.
- lib/audio-ping.ts: dependency-free synthesized chime + gesture unlock.
- components/notifications-provider.tsx: root-mounted provider owning the
  single global SSE subscription, unread state, and the tab-title badge.
- components/notification-bell.tsx + notification-settings.tsx: the header
  bell/inbox (mark read, dismiss, mute room, clear all) and a settings
  dialog (scope, sound + volume, desktop notifications, DND, muted rooms).

Deliberately client-side only for now (per #536's "or purely client-side to
start") — no persisted server-side notification ledger.
The audio ping / desktop notification gated on `document.visibilityState`,
which only flips to hidden when you switch tabs or minimize — a window merely
behind another app stays "visible", so alt-tabbing to another app silently
dropped every ping. Gate on `!document.hasFocus()` instead: false when the tab
is backgrounded, minimized, OR the browser isn't the focused app — matching the
"notify me when I'm not looking at this" intent.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants