Skip to content

Fix notification unread persistence when workspaces regain focus - #971

Merged
austinywang merged 2 commits into
mainfrom
fix-963
Mar 6, 2026
Merged

austinywang merged 2 commits into
mainfrom
fix-963

Conversation

@austinywang

@austinywang austinywang commented Mar 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • store focused-workspace notifications while suppressing native delivery
  • stop auto-marking notifications read on app activation, workspace focus, panel focus, and notification-driven focus
  • update regression coverage and notification E2E expectations for persistent unread state

Verification

  • python3 -m py_compile tests/test_notifications.py tests_v2/test_notifications.py tests/test_focus_notification_dismiss.py tests_v2/test_focus_notification_dismiss.py
  • xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" build

Closes #963


Summary by cubic

Fixes unread notification persistence when the app, workspace, or panel regains focus. Notifications stay unread while focused panels still flash for visibility; native delivery is suppressed when focus is present.

  • Bug Fixes
    • Stop auto-marking notifications read on app activation, workspace/tab selection, panel focus, and notification-driven focus.
    • Store focused-workspace notifications but suppress native delivery when the app and target panel are focused.
    • Prevent auto-reordering tabs on incoming notifications when delivery is suppressed.
    • Remove markRead calls from focus paths; use focus-flash only.
    • Update E2E and regression tests to expect persistent unread state with focus flashes.

Written for commit 82f8a13. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes

    • Notifications are no longer auto-marked read when you focus their tab or panel; panels now flash to indicate focus while the notification remains unread. Native delivery is suppressed when the app and focused panel are active.
  • Tests

    • Updated and added tests to ensure unread notifications persist across app activation, tab/workspace switching, notification clicks, and focus navigation; test helpers improved for polling and workspace checks.

@vercel

vercel Bot commented Mar 6, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Mar 6, 2026 2:56am

@coderabbitai

coderabbitai Bot commented Mar 6, 2026 •

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bf85f108-dad7-4496-a16b-79aaddd8af5f

📥 Commits

Reviewing files that changed from the base of the PR and between 82f8a13 and 97f5d3a.

📒 Files selected for processing (6)
  • Sources/TerminalNotificationStore.swift
  • cmuxTests/CmuxWebViewKeyEquivalentTests.swift
  • tests/test_focus_notification_dismiss.py
  • tests/test_notifications.py
  • tests_v2/test_focus_notification_dismiss.py
  • tests_v2/test_notifications.py

📝 Walkthrough

Walkthrough

Notification handling was changed to preserve unread notifications for focused panels while providing a flash visual cue instead of auto-marking them read; native delivery is suppressed when focused but notifications are stored; tests and helpers updated accordingly.

Changes

Cohort / File(s) Summary
App lifecycle
Sources/AppDelegate.swift
Removed automatic marking of focused notifications as read during application activation and deleted the markReadIfFocused(...) helper.
Tab & focus handling
Sources/TabManager.swift
Replaced read-marking helpers with flash-based equivalents (markFocusedPanelReadIfActive → flashFocusedPanelIfUnreadAndActive, markPanelReadOnFocusIfActive → flashPanelIfUnreadAndActive) and swapped call-sites to flash instead of mark-as-read.
Notification storage & delivery
Sources/TerminalNotificationStore.swift
Stop early-return when app+panel focused; suppress native delivery but store notifications, conditionally avoid moving tabs-to-top when suppression active, centralized display title and custom-command logic into notificationDisplayTitle and runNotificationCustomCommand.
Unit tests
cmuxTests/CmuxWebViewKeyEquivalentTests.swift
Added tests verifying unread preservation when native delivery is suppressed and updated teardown to reset global focus/AppDelegate state.
End-to-end tests (v1 & v2)
tests/test_focus_notification_dismiss.py, tests_v2/test_focus_notification_dismiss.py
Updated E2E expectations: focusing a panel preserves the unread notification and triggers a flash; assertions and pass messages updated accordingly.
Notification test suite
tests/test_notifications.py, tests_v2/test_notifications.py
Renamed tests from test_mark_read_on_* → test_preserve_unread_on_*, added wait_for_current_workspace helper, replaced sleeps with polling, and updated assertions to expect preserved unread state and flash behavior.

Sequence Diagram(s)

sequenceDiagram
    participant Source as Notification Source
    participant Store as TerminalNotificationStore
    participant App as AppDelegate/FocusState
    participant UI as TabManager/PanelView
    participant System as macOS Notification Center

    Source->>Store: deliver(notification)
    Store->>App: query isAppFocused && isFocusedPanel
    alt app+panel focused (suppress)
        Store->>Store: store(notification) rgba(0,128,255,0.5)
        Store-->>System: (skip scheduling) 
        Store->>UI: trigger flash for focused panel rgba(255,215,0,0.5)
    else not focused
        Store->>Store: store(notification) rgba(0,128,255,0.5)
        Store->>System: scheduleUserNotification(notification)
    end
    UI->>UI: flash panel (visual cue)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐇 I stored the ring, I kept it bright,
