diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 373778a4..dfbff6c2 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1837,7 +1837,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let tab = tabManager.tabs.first(where: { $0.id == tabId }) { tab.triggerNotificationFocusFlash(panelId: surfaceId, requiresSplit: false, shouldFocus: false) } - notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { @@ -8123,16 +8122,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) #endif - if let notificationId, let store = notificationStore { - markReadIfFocused( - notificationId: notificationId, - tabId: tabId, - surfaceId: surfaceId, - tabManager: context.tabManager, - notificationStore: store - ) - } - #if DEBUG recordMultiWindowNotificationFocusIfNeeded( windowId: context.windowId, @@ -8186,15 +8175,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) #endif - if let notificationId, let store = notificationStore { - markReadIfFocused( - notificationId: notificationId, - tabId: tabId, - surfaceId: surfaceId, - tabManager: tabManager, - notificationStore: store - ) - } #if DEBUG if ProcessInfo.processInfo.environment["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { writeJumpUnreadTestData(["jumpUnreadOpenInFallback": "1", "jumpUnreadOpenResult": "1"]) @@ -8254,22 +8234,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) } - private func markReadIfFocused( - notificationId: UUID, - tabId: UUID, - surfaceId: UUID?, - tabManager: TabManager, - notificationStore: TerminalNotificationStore - ) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - guard tabManager.selectedTabId == tabId else { return } - if let surfaceId { - guard tabManager.focusedSurfaceId(for: tabId) == surfaceId else { return } - } - notificationStore.markRead(id: notificationId) - } - } - #if DEBUG private func recordMultiWindowNotificationOpenFailureIfNeeded( tabId: UUID, diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index abdb3ea6..528669a6 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -599,7 +599,7 @@ class TabManager: ObservableObject { self.focusSelectedTabPanel(previousTabId: previousTabId) self.updateWindowTitleForSelectedTab() if let selectedTabId = self.selectedTabId { - self.markFocusedPanelReadIfActive(tabId: selectedTabId) + self.flashFocusedPanelIfUnreadAndActive(tabId: selectedTabId) } #if DEBUG let dtMs = self.debugWorkspaceSwitchStartTime > 0 @@ -671,7 +671,7 @@ class TabManager: ObservableObject { guard let self else { return } guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID else { return } guard let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { return } - markPanelReadOnFocusIfActive(tabId: tabId, panelId: surfaceId) + flashPanelIfUnreadAndActive(tabId: tabId, panelId: surfaceId) } }) @@ -1596,16 +1596,16 @@ class TabManager: ObservableObject { selectedTabId != pendingTabId } - private func markFocusedPanelReadIfActive(tabId: UUID) { + private func flashFocusedPanelIfUnreadAndActive(tabId: UUID) { let shouldSuppressFlash = suppressFocusFlash suppressFocusFlash = false guard !shouldSuppressFlash else { return } guard AppFocusState.isAppActive() else { return } guard let panelId = focusedPanelId(for: tabId) else { return } - markPanelReadOnFocusIfActive(tabId: tabId, panelId: panelId) + flashPanelIfUnreadAndActive(tabId: tabId, panelId: panelId) } - private func markPanelReadOnFocusIfActive(tabId: UUID, panelId: UUID) { + private func flashPanelIfUnreadAndActive(tabId: UUID, panelId: UUID) { guard selectedTabId == tabId else { return } guard !suppressFocusFlash else { return } guard AppFocusState.isAppActive() else { return } @@ -1614,7 +1614,6 @@ class TabManager: ObservableObject { if let tab = tabs.first(where: { $0.id == tabId }) { tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false) } - notificationStore.markRead(forTabId: tabId, surfaceId: panelId) } private func enqueuePanelTitleUpdate(tabId: UUID, panelId: UUID, title: String) { @@ -1740,7 +1739,6 @@ class TabManager: ObservableObject { guard let notificationStore = AppDelegate.shared?.notificationStore else { return } guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: targetPanelId) else { return } tab.triggerNotificationFocusFlash(panelId: targetPanelId, requiresSplit: false, shouldFocus: true) - notificationStore.markRead(forTabId: tabId, surfaceId: targetPanelId) } } diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 5bb768cb..1f1dac18 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -833,16 +833,9 @@ final class TerminalNotificationStore: ObservableObject { let isFocusedSurface = surfaceId == nil || focusedSurfaceId == surfaceId let isFocusedPanel = isActiveTab && isFocusedSurface let isAppFocused = AppFocusState.isAppFocused() - if isAppFocused && isFocusedPanel { - if !idsToClear.isEmpty { - notifications = updated - center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) - center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) - } - return - } + let suppressNativeDelivery = isAppFocused && isFocusedPanel - if WorkspaceAutoReorderSettings.isEnabled() { + if WorkspaceAutoReorderSettings.isEnabled() && !suppressNativeDelivery { AppDelegate.shared?.tabManager?.moveTabToTop(tabId) } @@ -862,7 +855,11 @@ final class TerminalNotificationStore: ObservableObject { center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } - scheduleUserNotification(notification) + if suppressNativeDelivery { + Self.runNotificationCustomCommand(notification) + } else { + scheduleUserNotification(notification) + } } func markRead(id: UUID) { @@ -993,10 +990,7 @@ final class TerminalNotificationStore: ObservableObject { guard let self, authorized else { return } let content = UNMutableNotificationContent() - let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String - ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String - ?? "cmux" - content.title = notification.title.isEmpty ? appName : notification.title + content.title = Self.notificationDisplayTitle(notification) content.subtitle = notification.subtitle content.body = notification.body content.sound = NotificationSoundSettings.sound() @@ -1019,16 +1013,27 @@ final class TerminalNotificationStore: ObservableObject { if let error { NSLog("Failed to schedule notification: \(error)") } else { - NotificationSoundSettings.runCustomCommand( - title: content.title, - subtitle: content.subtitle, - body: content.body - ) + Self.runNotificationCustomCommand(notification) } } } } + nonisolated private static func notificationDisplayTitle(_ notification: TerminalNotification) -> String { + let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String + ?? "cmux" + return notification.title.isEmpty ? appName : notification.title + } + + nonisolated private static func runNotificationCustomCommand(_ notification: TerminalNotification) { + NotificationSoundSettings.runCustomCommand( + title: notificationDisplayTitle(notification), + subtitle: notification.subtitle, + body: notification.body + ) + } + private func ensureAuthorization( origin: AuthorizationRequestOrigin, _ completion: @escaping (Bool) -> Void diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 7d428888..114c7b2b 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -6756,6 +6756,8 @@ final class NotificationDockBadgeTests: XCTestCase { } override func tearDown() { + AppFocusState.overrideIsFocused = nil + AppDelegate.shared = nil TerminalNotificationStore.shared.resetNotificationSettingsPromptHooksForTesting() TerminalNotificationStore.shared.replaceNotificationsForTesting([]) super.tearDown() @@ -7259,6 +7261,155 @@ final class NotificationDockBadgeTests: XCTestCase { XCTAssertEqual(store.latestNotification(forTabId: tabB)?.id, notificationBUnread.id) } + func testFocusedTabNotificationIsStoredWhenNativeDeliveryIsSuppressed() { + let store = TerminalNotificationStore.shared + store.replaceNotificationsForTesting([]) + + let appDelegate = AppDelegate() + let tabManager = TabManager() + appDelegate.tabManager = tabManager + AppDelegate.shared = appDelegate + AppFocusState.overrideIsFocused = true + + guard let tabId = tabManager.selectedTabId else { + XCTFail("Expected selected tab for notification test") + return + } + + store.addNotification( + tabId: tabId, + surfaceId: nil, + title: "Needs input", + subtitle: "", + body: "agent requires user action" + ) + + XCTAssertEqual(store.unreadCount(forTabId: tabId), 1) + guard let latest = store.latestNotification(forTabId: tabId) else { + XCTFail("Expected notification to be stored for focused tab") + return + } + XCTAssertEqual(latest.tabId, tabId) + XCTAssertEqual(latest.title, "Needs input") + XCTAssertEqual(latest.body, "agent requires user action") + XCTAssertFalse(latest.isRead) + } + + func testApplicationDidBecomeActiveDoesNotMarkFocusedNotificationRead() { + let store = TerminalNotificationStore.shared + let appDelegate = AppDelegate() + let tabManager = TabManager() + appDelegate.tabManager = tabManager + appDelegate.notificationStore = store + AppDelegate.shared = appDelegate + AppFocusState.overrideIsFocused = true + + guard let tabId = tabManager.selectedTabId, + let surfaceId = tabManager.focusedSurfaceId(for: tabId) else { + XCTFail("Expected selected tab and focused surface for activation test") + return + } + + let notification = TerminalNotification( + id: UUID(), + tabId: tabId, + surfaceId: surfaceId, + title: "Unread", + subtitle: "", + body: "should persist across app activation", + createdAt: Date(), + isRead: false + ) + store.replaceNotificationsForTesting([notification]) + + appDelegate.applicationDidBecomeActive( + Notification(name: NSApplication.didBecomeActiveNotification) + ) + + XCTAssertTrue(store.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId)) + XCTAssertFalse(store.notifications[0].isRead) + } + + func testSelectingWorkspaceDoesNotMarkFocusedNotificationRead() { + let store = TerminalNotificationStore.shared + let appDelegate = AppDelegate() + let tabManager = TabManager() + appDelegate.tabManager = tabManager + appDelegate.notificationStore = store + AppDelegate.shared = appDelegate + AppFocusState.overrideIsFocused = true + + guard let originalTabId = tabManager.selectedTabId, + let originalSurfaceId = tabManager.focusedSurfaceId(for: originalTabId) else { + XCTFail("Expected selected tab and focused surface for workspace selection test") + return + } + guard let originalWorkspace = tabManager.tabs.first(where: { $0.id == originalTabId }) else { + XCTFail("Expected original workspace for workspace selection test") + return + } + + let notification = TerminalNotification( + id: UUID(), + tabId: originalTabId, + surfaceId: originalSurfaceId, + title: "Unread", + subtitle: "", + body: "should persist across workspace selection", + createdAt: Date(), + isRead: false + ) + store.replaceNotificationsForTesting([notification]) + + _ = tabManager.addWorkspace(select: true) + tabManager.selectWorkspace(originalWorkspace) + + let drained = expectation(description: "workspace selection side effects drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 1.0) + + XCTAssertEqual(tabManager.selectedTabId, originalTabId) + XCTAssertTrue(store.hasUnreadNotification(forTabId: originalTabId, surfaceId: originalSurfaceId)) + XCTAssertFalse(store.notifications[0].isRead) + } + + func testNotificationFocusNavigationDoesNotMarkNotificationRead() { + let store = TerminalNotificationStore.shared + let appDelegate = AppDelegate() + let tabManager = TabManager() + appDelegate.tabManager = tabManager + appDelegate.notificationStore = store + AppDelegate.shared = appDelegate + AppFocusState.overrideIsFocused = true + + guard let tabId = tabManager.selectedTabId, + let surfaceId = tabManager.focusedSurfaceId(for: tabId) else { + XCTFail("Expected selected tab and focused surface for notification focus test") + return + } + + let notification = TerminalNotification( + id: UUID(), + tabId: tabId, + surfaceId: surfaceId, + title: "Unread", + subtitle: "", + body: "should persist after notification focus", + createdAt: Date(), + isRead: false + ) + store.replaceNotificationsForTesting([notification]) + + tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) + + let drained = expectation(description: "notification focus drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 1.0) + + XCTAssertTrue(store.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId)) + XCTAssertFalse(store.notifications[0].isRead) + } + func testNotificationIndexesUpdateAfterReadAndClearMutations() { let tab = UUID() let surfaceUnread = UUID() diff --git a/tests/test_focus_notification_dismiss.py b/tests/test_focus_notification_dismiss.py index d7569b65..14cef434 100755 --- a/tests/test_focus_notification_dismiss.py +++ b/tests/test_focus_notification_dismiss.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -E2E: focusing a panel clears its notification and triggers a flash. +E2E: focusing a panel preserves its notification and triggers a flash. Note: This uses the socket focus command (no assistive access needed). """ @@ -74,8 +74,12 @@ def main() -> int: client.send("x") time.sleep(0.2) - if not wait_for_notification(client, surface_id, is_read=True, timeout=2.0): - print("FAIL: Notification did not become read after focus") + if wait_for_notification(client, surface_id, is_read=True, timeout=2.0): + print("FAIL: Notification became read after focus") + return 1 + items = client.list_notifications() + if not any(item["surface_id"] == surface_id and not item["is_read"] for item in items): + print("FAIL: Notification did not remain present and unread after focus") return 1 final_flash = client.flash_count(term_b) @@ -93,7 +97,7 @@ def main() -> int: except Exception: pass - print("PASS: Focus clears notification and flashes panel") + print("PASS: Focus preserves notification and flashes panel") return 0 except (cmuxError, RuntimeError) as exc: print(f"FAIL: {exc}") diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 1ac25c4b..23b4bf10 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -58,6 +58,15 @@ def wait_for_flash_count(client: cmux, surface: str, minimum: int = 1, timeout: return last +def wait_for_current_workspace(client: cmux, expected: str, timeout: float = 2.0) -> bool: + start = time.time() + while time.time() - start < timeout: + if client.current_workspace() == expected: + return True + time.sleep(0.05) + return client.current_workspace() == expected + + def ensure_two_surfaces(client: cmux) -> list[tuple[int, str, bool]]: surfaces = client.list_surfaces() if len(surfaces) < 2: @@ -215,8 +224,8 @@ def test_rxvt_notification_osc777(client: cmux) -> TestResult: return result -def test_mark_read_on_focus_change(client: cmux) -> TestResult: - result = TestResult("Mark Read On Panel Focus") +def test_preserve_unread_on_focus_change(client: cmux) -> TestResult: + result = TestResult("Preserve Unread On Panel Focus") try: client.clear_notifications() client.reset_flash_counts() @@ -229,81 +238,88 @@ def test_mark_read_on_focus_change(client: cmux) -> TestResult: client.set_app_focus(False) client.notify_surface(other[0], "focusread") - time.sleep(0.1) + items = wait_for_notifications(client, 1) + target = next((n for n in items if n["surface_id"] == other[1]), None) + if target is None or target["is_read"]: + result.failure("Expected unread notification for target surface before focus") + return result client.set_app_focus(True) client.focus_surface(other[0]) - time.sleep(0.1) + count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0) + if count < 1: + result.failure("Expected flash on panel focus") + return result items = client.list_notifications() target = next((n for n in items if n["surface_id"] == other[1]), None) if target is None: result.failure("Expected notification for target surface") - elif not target["is_read"]: - result.failure("Expected notification to be marked read on focus") + elif target["is_read"]: + result.failure("Expected notification to remain unread on focus") else: - count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0) - if count < 1: - result.failure("Expected flash on panel focus dismissal") - else: - result.success("Notification marked read on focus") + result.success("Notification persisted across panel focus") except Exception as e: result.failure(f"Exception: {e}") return result -def test_mark_read_on_app_active(client: cmux) -> TestResult: - result = TestResult("Mark Read On App Active") +def test_preserve_unread_on_app_active(client: cmux) -> TestResult: + result = TestResult("Preserve Unread On App Active") try: client.clear_notifications() client.set_app_focus(False) client.notify("activate") - time.sleep(0.1) - - items = client.list_notifications() + items = wait_for_notifications(client, 1) if not items or items[0]["is_read"]: result.failure("Expected unread notification before activation") return result client.simulate_app_active() - time.sleep(0.1) - - items = client.list_notifications() + items = wait_for_notifications(client, 1) if not items: result.failure("Expected notification to remain after activation") - elif not items[0]["is_read"]: - result.failure("Expected notification to be marked read on app active") + elif items[0]["is_read"]: + result.failure("Expected notification to remain unread on app active") else: - result.success("Notification marked read on app active") + result.success("Notification persisted across app activation") except Exception as e: result.failure(f"Exception: {e}") return result -def test_mark_read_on_tab_switch(client: cmux) -> TestResult: - result = TestResult("Mark Read On Tab Switch") +def test_preserve_unread_on_tab_switch(client: cmux) -> TestResult: + result = TestResult("Preserve Unread On Tab Switch") try: client.clear_notifications() client.set_app_focus(False) tab1 = client.current_workspace() client.notify("tabswitch") - time.sleep(0.1) + items = wait_for_notifications(client, 1) + target = next((n for n in items if n["workspace_id"] == tab1), None) + if target is None or target["is_read"]: + result.failure("Expected unread notification for original tab before switching") + return result tab2 = client.new_workspace() - time.sleep(0.1) + if not wait_for_current_workspace(client, tab2): + result.failure("Expected new workspace to become selected") + return result client.set_app_focus(True) client.select_workspace(tab1) - time.sleep(0.1) + if not wait_for_current_workspace(client, tab1): + result.failure("Expected original workspace to become selected again") + return result - items = client.list_notifications() + items = wait_for_notifications(client, 1) target = next((n for n in items if n["workspace_id"] == tab1), None) if target is None: result.failure("Expected notification for original tab") - elif not target["is_read"]: - result.failure("Expected notification to be marked read on tab switch") + elif target["is_read"]: + result.failure("Expected notification to remain unread on tab switch") else: - result.success("Notification marked read on tab switch") + result.success("Notification persisted across tab switch") except Exception as e: result.failure(f"Exception: {e}") return result @@ -371,11 +387,20 @@ def test_focus_on_notification_click(client: cmux) -> TestResult: result.failure("Expected notification surface to be focused") return result + items = client.list_notifications() + notification = next((n for n in items if n["surface_id"] == other[1]), None) + if notification is None: + result.failure("Expected notification to remain listed after notification click") + return result + if notification["is_read"]: + result.failure("Expected notification click to preserve unread state") + return result + count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0) if count < 1: result.failure(f"Expected flash count >= 1, got {count}") else: - result.success("Notification click focuses and flashes panel") + result.success("Notification click focuses, flashes, and preserves unread state") except Exception as e: result.failure(f"Exception: {e}") return result @@ -455,9 +480,9 @@ def run_tests() -> int: results.append(test_kitty_notification_simple(client)) results.append(test_kitty_notification_chunked(client)) results.append(test_rxvt_notification_osc777(client)) - results.append(test_mark_read_on_focus_change(client)) - results.append(test_mark_read_on_app_active(client)) - results.append(test_mark_read_on_tab_switch(client)) + results.append(test_preserve_unread_on_focus_change(client)) + results.append(test_preserve_unread_on_app_active(client)) + results.append(test_preserve_unread_on_tab_switch(client)) results.append(test_flash_on_tab_switch(client)) results.append(test_focus_on_notification_click(client)) results.append(test_restore_focus_on_tab_switch(client)) diff --git a/tests_v2/test_focus_notification_dismiss.py b/tests_v2/test_focus_notification_dismiss.py index d7569b65..14cef434 100755 --- a/tests_v2/test_focus_notification_dismiss.py +++ b/tests_v2/test_focus_notification_dismiss.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -E2E: focusing a panel clears its notification and triggers a flash. +E2E: focusing a panel preserves its notification and triggers a flash. Note: This uses the socket focus command (no assistive access needed). """ @@ -74,8 +74,12 @@ def main() -> int: client.send("x") time.sleep(0.2) - if not wait_for_notification(client, surface_id, is_read=True, timeout=2.0): - print("FAIL: Notification did not become read after focus") + if wait_for_notification(client, surface_id, is_read=True, timeout=2.0): + print("FAIL: Notification became read after focus") + return 1 + items = client.list_notifications() + if not any(item["surface_id"] == surface_id and not item["is_read"] for item in items): + print("FAIL: Notification did not remain present and unread after focus") return 1 final_flash = client.flash_count(term_b) @@ -93,7 +97,7 @@ def main() -> int: except Exception: pass - print("PASS: Focus clears notification and flashes panel") + print("PASS: Focus preserves notification and flashes panel") return 0 except (cmuxError, RuntimeError) as exc: print(f"FAIL: {exc}") diff --git a/tests_v2/test_notifications.py b/tests_v2/test_notifications.py index 1ac25c4b..23b4bf10 100644 --- a/tests_v2/test_notifications.py +++ b/tests_v2/test_notifications.py @@ -58,6 +58,15 @@ def wait_for_flash_count(client: cmux, surface: str, minimum: int = 1, timeout: return last +def wait_for_current_workspace(client: cmux, expected: str, timeout: float = 2.0) -> bool: + start = time.time() + while time.time() - start < timeout: + if client.current_workspace() == expected: + return True + time.sleep(0.05) + return client.current_workspace() == expected + + def ensure_two_surfaces(client: cmux) -> list[tuple[int, str, bool]]: surfaces = client.list_surfaces() if len(surfaces) < 2: @@ -215,8 +224,8 @@ def test_rxvt_notification_osc777(client: cmux) -> TestResult: return result -def test_mark_read_on_focus_change(client: cmux) -> TestResult: - result = TestResult("Mark Read On Panel Focus") +def test_preserve_unread_on_focus_change(client: cmux) -> TestResult: + result = TestResult("Preserve Unread On Panel Focus") try: client.clear_notifications() client.reset_flash_counts() @@ -229,81 +238,88 @@ def test_mark_read_on_focus_change(client: cmux) -> TestResult: client.set_app_focus(False) client.notify_surface(other[0], "focusread") - time.sleep(0.1) + items = wait_for_notifications(client, 1) + target = next((n for n in items if n["surface_id"] == other[1]), None) + if target is None or target["is_read"]: + result.failure("Expected unread notification for target surface before focus") + return result client.set_app_focus(True) client.focus_surface(other[0]) - time.sleep(0.1) + count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0) + if count < 1: + result.failure("Expected flash on panel focus") + return result items = client.list_notifications() target = next((n for n in items if n["surface_id"] == other[1]), None) if target is None: result.failure("Expected notification for target surface") - elif not target["is_read"]: - result.failure("Expected notification to be marked read on focus") + elif target["is_read"]: + result.failure("Expected notification to remain unread on focus") else: - count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0) - if count < 1: - result.failure("Expected flash on panel focus dismissal") - else: - result.success("Notification marked read on focus") + result.success("Notification persisted across panel focus") except Exception as e: result.failure(f"Exception: {e}") return result -def test_mark_read_on_app_active(client: cmux) -> TestResult: - result = TestResult("Mark Read On App Active") +def test_preserve_unread_on_app_active(client: cmux) -> TestResult: + result = TestResult("Preserve Unread On App Active") try: client.clear_notifications() client.set_app_focus(False) client.notify("activate") - time.sleep(0.1) - - items = client.list_notifications() + items = wait_for_notifications(client, 1) if not items or items[0]["is_read"]: result.failure("Expected unread notification before activation") return result client.simulate_app_active() - time.sleep(0.1) - - items = client.list_notifications() + items = wait_for_notifications(client, 1) if not items: result.failure("Expected notification to remain after activation") - elif not items[0]["is_read"]: - result.failure("Expected notification to be marked read on app active") + elif items[0]["is_read"]: + result.failure("Expected notification to remain unread on app active") else: - result.success("Notification marked read on app active") + result.success("Notification persisted across app activation") except Exception as e: result.failure(f"Exception: {e}") return result -def test_mark_read_on_tab_switch(client: cmux) -> TestResult: - result = TestResult("Mark Read On Tab Switch") +def test_preserve_unread_on_tab_switch(client: cmux) -> TestResult: + result = TestResult("Preserve Unread On Tab Switch") try: client.clear_notifications() client.set_app_focus(False) tab1 = client.current_workspace() client.notify("tabswitch") - time.sleep(0.1) + items = wait_for_notifications(client, 1) + target = next((n for n in items if n["workspace_id"] == tab1), None) + if target is None or target["is_read"]: + result.failure("Expected unread notification for original tab before switching") + return result tab2 = client.new_workspace() - time.sleep(0.1) + if not wait_for_current_workspace(client, tab2): + result.failure("Expected new workspace to become selected") + return result client.set_app_focus(True) client.select_workspace(tab1) - time.sleep(0.1) + if not wait_for_current_workspace(client, tab1): + result.failure("Expected original workspace to become selected again") + return result - items = client.list_notifications() + items = wait_for_notifications(client, 1) target = next((n for n in items if n["workspace_id"] == tab1), None) if target is None: result.failure("Expected notification for original tab") - elif not target["is_read"]: - result.failure("Expected notification to be marked read on tab switch") + elif target["is_read"]: + result.failure("Expected notification to remain unread on tab switch") else: - result.success("Notification marked read on tab switch") + result.success("Notification persisted across tab switch") except Exception as e: result.failure(f"Exception: {e}") return result @@ -371,11 +387,20 @@ def test_focus_on_notification_click(client: cmux) -> TestResult: result.failure("Expected notification surface to be focused") return result + items = client.list_notifications() + notification = next((n for n in items if n["surface_id"] == other[1]), None) + if notification is None: + result.failure("Expected notification to remain listed after notification click") + return result + if notification["is_read"]: + result.failure("Expected notification click to preserve unread state") + return result + count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0) if count < 1: result.failure(f"Expected flash count >= 1, got {count}") else: - result.success("Notification click focuses and flashes panel") + result.success("Notification click focuses, flashes, and preserves unread state") except Exception as e: result.failure(f"Exception: {e}") return result @@ -455,9 +480,9 @@ def run_tests() -> int: results.append(test_kitty_notification_simple(client)) results.append(test_kitty_notification_chunked(client)) results.append(test_rxvt_notification_osc777(client)) - results.append(test_mark_read_on_focus_change(client)) - results.append(test_mark_read_on_app_active(client)) - results.append(test_mark_read_on_tab_switch(client)) + results.append(test_preserve_unread_on_focus_change(client)) + results.append(test_preserve_unread_on_app_active(client)) + results.append(test_preserve_unread_on_tab_switch(client)) results.append(test_flash_on_tab_switch(client)) results.append(test_focus_on_notification_click(client)) results.append(test_restore_focus_on_tab_switch(client))