Skip to content

fix(notifications): request OS permission at startup and fix socketService listener leak#998

Merged
senamakel merged 11 commits intotinyhumansai:mainfrom
M3gA-Mind:fix/notifications-permission-and-listener-leak
Apr 29, 2026
Merged

fix(notifications): request OS permission at startup and fix socketService listener leak#998
senamakel merged 11 commits intotinyhumansai:mainfrom
M3gA-Mind:fix/notifications-permission-and-listener-leak

Conversation

@M3gA-Mind
Copy link
Copy Markdown
Contributor

@M3gA-Mind M3gA-Mind commented Apr 28, 2026

Summary

  • Root cause 1 (primary): OS notification permission was never proactively requested. tauri-plugin-notification's .show() silently fails on macOS/Windows when permission has not been granted. Added ensureNotificationPermission() to tauriBridge.ts that checks isPermissionGranted and calls requestPermission if needed. Called at startNativeNotificationsService() time.
  • Root cause 2: socketService.on() wrapped every callback in a new closure but off() passed the original reference to socket.off() — which uses reference equality and therefore never matched. Listeners were never actually removed. Fixed by adding a listenerMap: Map<original, wrapped> to track the mapping; off() now looks up the wrapped reference, deletes from the map, and filters the pending queue.
  • Diagnostic logging: Added log::debug! to the Rust show_native_notification command to surface permission state and call metadata in logs.

Test plan

  • pnpm typecheck — zero errors
  • pnpm lint — zero errors (pre-existing warnings only)
  • cargo check --manifest-path app/src-tauri/Cargo.toml — passes
  • Prettier formatting clean
  • New WDIO E2E spec app/test/e2e/specs/notifications.spec.ts covers:
    • notification_ingest via core RPC — creates a test notification
    • notification_list — asserts it's returned
    • notification_mark_read — asserts status transition
    • notification_stats — asserts aggregate statistics
    • UI: Notifications page renders integration notifications section (Linux CI; auto-skipped on Mac2)
    • UI: System Events section renders (Linux CI; auto-skipped on Mac2)
  • Run on Linux CI: bash app/scripts/e2e-run-spec.sh test/e2e/specs/notifications.spec.ts notifications
  • macOS: RPC tests run on both platforms; UI assertions auto-skip on Appium Mac2 (documented in docs/E2E-TESTING.md)

Closes #932 #984

Summary by CodeRabbit

  • New Features

    • App can check/request OS notification permission; test-notification queues with a 3.5s countdown and guidance when denied.
    • Notifications now deep-link into appropriate in-app routes when clicked; integration "acted" state applied optimistically.
  • Bug Fixes

    • Prevent duplicate notifications on receive; more robust socket listener handling.
    • Native notifications use default sound on macOS.
  • Tests

    • Added E2E spec for ingest/list/read/stats, UI and native checks; unit tests for duplicate suppression and routing.
  • Documentation

    • Updated E2E testing guide with notification coverage.

…rvice listener leak

Two independent bugs prevented desktop notifications from firing:

1. OS notification permission was never proactively requested. The Tauri
   plugin's .show() call silently fails on macOS/Windows when the user
   hasn't granted permission. Added ensureNotificationPermission() to
   tauriBridge.ts (checks isPermissionGranted, requests if denied) and
   calls it at startNativeNotificationsService() time.

2. socketService.on() wrapped every callback in a new closure but
   off() passed the original reference to socket.off(), which uses
   reference equality — so listeners were never actually removed.
   Fixed by tracking original→wrapped in a listenerMap; off() now
   looks up the wrapped reference, deletes from the map, and filters
   the pendingListeners queue.

Also adds diagnostic logging to the Rust show_native_notification
command (permission state now visible in logs), data-testid attributes
to Notifications.tsx, and a new WDIO E2E spec that tests the full
integration notification pipeline via core RPC (notification_ingest,
notification_list, notification_mark_read, notification_stats) plus
DOM assertions for the Notifications page (Linux CI only).

Closes tinyhumansai#932
@M3gA-Mind M3gA-Mind requested a review from a team April 28, 2026 10:13
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 28, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds OS notification permission queries/requests to the Tauri bridge and frontend; ensures permissions at service startup and before test sends; switches native notify to plugin API (macOS default sound); tightens socket listener lifecycle; deduplicates notifications in reducer; adds E2E tests and test selectors for Notifications UI.

Changes