I blink and shimmer in the night.
No silent read, the badge stays true,
A flash to nudge — from me to you. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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
Title check ✅ Passed Title accurately summarizes the main objective: fixing notification unread persistence when workspaces regain focus.
Description check ✅ Passed Description covers all template sections: summary of changes, verification steps, and issue closure reference.
Linked Issues check ✅ Passed Changes address all primary objectives from #963: store focused notifications while suppressing native delivery, prevent auto-read marking, and preserve unread state with visual feedback.
Out of Scope Changes check ✅ Passed All changes directly relate to the notification persistence fix: backend storage logic, removal of auto-read marking, method renaming for flash-based feedback, and corresponding test updates.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix-963

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

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2 issues found across 8 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="cmuxTests/CmuxWebViewKeyEquivalentTests.swift">

<violation number="1" location="cmuxTests/CmuxWebViewKeyEquivalentTests.swift:7307">
P2: Avoid fixed-time async delays in this test; the hardcoded `0.1s` wait can make CI runs flaky and slower. Use a deterministic queue-drain or event-based expectation instead.

(Based on your team's feedback about avoiding fixed sleeps in tests to reduce flakiness.) [FEEDBACK_USED]</violation>
</file>

<file name="tests/test_focus_notification_dismiss.py">

<violation number="1" location="tests/test_focus_notification_dismiss.py:77">
P2: The post-focus unread assertion is too weak and can pass even if the notification becomes read shortly after focus, causing a false-negative regression test.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread cmuxTests/CmuxWebViewKeyEquivalentTests.swift Outdated
Comment thread tests/test_focus_notification_dismiss.py Outdated
@greptile-apps

greptile-apps Bot commented Mar 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR changes the notification lifecycle so that unread state persists when a workspace regains focus — previously, focusing a panel, switching workspaces, or activating the app would silently mark notifications as read. Now the internal TerminalNotificationStore still stores notifications for the currently-focused panel (so the dock badge and in-app UI stay accurate), but skips native OS delivery for them.

Key changes:

  • TerminalNotificationStore.addNotification: replaces the early-return-and-discard path for focused panels with a suppressNativeDelivery flag; the notification is always written to the store with isRead: false, but scheduleUserNotification is only called when the panel is not currently focused.
  • AppDelegate / TabManager: removes all markRead calls that were triggered by focus events (app activation, workspace selection, panel focus, notification-driven navigation); the private markReadIfFocused helper is deleted entirely.
  • Tests: four new unit tests cover the storage and persistence guarantees for each removed auto-read path; E2E assertions in tests/ and tests_v2/ are inverted to expect is_read=False on focus; tearDown is updated to clean up the new test state (AppFocusState.overrideIsFocused, AppDelegate.shared).
  • The only remaining automatic markRead paths are explicit user actions: dismissing an OS notification banner, using the "Mark Read" context menu in the UI, and closing a workspace.

Confidence Score: 5/5

  • Safe to merge — logic change is well-scoped, all affected call sites are updated, and regression coverage is thorough.
  • The diff is a clean removal of auto-mark-read calls across three independent trigger paths, with the store-level change being the only non-trivial logic delta. Existing markRead paths (user dismiss, context menu, workspace close) are untouched. Four new unit tests directly cover the removed paths, and E2E expectations are correctly inverted. The one style note (missing AppFocusState.overrideIsFocused in one test) does not affect correctness.
  • No files require special attention.

Important Files Changed

Filename Overview
Sources/TerminalNotificationStore.swift Core logic change: focused-panel notifications are now stored in the internal store even when native OS delivery is suppressed. The early-return that previously discarded notifications for the active panel is replaced with a suppressNativeDelivery flag that only skips scheduleUserNotification. Workspace auto-reorder is correctly gated behind the same flag.
Sources/AppDelegate.swift Removes three call sites of markRead: (1) after notification-driven panel focus flash in the notification response handler, (2) in markReadIfFocused (the private helper itself is deleted), and (3) after applicationDidBecomeActive focus flash. Only the UNNotificationDismissActionIdentifier dismiss path still calls markRead, which is intentional.
Sources/TabManager.swift Removes markRead from two focus-flash paths (workspace switch and focusTabFromNotification), and renames the private helpers from markPanelRead…/markFocusedPanelRead… to flash… to reflect their reduced responsibility. Logic is otherwise unchanged.
cmuxTests/CmuxWebViewKeyEquivalentTests.swift Adds four new unit tests covering focused-tab notification storage, app-activation persistence, workspace-switch persistence, and notification-focus-navigation persistence. Also adds AppFocusState.overrideIsFocused = nil and AppDelegate.shared = nil to tearDown to prevent cross-test pollution introduced by the new tests.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[addNotification called] --> B{isAppFocused AND isFocusedPanel?}
    B -- Yes --> C[suppressNativeDelivery = true]
    B -- No --> D[suppressNativeDelivery = false]
    C --> E[Remove old notifications for tab/surface]
    D --> E
    E --> F{suppressNativeDelivery?}
    F -- No --> G[moveTabToTop via WorkspaceAutoReorderSettings]
    F -- Yes --> H
    G --> H[Create TerminalNotification with isRead=false]
    H --> I[Insert into internal store]
    I --> J[Remove old IDs from OS notification center]
    J --> K{suppressNativeDelivery?}
    K -- No --> L[scheduleUserNotification]
    K -- Yes --> M[Skip OS notification\nNotification stored as unread internally]

    subgraph FocusEvents [Focus / Activation Events - REMOVED markRead calls]
        N[applicationDidBecomeActive] --> O[triggerNotificationFocusFlash only]
        P[selectWorkspace / tab switch] --> Q[flashFocusedPanelIfUnreadAndActive only]
        R[focusTabFromNotification] --> S[triggerNotificationFocusFlash only]
    end

    subgraph MarkReadPaths [Remaining markRead Paths]
        T[User dismisses OS notification\nUNNotificationDismissActionIdentifier] --> U[markRead by ID]
        V[Workspace.markPanelRead via UI] --> W[markRead forTabId+surfaceId]
        X[TerminalController workspace close] --> Y[markRead forTabId]
    end
Loading

Last reviewed commit: 82f8a13

Comment thread cmuxTests/CmuxWebViewKeyEquivalentTests.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
cmuxTests/CmuxWebViewKeyEquivalentTests.swift (1)

7306-7308: Remove fixed-delay synchronization in test.

Line 7307 uses a 0.1-second explicit delay before draining the queue. Replace with deterministic DispatchQueue.main.async to reduce test latency and eliminate timing sensitivity.

♻️ Proposed change
-        let drained = expectation(description: "notification focus drained")
-        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { drained.fulfill() }
-        wait(for: [drained], timeout: 1.0)
+        let drained = expectation(description: "notification focus drained")
+        DispatchQueue.main.async { drained.fulfill() }
+        wait(for: [drained], timeout: 1.0)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift` around lines 7306 - 7308,
Replace the fixed 0.1s delay used to drain the main queue: find the expectation
named `drained` and the `DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
drained.fulfill() }` call and change it to use `DispatchQueue.main.async {
drained.fulfill() }` so the test deterministically waits for the next runloop
turn without an arbitrary sleep; keep the existing `wait(for: [drained],
timeout: 1.0)` assertion unchanged.
tests/test_notifications.py (1)

218-307: Prefer polling over fixed 100ms sleeps in unread-preservation tests.

Line 232, Line 236, Line 261, Line 269, Line 290, Line 297 use fixed time.sleep(0.1) before assertions. These transitions are async and can intermittently race in CI. Please switch these checkpoints to existing polling helpers (wait_for_notifications / wait_for_flash_count) to reduce flakes.

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

In `@tests/test_notifications.py` around lines 218 - 307, Replace the fixed 0.1s
time.sleep calls in the unread-preservation tests with the existing polling
helpers to avoid races: in test_preserve_unread_on_focus_change, replace the
sleeps after client.notify_surface(other[0], ...),
client.focus_surface(other[0]) with wait_for_flash_count(client, other[1],
minimum=1, timeout=...) (or wait_for_notifications to detect the new
notification) and use that result before asserting; in
test_preserve_unread_on_app_active, after client.notify("activate") and after
client.simulate_app_active() poll with wait_for_notifications(...) to ensure the
notification appears and remains unread before checking items; in
test_preserve_unread_on_tab_switch, replace sleeps after
client.notify("tabswitch"), client.new_workspace(), and
client.select_workspace(tab1) with wait_for_notifications(...) (and/or
wait_for_flash_count where appropriate) to wait for the notification to be
present for tab1 before asserting. Ensure time.sleep removals are paired with
sensible timeouts so assertions only run after the async events complete.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/TerminalNotificationStore.swift`:
- Around line 858-860: When suppressNativeDelivery is true the call to
scheduleUserNotification(notification) is skipped which prevents
NotificationSoundSettings.runCustomCommand(...) from executing; ensure custom
command hooks still run even when native delivery is suppressed by invoking
NotificationSoundSettings.runCustomCommand(...) (or a helper that triggers the
same hook) in the branch where scheduleUserNotification would be skipped. Update
the logic around suppressNativeDelivery and scheduleUserNotification so that
after deciding not to call scheduleUserNotification(notification) you still call
NotificationSoundSettings.runCustomCommand(for: notification) (or refactor to a
shared method invoked in both paths) and preserve any existing early-return
behavior.

