-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Add floating bar custom notifications #5606
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
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 |
|---|---|---|
|
|
@@ -11,6 +11,8 @@ class FloatingControlBarWindow: NSWindow, NSWindowDelegate { | |
| static let expandedBarSize = NSSize(width: 210, height: 50) | ||
| private static let maxBarSize = NSSize(width: 1200, height: 1000) | ||
| private static let expandedWidth: CGFloat = 430 | ||
| private static let notificationHeight: CGFloat = 108 | ||
| private static let notificationSpacing: CGFloat = 8 | ||
| /// Minimum window height when AI response first appears. | ||
| private static let minResponseHeight: CGFloat = 250 | ||
| /// Base height used as the reference for 2× cap (same as current default response height). | ||
|
|
@@ -335,6 +337,7 @@ class FloatingControlBarWindow: NSWindow, NSWindowDelegate { | |
| // Allow hover resizes again after the animation settles. | ||
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in | ||
| self?.suppressHoverResize = false | ||
| FloatingControlBarManager.shared.flushQueuedNotificationsIfPossible() | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -507,7 +510,7 @@ class FloatingControlBarWindow: NSWindow, NSWindowDelegate { | |
|
|
||
| /// Resize for hover expand/collapse — anchored from center so the circle grows outward. | ||
| func resizeForHover(expanded: Bool) { | ||
| guard !state.showingAIConversation, !state.isVoiceListening, !suppressHoverResize else { return } | ||
| guard !state.showingAIConversation, !state.isVoiceListening, !state.isShowingNotification, !suppressHoverResize else { return } | ||
| resizeWorkItem?.cancel() | ||
| resizeWorkItem = nil | ||
|
|
||
|
|
@@ -549,6 +552,29 @@ class FloatingControlBarWindow: NSWindow, NSWindowDelegate { | |
| resizeAnchored(to: size, makeResizable: false, animated: true) | ||
| } | ||
|
|
||
| func showNotification(_ notification: FloatingBarNotification, animated: Bool = true) { | ||
| guard !state.showingAIConversation else { return } | ||
| state.currentNotification = notification | ||
| let targetSize = NSSize( | ||
| width: Self.expandedWidth, | ||
| height: Self.expandedBarSize.height + Self.notificationSpacing + Self.notificationHeight | ||
| ) | ||
| resizeAnchored(to: targetSize, makeResizable: false, animated: animated, anchorTop: true) | ||
| } | ||
|
|
||
| func dismissNotification(animated: Bool = true) { | ||
| guard state.currentNotification != nil else { return } | ||
| state.currentNotification = nil | ||
|
|
||
| let targetSize: NSSize | ||
| if state.isVoiceListening { | ||
| targetSize = NSSize(width: Self.expandedWidth, height: Self.expandedBarSize.height) | ||
| } else { | ||
| targetSize = frame.contains(NSEvent.mouseLocation) ? Self.expandedBarSize : Self.minBarSize | ||
| } | ||
| resizeAnchored(to: targetSize, makeResizable: false, animated: animated, anchorTop: true) | ||
| } | ||
|
|
||
| private func resizeToResponseHeight(animated: Bool = false) { | ||
| // Determine the 2× cap from the user's saved (or default) preferred height. | ||
| let savedSize = UserDefaults.standard.string(forKey: FloatingControlBarWindow.sizeKey) | ||
|
|
@@ -720,6 +746,9 @@ class FloatingControlBarManager { | |
| private var durationCancellable: AnyCancellable? | ||
| private var chatCancellable: AnyCancellable? | ||
| private var chatProvider: ChatProvider? | ||
| private var pendingNotifications: [FloatingBarNotification] = [] | ||
| private var notificationDismissWorkItem: DispatchWorkItem? | ||
| private var notificationWasTemporarilyShown = false | ||
|
|
||
| /// Whether the user has enabled the Ask Omi bar (persisted across launches). | ||
| /// Defaults to true for new users. | ||
|
|
@@ -841,6 +870,41 @@ class FloatingControlBarManager { | |
| window?.makeKeyAndOrderFront(nil) | ||
| } | ||
|
|
||
| func showNotification(title: String, message: String, assistantId: String, sound: NotificationSound) { | ||
| let notification = FloatingBarNotification(title: title, message: message, assistantId: assistantId) | ||
| guard let window else { | ||
| log("FloatingControlBarManager: dropping notification because window is not set up") | ||
| return | ||
| } | ||
|
|
||
| switch sound { | ||
| case .focusLost, .focusRegained: | ||
| sound.playCustomSound() | ||
| case .default, .none: | ||
| break | ||
| } | ||
|
|
||
| if window.state.currentNotification != nil || window.state.showingAIConversation { | ||
| pendingNotifications.append(notification) | ||
| return | ||
| } | ||
|
|
||
| presentNotification(notification, in: window) | ||
| } | ||
|
|
||
| func dismissCurrentNotification() { | ||
| notificationDismissWorkItem?.cancel() | ||
| notificationDismissWorkItem = nil | ||
| dismissNotificationAndAdvanceQueue() | ||
| } | ||
|
|
||
| func flushQueuedNotificationsIfPossible() { | ||
| guard let window, window.state.currentNotification == nil, !window.state.showingAIConversation, | ||
| !pendingNotifications.isEmpty else { return } | ||
| let nextNotification = pendingNotifications.removeFirst() | ||
| presentNotification(nextNotification, in: window) | ||
| } | ||
|
|
||
| /// Cancel any in-flight chat streaming. | ||
| func cancelChat() { | ||
| chatCancellable?.cancel() | ||
|
|
@@ -977,6 +1041,54 @@ class FloatingControlBarManager { | |
| window.onSendQuery?(query) | ||
| } | ||
|
|
||
| private func presentNotification(_ notification: FloatingBarNotification, in window: FloatingControlBarWindow) { | ||
| if !window.isVisible { | ||
| notificationWasTemporarilyShown = true | ||
| window.orderFrontRegardless() | ||
| } else { | ||
| notificationWasTemporarilyShown = false | ||
| } | ||
|
Comment on lines
+1044
to
+1050
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.
When the bar is disabled (
Result: the floating bar window remains visible even when the user has the bar disabled, as long as at least two notifications fired. The fix is to not reset private func presentNotification(_ notification: FloatingBarNotification, in window: FloatingControlBarWindow) {
if !window.isVisible {
notificationWasTemporarilyShown = true
window.orderFrontRegardless()
}
// Do NOT set notificationWasTemporarilyShown = false here;
// it was set true by the first notification and should persist through the chain.
// ...
} |
||
|
|
||
| window.showNotification(notification) | ||
| AnalyticsManager.shared.notificationSent( | ||
| notificationId: notification.id.uuidString, | ||
| title: notification.title, | ||
| assistantId: notification.assistantId | ||
| ) | ||
|
|
||
| let dismissWorkItem = DispatchWorkItem { [weak self] in | ||
| self?.dismissNotificationAndAdvanceQueue() | ||
| } | ||
| notificationDismissWorkItem = dismissWorkItem | ||
| DispatchQueue.main.asyncAfter(deadline: .now() + 6.0, execute: dismissWorkItem) | ||
| } | ||
|
|
||
| private func dismissNotificationAndAdvanceQueue() { | ||
| guard let window else { return } | ||
|
|
||
| let dismissedNotification = window.state.currentNotification | ||
| window.dismissNotification() | ||
|
|
||
| if let dismissedNotification { | ||
| AnalyticsManager.shared.notificationDismissed( | ||
| notificationId: dismissedNotification.id.uuidString, | ||
| title: dismissedNotification.title, | ||
| assistantId: dismissedNotification.assistantId | ||
| ) | ||
| } | ||
|
|
||
| if !pendingNotifications.isEmpty, !window.state.showingAIConversation { | ||
| let nextNotification = pendingNotifications.removeFirst() | ||
| presentNotification(nextNotification, in: window) | ||
| return | ||
| } | ||
|
|
||
| if notificationWasTemporarilyShown && !isEnabled && !window.state.showingAIConversation { | ||
| window.orderOut(nil) | ||
| } | ||
| notificationWasTemporarilyShown = false | ||
| } | ||
|
|
||
| /// Access the bar state for PTT updates. | ||
| var barState: FloatingControlBarState? { | ||
| return window?.state | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -198,34 +198,12 @@ class NotificationService: NSObject, UNUserNotificationCenterDelegate { | |
| } | ||
|
|
||
| func sendNotification(title: String, message: String, assistantId: String = "default", sound: NotificationSound = .default) { | ||
| // Check permission before attempting delivery to avoid UNErrorDomain code 1 errors | ||
| UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in | ||
| Task { @MainActor in | ||
| guard settings.authorizationStatus == .authorized else { | ||
| log("Notification skipped (auth=\(settings.authorizationStatus.rawValue)): \(title)") | ||
|
|
||
| // If auth reverted to notDetermined (not explicitly denied), trigger repair | ||
| // Debounce: at most once per 10 minutes to avoid hammering lsregister | ||
| if settings.authorizationStatus == .notDetermined { | ||
| let now = Date() | ||
| if self?.lastRepairAttempt == nil || now.timeIntervalSince(self?.lastRepairAttempt ?? .distantPast) > 600 { | ||
| self?.lastRepairAttempt = now | ||
| log("Notification auth is notDetermined at send time — triggering repair") | ||
| AnalyticsManager.shared.notificationRepairTriggered( | ||
| reason: "send_time_not_determined", | ||
| previousStatus: "unknown", | ||
| currentStatus: "notDetermined" | ||
| ) | ||
| ProactiveAssistantsPlugin.repairNotificationRegistration() | ||
| } | ||
| } | ||
|
|
||
| return | ||
| } | ||
|
|
||
| self?.deliverNotification(title: title, message: message, assistantId: assistantId, sound: sound) | ||
| } | ||
| } | ||
| FloatingControlBarManager.shared.showNotification( | ||
| title: title, | ||
| message: message, | ||
| assistantId: assistantId, | ||
| sound: sound | ||
| ) | ||
| } | ||
|
Comment on lines
200
to
207
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. Screen capture reset "Reset Now" action button silently dropped
Previously, With this change:
The screen capture reset notification likely needs to keep using a system |
||
|
|
||
| private func deliverNotification(title: String, message: String, assistantId: String, sound: NotificationSound) { | ||
|
|
||
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.
Default notification sound is silently dropped
In
showNotification, theswitchonly plays sounds for.focusLostand.focusRegained. ForNotificationSound.default(the case used by most proactive assistant notifications), no sound is played at all.Previously,
deliverNotificationpassedsound.unSound(which returnsUNNotificationSound.defaultfor the.defaultcase) toUNMutableNotificationContent.sound, so the system played the default notification chime. That feedback is now absent for all standard notifications.If silent in-app notifications are intentional, the
NotificationSound.defaultcase inNotificationSound.unSoundand the comment on line 130 ofOnboardingNotificationStepViewshould be updated to reflect this. If not, a sound should be played here for.default— for example:or load and play a bundled sound similar to the custom cases.