Cohort / File(s) Summary
Tauri native commands
app/src-tauri/src/lib.rs
Adds notification_permission_state and notification_permission_request commands; show_native_notification logs permission state and sets macOS sound to "default" before notifying.
Frontend native bridge & API
app/src/lib/nativeNotifications/tauriBridge.ts, app/src/lib/nativeNotifications/index.ts
Adds ensureNotificationPermission() (Tauri-only) to query/request permissions; updates showNativeNotification to invoke `plugin:notification
Notification runtime service & UI send flow
app/src/lib/nativeNotifications/service.ts, app/src/components/OpenhumanLinkModal.tsx
Calls ensureNotificationPermission() at startup and before test sends (fire-and-forget); adds logging and queued 3.5s send with cancellation and queued UI state; native notify no longer uses tag.
Socket management
app/src/services/socketService.ts
Introduces listenerMap mapping original→wrapped callbacks for on/once/off; disconnect() clears maps and pending listeners and dispatches reset.
In‑app notifications state & tests
app/src/store/notificationSlice.ts, app/src/store/__tests__/notificationSlice.test.ts
Deduplicates notificationReceived by replacing existing item with same id; adds markIntegrationActed reducer and tests preventing duplicate entries and validating acted/unread behavior.
Notifications UI & routing
app/src/pages/Notifications.tsx, app/src/components/notifications/NotificationCard.tsx, app/src/components/notifications/NotificationCenter.tsx
Adds test IDs, changes navigation to use resolved routes, adds optional onNavigate to NotificationCard, optimistic markIntegrationActed dispatch and route resolution via new router utility.
Notification router & types
app/src/lib/notificationRouter.ts, app/src/lib/notificationRouter.test.ts, app/src/types/notifications.ts
Adds route resolution helpers resolveIntegrationRoute/resolveSystemRoute and tests; extends IntegrationNotification with optional deep_link.
E2E spec, helpers & docs
app/test/e2e/specs/notifications.spec.ts, app/test/e2e/helpers/shared-flows.ts, docs/E2E-TESTING.md
Adds full notifications E2E suite (core RPC + conditional UI assertions), new navigateToNotifications() helper, and doc updates describing the spec and platform notes.
Tauri deps & plugin update
app/src-tauri/Cargo.toml, app/src-tauri/vendor/tauri-plugin-notification
Adds macOS objc2-related dependencies and updates vendored tauri-plugin-notification revision.

Sequence Diagram(s)

sequenceDiagram
    participant App as App Startup
    participant Service as Notifications Service
    participant Bridge as Frontend Bridge
    participant Tauri as Tauri Runtime
    participant OS as OS / User
    participant Socket as Socket Service

    App->>Service: startNativeNotificationsService()
    Service->>Bridge: ensureNotificationPermission()
    Bridge->>Tauri: invoke `notification_permission_state`
    Tauri->>OS: Query UNUserNotificationCenter / OS permission
    OS-->>Tauri: Permission state
    Tauri-->>Bridge: state (granted/denied/err)
    alt Not granted on supported platform
        Bridge->>Tauri: invoke `notification_permission_request`
        Tauri->>OS: Prompt user
        OS-->>Tauri: User response
        Tauri-->>Bridge: request result
    end
    Bridge-->>Service: boolean granted
    Service->>Socket: register `core_notification` listener (wrapped callback)
    Socket->>Service: `core_notification` event (id,title,...)
    Service->>Bridge: showNativeNotification(options with sound="default" on macOS)
    Bridge->>Tauri: invoke `plugin:notification|notify`
    Tauri->>OS: Display native toast
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • senamakel

Poem

🐰 I tapped the bridge and asked the sky,
Tauri hummed back, "May I?"
Sockets hopped, the service said "go,"
A toast rang out with default glow,
E2E watched — now notifications fly. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 73.68% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the two main changes: OS permission request at startup and socketService listener leak fix, both core to the PR objectives.
Linked Issues check ✅ Passed All key coding objectives from issue #932 are met: OS permission check and request [tauriBridge], socketService listener lifecycle fix [socketService], E2E spec with UI and core RPC coverage [notifications.spec], diagnostic logging [Rust lib.rs], and notification routing logic [notificationRouter].
Out of Scope Changes check ✅ Passed All changes directly support the linked issue objectives: notification permission workflow, socket listener tracking, E2E testing, UI navigation/routing, and supporting infrastructure are all within scope of fixing the broken notification stack.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

@M3gA-Mind M3gA-Mind marked this pull request as draft April 28, 2026 10:15
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/services/socketService.ts (1)

327-355: ⚠️ Potential issue | 🟠 Major

Listener map needs event scoping; current shape can still leak listeners.

The new listenerMap is keyed only by callback. If the same callback is subscribed to multiple events, later on() calls overwrite earlier mappings, and off(event, callback) may detach the wrong wrapped function. Also, off(event) does not clear queued pendingListeners for that event.

🔧 Suggested refactor
-  private listenerMap = new Map<(...args: unknown[]) => void, (...args: unknown[]) => void>();
+  private listenerMap = new Map<
+    string,
+    Map<(...args: unknown[]) => void, (...args: unknown[]) => void>
+  >();

   on(event: string, callback: (...args: unknown[]) => void): void {
     const wrappedCallback = (...args: unknown[]) => {
       socketLog('Received event', { event, argsCount: args.length, hasData: args.length > 0 });
       callback(...args);
     };
-    this.listenerMap.set(callback, wrappedCallback);
+    const byEvent = this.listenerMap.get(event) ?? new Map();
+    byEvent.set(callback, wrappedCallback);
+    this.listenerMap.set(event, byEvent);

     if (this.socket) {
       this.socket.on(event, wrappedCallback);
     } else {
       socketLog('Socket not ready, queuing listener', { event });
       this.pendingListeners.push({ event, callback: wrappedCallback });
     }
   }

   off(event: string, callback?: (...args: unknown[]) => void): void {
     if (callback) {
-      const wrapped = this.listenerMap.get(callback) ?? callback;
-      const hadWrapped = this.listenerMap.has(callback);
-      this.listenerMap.delete(callback);
+      const byEvent = this.listenerMap.get(event);
+      const wrapped = byEvent?.get(callback) ?? callback;
+      const hadWrapped = byEvent?.has(callback) ?? false;
+      byEvent?.delete(callback);
+      if (byEvent && byEvent.size === 0) this.listenerMap.delete(event);

       socketLog('Removing listener', { event, hadWrappedVersion: hadWrapped });
       if (this.socket) this.socket.off(event, wrapped);

       this.pendingListeners = this.pendingListeners.filter(
         p => !(p.event === event && p.callback === wrapped)
       );
     } else {
       socketLog('Removing all listeners for event', { event });
       this.socket?.off(event);
+      this.pendingListeners = this.pendingListeners.filter(p => p.event !== event);
+      this.listenerMap.delete(event);
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/services/socketService.ts` around lines 327 - 355, The listenerMap
must be scoped by event so callbacks subscribed to multiple events don't
collide: change listenerMap from Map<Function, Wrapped> to a two-level structure
keyed by event (e.g., Map<string, Map<callback, wrappedCallback>>) or use a
composite key (event+callback) when storing in on(), and update off(event,
callback) to look up and remove the wrappedCallback only within that event's
scope (use the inner map or composite key to delete and decide hadWrapped). Also
ensure off(event) removes any pendingListeners for that event (filter
pendingListeners by p.event === event) in addition to calling
this.socket?.off(event). Keep references to wrappedCallback when queuing and
clearing so queued listeners are removed correctly.
🧹 Nitpick comments (1)
app/test/e2e/specs/notifications.spec.ts (1)

