feat(notifications): cross-room, cross-tab notification center - #537
Merged
Conversation
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.
This was referenced Aug 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 sendfrom #534): cross-room delivery, consensus classification, inbox/badge/title, and the audio ping all confirmed.Changes
Backend
GET /notifications/stream: aggregates every room's activity (plusroom_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 carriesroom_name.parseEventonly unwrappedl9_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 feedsNegotiationView.Frontend
lib/notifications.ts— classifies a stream frame intomention/direct/consensus/knowledge/join, scoped to the acting-as principal;isAdmittedfor scope ("Needs me" vs "Everything"), per-room mute, DND; localStorage persistence;BroadcastChannelcross-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).document.visibilityStateonly 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
pytest(incl. newtest_notifications_stream.py), ruff/format/ty.vitest(incl.notifications.test.ts,notification-bell.test.tsx), tsc, build (confirms/api/notifications/streamregisters as its own streaming route).Known follow-ups (non-blocking)
electLeaderisn'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.Related
Relates to #536. Supersedes #532 (recommend closing without merging its per-room ping).