Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,22 @@ struct FloatingChatExchange: Identifiable {
let aiMessage: ChatMessage
}

/// A custom in-app notification rendered directly below the floating bar.
struct FloatingBarNotification: Identifiable, Equatable {
let id = UUID()
let title: String
let message: String
let assistantId: String
}

/// Observable object holding the state for the floating control bar.
@MainActor
class FloatingControlBarState: NSObject, ObservableObject {
@Published var isRecording: Bool = false
@Published var duration: Int = 0
@Published var isInitialising: Bool = false
@Published var isDragging: Bool = false
@Published var currentNotification: FloatingBarNotification? = nil

// AI conversation state
@Published var showingAIConversation: Bool = false
Expand Down Expand Up @@ -56,4 +65,8 @@ class FloatingControlBarState: NSObject, ObservableObject {
("claude-sonnet-4-6", "Sonnet"),
("claude-opus-4-6", "Opus"),
]

var isShowingNotification: Bool {
currentNotification != nil
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,34 @@ struct FloatingControlBarView: View {
@State private var isHovering = false

var body: some View {
VStack(spacing: state.isShowingNotification && !state.showingAIConversation ? 8 : 0) {
barChrome

if let notification = state.currentNotification, !state.showingAIConversation {
notificationView(notification)
.padding(.horizontal, 8)
.padding(.bottom, 8)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onHover { hovering in
// Resize window BEFORE updating SwiftUI state on expand so the expanded
// content never renders in a too-small window (which causes overflow).
if hovering {
(window as? FloatingControlBarWindow)?.resizeForHover(expanded: true)
}
withAnimation(.easeInOut(duration: 0.2)) {
isHovering = hovering
}
if !hovering {
(window as? FloatingControlBarWindow)?.resizeForHover(expanded: false)
}
}
.animation(.spring(response: 0.35, dampingFraction: 0.82), value: state.currentNotification?.id)
}

private var barChrome: some View {
VStack(spacing: 0) {
// Main control bar - always visible
controlBarView
Expand All @@ -35,7 +63,7 @@ struct FloatingControlBarView: View {
.padding(.bottom, 8)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.frame(maxWidth: .infinity, alignment: .top)
.overlay(alignment: .topLeading) {
if state.showingAIConversation {
Button {
Expand Down Expand Up @@ -83,21 +111,53 @@ struct FloatingControlBarView: View {
}
}
.clipped()
.onHover { hovering in
// Resize window BEFORE updating SwiftUI state on expand so the expanded
// content never renders in a too-small window (which causes overflow).
if hovering {
(window as? FloatingControlBarWindow)?.resizeForHover(expanded: true)
.background(DraggableAreaView(targetWindow: window))
.floatingBackground(cornerRadius: isHovering || state.showingAIConversation || state.isVoiceListening || state.isShowingNotification ? 20 : 5)
}

private func notificationView(_ notification: FloatingBarNotification) -> some View {
HStack(alignment: .top, spacing: 10) {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(Color.white.opacity(0.08))
.frame(width: 34, height: 34)

Image(systemName: "bell.badge.fill")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.white)
}
withAnimation(.easeInOut(duration: 0.2)) {
isHovering = hovering

VStack(alignment: .leading, spacing: 4) {
Text(notification.title)
.scaledFont(size: 13, weight: .semibold)
.foregroundColor(.white)
.lineLimit(1)

Text(notification.message)
.scaledFont(size: 12)
.foregroundColor(.white.opacity(0.72))
.lineLimit(3)
.fixedSize(horizontal: false, vertical: true)
}
if !hovering {
(window as? FloatingControlBarWindow)?.resizeForHover(expanded: false)

Spacer(minLength: 0)

Button {
FloatingControlBarManager.shared.dismissCurrentNotification()
} label: {
Image(systemName: "xmark")
.font(.system(size: 10, weight: .bold))
.foregroundColor(.white.opacity(0.62))
.frame(width: 18, height: 18)
.background(Color.white.opacity(0.08))
.clipShape(Circle())
}
.buttonStyle(.plain)
}
.background(DraggableAreaView(targetWindow: window))
.floatingBackground(cornerRadius: isHovering || state.showingAIConversation || state.isVoiceListening ? 20 : 5)
.padding(.horizontal, 12)
.padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.floatingBackground(cornerRadius: 18)
}

private func openFloatingBarSettings() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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()
}
}

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Comment on lines +880 to +885
Copy link
Contributor

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, the switch only plays sounds for .focusLost and .focusRegained. For NotificationSound.default (the case used by most proactive assistant notifications), no sound is played at all.

Previously, deliverNotification passed sound.unSound (which returns UNNotificationSound.default for the .default case) to UNMutableNotificationContent.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.default case in NotificationSound.unSound and the comment on line 130 of OnboardingNotificationStepView should be updated to reflect this. If not, a sound should be played here for .default — for example:

case .default:
    NSSound.beep()

or load and play a bundled sound similar to the custom cases.


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()
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

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

notificationWasTemporarilyShown incorrectly reset when chaining queued notifications

When the bar is disabled (!isEnabled) and two notifications arrive in quick succession:

  1. First notification: window is hidden → notificationWasTemporarilyShown = true, window shown temporarily.
  2. Second notification: queued in pendingNotifications.
  3. First dismissed → dismissNotificationAndAdvanceQueue calls presentNotification(#2, window) and returns early, skipping the orderOut + reset block.
  4. In presentNotification(#2), the window is now visible so notificationWasTemporarilyShown = false is set unconditionally.
  5. Second notification dismissed → notificationWasTemporarilyShown == false, so window.orderOut(nil) is never called.

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 notificationWasTemporarilyShown to false when window is already visible during a queued-notification chain:

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
Expand Down
6 changes: 5 additions & 1 deletion desktop/Desktop/Sources/OnboardingNotificationStepView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import SwiftUI
/// Uses a static example tip — no Gemini call needed.
struct OnboardingNotificationStepView: View {
@ObservedObject var appState: AppState
@ObservedObject var chatProvider: ChatProvider
var onContinue: () -> Void
var onSkip: () -> Void

Expand Down Expand Up @@ -94,7 +95,7 @@ struct OnboardingNotificationStepView: View {
Image(systemName: "bell.badge.fill")
.foregroundColor(OmiColors.purplePrimary)
.font(.system(size: 12))
Text("Notification sent to your Mac")
Text("Notification shown below Ask omi")
.font(.system(size: 12))
.foregroundColor(OmiColors.textTertiary)
}
Expand All @@ -117,6 +118,9 @@ struct OnboardingNotificationStepView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(OmiColors.backgroundPrimary)
.onAppear {
FloatingControlBarManager.shared.setup(appState: appState, chatProvider: chatProvider)
FloatingControlBarManager.shared.showTemporarily()

// Show the notification preview after a brief delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
Expand Down
1 change: 1 addition & 0 deletions desktop/Desktop/Sources/OnboardingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ struct OnboardingView: View {
// Step 1: Smart Notifications Demo
OnboardingNotificationStepView(
appState: appState,
chatProvider: chatProvider,
onContinue: {
AnalyticsManager.shared.onboardingStepCompleted(step: 1, stepName: "Notifications")
currentStep = 2
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

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

Screen capture reset "Reset Now" action button silently dropped

sendNotification is now the only public entry point and it routes all notifications through FloatingControlBarManager.showNotification. However, the screen capture reset notification (NotificationService.screenCaptureResetTitle) is sent via NotificationService.shared.sendNotification(...) from ProactiveAssistantsPlugin (lines 1342–1345 and 1355–1358 in ProactiveAssistantsPlugin.swift).

Previously, deliverNotification detected this title and assigned screenCaptureResetCategoryId, which added a "Reset Now" UNNotificationAction. The UNUserNotificationCenter delegate then called handleScreenCaptureResetAction when the user tapped it, triggering ScreenCaptureService.resetScreenCapturePermissionAndRestart().

With this change:

  • The notification appears only as a floating bar notification with no action button.
  • deliverNotification (and all its screenCaptureResetCategoryId logic) is now unreachable dead code.
  • Users who encounter a broken screen recording session will see a message saying "Click to open Settings" but no actionable button and no automatic reset mechanism.

The screen capture reset notification likely needs to keep using a system UNNotification with its action button, or a dedicated action button needs to be added to the floating bar notification view for this specific case.


private func deliverNotification(title: String, message: String, assistantId: String, sound: NotificationSound) {
Expand Down