116-117: Replace the fixed 2s sleeps with state-based waits.

browser.pause(2_000) is a common CI flake source here. Wait for the route-specific section or notification text instead so the test advances when the page is actually ready, not after an arbitrary delay.

Also applies to: 142-143

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/test/e2e/specs/notifications.spec.ts` around lines 116 - 117, Replace the
fixed browser.pause(2_000) after navigateViaHash('/notifications') with a
state-based wait: instead of sleeping, wait for a route-specific element to
exist/ be displayed (e.g., the notifications container or a notification text)
using the test framework's waitForExist/waitForDisplayed or browser.waitUntil so
the test proceeds when the page is ready; update the same pattern at the other
occurrence(s) around the 142-143 region. Target symbols: navigateViaHash and
browser.pause and the notifications container/notification text selector used in
this spec.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/test/e2e/helpers/shared-flows.ts`:
- Around line 250-252: The helper navigateToNotifications calls
navigateViaHash('/notifications') but the Mac2 sidebar fallback mapping lacks a
'/notifications' entry causing failures; add a '/notifications' key to the Mac2
sidebar fallback mapping object (the same mapping structure used for other Mac2
routes) and point it to the corresponding internal route used by Mac2 so
navigateViaHash('/notifications') resolves correctly (update the Mac2 sidebar
fallback mapping where sidebar route mappings are defined so
navigateToNotifications and navigateViaHash no longer throw on non-execute
sessions).

In `@app/test/e2e/specs/notifications.spec.ts`:
- Around line 92-108: Before calling openhuman.notification_mark_read, call
callOpenhumanRpc('openhuman.notification_stats', {}) to capture the initial
unread_count from result.result?.result (e.g. store as initialUnread), then call
callOpenhumanRpc('openhuman.notification_mark_read', { id: 'e2e-notif-001' })
and finally re-fetch stats and assert the unread_count decreased (or if
initialUnread is 0, that it did not increase). Update the test that currently
uses variables result/stats to perform these three RPCs and add an assertion
comparing the two unread_count values to ensure the mutation actually changed
state.
- Around line 64-74: The test currently only asserts RPC-level success; update
the 'notification_ingest creates a new notification via core RPC' test to also
assert the inner payload indicates a real creation (not a skipped outcome) by
checking the returned payload (result.result) for an outcome like 'created' and
that it is not 'skipped' or 'provider_disabled', and then verify persistence by
calling the read RPC (e.g., callOpenhumanRpc('openhuman.notification_get' or the
appropriate fetch/list RPC) to fetch the notification with id 'e2e-notif-001'
and assert the fetched record exists and has the expected id/title; use the
symbols callOpenhumanRpc, result, result.result, and the test id 'e2e-notif-001'
to locate and change the assertions.

---

Outside diff comments:
In `@app/src/services/socketService.ts`:
- Around line 327-355: The listenerMap must be scoped by event so callbacks
subscribed to multiple events don't collide: change listenerMap from
Map<Function, Wrapped> to a two-level structure keyed by event (e.g.,
Map<string, Map<callback, wrappedCallback>>) or use a composite key
(event+callback) when storing in on(), and update off(event, callback) to look
up and remove the wrappedCallback only within that event's scope (use the inner
map or composite key to delete and decide hadWrapped). Also ensure off(event)
removes any pendingListeners for that event (filter pendingListeners by p.event
=== event) in addition to calling this.socket?.off(event). Keep references to
wrappedCallback when queuing and clearing so queued listeners are removed
correctly.

---

Nitpick comments:
In `@app/test/e2e/specs/notifications.spec.ts`:
- Around line 116-117: Replace the fixed browser.pause(2_000) after
navigateViaHash('/notifications') with a state-based wait: instead of sleeping,
wait for a route-specific element to exist/ be displayed (e.g., the
notifications container or a notification text) using the test framework's
waitForExist/waitForDisplayed or browser.waitUntil so the test proceeds when the
page is ready; update the same pattern at the other occurrence(s) around the
142-143 region. Target symbols: navigateViaHash and browser.pause and the
notifications container/notification text selector used in this spec.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f398e4e2-de7e-4b03-b736-4d3346668db8

