From 2af34b4c4660947df0491f2fdab7e5510a8943f8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 9 Mar 2026 16:53:38 -0700 Subject: [PATCH 1/7] Add regression test for terminal notification click dismissal --- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 724d1fa06780..afe615186ad3 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -7446,6 +7446,114 @@ final class NotificationDockBadgeTests: XCTestCase { } } +@MainActor +final class TerminalNotificationDirectInteractionTests: XCTestCase { + private func makeWindow() -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) + return window + } + + private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: type, + location: location, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Failed to create \(type) mouse event") + } + return event + } + + private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> NSView? { + hostedView.subviews + .compactMap { $0 as? NSScrollView } + .first? + .documentView? + .subviews + .first + } + + func testTerminalMouseDownDismissesUnreadWhenSurfaceIsAlreadyFirstResponder() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let store = TerminalNotificationStore.shared + let window = makeWindow() + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + window.orderOut(nil) + } + + guard let workspace = manager.selectedWorkspace, + let terminalPanel = workspace.focusedTerminalPanel else { + XCTFail("Expected an initial focused terminal panel") + return + } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let hostedView = terminalPanel.hostedView + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + contentView.layoutSubtreeIfNeeded() + hostedView.layoutSubtreeIfNeeded() + + guard let surfaceView = surfaceView(in: hostedView) else { + XCTFail("Expected terminal surface view") + return + } + + AppFocusState.overrideIsFocused = true + XCTAssertTrue(window.makeFirstResponder(surfaceView)) + + AppFocusState.overrideIsFocused = false + store.addNotification( + tabId: workspace.id, + surfaceId: terminalPanel.id, + title: "Unread", + subtitle: "", + body: "" + ) + XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) + + AppFocusState.overrideIsFocused = true + let pointInWindow = surfaceView.convert(NSPoint(x: 20, y: 20), to: nil) + let event = makeMouseEvent(type: .leftMouseDown, location: pointInWindow, window: window) + surfaceView.mouseDown(with: event) + + XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) + } +} + final class MenuBarBadgeLabelFormatterTests: XCTestCase { func testBadgeLabelFormatting() { From e5e6ba7adc9a31ad0acd4e7da724265e8eb4d2f1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 9 Mar 2026 16:53:40 -0700 Subject: [PATCH 2/7] Dismiss terminal notifications on direct clicks --- Sources/ContentView.swift | 7 +++++++ Sources/GhosttyTerminalView.swift | 6 ++++++ Sources/TerminalNotificationStore.swift | 8 ++++++++ 3 files changed, 21 insertions(+) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index a88523259a36..8cc309017959 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9816,6 +9816,7 @@ private struct TabItemView: View { let modifiers = NSEvent.modifierFlags let isCommand = modifiers.contains(.command) let isShift = modifiers.contains(.shift) + let wasSelected = tabManager.selectedTabId == tab.id if isShift, let lastIndex = lastSidebarSelectionIndex { let lower = min(lastIndex, index) @@ -9838,6 +9839,12 @@ private struct TabItemView: View { lastSidebarSelectionIndex = index tabManager.selectTab(tab) + if wasSelected, !isCommand, !isShift { + notificationStore.dismissUnreadNotificationIfActive( + tabId: tab.id, + surfaceId: tabManager.focusedSurfaceId(for: tab.id) + ) + } selection = .tabs } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 5f3c18263d93..54a905a03805 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4547,6 +4547,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { dlog("terminal.mouseDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") mods=[\(debugModifierString(event.modifierFlags))] clickCount=\(event.clickCount) point=(\(String(format: "%.0f", debugPoint.x)),\(String(format: "%.0f", debugPoint.y)))") #endif window?.makeFirstResponder(self) + if let terminalSurface { + TerminalNotificationStore.shared.dismissUnreadNotificationIfActive( + tabId: terminalSurface.tabId, + surfaceId: terminalSurface.id + ) + } guard let surface = surface else { return } let point = convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 9cdcf1f9dbfc..55bc1f711195 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -820,6 +820,14 @@ final class TerminalNotificationStore: ObservableObject { indexes.unreadByTabSurface.contains(TabSurfaceKey(tabId: tabId, surfaceId: surfaceId)) } + @discardableResult + func dismissUnreadNotificationIfActive(tabId: UUID, surfaceId: UUID?) -> Bool { + guard AppFocusState.isAppActive() else { return false } + guard hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return false } + markRead(forTabId: tabId, surfaceId: surfaceId) + return true + } + func latestNotification(forTabId tabId: UUID) -> TerminalNotification? { indexes.latestUnreadByTabId[tabId] ?? indexes.latestByTabId[tabId] } From 650bafaa09ff5ccf2fb61b8814fc024361c21604 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 9 Mar 2026 17:14:47 -0700 Subject: [PATCH 3/7] Add regression for focused terminal notification ring --- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index afe615186ad3..66e1014ea7fd 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -7535,7 +7535,6 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { AppFocusState.overrideIsFocused = true XCTAssertTrue(window.makeFirstResponder(surfaceView)) - AppFocusState.overrideIsFocused = false store.addNotification( tabId: workspace.id, surfaceId: terminalPanel.id, From 989645dcac763e6fc9ca22c2d823209e62f0741e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 9 Mar 2026 17:14:58 -0700 Subject: [PATCH 4/7] Keep focused terminal notifications unread until click --- Sources/TerminalNotificationStore.swift | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 55bc1f711195..9f21c3f4f198 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -846,14 +846,7 @@ 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 shouldSuppressExternalDelivery = isAppFocused && isFocusedPanel if WorkspaceAutoReorderSettings.isEnabled() { AppDelegate.shared?.tabManager?.moveTabToTopForNotification(tabId) @@ -875,7 +868,9 @@ final class TerminalNotificationStore: ObservableObject { center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } - notificationDeliveryHandler(self, notification) + if !shouldSuppressExternalDelivery { + notificationDeliveryHandler(self, notification) + } } func markRead(id: UUID) { From 28a1b9b133e6b13897986426bd0bd6cbd6bfc325 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 9 Mar 2026 17:40:52 -0700 Subject: [PATCH 5/7] Verify direct notification dismiss triggers flash --- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 66e1014ea7fd..ad356b203c75 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -7532,6 +7532,7 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { return } + GhosttySurfaceScrollView.resetFlashCounts() AppFocusState.overrideIsFocused = true XCTAssertTrue(window.makeFirstResponder(surfaceView)) @@ -7548,8 +7549,12 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase { let pointInWindow = surfaceView.convert(NSPoint(x: 20, y: 20), to: nil) let event = makeMouseEvent(type: .leftMouseDown, location: pointInWindow, window: window) surfaceView.mouseDown(with: event) + let drained = expectation(description: "flash drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 1.0) XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) + XCTAssertEqual(GhosttySurfaceScrollView.flashCount(for: terminalPanel.id), 1) } } From bb4ab6f9dc4ef2c03204df3d71ab90d5305c7ba9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 9 Mar 2026 17:40:56 -0700 Subject: [PATCH 6/7] Use focus-flash path for direct notification dismiss --- Sources/ContentView.swift | 2 +- Sources/GhosttyTerminalView.swift | 2 +- Sources/TabManager.swift | 30 ++++++++++++++++++++----- Sources/TerminalNotificationStore.swift | 8 ------- 4 files changed, 26 insertions(+), 16 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 8cc309017959..6248cd0dfcf9 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9840,7 +9840,7 @@ private struct TabItemView: View { lastSidebarSelectionIndex = index tabManager.selectTab(tab) if wasSelected, !isCommand, !isShift { - notificationStore.dismissUnreadNotificationIfActive( + tabManager.dismissNotificationOnDirectInteraction( tabId: tab.id, surfaceId: tabManager.focusedSurfaceId(for: tab.id) ) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 54a905a03805..0cdde3e3bb49 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4548,7 +4548,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #endif window?.makeFirstResponder(self) if let terminalSurface { - TerminalNotificationStore.shared.dismissUnreadNotificationIfActive( + AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction( tabId: terminalSurface.tabId, surfaceId: terminalSurface.id ) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 7afd48ae9da8..07b13a75918d 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1819,19 +1819,37 @@ class TabManager: ObservableObject { guard !shouldSuppressFlash else { return } guard AppFocusState.isAppActive() else { return } guard let panelId = focusedPanelId(for: tabId) else { return } - markPanelReadOnFocusIfActive(tabId: tabId, panelId: panelId) + _ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true) } private func markPanelReadOnFocusIfActive(tabId: UUID, panelId: UUID) { guard selectedTabId == tabId else { return } guard !suppressFocusFlash else { return } - guard AppFocusState.isAppActive() else { return } - guard let notificationStore = AppDelegate.shared?.notificationStore else { return } - guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: panelId) else { return } - if let tab = tabs.first(where: { $0.id == tabId }) { + _ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true) + } + + @discardableResult + func dismissNotificationOnDirectInteraction(tabId: UUID, surfaceId: UUID?) -> Bool { + dismissNotificationIfActive(tabId: tabId, surfaceId: surfaceId, triggerFlash: true) + } + + @discardableResult + private func dismissNotificationIfActive( + tabId: UUID, + surfaceId: UUID?, + triggerFlash: Bool + ) -> Bool { + guard selectedTabId == tabId else { return false } + guard AppFocusState.isAppActive() else { return false } + guard let notificationStore = AppDelegate.shared?.notificationStore else { return false } + guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return false } + if triggerFlash, + let panelId = surfaceId, + let tab = tabs.first(where: { $0.id == tabId }) { tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false) } - notificationStore.markRead(forTabId: tabId, surfaceId: panelId) + notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) + return true } private func enqueuePanelTitleUpdate(tabId: UUID, panelId: UUID, title: String) { diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 9f21c3f4f198..fb1f0b90ffbf 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -820,14 +820,6 @@ final class TerminalNotificationStore: ObservableObject { indexes.unreadByTabSurface.contains(TabSurfaceKey(tabId: tabId, surfaceId: surfaceId)) } - @discardableResult - func dismissUnreadNotificationIfActive(tabId: UUID, surfaceId: UUID?) -> Bool { - guard AppFocusState.isAppActive() else { return false } - guard hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return false } - markRead(forTabId: tabId, surfaceId: surfaceId) - return true - } - func latestNotification(forTabId tabId: UUID) -> TerminalNotification? { indexes.latestUnreadByTabId[tabId] ?? indexes.latestByTabId[tabId] } From e29e7c8af00f9e86fe34640934274daba4082ad2 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 9 Mar 2026 17:45:54 -0700 Subject: [PATCH 7/7] Align notification dismiss flash with ring geometry --- Sources/GhosttyTerminalView.swift | 36 +++++++++++++++++++++++------- Sources/Panels/TerminalPanel.swift | 4 ++++ Sources/Workspace.swift | 2 +- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 0cdde3e3bb49..a74ec21e3834 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5023,6 +5023,16 @@ private final class GhosttyPassthroughVisualEffectView: NSVisualEffectView { } final class GhosttySurfaceScrollView: NSView { + enum FlashStyle { + case standardFocus + case notificationDismiss + } + + private enum NotificationRingMetrics { + static let inset: CGFloat = 2 + static let cornerRadius: CGFloat = 6 + } + private let backgroundView: NSView private let scrollView: GhosttyScrollView private let documentView: NSView @@ -5463,7 +5473,7 @@ final class GhosttySurfaceScrollView: NSView { _ = setFrameIfNeeded(notificationRingOverlayView, to: bounds) _ = setFrameIfNeeded(flashOverlayView, to: bounds) updateNotificationRingPath() - updateFlashPath() + updateFlashPath(style: .standardFocus) synchronizeScrollView() synchronizeSurfaceView() let didCoreSurfaceChange = synchronizeCoreSurface() @@ -5898,7 +5908,7 @@ final class GhosttySurfaceScrollView: NSView { } #endif - func triggerFlash() { + func triggerFlash(style: FlashStyle = .standardFocus) { DispatchQueue.main.async { [weak self] in guard let self else { return } #if DEBUG @@ -5906,7 +5916,7 @@ final class GhosttySurfaceScrollView: NSView { Self.recordFlash(for: surfaceId) } #endif - self.updateFlashPath() + self.updateFlashPath(style: style) self.flashLayer.removeAllAnimations() self.flashLayer.opacity = 0 let animation = CAKeyframeAnimation(keyPath: "opacity") @@ -6648,17 +6658,27 @@ final class GhosttySurfaceScrollView: NSView { updateOverlayRingPath( layer: notificationRingLayer, bounds: notificationRingOverlayView.bounds, - inset: 2, - radius: 6 + inset: NotificationRingMetrics.inset, + radius: NotificationRingMetrics.cornerRadius ) } - private func updateFlashPath() { + private func updateFlashPath(style: FlashStyle) { + let inset: CGFloat + let radius: CGFloat + switch style { + case .standardFocus: + inset = CGFloat(FocusFlashPattern.ringInset) + radius = CGFloat(FocusFlashPattern.ringCornerRadius) + case .notificationDismiss: + inset = NotificationRingMetrics.inset + radius = NotificationRingMetrics.cornerRadius + } updateOverlayRingPath( layer: flashLayer, bounds: flashOverlayView.bounds, - inset: CGFloat(FocusFlashPattern.ringInset), - radius: CGFloat(FocusFlashPattern.ringCornerRadius) + inset: inset, + radius: radius ) } diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index d4bb68b3ab43..7e863f5def2c 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -190,6 +190,10 @@ final class TerminalPanel: Panel, ObservableObject { hostedView.triggerFlash() } + func triggerNotificationDismissFlash() { + hostedView.triggerFlash(style: .notificationDismiss) + } + func applyWindowBackgroundIfActive() { surface.applyWindowBackgroundIfActive() } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 7a4bb58a7139..4993be853fcf 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3260,7 +3260,7 @@ final class Workspace: Identifiable, ObservableObject { if requiresSplit && !isSplit { return } - terminalPanel.triggerFlash() + terminalPanel.triggerNotificationDismissFlash() } func triggerDebugFlash(panelId: UUID) {