-
-
Notifications
You must be signed in to change notification settings - Fork 2.4k
Fix notification ring dismissal on direct terminal clicks #1126
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2af34b4
e5e6ba7
650bafa
989645d
28a1b9b
bb4ab6f
e29e7c8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
| AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction( | ||
| 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)) | ||
|
|
@@ -5017,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 | ||
|
|
@@ -5457,7 +5473,7 @@ final class GhosttySurfaceScrollView: NSView { | |
| _ = setFrameIfNeeded(notificationRingOverlayView, to: bounds) | ||
| _ = setFrameIfNeeded(flashOverlayView, to: bounds) | ||
| updateNotificationRingPath() | ||
| updateFlashPath() | ||
| updateFlashPath(style: .standardFocus) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The flash path is always reset to Prompt for AI agents |
||
| synchronizeScrollView() | ||
| synchronizeSurfaceView() | ||
| let didCoreSurfaceChange = synchronizeCoreSurface() | ||
|
|
@@ -5892,15 +5908,15 @@ 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 | ||
| if let surfaceId = self.surfaceView.terminalSurface?.id { | ||
| Self.recordFlash(for: surfaceId) | ||
| } | ||
| #endif | ||
| self.updateFlashPath() | ||
| self.updateFlashPath(style: style) | ||
| self.flashLayer.removeAllAnimations() | ||
| self.flashLayer.opacity = 0 | ||
| let animation = CAKeyframeAnimation(keyPath: "opacity") | ||
|
|
@@ -6642,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 | ||
| ) | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7446,6 +7446,118 @@ 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 | ||
| } | ||
|
|
||
| GhosttySurfaceScrollView.resetFlashCounts() | ||
| AppFocusState.overrideIsFocused = true | ||
| XCTAssertTrue(window.makeFirstResponder(surfaceView)) | ||
|
|
||
| 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) | ||
| 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) | ||
| } | ||
|
Comment on lines
+7479
to
+7558
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
ghostty_file=$(fd 'GhosttyTerminalView.swift$' | head -n1)
test_file=$(fd 'CmuxWebViewKeyEquivalentTests.swift$' | head -n1)
echo "== Production mouseDown wiring =="
rg -n -C4 'class GhosttySurfaceScrollView|func mouseDown|dismissUnreadNotificationIfActive' "$ghostty_file"
echo
echo "== Test click target + assertions =="
rg -n -C3 'surfaceView\(in: hostedView\)|surfaceView\.mouseDown|hostedView\.mouseDown|unreadCount\(forTabId:' "$test_file"Repository: manaflow-ai/cmux Length of output: 3284 Exercise the real terminal click path and assert the tab-level unread state. The test at line 7551 calls Suggested test adjustment- let pointInWindow = surfaceView.convert(NSPoint(x: 20, y: 20), to: nil)
+ let pointInWindow = hostedView.convert(NSPoint(x: 20, y: 20), to: nil)
let event = makeMouseEvent(type: .leftMouseDown, location: pointInWindow, window: window)
- surfaceView.mouseDown(with: event)
+ hostedView.mouseDown(with: event)
+ XCTAssertEqual(store.unreadCount(forTabId: workspace.id), 0)
XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id))Per coding guidelines ( 🤖 Prompt for AI Agents
Comment on lines
+7488
to
+7558
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing test for the workspace-row click dismissal path The new test covers the |
||
| } | ||
|
|
||
|
|
||
| final class MenuBarBadgeLabelFormatterTests: XCTestCase { | ||
| func testBadgeLabelFormatting() { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also dismiss workspace-scoped unread on row reselect.
dismissUnreadNotificationIfActivematches the exact(tabId, surfaceId)pair. Here we always passfocusedSurfaceId(for:), so a workspace-level unread stored withsurfaceId == nilwill survive clicking the already-selected workspace row—the case this PR is supposed to fix.Suggested fix
tabManager.selectTab(tab) if wasSelected, !isCommand, !isShift { - notificationStore.dismissUnreadNotificationIfActive( + let focusedSurfaceId = tabManager.focusedSurfaceId(for: tab.id) + let dismissedFocusedSurface = notificationStore.dismissUnreadNotificationIfActive( tabId: tab.id, - surfaceId: tabManager.focusedSurfaceId(for: tab.id) + surfaceId: focusedSurfaceId + ) + if !dismissedFocusedSurface { + notificationStore.dismissUnreadNotificationIfActive( + tabId: tab.id, + surfaceId: nil + ) ) } selection = .tabs🤖 Prompt for AI Agents