📥 Commits

Reviewing files that changed from the base of the PR and between f9c15bd and e1eda24.

📒 Files selected for processing (9)
  • app/src-tauri/src/lib.rs
  • app/src/lib/nativeNotifications/index.ts
  • app/src/lib/nativeNotifications/service.ts
  • app/src/lib/nativeNotifications/tauriBridge.ts
  • app/src/pages/Notifications.tsx
  • app/src/services/socketService.ts
  • app/test/e2e/helpers/shared-flows.ts
  • app/test/e2e/specs/notifications.spec.ts
  • docs/E2E-TESTING.md

Comment thread app/test/e2e/helpers/shared-flows.ts
Comment thread app/test/e2e/specs/notifications.spec.ts
Comment thread app/test/e2e/specs/notifications.spec.ts
Comment thread app/test/e2e/specs/notifications.spec.ts
Unify notification permission/send behavior on the plugin command path, add a delayed test send for macOS banner verification, and dedupe repeated notification IDs to prevent duplicate System Events/Alerts entries.

Made-with: Cursor
Capture formatter changes triggered by repository hooks so notification-related updates pass pre-push quality checks.

Made-with: Cursor
Scope socket listener wrappers per event to prevent callback collisions, add Mac2 notifications route mapping, and strengthen notification E2E assertions/waits to validate ingest and read-state transitions.

Made-with: Cursor
Apply Prettier formatting adjustments required by pre-push checks.

Made-with: Cursor
@M3gA-Mind M3gA-Mind marked this pull request as ready for review April 28, 2026 13:02
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
app/src/components/OpenhumanLinkModal.tsx (1)

191-241: ⚠️ Potential issue | 🟠 Major

Cancel the queued notification when the modal closes.

The 3.5s timer survives unmount/close, so the test notification can still fire after the user dismisses the flow, and the final setStatus('sent') will hit an unmounted component. Track the timeout in a ref and clear it on close/unmount.