---

Nitpick comments:
In `@cmuxTests/CmuxWebViewKeyEquivalentTests.swift`:
- Around line 7306-7308: Replace the fixed 0.1s delay used to drain the main
queue: find the expectation named `drained` and the
`DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { drained.fulfill() }`
call and change it to use `DispatchQueue.main.async { drained.fulfill() }` so
the test deterministically waits for the next runloop turn without an arbitrary
sleep; keep the existing `wait(for: [drained], timeout: 1.0)` assertion
unchanged.

In `@tests/test_notifications.py`:
- Around line 218-307: Replace the fixed 0.1s time.sleep calls in the
unread-preservation tests with the existing polling helpers to avoid races: in
test_preserve_unread_on_focus_change, replace the sleeps after
client.notify_surface(other[0], ...), client.focus_surface(other[0]) with
wait_for_flash_count(client, other[1], minimum=1, timeout=...) (or
wait_for_notifications to detect the new notification) and use that result
before asserting; in test_preserve_unread_on_app_active, after
client.notify("activate") and after client.simulate_app_active() poll with
wait_for_notifications(...) to ensure the notification appears and remains
unread before checking items; in test_preserve_unread_on_tab_switch, replace
sleeps after client.notify("tabswitch"), client.new_workspace(), and
client.select_workspace(tab1) with wait_for_notifications(...) (and/or
wait_for_flash_count where appropriate) to wait for the notification to be
present for tab1 before asserting. Ensure time.sleep removals are paired with
sensible timeouts so assertions only run after the async events complete.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 472384eb-c8c9-4344-8ddb-f3b085f306c3

📥 Commits

Reviewing files that changed from the base of the PR and between 0fb2b41 and 82f8a13.

📒 Files selected for processing (8)
  • Sources/AppDelegate.swift
  • Sources/TabManager.swift
  • Sources/TerminalNotificationStore.swift
  • cmuxTests/CmuxWebViewKeyEquivalentTests.swift
  • tests/test_focus_notification_dismiss.py
  • tests/test_notifications.py
  • tests_v2/test_focus_notification_dismiss.py
  • tests_v2/test_notifications.py
💤 Files with no reviewable changes (1)
  • Sources/AppDelegate.swift

Comment thread Sources/TerminalNotificationStore.swift Outdated
@austinywang
austinywang merged commit 5f43a3f into main Mar 6, 2026
10 of 12 checks passed
@austinywang
austinywang deleted the fix-963 branch March 6, 2026 02:56

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 6 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="cmuxTests/CmuxWebViewKeyEquivalentTests.swift">

<violation number="1" location="cmuxTests/CmuxWebViewKeyEquivalentTests.swift:7308">
P2: This async drain can finish before notification-focus delayed side effects run, so the test may assert too early and miss regressions.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId)

let drained = expectation(description: "notification focus drained")
DispatchQueue.main.async { drained.fulfill() }

@cubic-dev-ai cubic-dev-ai Bot Mar 6, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: This async drain can finish before notification-focus delayed side effects run, so the test may assert too early and miss regressions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmuxTests/CmuxWebViewKeyEquivalentTests.swift, line 7308:

<comment>This async drain can finish before notification-focus delayed side effects run, so the test may assert too early and miss regressions.</comment>

<file context>
@@ -7304,7 +7305,7 @@ final class NotificationDockBadgeTests: XCTestCase {
 
         let drained = expectation(description: "notification focus drained")
-        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { drained.fulfill() }
+        DispatchQueue.main.async { drained.fulfill() }
         wait(for: [drained], timeout: 1.0)
 
</file context>
Fix with Cubic

austinywang added a commit that referenced this pull request Mar 6, 2026
austinywang added a commit that referenced this pull request Mar 6, 2026
bn-l pushed a commit to bn-l/cmux that referenced this pull request Apr 3, 2026
…aflow-ai#971)

* Fix notification unread persistence on focus

* Address review feedback on notification unread fix
bn-l pushed a commit to bn-l/cmux that referenced this pull request Apr 3, 2026
israeligal pushed a commit to israeligal/cmux that referenced this pull request May 2, 2026

This branch was successfully deployed

1 active deployment
Preview — 97f5d3a5 Deployed Mar 6, 2026 by vercel[bot]
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.

Notification ring silently dropped when workspace is focused

1 participant