♻️ Suggested fix
 const NotificationsBody = ({ close }: { close: () => void }) => {
   const [status, setStatus] = useState<'idle' | 'sending' | 'queued' | 'sent' | 'error'>('idle');
   const [error, setError] = useState<string | null>(null);
+  const timeoutRef = useRef<number | null>(null);
+
+  useEffect(
+    () => () => {
+      if (timeoutRef.current !== null) {
+        window.clearTimeout(timeoutRef.current);
+      }
+    },
+    []
+  );
@@
-      await new Promise(resolve => setTimeout(resolve, TEST_NOTIFICATION_DELAY_MS));
+      await new Promise<void>(resolve => {
+        timeoutRef.current = window.setTimeout(resolve, TEST_NOTIFICATION_DELAY_MS);
+      });
+      timeoutRef.current = null;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/OpenhumanLinkModal.tsx` around lines 191 - 241, The queued
3.5s timer in handleAllow can outlive the modal and call state on an unmounted
component; modify handleAllow to capture the timeout id (from setTimeout or
Promise wrapper) in a ref (e.g., notificationTimeoutRef) and clear it when the
modal closes or the component unmounts, and also guard the post-timeout work
(the showNativeNotification and final setStatus('sent')) by checking the
ref/state to avoid running after clear; implement a cleanup in the modal close
handler or a useEffect return that calls
clearTimeout(notificationTimeoutRef.current) and resets the ref so the pending
promise/timeout is cancelled if the user dismisses the modal before
TEST_NOTIFICATION_DELAY_MS elapses.
app/src/services/socketService.ts (1)

276-285: ⚠️ Potential issue | 🟠 Major

Clear queued listeners even when there is no active socket.

As written, the cleanup is skipped when this.socket is null, so callbacks queued during an in-flight connect survive logout and can be replayed on the next session. Move the listener-state reset outside the if (this.socket) guard, and clear pendingListeners as part of the disconnect path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/services/socketService.ts` around lines 276 - 285, The disconnect()
method only performs cleanup when this.socket exists, leaving queued callbacks
in pendingListeners and listenerMap when there is no active socket; to fix it,
compute uid (via getSocketUserId()) then always clear listener state and
auth/transport fields (listenerMap.clear(), pendingListeners.clear(), this.token
= null, this.mcpTransport = null) and dispatch resetForUser({ userId: uid })
outside the if-block, while keeping the socket-specific actions (socketLog,
this.socket.disconnect(), this.socket = null) inside the existing if
(this.socket) guard so we both properly disconnect an active socket and ensure
queued listeners are cleared on logout even if no socket was present.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/services/socketService.ts`:
- Around line 120-125: listenerMap currently maps event -> Map<originalFn,
singleWrappedFn> so registering the same original function twice overwrites the
stored wrapper and leaves the first wrapper attached; change listenerMap to map
event -> Map<originalFn, Set<wrappedFn>> (or array) in the on(event, fn) path:
when creating a new wrappedCallback push/add it into the Set instead of
replacing, and in off(event, fn) iterate the Set removing each stored wrapped
callback from socket.off and then clear/delete the entry; also ensure
once(event, fn) creates a tracked wrapped callback (and is removed from the Set
after it fires) so bookkeeping remains consistent with on/off.

---

Outside diff comments:
In `@app/src/components/OpenhumanLinkModal.tsx`:
- Around line 191-241: The queued 3.5s timer in handleAllow can outlive the
modal and call state on an unmounted component; modify handleAllow to capture
the timeout id (from setTimeout or Promise wrapper) in a ref (e.g.,
notificationTimeoutRef) and clear it when the modal closes or the component
unmounts, and also guard the post-timeout work (the showNativeNotification and
final setStatus('sent')) by checking the ref/state to avoid running after clear;
implement a cleanup in the modal close handler or a useEffect return that calls
clearTimeout(notificationTimeoutRef.current) and resets the ref so the pending
promise/timeout is cancelled if the user dismisses the modal before
TEST_NOTIFICATION_DELAY_MS elapses.

In `@app/src/services/socketService.ts`:
- Around line 276-285: The disconnect() method only performs cleanup when
this.socket exists, leaving queued callbacks in pendingListeners and listenerMap
when there is no active socket; to fix it, compute uid (via getSocketUserId())
then always clear listener state and auth/transport fields (listenerMap.clear(),
pendingListeners.clear(), this.token = null, this.mcpTransport = null) and
dispatch resetForUser({ userId: uid }) outside the if-block, while keeping the
socket-specific actions (socketLog, this.socket.disconnect(), this.socket =
null) inside the existing if (this.socket) guard so we both properly disconnect
an active socket and ensure queued listeners are cleared on logout even if no
socket was present.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7d92eb24-61bb-407c-9d81-deacfbde0c36

📥 Commits

Reviewing files that changed from the base of the PR and between e1eda24 and 5c53103.

⛔ Files ignored due to path filters (2)
  • Cargo.lock is excluded by !**/*.lock
  • app/src-tauri/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (11)
  • app/src-tauri/Cargo.toml
  • app/src-tauri/src/lib.rs
  • app/src-tauri/vendor/tauri-plugin-notification
  • app/src/components/OpenhumanLinkModal.tsx
  • app/src/lib/nativeNotifications/service.ts
  • app/src/lib/nativeNotifications/tauriBridge.ts
  • app/src/services/socketService.ts
  • app/src/store/__tests__/notificationSlice.test.ts
  • app/src/store/notificationSlice.ts
  • app/test/e2e/helpers/shared-flows.ts
  • app/test/e2e/specs/notifications.spec.ts
✅ Files skipped from review due to trivial changes (2)
  • app/src-tauri/Cargo.toml
  • app/src-tauri/vendor/tauri-plugin-notification
🚧 Files skipped from review as they are similar to previous changes (4)
  • app/test/e2e/helpers/shared-flows.ts
  • app/test/e2e/specs/notifications.spec.ts
  • app/src/lib/nativeNotifications/service.ts
  • app/src-tauri/src/lib.rs

Comment thread app/src/lib/nativeNotifications/tauriBridge.ts
Comment thread app/src/services/socketService.ts
Cancel queued notification timers on modal unmount and harden socket listener cleanup by tracking multiple wrapped callbacks per event+handler and clearing queued listeners during disconnect.

Made-with: Cursor
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/services/socketService.ts (1)

179-185: ⚠️ Potential issue | 🟠 Major

Preserve once() semantics when the listener is queued before connect.

If once() runs before a socket exists, it queues the wrapped handler, but the connect path replays every pending entry with socket.on(...). That turns a one-shot listener into a permanent one, and after the first fire the finally block removes its listenerMap entry, so later off() calls can no longer detach the still-registered wrapper.

💡 Suggested fix
-  private pendingListeners: Array<{ event: string; callback: (...args: unknown[]) => void }> = [];
+  private pendingListeners: Array<{
+    event: string;
+    callback: (...args: unknown[]) => void;
+    once: boolean;
+  }> = [];
-      for (const { event, callback } of this.pendingListeners) {
-        this.socket.on(event, callback);
+      for (const { event, callback, once } of this.pendingListeners) {
+        if (once) {
+          this.socket.once(event, callback);
+        } else {
+          this.socket.on(event, callback);
+        }
       }
-      this.pendingListeners.push({ event, callback: wrappedCallback });
+      this.pendingListeners.push({ event, callback: wrappedCallback, once: false });
-      this.pendingListeners.push({ event, callback: wrappedCallback });
+      this.pendingListeners.push({ event, callback: wrappedCallback, once: true });

Also applies to: 381-412

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/services/socketService.ts` around lines 179 - 185, Pending one-time
listeners queued in this.pendingListeners get registered with socket.on during
the flush, turning them into permanent listeners and breaking off() removal;
change the flush logic (both the block that iterates pendingListeners and the
similar code at lines ~381-412) to detect queued entries that were created via
once() and call socket.once(...) for them instead of socket.on(...), and ensure
the wrapper function stored in listenerMap (and removed in the finally block)
remains the same wrapper reference so off() can locate and remove the underlying
socket listener correctly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@app/src/services/socketService.ts`:
- Around line 179-185: Pending one-time listeners queued in
this.pendingListeners get registered with socket.on during the flush, turning
them into permanent listeners and breaking off() removal; change the flush logic
(both the block that iterates pendingListeners and the similar code at lines
~381-412) to detect queued entries that were created via once() and call
socket.once(...) for them instead of socket.on(...), and ensure the wrapper
function stored in listenerMap (and removed in the finally block) remains the
same wrapper reference so off() can locate and remove the underlying socket
listener correctly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 630a6052-28b9-40e2-af18-b84ebe77f0a3

📥 Commits

Reviewing files that changed from the base of the PR and between 5c53103 and 3258271.

📒 Files selected for processing (2)
  • app/src/components/OpenhumanLinkModal.tsx
  • app/src/services/socketService.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/components/OpenhumanLinkModal.tsx

Copy link
Copy Markdown
Contributor

@graycyrus graycyrus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the two root-cause fixes (permission request + listener leak) and the surrounding changes. The core ideas are sound — ensureNotificationPermission and listenerMap are the right primitives. However there are a few correctness issues worth addressing before merge, and two metadata points the author should clarify.

#984 is not closed by this PR. The PR body says "Closes #984" but there is zero routing logic anywhere in the diff. handleClick in Notifications.tsx is unchanged, and the deep_link field that the service already populates on NotificationItem is never consumed on click. Please remove #984 from the closing references and open a separate PR/issue for that work.

@ts-nocheck in the new spec. app/test/e2e/specs/notifications.spec.ts opens with // @ts-nocheck, which is not used in any other spec in the project. This silences the type errors from the raw window.__TAURI_INTERNALS__ access (see inline comment below) but also suppresses every other type check in the file. Please remove it and fix the underlying types instead.

Comment thread app/src/services/socketService.ts
Comment thread app/src/services/socketService.ts
Comment thread app/src/lib/nativeNotifications/tauriBridge.ts
Comment thread app/src/lib/nativeNotifications/tauriBridge.ts
Comment thread app/src/lib/nativeNotifications/service.ts Outdated
Comment thread app/src-tauri/src/lib.rs
Comment thread app/src-tauri/src/lib.rs
Comment thread app/test/e2e/specs/notifications.spec.ts
Comment thread app/test/e2e/specs/notifications.spec.ts
Comment thread app/test/e2e/specs/notifications.spec.ts
Address remaining PR feedback by scoping socket reset dispatch to active connections, removing always-on notification console logs, handling non-Tauri notification checks in the modal flow, and tightening notifications E2E polling/assertions.

Made-with: Cursor
Include formatter changes from pre-push checks so branch is clean for push.

Made-with: Cursor
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/services/socketService.ts (1)

407-412: ⚠️ Potential issue | 🟠 Major

once() queues wrapped callback but flush uses socket.on() instead of socket.once().

When the socket isn't ready, once() pushes to pendingListeners (line 411). However, connectAsync() flushes all pending listeners via this.socket.on(event, callback) (line 183), not socket.once(). This means a once() listener registered before connection will fire on every subsequent event, not just once.

🐛 Proposed fix — track listener type in pendingListeners
-  private pendingListeners: Array<{ event: string; callback: (...args: unknown[]) => void }> = [];
+  private pendingListeners: Array<{ event: string; callback: (...args: unknown[]) => void; once?: boolean }> = [];

 // In connectAsync flush:
     for (const { event, callback } of this.pendingListeners) {
-      this.socket.on(event, callback);
+      const { event, callback, once } = pending;
+      if (once) {
+        this.socket.once(event, callback);
+      } else {
+        this.socket.on(event, callback);
+      }
     }

 // In once():
-      this.pendingListeners.push({ event, callback: wrappedCallback });
+      this.pendingListeners.push({ event, callback: wrappedCallback, once: true });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/services/socketService.ts` around lines 407 - 412, The pending
once-listeners are being queued by once() but connectAsync() flushes everything
with this.socket.on, causing once listeners to become persistent; modify the
shape pushed into pendingListeners (from { event, callback: wrappedCallback } to
include a type or once boolean, e.g. { event, callback: wrappedCallback, once:
true }) in the once() implementation, and update the flush logic in
connectAsync() (where it currently calls this.socket.on(event, callback)) to
call this.socket.once(event, callback) for entries with once: true and
this.socket.on for the rest so queued once-listeners fire only once.
🧹 Nitpick comments (2)
app/src/components/OpenhumanLinkModal.tsx (1)

219-227: Promise-based delay works but could be cleaner with AbortController.

The current pattern using cancelledRef is functional. For future consideration, an AbortController pattern would be more idiomatic for cancellable async operations, but this works correctly as-is.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/OpenhumanLinkModal.tsx` around lines 219 - 227, Replace
the manual cancelledRef + timeout Promise pattern with an AbortController-driven
cancellable delay: create an AbortController (e.g., local or stored in a ref)
whose signal is passed to the delay logic that currently uses
notificationTimeoutRef and TEST_NOTIFICATION_DELAY_MS; set the timeout as before
but also add signal.addEventListener('abort') to clear the timeout and
reject/resolve the Promise early, and call controller.abort() from the modal
cleanup/close path instead of setting cancelledRef. Remove usages of
cancelledRef and ensure notificationTimeoutRef is cleared when aborting.
app/test/e2e/specs/notifications.spec.ts (1)

1-1: Consider removing @ts-nocheck and fixing type issues.

@ts-nocheck disables all TypeScript checking for the file. While this may be expedient for E2E specs with complex browser context types, it masks potential issues. Consider using targeted // @ts-expect-error`` comments or proper type assertions where needed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/test/e2e/specs/notifications.spec.ts` at line 1, Remove the top-level "//
`@ts-nocheck`" and instead address the specific TypeScript errors in the file: run
the type checker to locate failing symbols (e.g., references to Playwright's
page, browser, locator, or test helpers inside notifications.spec.ts), replace
broad suppression with targeted "// `@ts-expect-error`" only where unavoidable,
add proper type assertions/casts (e.g., as Page, Locator) or import correct
types, and ensure any custom helpers have accurate type definitions so the file
can be type-checked without disabling TypeScript globally.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/test/e2e/specs/notifications.spec.ts`:
- Around line 252-257: The test calls the notification plugin via
invoker('plugin:notification|notify', { options: { title, body } }) but omits
the sound field used in production; update the options passed in the test (the
invoker call in notifications.spec.ts) to include sound: 'default' so it mirrors
the production showNativeNotification behavior and exercises the same code path.

---

Outside diff comments:
In `@app/src/services/socketService.ts`:
- Around line 407-412: The pending once-listeners are being queued by once() but
connectAsync() flushes everything with this.socket.on, causing once listeners to
become persistent; modify the shape pushed into pendingListeners (from { event,
callback: wrappedCallback } to include a type or once boolean, e.g. { event,
callback: wrappedCallback, once: true }) in the once() implementation, and
update the flush logic in connectAsync() (where it currently calls
this.socket.on(event, callback)) to call this.socket.once(event, callback) for
entries with once: true and this.socket.on for the rest so queued once-listeners
fire only once.

---

Nitpick comments:
In `@app/src/components/OpenhumanLinkModal.tsx`:
- Around line 219-227: Replace the manual cancelledRef + timeout Promise pattern
with an AbortController-driven cancellable delay: create an AbortController
(e.g., local or stored in a ref) whose signal is passed to the delay logic that
currently uses notificationTimeoutRef and TEST_NOTIFICATION_DELAY_MS; set the
timeout as before but also add signal.addEventListener('abort') to clear the
timeout and reject/resolve the Promise early, and call controller.abort() from
the modal cleanup/close path instead of setting cancelledRef. Remove usages of
cancelledRef and ensure notificationTimeoutRef is cleared when aborting.

In `@app/test/e2e/specs/notifications.spec.ts`:
- Line 1: Remove the top-level "// `@ts-nocheck`" and instead address the specific
TypeScript errors in the file: run the type checker to locate failing symbols
(e.g., references to Playwright's page, browser, locator, or test helpers inside
notifications.spec.ts), replace broad suppression with targeted "//
`@ts-expect-error`" only where unavoidable, add proper type assertions/casts
(e.g., as Page, Locator) or import correct types, and ensure any custom helpers
have accurate type definitions so the file can be type-checked without disabling
TypeScript globally.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f8fe2576-a320-4e12-8d37-e2b1dcf26e7d

📥 Commits

Reviewing files that changed from the base of the PR and between 3258271 and 4ba5b20.

📒 Files selected for processing (5)
  • app/src/components/OpenhumanLinkModal.tsx
  • app/src/lib/nativeNotifications/service.ts
  • app/src/lib/nativeNotifications/tauriBridge.ts
  • app/src/services/socketService.ts
  • app/test/e2e/specs/notifications.spec.ts

Comment thread app/test/e2e/specs/notifications.spec.ts
Add shared notification route resolution for integration and system alerts so card clicks navigate to the best destination with deterministic fallbacks, mark integration notifications as acted after click, and cover routing + acted-state behavior with new unit tests.

Closes tinyhumansai#984

Made-with: Cursor
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
app/src/lib/notificationRouter.test.ts (1)

67-93: Consider adding test for unknown category fallback.

The resolveSystemRoute function has a default case that returns /notifications for unrecognized categories, but this path isn't tested. Adding coverage ensures the fallback behavior is verified.

💚 Suggested test addition
   it('prefers deepLink over category default', () => {
     const item = makeSystem({ category: 'messages', deepLink: '/notifications' });
     expect(resolveSystemRoute(item)).toBe('/notifications');
   });
+
+  it('falls back to /notifications for unknown category', () => {
+    // Cast to bypass TypeScript for edge-case testing
+    const item = makeSystem({ category: 'unknown-category' as NotificationItem['category'] });
+    expect(resolveSystemRoute(item)).toBe('/notifications');
+  });
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/lib/notificationRouter.test.ts` around lines 67 - 93, Add a unit test
verifying the default fallback path from resolveSystemRoute for unrecognized
categories: create a system item via makeSystem with an unknown category (e.g.,
'unknown' or 'bogus') and assert resolveSystemRoute(...) returns
'/notifications'; this ensures the function's default case is covered alongside
the existing category-specific tests.
app/src/lib/notificationRouter.ts (1)

46-47: Consider removing redundant prefix from log messages.

The debug namespace already identifies the source as notifications:router. The [notification-router] prefix in each log call is redundant.

♻️ Suggested simplification
-    log('[notification-router] integration id=%s explicit deep_link=%s', n.id, n.deep_link);
+    log('integration id=%s explicit deep_link=%s', n.id, n.deep_link);

Apply similar changes to other log calls in both functions.

Also applies to: 51-52, 55-59

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/lib/notificationRouter.ts` around lines 46 - 47, Remove the redundant
"[notification-router]" prefix from the debug log messages and keep the existing
debug namespace (notifications:router) consistent; update the format strings
passed to log(...) in notificationRouter.ts (the calls around the return of
n.deep_link and the other log calls in the same functions) to drop the prefix
and only include the concise message and variables (e.g., change
log('[notification-router] integration id=%s explicit deep_link=%s', n.id,
n.deep_link) to log('integration id=%s explicit deep_link=%s', n.id,
n.deep_link)), and apply the same simplification to the other log calls
referenced in these functions so messages remain clear but not duplicated.
app/src/components/notifications/NotificationCenter.tsx (1)

81-93: Optimistic update without rollback could cause state drift.

If markNotificationActed(id) fails (per context snippet showing it throws), the UI will display acted status while the server retains unread. Unlike transient read-marks, an "acted" status implies the user took meaningful action — persisting this mismatch could cause confusion (e.g., notification reappears as unread on refresh).

Consider logging the error for diagnostics or implementing a rollback:

🛡️ Option 1: Log the error for observability
     try {
       await markNotificationActed(id);
     } catch {
-      // Optimistic update already applied; failure is non-critical.
+      // Optimistic update already applied; log for diagnostics.
+      console.warn('[NotificationCenter] markNotificationActed failed for id=%s', id);
     }
🛡️ Option 2: Rollback on failure (more robust)
+import { markIntegrationRead } from '../../store/notificationSlice';
+
   const handleNavigate = async (id: string) => {
     const n = items.find(i => i.id === id);
     if (!n) return;
+    const previousStatus = n.status;
     const route = resolveIntegrationRoute(n);
     dispatch(markIntegrationActed(id));
     navigate(route);
     try {
       await markNotificationActed(id);
     } catch {
-      // Optimistic update already applied; failure is non-critical.
+      // Rollback to previous status on failure
+      if (previousStatus === 'unread') {
+        dispatch(markIntegrationRead(id)); // or a dedicated rollback action
+      }
     }
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/notifications/NotificationCenter.tsx` around lines 81 -
93, handleNavigate currently applies an optimistic "acted" update
(dispatch(markIntegrationActed(id))) then calls markNotificationActed(id) but
does not undo the optimistic change on failure or log the error; update
handleNavigate to catch errors from markNotificationActed(id), log the error
(using the same logger/context used elsewhere) and perform a rollback by
dispatching an undo action (e.g., dispatch(unmarkIntegrationActed(id)) or a
dedicated revert action) so UI state stays consistent with server state; keep
the optimistic flow (resolveIntegrationRoute, navigate) but ensure you reference
the existing symbols handleNavigate, markNotificationActed,
markIntegrationActed, unmarkIntegrationActed (or create it if missing), items,
resolveIntegrationRoute, navigate and add the error logging and rollback inside
the catch block.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@app/src/components/notifications/NotificationCenter.tsx`:
- Around line 81-93: handleNavigate currently applies an optimistic "acted"
update (dispatch(markIntegrationActed(id))) then calls markNotificationActed(id)
but does not undo the optimistic change on failure or log the error; update
handleNavigate to catch errors from markNotificationActed(id), log the error
(using the same logger/context used elsewhere) and perform a rollback by
dispatching an undo action (e.g., dispatch(unmarkIntegrationActed(id)) or a
dedicated revert action) so UI state stays consistent with server state; keep
the optimistic flow (resolveIntegrationRoute, navigate) but ensure you reference
the existing symbols handleNavigate, markNotificationActed,
markIntegrationActed, unmarkIntegrationActed (or create it if missing), items,
resolveIntegrationRoute, navigate and add the error logging and rollback inside
the catch block.

In `@app/src/lib/notificationRouter.test.ts`:
- Around line 67-93: Add a unit test verifying the default fallback path from
resolveSystemRoute for unrecognized categories: create a system item via
makeSystem with an unknown category (e.g., 'unknown' or 'bogus') and assert
resolveSystemRoute(...) returns '/notifications'; this ensures the function's
default case is covered alongside the existing category-specific tests.

In `@app/src/lib/notificationRouter.ts`:
- Around line 46-47: Remove the redundant "[notification-router]" prefix from
the debug log messages and keep the existing debug namespace
(notifications:router) consistent; update the format strings passed to log(...)
in notificationRouter.ts (the calls around the return of n.deep_link and the
other log calls in the same functions) to drop the prefix and only include the
concise message and variables (e.g., change log('[notification-router]
integration id=%s explicit deep_link=%s', n.id, n.deep_link) to log('integration
id=%s explicit deep_link=%s', n.id, n.deep_link)), and apply the same
simplification to the other log calls referenced in these functions so messages
remain clear but not duplicated.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4767fb65-2b2c-47a2-9165-372984f7b946

📥 Commits

Reviewing files that changed from the base of the PR and between 4ba5b20 and b15b34d.

📒 Files selected for processing (8)
  • app/src/components/notifications/NotificationCard.tsx
  • app/src/components/notifications/NotificationCenter.tsx
  • app/src/lib/notificationRouter.test.ts
  • app/src/lib/notificationRouter.ts
  • app/src/pages/Notifications.tsx
  • app/src/store/__tests__/notificationsSlice.test.ts
  • app/src/store/notificationSlice.ts
  • app/src/types/notifications.ts
✅ Files skipped from review due to trivial changes (1)
  • app/src/types/notifications.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/pages/Notifications.tsx

@senamakel senamakel merged commit 23a18c9 into tinyhumansai:main Apr 29, 2026
9 checks passed
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.

Fix notification click routing: notifications should open relevant context [Bug] Notifications not working; add E2E coverage

3 participants