diff --git a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-floatingcontrolbar.json b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-floatingcontrolbar.json index 42034731498..fa1cb83c6ea 100644 --- a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-floatingcontrolbar.json +++ b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-floatingcontrolbar.json @@ -1,8 +1,8 @@ { "files": { "desktop/macos/Desktop/Sources/FloatingControlBar/AgentPill.swift": 2375, - "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift": 2853, - "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift": 4842, + "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift": 2818, + "desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift": 4515, "desktop/macos/Desktop/Sources/FloatingControlBar/PushToTalkManager.swift": 2423, "desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift": 1621 }, diff --git a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-mainwindow.json b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-mainwindow.json index d4ab37d85cc..34050a5559f 100644 --- a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-mainwindow.json +++ b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-mainwindow.json @@ -1,6 +1,6 @@ { "files": { - "desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift": 1926, + "desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift": 1930, "desktop/macos/Desktop/Sources/MainWindow/Pages/AppsPage.swift": 3643, "desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift": 4543, "desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift": 3298, @@ -8,7 +8,7 @@ "desktop/macos/Desktop/Sources/MainWindow/SidebarView.swift": 1570 }, "raise_justifications": { - "desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift": "Keeps all chat tool groups compact and collapsed while streamed steps increment, with an explicit expansion policy, fixed collapsed height, and regression coverage.", + "desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift": "The whole agent spawn card now opens the agent (card-level tap plus the corner affordance) so a delegated task is reachable from every surface that renders it; +4 lines.", "desktop/macos/Desktop/Sources/MainWindow/Pages/AppsPage.swift": "Rebase reconciliation applies the pinned swift-format to the main-branch app-detail safety fixes; runtime behavior is unchanged.", "desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift": "Home redesign: chat-as-home surface plus the 2nd-brain knows-list hub replace the wordmark/ribbon/WMN-card layout; a component split is tracked follow-up.", "desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift": "Bridge memory search now awaits the active initial or pagination projection before refreshing, preventing an immediate post-navigation marker search from using stale lifecycle capability state.", diff --git a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-providers.json b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-providers.json index 6adec2dd416..99643e8ed32 100644 --- a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-providers.json +++ b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-providers.json @@ -1,10 +1,10 @@ { "files": { - "desktop/macos/Desktop/Sources/Providers/ChatProvider.swift": 6214, + "desktop/macos/Desktop/Sources/Providers/ChatProvider.swift": 6218, "desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift": 2861 }, "raise_justifications": { - "desktop/macos/Desktop/Sources/Providers/ChatProvider.swift": "ChatTurnOwner reasoning-effort lane derivation", + "desktop/macos/Desktop/Sources/Providers/ChatProvider.swift": "sendMainDraft clears the sent draft up front and restores it only if the send never lands, so the notch composer can't retain or resurface the sent text; +4 lines.", "desktop/macos/Desktop/Sources/Providers/ChatToolExecutor.swift": "Already-granted permissions no longer reopen System Settings: screen-recording and full-disk-access requests gate their Settings/drag-card opens behind the granted result." }, "threshold": 1500 diff --git a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-root.json b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-root.json index 8cb958b2ca3..31e846539bd 100644 --- a/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-root.json +++ b/.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-root.json @@ -2,13 +2,13 @@ "files": { "desktop/macos/Desktop/Sources/AuthService.swift": 3294, "desktop/macos/Desktop/Sources/CloudConnectorFormAutomation.swift": 1678, - "desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift": 4238, + "desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift": 4264, "desktop/macos/Desktop/Sources/MemoryExportService.swift": 1578, "desktop/macos/Desktop/Sources/OmiApp.swift": 1638 }, "raise_justifications": { "desktop/macos/Desktop/Sources/AuthService.swift": "Apple first-auth name capture signals observers (authNameDidUpdate) and persists the name to Firebase so it survives reinstalls.", - "desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift": "Reach-error card gains a debug bridge action so the actionable failure surface is verifiable in-process.", + "desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift": "Adds a non-prod send_main_draft action that drives the composer's sendMainDraft path so the draft clear/restore behavior is verifiable end to end; +26 lines.", "desktop/macos/Desktop/Sources/MemoryExportService.swift": "Pinned swift-format normalizes line layout without changing this source's runtime behavior.", "desktop/macos/Desktop/Sources/OmiApp.swift": "shared openMainAppWindow helper for menu bar + Open Omi shortcut" }, diff --git a/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift b/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift index a6dcaa654e8..8e1395793bb 100644 --- a/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift +++ b/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift @@ -1821,6 +1821,32 @@ final class DesktopAutomationActionRegistry { ] } + register( + name: "send_main_draft", + summary: "Send the main composer via the draft path (sendMainDraft), exactly like the notch/main text composer", + params: ["text"], + category: "chat", + surfaces: ["main_chat", "ask_omi"], + safety: "local", + sideEffects: ["local_storage", "network"], + examples: ["./scripts/omi-ctl action send_main_draft text='what did I do today?'"] + ) { params in + guard AppBuild.isNonProduction else { + return ["error": "send_main_draft is disabled on production bundles"] + } + let text = (params["text"] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return ["error": "missing 'text'"] } + guard let provider = ChatProvider.mainInstance else { + return ["error": "main ChatProvider not yet initialized"] + } + provider.draftText = text + let tracer = QueryTracer(query: text, inputMode: .text) + await QueryTracerContext.$current.withValue(tracer) { + _ = await provider.sendMainDraft(text) + } + return ["sent": text, "draft_after": provider.draftText] + } + register( name: "chat_drafts_snapshot", summary: "Read current main and floating composer drafts (non-prod persistence harness)", diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift index a5fbdab3c33..f69347e4ec1 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarState.swift @@ -265,20 +265,12 @@ class FloatingControlBarState: NSObject, ObservableObject { func apply(_ projection: VoiceTurnUIProjection) { guard let barState else { return } - let wasExpandedForVoice = barState.isVoiceListening barState.applyVoiceProjection(projection) - let shouldExpandForVoice = barState.isVoiceListening - // Clear idle hover before the PTT resize so its animated surface cannot - // compete with the reducer-owned voice presentation. + // Clear idle hover so its animated surface cannot compete with the + // reducer-owned voice presentation. Notch PTT sizing is owned by the + // presentation ladder off applyVoiceProjection above. barState.dismissNotchHoverForVoicePresentation() - - if shouldExpandForVoice != wasExpandedForVoice, - !barState.showingAIConversation, - UserDefaults.standard.bool(forKey: .hasCompletedOnboarding) - { - FloatingControlBarManager.shared.resizeForPTT(expanded: shouldExpandForVoice) - } } } @@ -381,10 +373,50 @@ class FloatingControlBarState: NSObject, ObservableObject { /// True only when the notch-mode setting is enabled and the current display /// exposes a real camera housing safe area. External displays keep old pill UI. @Published var usesNotchIsland: Bool = false + + /// Live voice-turn mirror for the notch chat. Hub voice turns journal the + /// exchange only at turn end, so mid-turn the provider timeline has nothing + /// to render; these carry the in-flight transcript and streaming assistant + /// text purely for display (never a second transcript store — the journaled + /// pair replaces them when it lands). + @Published var liveVoiceUserText: String = "" + @Published var liveVoiceAssistantText: String = "" + + /// One-shot notch hint outside the voice projection (e.g. PTT blocked by + /// the usage limit). The notch's hint presentation falls back to this when + /// the projection-derived hint is empty. + @Published var transientHintText: String = "" + private var transientHintClearTask: Task? + + func flashHint(_ text: String, for seconds: TimeInterval = 3) { + transientHintText = text + transientHintClearTask?.cancel() + transientHintClearTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(seconds)) + guard !Task.isCancelled else { return } + self?.transientHintText = "" + } + } @Published var notchRevealProgress: CGFloat = 1 private func applyVoiceProjection(_ projection: VoiceTurnUIProjection) { + // A new hold is a new turn: drop the previous turn's streamed reply so it + // can't flash under the fresh question. + if projection.isListening, !voiceProjection.isListening { + liveVoiceAssistantText = "" + liveVoiceUserText = "" + } voiceProjection = projection + // Mirror the in-flight transcript for the notch chat's live strip; clear + // the whole mirror once the voice presentation fully ends (by then the + // journaled exchange has landed on the shared timeline). + if !projection.transcript.isEmpty { + liveVoiceUserText = projection.transcript + } + if !isVoicePresentationActive { + liveVoiceUserText = "" + liveVoiceAssistantText = "" + } } /// Whether the current query originated from voice (PTT). Used to decide diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift index 8fffc586e81..d3dd41b98ee 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift @@ -1854,41 +1854,6 @@ private struct NotchResponseGlowView: View { } } -private struct NotchOmiMark: View { - var dotColors: [Color] = [] - - private static let dotCount = 8 - private static let dotDiameterRatio: CGFloat = 0.18 - private static let ringRadiusRatio: CGFloat = 0.33 - - var body: some View { - GeometryReader { geometry in - let size = min(geometry.size.width, geometry.size.height) - let center = CGPoint( - x: geometry.size.width / 2, - y: geometry.size.height / 2 - ) - let dotDiameter = size * Self.dotDiameterRatio - let ringRadius = size * Self.ringRadiusRatio - - ZStack { - ForEach(0.. Void)? = nil) { openAgentChatFromTimeline( @@ -2566,7 +2570,7 @@ class FloatingControlBarManager { } func openAgentChatFromTimeline(ref: AgentTimelineRef, completion: ((Bool) -> Void)? = nil) { - guard let window else { + guard let notchScreenManager else { completion?(false) return } @@ -2602,13 +2606,7 @@ class FloatingControlBarManager { return } AgentPillsManager.shared.markViewed(pillID: pillID) - window.state.setNotchHoverMenuOpen(false) - window.makeKeyAndOrderFront(nil) - OmiMotion.withGated(.easeOut(duration: 0.10)) { - window.state.present(.agent(pillID)) - window.state.isAILoading = false - } - window.resizeForActiveAgentChatPublic(pillID: pillID, animated: true) + notchScreenManager.openAgent(pillID: pillID) completion?(true) } } @@ -2618,15 +2616,16 @@ class FloatingControlBarManager { /// of dangling as .agent(id) for a removed pill. (Codex P2 — clear active /// chat when dismissing a pill.) func leaveActiveAgentSurfaceFromPillDismiss() { - guard let window else { return } - window.leaveAgentConversation() + if let pillID = notchState.activeAgentChatPillID { + notchScreenManager?.clearAgentDrillIn(pillID: pillID) + } + notchState.activeAgentChatPillID = nil } private var pendingNotifications: [FloatingBarNotification] = [] private var notificationDismissWorkItem: DispatchWorkItem? private var notificationWasTemporarilyShown = false private var storedNotificationMessages: [OwnerNotificationKey: StoredNotificationMessage] = [:] private var pendingNotificationJournalWrites: Set = [] - private var mostRecentNotificationKey: OwnerNotificationKey? private var ownerChangeCancellable: AnyCancellable? private var pendingNotificationContext: PendingNotificationContext? private var activeQueryGeneration: Int = 0 @@ -2680,10 +2679,8 @@ class FloatingControlBarManager { notificationDismissWorkItem?.cancel() notificationDismissWorkItem = nil pendingNotifications.removeAll() - if let window, window.state.currentNotification != nil { - window.dismissNotification(animated: false) - } - window?.orderOut(nil) + notchState.currentNotification = nil + notchScreenManager?.hideAll() scheduleSnoozeTimer() AnalyticsManager.shared.floatingBarToggled(visible: false, source: "snooze") } @@ -2694,7 +2691,7 @@ class FloatingControlBarManager { snoozeTimer?.invalidate() snoozeTimer = nil if isEnabled { - window?.makeKeyAndOrderFront(nil) + notchScreenManager?.showAll() } } @@ -2729,12 +2726,9 @@ class FloatingControlBarManager { pendingNotifications.removeAll() pendingNotificationJournalWrites.removeAll() storedNotificationMessages.removeAll() - mostRecentNotificationKey = nil pendingNotificationContext = nil - if window?.state.currentNotification != nil { - window?.dismissNotification(animated: false) - } - window?.state.clearVisibleConversation() + notchState.currentNotification = nil + notchState.clearVisibleConversation() } var notificationProjectionSnapshot: NotificationProjectionSnapshot { @@ -2758,99 +2752,31 @@ class FloatingControlBarManager { return value } - /// Create the floating bar window and wire up AppState bindings. + /// Start the notch: one panel per display, chat-first. The notch renders the + /// same timeline as the main chat window over the shared provider. func setup(appState: AppState, chatProvider: ChatProvider) { - guard window == nil else { - log("FloatingControlBarManager: setup() called but window already exists") + guard notchScreenManager == nil else { + log("FloatingControlBarManager: setup() called but notch is already running") return } - log("FloatingControlBarManager: setup() creating floating bar window") - - let barWindow = FloatingControlBarWindow( - contentRect: .zero, - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - - // Play/pause toggles transcription - barWindow.onPlayPause = { [weak appState] in - guard let appState = appState else { return } - appState.toggleTranscription() - } - - // Typing lives in the main app — the bar's "chat" affordances jump there. - barWindow.onAskAI = { - (NSApp.delegate as? AppDelegate)?.openMainAppWindow() - } - - // Hide persists the preference so bar stays hidden across restarts - barWindow.onHide = { [weak self] in - self?.isEnabled = false - } + log("FloatingControlBarManager: setup() starting notch screen manager") // Default floating/notch chat is a second view over the main chat provider. // That keeps streamed deltas, unsynced local IDs, and prompt history in one // canonical transcript instead of waiting for backend polling to reconcile. historyChatProvider = chatProvider - barWindow.onSendQuery = { [weak self, weak barWindow, weak chatProvider] message in - guard let self = self, let barWindow = barWindow, let provider = chatProvider else { return } - Task { @MainActor in - await self.withQueryTracer(query: message, fromVoice: false) { - await self.routeQuery(message, barWindow: barWindow, provider: provider, fromVoice: false) - } - } - } - - barWindow.onRate = { [weak chatProvider] messageId, rating in - guard let provider = chatProvider else { return } - Task { @MainActor in - await provider.rateMessage(messageId, rating: rating) - } - } - - barWindow.onShareLink = { [weak self, weak barWindow] in - guard let self, let barWindow = barWindow else { return nil } - // Share synced message ids from the viewport cursor over the shared provider. - let orderedUniqueMessageIds = barWindow.state.syncedShareMessageIds( - from: self.historyChatProvider - ) - guard !orderedUniqueMessageIds.isEmpty else { return nil } - do { - let response = try await APIClient.shared.shareChatMessages(messageIds: orderedUniqueMessageIds) - return response.url - } catch { - log("Failed to get chat share link: \(error)") - return nil - } - } - - // Observe recording state + // The closed chrome shows a recording dot while transcription runs (the + // legacy bar's recording indicator, retained on the notch). recordingCancellable = appState.$isTranscribing - .combineLatest(appState.$isSavingConversation) .receive(on: DispatchQueue.main) - .sink { [weak barWindow] isTranscribing, isSaving in - barWindow?.updateRecordingState( - isRecording: isTranscribing, - duration: Int(RecordingTimer.shared.duration), - isInitialising: isSaving - ) - } - - // Observe duration from RecordingTimer - durationCancellable = RecordingTimer.shared.$duration - .receive(on: DispatchQueue.main) - .sink { [weak barWindow, weak appState] duration in - guard let appState = appState else { return } - barWindow?.updateRecordingState( - isRecording: appState.isTranscribing, - duration: Int(duration), - isInitialising: appState.isSavingConversation - ) + .sink { [weak self] isTranscribing in + self?.notchState.isRecording = isTranscribing } - self.window = barWindow + let screenManager = NotchScreenManager() + screenManager.start(barState: notchState, chatProvider: chatProvider) + notchScreenManager = screenManager // Re-apply any in-flight snooze that survived app relaunch. if isSnoozed { @@ -2858,12 +2784,11 @@ class FloatingControlBarManager { } else if snoozedUntil != nil { snoozedUntil = nil } - } - /// Whether the floating bar window is currently visible. + /// Whether the notch is running (panels exist on every display). var isVisible: Bool { - window?.isVisible ?? false + notchScreenManager != nil } struct AutomationState { @@ -2877,7 +2802,7 @@ class FloatingControlBarManager { } var automationState: AutomationState { - guard let window else { + guard let notchScreenManager else { return AutomationState( isVisible: false, isAskOmiOpen: false, @@ -2888,20 +2813,19 @@ class FloatingControlBarManager { usesNotchIsland: false ) } - let focused = window.firstResponder is NSTextView return AutomationState( - isVisible: window.isVisible, - isAskOmiOpen: window.state.showingAIConversation, - isAskOmiFocused: focused, - frame: NSStringFromRect(window.frame), - isVoiceListening: window.state.isVoiceListening, - isVoiceResponseActive: window.state.isVoiceResponseGlowActive, - usesNotchIsland: window.state.usesNotchIsland + isVisible: true, + isAskOmiOpen: notchScreenManager.hasOpenPanel, + isAskOmiFocused: notchScreenManager.anyPanelKeyboardFocused, + frame: notchScreenManager.primaryPanelFrame.map(NSStringFromRect), + isVoiceListening: notchState.isVoiceListening, + isVoiceResponseActive: notchState.isVoiceResponseGlowActive, + usesNotchIsland: true ) } func openAskOmiForAutomation(reset: Bool, wait: Bool = true) async -> [String: String] { - guard let window else { + guard let notchScreenManager else { return ["error": "floating_bar_window_unavailable"] } if reset { @@ -2910,37 +2834,35 @@ class FloatingControlBarManager { return ["error": error] } } - if window.state.showingAIConversation { - window.closeAIConversation() - _ = await waitForAskOmiClosed(in: window) + if notchScreenManager.hasOpenPanel { + notchScreenManager.closeAll() + _ = await waitForAutomationCondition { !notchScreenManager.hasOpenPanel } } } let start = ContinuousClock.now openAIInput() + let frameString = { notchScreenManager.primaryPanelFrame.map(NSStringFromRect) ?? "" } guard wait else { return [ "triggered": "true", - "frame": NSStringFromRect(window.frame), - "focused": (window.firstResponder is NSTextView) ? "true" : "false", + "frame": frameString(), + "focused": notchScreenManager.anyPanelKeyboardFocused ? "true" : "false", ] } let openMs = await waitForAutomationCondition { - window.isVisible && window.state.showingAIConversation && !window.state.showingAIResponse - } - if !(window.firstResponder is NSTextView) { - _ = window.focusInputField() + notchScreenManager.hasOpenPanel } let focusMs = await waitForAutomationCondition { - window.firstResponder is NSTextView + notchScreenManager.anyPanelKeyboardFocused } let elapsedMs = start.duration(to: .now).millisecondsString return [ "openMs": openMs ?? "timeout", "focusMs": focusMs ?? "timeout", "elapsedMs": elapsedMs, - "frame": NSStringFromRect(window.frame), - "focused": (window.firstResponder is NSTextView) ? "true" : "false", + "frame": frameString(), + "focused": notchScreenManager.anyPanelKeyboardFocused ? "true" : "false", ] } @@ -2960,15 +2882,13 @@ class FloatingControlBarManager { notificationDismissWorkItem?.cancel() notificationDismissWorkItem = nil if !isVisible { show() } - // Use the window's presenter directly (not the owner-gated manager - // overload): a reach error is UI state, not a runtime-owner notification. - window?.showNotification( - FloatingBarNotification( - ownerID: RuntimeOwnerIdentity.currentOwnerId() ?? "", - title: "Couldn't reach Omi", - message: message, - assistantId: "reach_error" - ) + // Present directly (not the owner-gated manager overload): a reach error + // is UI state, not a runtime-owner notification. + notchState.currentNotification = FloatingBarNotification( + ownerID: RuntimeOwnerIdentity.currentOwnerId() ?? "", + title: "Couldn't reach Omi", + message: message, + assistantId: "reach_error" ) } @@ -2985,29 +2905,30 @@ class FloatingControlBarManager { } func closeAskOmiForAutomation(wait: Bool = true) async -> [String: String] { - guard let window else { + guard let notchScreenManager else { return ["error": "floating_bar_window_unavailable"] } let start = ContinuousClock.now - if window.state.showingAIConversation { - window.closeAIConversation() + let frameString = { notchScreenManager.primaryPanelFrame.map(NSStringFromRect) ?? "" } + if notchScreenManager.hasOpenPanel { + notchScreenManager.closeAll() } guard wait else { return [ "triggered": "true", - "visible": window.isVisible ? "true" : "false", - "askOmiOpen": window.state.showingAIConversation ? "true" : "false", - "frame": NSStringFromRect(window.frame), + "visible": isVisible ? "true" : "false", + "askOmiOpen": notchScreenManager.hasOpenPanel ? "true" : "false", + "frame": frameString(), ] } - let closeMs = await waitForAskOmiClosed(in: window) + let closeMs = await waitForAutomationCondition { !notchScreenManager.hasOpenPanel } let elapsedMs = start.duration(to: .now).millisecondsString return [ "closeMs": closeMs ?? "timeout", "elapsedMs": elapsedMs, - "visible": window.isVisible ? "true" : "false", - "askOmiOpen": window.state.showingAIConversation ? "true" : "false", - "frame": NSStringFromRect(window.frame), + "visible": isVisible ? "true" : "false", + "askOmiOpen": notchScreenManager.hasOpenPanel ? "true" : "false", + "frame": frameString(), ] } @@ -3019,26 +2940,20 @@ class FloatingControlBarManager { } func seedSubagentsForAutomation(count: Int) async -> [String: String] { - guard let window else { + guard let notchScreenManager else { return ["error": "floating_bar_window_unavailable"] } let pills = AgentPillsManager.shared.replaceWithAutomationPills(count: count) - if !window.state.showingAIConversation { - window.showAIConversation() - } - window.state.present(.mainInput) - window.state.isAILoading = false - window.resizeToResponseHeightPublic(animated: false) - window.state.present(.mainInput) + notchScreenManager.openPrimary(tab: .agents) return [ "count": "\(pills.count)", "first": pills.first?.id.uuidString ?? "", - "frame": NSStringFromRect(window.frame), + "frame": notchScreenManager.primaryPanelFrame.map(NSStringFromRect) ?? "", ] } func openSeededSubagentForAutomation(index: Int, wait: Bool = true) async -> [String: String] { - guard let window else { + guard let notchScreenManager else { return ["error": "floating_bar_window_unavailable"] } let pills = AgentPillsManager.shared.pills @@ -3048,52 +2963,49 @@ class FloatingControlBarManager { let pill = pills[index] let start = ContinuousClock.now AgentPillsManager.shared.markViewed(pillID: pill.id) - OmiMotion.withGated(.easeOut(duration: 0.10)) { - window.state.present(.agent(pill.id)) - window.state.isAILoading = false - } - window.resizeForActiveAgentChatPublic(pillID: pill.id, animated: false) + notchState.activeAgentChatPillID = pill.id + notchScreenManager.openAgent(pillID: pill.id) guard wait else { return ["triggered": "true", "active": pill.id.uuidString] } let selectMs = await waitForAutomationCondition { - window.state.activeAgentChatPillID == pill.id && window.state.showingAIResponse + notchScreenManager.hasOpenPanel } return [ "selectMs": selectMs ?? "timeout", "elapsedMs": start.duration(to: .now).millisecondsString, "active": pill.id.uuidString, - "frame": NSStringFromRect(window.frame), + "frame": notchScreenManager.primaryPanelFrame.map(NSStringFromRect) ?? "", ] } func backFromSubagentForAutomation(wait: Bool = true) async -> [String: String] { - guard let window else { + guard let notchScreenManager else { return ["error": "floating_bar_window_unavailable"] } let start = ContinuousClock.now let expectsRows = !AgentPillsManager.shared.pills.isEmpty - window.leaveAgentConversation() + if let pillID = notchState.activeAgentChatPillID { + notchScreenManager.clearAgentDrillIn(pillID: pillID) + } + notchState.activeAgentChatPillID = nil guard wait else { return [ "triggered": "true", - "active": window.state.activeAgentChatPillID?.uuidString ?? "", + "active": notchState.activeAgentChatPillID?.uuidString ?? "", "mode": expectsRows ? "rows" : "main", - "rowsOpen": window.state.isNotchHoverMenuVisible ? "true" : "false", + "rowsOpen": "false", ] } - let backMs = await waitForAutomationCondition { - window.state.activeAgentChatPillID == nil - && (expectsRows - ? (!window.state.showingAIConversation && window.state.isNotchHoverMenuVisible) - : window.state.showingAIConversation) + let backMs = await waitForAutomationCondition { [notchState] in + notchState.activeAgentChatPillID == nil } return [ "backMs": backMs ?? "timeout", "elapsedMs": start.duration(to: .now).millisecondsString, "mode": expectsRows ? "rows" : "main", - "rowsOpen": window.state.isNotchHoverMenuVisible ? "true" : "false", - "frame": NSStringFromRect(window.frame), + "rowsOpen": "false", + "frame": notchScreenManager.primaryPanelFrame.map(NSStringFromRect) ?? "", ] } @@ -3108,12 +3020,6 @@ class FloatingControlBarManager { return nil } - private func waitForAskOmiClosed(in window: FloatingControlBarWindow) async -> String? { - await waitForAutomationCondition { - window.hasSettledClosedForAutomation - } - } - /// Apply the product-level launch presentation policy. /// /// Normal signed-in Desktop launch must show the floating bar when enabled; @@ -3123,7 +3029,7 @@ class FloatingControlBarManager { let presentation = FloatingBarLaunchPolicy.presentation( isEnabled: isEnabled, context: context, - displayHasNotch: window?.usesNotchIslandForCurrentScreen == true + displayHasNotch: NotchMetrics.screenHasCameraHousing(NSScreen.main) ) switch presentation { @@ -3139,7 +3045,7 @@ class FloatingControlBarManager { /// Opt-in presentation for contexts where the notch should stay hidden until /// the user's first Push-to-Talk press (which calls `show()`). func showDeferredUntilFirstPushToTalk() { - if window?.usesNotchIslandForCurrentScreen == true, !hasRevealedNotchThisSession { + if NotchMetrics.screenHasCameraHousing(NSScreen.main), !hasRevealedNotchThisSession { isEnabled = true log("FloatingControlBarManager: showDeferredUntilFirstPushToTalk() — notch hidden until first Push-to-Talk") return @@ -3149,48 +3055,26 @@ class FloatingControlBarManager { /// Show the floating bar and persist the preference. func show() { - log("FloatingControlBarManager: show() called, window=\(window != nil), isVisible=\(window?.isVisible ?? false)") + log("FloatingControlBarManager: show() called") isEnabled = true if isSnoozed { log( "FloatingControlBarManager: show() suppressed because bar is snoozed until \(snoozedUntil?.description ?? "?")") return } - // Reveal on every hidden→present transition (not just once per session): - // the island should always grow out of the notch instead of popping in. - let shouldPlayNotchReveal = - window?.usesNotchIslandForCurrentScreen == true - && (window?.isVisible != true || !hasRevealedNotchThisSession) - hasRevealedNotchThisSession = true - window?.normalizeForTemporaryShow() - window?.makeKeyAndOrderFront(nil) - if shouldPlayNotchReveal { - window?.playNotchRevealAnimation() - } - log("FloatingControlBarManager: show() done, frame=\(window?.frame ?? .zero)") - - // Auto-focus input if AI conversation is open - if let window = window, window.state.showingAIConversation && !window.state.showingAIResponse { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - window.focusInputField() - } - } + notchScreenManager?.showAll() } /// Hide the floating bar and persist the preference. func hide() { isEnabled = false - if let window { - window.retractIntoNotch { [weak window] in - window?.orderOut(nil) - } - } + notchScreenManager?.hideAll() } /// Show the floating bar temporarily without changing the user's persisted preference. /// Used when browser tools activate so the bar stays visible above Chrome. func showTemporarily() { - guard window != nil else { return } + guard notchScreenManager != nil else { return } if !isEnabled { // The user has explicitly disabled the floating bar. Honor that even when // a background browser tool would otherwise surface it — unlike the @@ -3204,8 +3088,7 @@ class FloatingControlBarManager { return } log("FloatingControlBarManager: showTemporarily() — showing bar above Chrome") - window?.normalizeForTemporaryShow() - window?.makeKeyAndOrderFront(nil) + notchScreenManager?.showAll() } @discardableResult @@ -3232,8 +3115,8 @@ class FloatingControlBarManager { action: action, screenshotData: screenshotData ) - guard let window else { - log("FloatingControlBarManager: dropping notification because window is not set up") + guard notchScreenManager != nil else { + log("FloatingControlBarManager: dropping notification because notch is not set up") return .windowUnavailable } @@ -3251,16 +3134,16 @@ class FloatingControlBarManager { break } - if !window.state.showingAIConversation { + if !notchState.showingAIConversation { persistNotificationMessageIfNeeded(notification) } - if window.state.currentNotification != nil || window.state.showingAIConversation { + if notchState.currentNotification != nil || notchScreenManager?.hasOpenPanel == true { pendingNotifications.append(notification) return .queued } - presentNotification(notification, in: window) + presentNotification(notification) return .presented } @@ -3271,7 +3154,7 @@ class FloatingControlBarManager { } func flushQueuedNotificationsIfPossible() { - guard let window, window.state.currentNotification == nil, !window.state.showingAIConversation + guard notchState.currentNotification == nil, notchScreenManager?.hasOpenPanel != true else { return } while !pendingNotifications.isEmpty { let nextNotification = pendingNotifications.removeFirst() @@ -3279,7 +3162,7 @@ class FloatingControlBarManager { log("FloatingControlBarManager: dropping queued notification from stale runtime owner") continue } - presentNotification(nextNotification, in: window) + presentNotification(nextNotification) return } } @@ -3377,8 +3260,7 @@ class FloatingControlBarManager { /// Toggle visibility. func toggle() { - guard let window = window else { return } - if window.isVisible { + if isEnabled { AnalyticsManager.shared.floatingBarToggled(visible: false, source: "shortcut") hide() } else { @@ -3387,53 +3269,23 @@ class FloatingControlBarManager { } } - /// Toggle for the retired typed-input panel: collapsing an open - /// conversation still works, but opening now routes to the main app — - /// the floating bar no longer offers typing. + /// Toggle the notch chat panel (chat-first: opening always lands on chat). func toggleAIInput() { - guard let window = window else { + guard let notchScreenManager else { (NSApp.delegate as? AppDelegate)?.openMainAppWindow() return } - if window.isVisible && window.state.showingAIConversation { - window.closeAIConversation() + if notchScreenManager.hasOpenPanel { + notchScreenManager.closeAll() } else { - (NSApp.delegate as? AppDelegate)?.openMainAppWindow() + notchScreenManager.openPrimary(tab: .chat) } } - /// Open the floating conversation surface. Harness/automation-only entry: - /// every user-facing typed-input path now opens the main app instead. + /// Open the notch chat surface (harness/automation + shortcut entry). func openAIInput() { - guard let window = window else { return } - - // The bar is a non-activating panel, so it can become key for text input - // without surfacing the main Omi window. - - // If a conversation is already showing, just focus the follow-up input - if window.state.showingAIConversation && window.state.showingAIResponse { - if !window.isVisible { - // Show without persisting enabled state — bar hides again when conversation closes - window.makeKeyAndOrderFront(nil) - } - window.makeKeyAndOrderFront(nil) - window.focusInputField() - return - } - AnalyticsManager.shared.floatingBarAskOmiOpened(source: "shortcut") - if !window.isVisible { - // Show window without persisting enabled state — if the user has the bar - // disabled, it will hide again when the AI conversation closes. - window.makeKeyAndOrderFront(nil) - } - - if openRecentNotificationConversationIfAvailable(in: window) { - return - } - - window.showAIConversation() - window.orderFrontRegardless() + notchScreenManager?.openPrimary(tab: .chat) } /// Open AI input with a pre-filled query and auto-send (used by PTT). @@ -3442,7 +3294,6 @@ class FloatingControlBarManager { fromVoice: Bool = false, voiceTurnID: VoiceTurnID? = nil ) { - guard let window = window else { return } guard let provider = activeFloatingProvider() else { return } if fromVoice { @@ -3451,13 +3302,7 @@ class FloatingControlBarManager { else { return } chatCancellable?.cancel() chatCancellable = nil - window.cancelInputHeightObserver() - window.state.currentQueryFromVoice = true - if window.state.showingAIConversation { - window.closeAIConversation(intent: .voiceHandoff) - } else if !window.isVisible { - window.makeKeyAndOrderFront(nil) - } + notchState.currentQueryFromVoice = true Task { @MainActor in guard VoiceTurnCoordinator.shared.requireCurrentOwner(for: voiceTurnID) != nil else { return @@ -3465,7 +3310,6 @@ class FloatingControlBarManager { await self.withQueryTracer(query: query, fromVoice: true) { await self.routeQuery( query, - barWindow: window, provider: provider, presentation: .voiceOnly, voiceTurnID: voiceTurnID @@ -3478,49 +3322,21 @@ class FloatingControlBarManager { // Cancel stale subscriptions immediately to prevent old data from flashing chatCancellable?.cancel() chatCancellable = nil - window.cancelInputHeightObserver() // Reset visible state without animation; keep provider session (cancelInFlightWork: false). - window.state.showingAIConversation = false - window.state.clearVisibleConversation(cancelInFlightWork: false) - window.state.currentQueryFromVoice = fromVoice + notchState.showingAIConversation = false + notchState.clearVisibleConversation(cancelInFlightWork: false) + notchState.currentQueryFromVoice = fromVoice pendingNotificationContext = nil - // Re-wire onSendQuery for typed follow-ups (force fromVoice:false after voice turns). - window.onSendQuery = { [weak self, weak window, weak provider] message in - guard let self = self, let window = window, let provider = provider else { return } - Task { @MainActor in - await self.withQueryTracer(query: message, fromVoice: false) { - await self.routeQuery(message, barWindow: window, provider: provider, fromVoice: false) - } - } - } - - if !window.isVisible { - // Show window without persisting enabled state — if the user has the bar - // disabled, it will hide again when the AI conversation closes. - window.makeKeyAndOrderFront(nil) - } - - // Cancel any in-flight windowDidResignKey dismiss animation before saving the - // pre-chat center. Without this, the stale completion block fires after the new - // query opens and immediately closes it. - window.cancelPendingDismiss() - - // Save pre-chat center so closeAIConversation can restore the original position. - // Without this, Escape after a PTT query places the bar at the response window's - // center instead of where it was before the chat opened. - window.savePreChatCenterIfNeeded() - - // Mark the query source before sending so playback behavior is correct. - window.state.currentQueryFromVoice = fromVoice - window.orderFrontRegardless() + // Chat-first: the visible surface is the notch chat over the shared timeline. + notchScreenManager?.openPrimary(tab: .chat) - // Auto-send the query. PTT bypasses the typed onSendQuery closure, so - // we need to apply the same router rule here ourselves. + // Auto-send the query. PTT bypasses the typed composer, so we apply the + // same router rule here ourselves. Task { @MainActor in await self.withQueryTracer(query: query, fromVoice: fromVoice) { - await self.routeQuery(query, barWindow: window, provider: provider, fromVoice: fromVoice) + await self.routeQuery(query, provider: provider, fromVoice: fromVoice) } } } @@ -3543,14 +3359,12 @@ class FloatingControlBarManager { /// whether to call `spawn_agent`; Swift never interprets provider wording. private func routeQuery( _ message: String, - barWindow: FloatingControlBarWindow, provider: ChatProvider, fromVoice: Bool, voiceTurnID: VoiceTurnID? = nil ) async { await routeQuery( message, - barWindow: barWindow, provider: provider, presentation: .visible(fromVoice: fromVoice), voiceTurnID: voiceTurnID @@ -3559,7 +3373,6 @@ class FloatingControlBarManager { private func routeQuery( _ message: String, - barWindow: FloatingControlBarWindow, provider: ChatProvider, presentation: QueryPresentation, voiceTurnID: VoiceTurnID? = nil @@ -3571,7 +3384,7 @@ class FloatingControlBarManager { let turnOwner = chatTurnOwner(for: presentation) if provider.isSending { guard provider.canInterruptActiveTurn(owner: turnOwner) else { - showSharedProviderBusy(in: barWindow, presentation: presentation) + showSharedProviderBusy(presentation: presentation) return } pendingFollowUpQuery = PendingFollowUpQuery( @@ -3580,7 +3393,7 @@ class FloatingControlBarManager { voiceTurnID: voiceTurnID ) if case .visible(let fromVoice) = presentation { - prepareVisibleQueryState(message, in: barWindow, fromVoice: fromVoice) + prepareVisibleQueryState(message, fromVoice: fromVoice) } provider.stopAgent(owner: turnOwner, reason: .superseded) return @@ -3588,14 +3401,13 @@ class FloatingControlBarManager { // Show the thinking state immediately while the kernel accepts the turn. if case .visible(let fromVoice) = presentation { - prepareVisibleQueryState(message, in: barWindow, fromVoice: fromVoice) + prepareVisibleQueryState(message, fromVoice: fromVoice) } let routerTracer = QueryTracerContext.current routerTracer?.mark("kernel_route", metadata: ["authority": "agent_kernel"]) await dispatchChatQuery( message, - barWindow: barWindow, provider: provider, presentation: presentation, voiceTurnID: voiceTurnID @@ -3604,7 +3416,6 @@ class FloatingControlBarManager { private func dispatchChatQuery( _ message: String, - barWindow: FloatingControlBarWindow, provider: ChatProvider, presentation: QueryPresentation, voiceTurnID: VoiceTurnID? @@ -3617,7 +3428,6 @@ class FloatingControlBarManager { case .visible: await sendAIQuery( message, - barWindow: barWindow, provider: provider, voiceTurnID: voiceTurnID ) @@ -3625,7 +3435,6 @@ class FloatingControlBarManager { guard let voiceTurnID else { return } await sendVoiceOnlyQuery( message, - barWindow: barWindow, provider: provider, voiceTurnID: voiceTurnID ) @@ -3641,19 +3450,18 @@ class FloatingControlBarManager { } } - private func showSharedProviderBusy(in barWindow: FloatingControlBarWindow, presentation: QueryPresentation) { + private func showSharedProviderBusy(presentation: QueryPresentation) { let message = ChatMessage(text: "Omi is already responding in the app.", sender: .ai) switch presentation { case .visible: chatCancellable?.cancel() chatCancellable = nil - barWindow.state.displayedQuery = "" - barWindow.state.bindQuestionMessageId(nil) - barWindow.state.setLocalAnswerOverride(message) - barWindow.state.isAILoading = false - barWindow.state.present(.mainResponse) - barWindow.state.markConversationActivity() - barWindow.resizeToResponseHeightPublic(animated: true) + notchState.displayedQuery = "" + notchState.bindQuestionMessageId(nil) + notchState.setLocalAnswerOverride(message) + notchState.isAILoading = false + notchState.present(.mainResponse) + notchState.markConversationActivity() case .voiceOnly: FloatingBarVoicePlaybackService.shared.speakOneShot(message.text) } @@ -3702,19 +3510,16 @@ class FloatingControlBarManager { log("FloatingControlBarManager: refusing unjournaled visible response") chatCancellable?.cancel() chatCancellable = nil - appendJournalSaveWarning(in: barWindow, provider: activeFloatingProvider()) + appendJournalSaveWarning(provider: activeFloatingProvider()) barWindow.state.isAILoading = false barWindow.state.present(.mainResponse) barWindow.resizeToResponseHeightPublic(animated: true) } /// Keep any visible partial/override and append a save warning (do not replace content). - private func appendJournalSaveWarning( - in barWindow: FloatingControlBarWindow, - provider: ChatProvider? - ) { + private func appendJournalSaveWarning(provider: ChatProvider?) { let warning = "⚠️ I couldn't save that response. Please try again." - if let existing = barWindow.state.currentAIMessage(from: provider), + if let existing = notchState.currentAIMessage(from: provider), FloatingControlBarState.messageHasAnswerContent(existing) { if let provider, @@ -3731,7 +3536,7 @@ class FloatingControlBarManager { if provider.messages[index].journalStatus != .failed { provider.messages[index].journalStatus = .failed } - barWindow.state.bindAnswerMessage(provider.messages[index]) + notchState.bindAnswerMessage(provider.messages[index]) return } let existingText = existing.text @@ -3743,24 +3548,20 @@ class FloatingControlBarManager { override.text = combined override.isStreaming = false override.journalStatus = .failed - barWindow.state.setLocalAnswerOverride(override) + notchState.setLocalAnswerOverride(override) return } - barWindow.state.setLocalAnswerOverride( + notchState.setLocalAnswerOverride( ChatMessage(text: warning, sender: .ai, journalStatus: .failed) ) } - private func dispatchPendingQueryIfNeeded( - barWindow: FloatingControlBarWindow, - provider: ChatProvider - ) async -> Bool { + private func dispatchPendingQueryIfNeeded(provider: ChatProvider) async -> Bool { guard let pending = pendingFollowUpQuery else { return false } pendingFollowUpQuery = nil - barWindow.state.currentQueryFromVoice = pending.presentation.fromVoice + notchState.currentQueryFromVoice = pending.presentation.fromVoice await routeQuery( pending.text, - barWindow: barWindow, provider: provider, presentation: pending.presentation, voiceTurnID: pending.voiceTurnID @@ -3779,7 +3580,7 @@ class FloatingControlBarManager { VoiceTurnCoordinator.shared.requireCurrentOwner(for: voiceTurnID) != nil else { return } } - guard let window = window, window.state.showingAIResponse else { + guard notchState.showingAIResponse else { // No active conversation — fall back to new conversation openAIInputWithQuery(query, fromVoice: fromVoice, voiceTurnID: voiceTurnID) return @@ -3787,12 +3588,12 @@ class FloatingControlBarManager { guard let provider = activeFloatingProvider() else { return } // Archive current exchange as viewport id anchors (content stays on provider). - window.state.archiveCurrentExchange(using: provider) + notchState.archiveCurrentExchange(using: provider) if provider.isSending { let turnOwner = chatTurnOwner(for: .visible(fromVoice: fromVoice)) guard provider.canInterruptActiveTurn(owner: turnOwner) else { - showSharedProviderBusy(in: window, presentation: .visible(fromVoice: fromVoice)) + showSharedProviderBusy(presentation: .visible(fromVoice: fromVoice)) return } pendingFollowUpQuery = PendingFollowUpQuery( @@ -3800,12 +3601,12 @@ class FloatingControlBarManager { presentation: .visible(fromVoice: fromVoice), voiceTurnID: voiceTurnID ) - prepareVisibleQueryState(query, in: window, fromVoice: fromVoice) + prepareVisibleQueryState(query, fromVoice: fromVoice) provider.stopAgent(owner: turnOwner, reason: .superseded) return } - window.state.currentQueryFromVoice = fromVoice + notchState.currentQueryFromVoice = fromVoice Task { @MainActor in guard voiceTurnID.map({ VoiceTurnCoordinator.shared.requireCurrentOwner(for: $0) != nil }) @@ -3814,7 +3615,6 @@ class FloatingControlBarManager { await self.withQueryTracer(query: query, fromVoice: fromVoice) { await self.sendAIQuery( query, - barWindow: window, provider: provider, voiceTurnID: voiceTurnID ) @@ -3823,9 +3623,7 @@ class FloatingControlBarManager { } func openNotificationAsChat(_ notification: FloatingBarNotification) { - guard notification.ownerID == RuntimeOwnerIdentity.currentOwnerId(), - let window - else { return } + guard notification.ownerID == RuntimeOwnerIdentity.currentOwnerId() else { return } AnalyticsManager.shared.notificationClicked( notificationId: notification.id.uuidString, @@ -3841,32 +3639,29 @@ class FloatingControlBarManager { ContextualTaskNavigationRouter.shared.request(recommendationID: recommendationID) return } - _ = openNotificationConversation(notificationID: notification.id, in: window) + // Chat-first: the journaled notification message already lives in the one + // shared timeline, so opening the notch chat lands on it. + notchScreenManager?.openPrimary(tab: .chat) } - private func presentNotification(_ notification: FloatingBarNotification, in window: FloatingControlBarWindow) { + private func presentNotification(_ notification: FloatingBarNotification) { guard notification.ownerID == RuntimeOwnerIdentity.currentOwnerId() else { log("FloatingControlBarManager: refusing to present stale-owner notification") return } persistNotificationMessageIfNeeded(notification) - // The flag must survive the whole notification chain: when a queued - // notification is presented the window is already visible from the - // temp-show, so resetting it here would skip the re-hide in - // dismissNotificationAndAdvanceQueue and leave the bar on screen - // forever with "Show floating bar" off (#6972). The bar can also be - // visible while disabled (e.g. a notification flushed right as an AI - // conversation closes), so any presentation with the bar disabled - // must arm the re-hide; dismissNotificationAndAdvanceQueue owns the reset. - if !window.isVisible || !isEnabled { + // The flag must survive the whole notification chain: a notification can + // present while the bar is disabled/hidden, so any presentation with the + // bar disabled must arm the re-hide; dismissNotificationAndAdvanceQueue + // owns the reset (#6972). + if !isEnabled { notificationWasTemporarilyShown = true - if !window.isVisible { - window.orderFrontRegardless() - } + notchScreenManager?.showAll() } - window.showNotification(notification) + guard !notchState.showingAIConversation else { return } + notchState.currentNotification = notification AnalyticsManager.shared.notificationSent( notificationId: notification.id.uuidString, title: notification.title, @@ -3882,10 +3677,8 @@ class FloatingControlBarManager { } private func dismissNotificationAndAdvanceQueue(trackDismissal: Bool) { - guard let window else { return } - - let dismissedNotification = window.state.currentNotification - window.dismissNotification() + let dismissedNotification = notchState.currentNotification + notchState.currentNotification = nil if trackDismissal, let dismissedNotification { AnalyticsManager.shared.notificationDismissed( @@ -3896,20 +3689,20 @@ class FloatingControlBarManager { ) } - if !window.state.showingAIConversation { + if notchScreenManager?.hasOpenPanel != true { while !pendingNotifications.isEmpty { let nextNotification = pendingNotifications.removeFirst() guard nextNotification.ownerID == RuntimeOwnerIdentity.currentOwnerId() else { log("FloatingControlBarManager: dropping queued notification from stale runtime owner") continue } - presentNotification(nextNotification, in: window) + presentNotification(nextNotification) return } } - if notificationWasTemporarilyShown && !isEnabled && !window.state.showingAIConversation { - window.orderOut(nil) + if notificationWasTemporarilyShown && !isEnabled && notchScreenManager?.hasOpenPanel != true { + notchScreenManager?.hideAll() } notificationWasTemporarilyShown = false } @@ -3963,7 +3756,6 @@ class FloatingControlBarManager { messageClientTurnId: continuityKey, createdAt: Date() ) - self.mostRecentNotificationKey = key } } @@ -4078,10 +3870,9 @@ class FloatingControlBarManager { else { return false } observeAgentCompletionContext(pillID: pillID, runId: runId) if !resources.isEmpty { - // Project the canonical journal revision synchronously so main and notch agree before the floating - // viewport update. + // Project the canonical journal revision synchronously so main and notch + // render the completed artifact from the shared provider timeline. provider.projectJournalTurn(updated) - deliverAgentArtifactCompletionToFloatingSurface(updated.chatMessage()) } return true } @@ -4120,140 +3911,31 @@ class FloatingControlBarManager { Task { await TaskContextualResurfacingService.shared.observe(matched) } } - private func openRecentNotificationConversationIfAvailable(in window: FloatingControlBarWindow) -> Bool { - guard let key = mostRecentNotificationKey, - key.ownerID == RuntimeOwnerIdentity.currentOwnerId() - else { return false } - return openNotificationConversation(notificationID: key.notificationID, in: window) - } - - @discardableResult - private func openNotificationConversation(notificationID: UUID, in window: FloatingControlBarWindow) -> Bool { - purgeExpiredNotificationMessages() - - guard let ownerID = RuntimeOwnerIdentity.currentOwnerId() else { return false } - let key = OwnerNotificationKey(ownerID: ownerID, notificationID: notificationID) - guard let stored = storedNotificationMessages[key], - stored.ownerID == ownerID, - Date().timeIntervalSince(stored.createdAt) <= Self.recentNotificationReuseInterval, - let provider = historyChatProvider, - let notificationMessage = provider.messages.last(where: { - $0.clientTurnId == stored.messageClientTurnId - }) - else { - return false - } - notificationDismissWorkItem?.cancel() - notificationDismissWorkItem = nil - pendingNotifications.removeAll { $0.id == notificationID } - if window.state.currentNotification != nil { - window.dismissNotification() - } - - window.cancelPendingDismiss() - window.savePreChatCenterIfNeeded() - window.cancelInputHeightObserver() - let shouldRestoreVisibleConversation = window.state.canRestoreVisibleConversation - if shouldRestoreVisibleConversation { - archiveVisibleConversationIfNeeded(in: window) - } else if window.state.hasVisibleConversation { - window.state.clearVisibleConversation() - } - - window.state.present(.mainResponse) - window.state.isAILoading = false - if !shouldRestoreVisibleConversation { - window.state.clearViewport() - } - window.state.bindAnswerMessage(notificationMessage) - window.state.markConversationActivity() - window.resizeToResponseHeightPublic(animated: true) - window.orderFrontRegardless() - window.focusInputField() - - pendingNotificationContext = PendingNotificationContext( - message: notificationMessage, - context: stored.context - ) - Task { - if let provider = activeFloatingProvider() { - await provider.invalidateAgentSurface(surface: provider.mainChatSurfaceReference()) - } - } - storedNotificationMessages.removeValue(forKey: key) - if mostRecentNotificationKey == key { - mostRecentNotificationKey = nil - } - return true - } - - private func archiveVisibleConversationIfNeeded(in window: FloatingControlBarWindow) { - window.state.archiveCurrentExchange(using: self.historyChatProvider) - window.state.displayedQuery = "" - window.state.bindQuestionMessageId(nil) - } - - private func purgeExpiredNotificationMessages() { - let now = Date() - storedNotificationMessages = storedNotificationMessages.filter { _, stored in - now.timeIntervalSince(stored.createdAt) <= Self.recentNotificationReuseInterval - } - - if let mostRecentNotificationKey, - storedNotificationMessages[mostRecentNotificationKey] == nil - { - self.mostRecentNotificationKey = nil - } - } - private func activeFloatingProvider() -> ChatProvider? { historyChatProvider } - private func deliverAgentArtifactCompletionToFloatingSurface(_ message: ChatMessage) { - guard let window else { return } - chatCancellable?.cancel() - chatCancellable = nil - - var completedMessage = message - completedMessage.isStreaming = false - - window.state.archiveCurrentExchange(using: self.historyChatProvider) - - if self.historyChatProvider?.messages.contains(where: { $0.id == completedMessage.id }) == true { - window.state.bindAnswerMessage(completedMessage) - } else { - window.state.setLocalAnswerOverride(completedMessage) - } - window.state.displayedQuery = "" - window.state.bindQuestionMessageId(nil) - window.state.isAILoading = false - if window.state.conversationSurface == .mainInput || window.state.conversationSurface == .mainResponse { - window.state.present(.mainResponse) - window.resizeToResponseHeightPublic(animated: true) - } else { - window.state.markConversationActivity() - } - } - /// Access the bar state for PTT updates. var barState: FloatingControlBarState? { - return window?.state - } - - /// Resize the floating bar for PTT state changes. - func resizeForPTT(expanded: Bool) { - window?.resizeForPTTState(expanded: expanded) + return notchState } // MARK: - AI Query - private func prepareVisibleQueryState(_ message: String, in barWindow: FloatingControlBarWindow, fromVoice: Bool) { + private func prepareVisibleQueryState(_ message: String, fromVoice: Bool) { activeQueryGeneration += 1 chatCancellable?.cancel() chatCancellable = nil FloatingBarVoicePlaybackService.shared.interruptCurrentResponse() - barWindow.beginVisibleMainQuery(message, fromVoice: fromVoice, animated: true) + // State effects of the old window's beginVisibleMainQuery; sizing is now + // owned by the notch's presentation ladder. + notchState.currentQueryFromVoice = fromVoice + notchState.markAIDraftSubmitted(message) + notchState.displayedQuery = message + notchState.clearCurrentAnswerAnchors() + notchState.isAILoading = true + notchState.markConversationActivity() + notchScreenManager?.openPrimary(tab: .chat) } private func isActiveQueryGeneration(_ generation: Int) -> Bool { @@ -4287,7 +3969,6 @@ class FloatingControlBarManager { private func sendAIQuery( _ message: String, - barWindow: FloatingControlBarWindow, provider: ChatProvider, voiceTurnID: VoiceTurnID? = nil ) async { @@ -4309,7 +3990,7 @@ class FloatingControlBarManager { // the ChatProvider call (screenshot capture, usage checks, filler audio). let currentTracer = QueryTracerContext.current currentTracer?.begin("pre_llm") - let queryFromVoice = barWindow.state.currentQueryFromVoice + let queryFromVoice = notchState.currentQueryFromVoice let voiceCompletionToken = queryFromVoice ? VoiceTurnCoordinator.shared.nonHubCompletionToken() @@ -4323,7 +4004,7 @@ class FloatingControlBarManager { ) } } - prepareVisibleQueryState(message, in: barWindow, fromVoice: queryFromVoice) + prepareVisibleQueryState(message, fromVoice: queryFromVoice) let generation = activeQueryGeneration // Re-check after the await-free setup work above. @@ -4354,12 +4035,11 @@ class FloatingControlBarManager { screenshotData = nil currentTracer?.mark("screenshot_capture") } - barWindow.orderFrontRegardless() AnalyticsManager.shared.floatingBarQuerySent(messageLength: message.count, hasScreenshot: screenshotData != nil) let shouldPlayVoice = ShortcutSettings.shared.shouldSpeakFloatingBarResponse( - forVoiceQuery: barWindow.state.currentQueryFromVoice + forVoiceQuery: notchState.currentQueryFromVoice ) if shouldPlayVoice { // QueryTracer: hand the tracer to the playback service so it can close @@ -4374,12 +4054,12 @@ class FloatingControlBarManager { // Observe messages for streaming response — bind viewport ids only. chatCancellable?.cancel() - barWindow.state.beginTurn(clientTurnId: clientTurnId) - barWindow.state.isAILoading = true - var hasSetUpResponseHeight = false + notchState.beginTurn(clientTurnId: clientTurnId) + notchState.isAILoading = true + var hasPresentedResponse = false chatCancellable = provider.$messages .receive(on: DispatchQueue.main) - .sink { [weak self, weak barWindow] messages in + .sink { [weak self] messages in guard let self, self.isActiveQueryGeneration(generation) else { return } guard let aiMessage = messages.last(where: { @@ -4388,11 +4068,11 @@ class FloatingControlBarManager { else { return } // Viewport cursor over provider messages (preserves contentBlocks via id lookup) - barWindow?.state.bindAnswerMessage(aiMessage) + self.notchState.bindAnswerMessage(aiMessage) if let userMessage = messages.last(where: { $0.clientTurnId == clientTurnId && $0.sender == .user }) { - barWindow?.state.bindQuestionMessageId(userMessage.id) + self.notchState.bindQuestionMessageId(userMessage.id) } if shouldPlayVoice { FloatingBarVoicePlaybackService.shared.updateStreamingResponseIfEnabled( @@ -4401,19 +4081,14 @@ class FloatingControlBarManager { ) } - if aiMessage.isStreaming { - barWindow?.state.isAILoading = false - if let barWindow = barWindow, !hasSetUpResponseHeight { - hasSetUpResponseHeight = true - if !barWindow.state.showingAIResponse { - OmiMotion.withGated(.spring(response: 0.24, dampingFraction: 0.9)) { - barWindow.state.present(.mainResponse) - } + self.notchState.isAILoading = false + if aiMessage.isStreaming, !hasPresentedResponse { + hasPresentedResponse = true + if !self.notchState.showingAIResponse { + OmiMotion.withGated(.spring(response: 0.24, dampingFraction: 0.9)) { + self.notchState.present(.mainResponse) } - barWindow.resizeToResponseHeightPublic(animated: true) } - } else { - barWindow?.state.isAILoading = false } } @@ -4438,8 +4113,8 @@ class FloatingControlBarManager { imageData: screenshotData, turnOwner: chatTurnOwner(for: .visible(fromVoice: queryFromVoice)), clientTurnId: clientTurnId, - onAccepted: { [weak barWindow] in - barWindow?.state.clearSubmittedAIDraftIfUnchanged(message) + onAccepted: { [weak self] in + self?.notchState.clearSubmittedAIDraftIfUnchanged(message) }, onJournalFinalized: { accepted in journalAccepted = accepted @@ -4458,8 +4133,8 @@ class FloatingControlBarManager { imageData: screenshotData, turnOwner: chatTurnOwner(for: .visible(fromVoice: queryFromVoice)), clientTurnId: clientTurnId, - onAccepted: { [weak barWindow] in - barWindow?.state.clearSubmittedAIDraftIfUnchanged(message) + onAccepted: { [weak self] in + self?.notchState.clearSubmittedAIDraftIfUnchanged(message) }, onJournalFinalized: { accepted in journalAccepted = accepted @@ -4474,7 +4149,7 @@ class FloatingControlBarManager { voiceCompletionOutcome = journalAccepted == true ? .journalAccepted : .journalFailed } - if await dispatchPendingQueryIfNeeded(barWindow: barWindow, provider: provider) { + if await dispatchPendingQueryIfNeeded(provider: provider) { return } @@ -4482,12 +4157,12 @@ class FloatingControlBarManager { if let syncedUserMessage = provider.messages.last(where: { $0.clientTurnId == clientTurnId && $0.sender == .user && $0.isSynced }) { - barWindow.state.bindQuestionMessageId(syncedUserMessage.id) + notchState.bindQuestionMessageId(syncedUserMessage.id) } if let finalAIMessage = provider.messages.last(where: { $0.clientTurnId == clientTurnId && $0.sender == .ai }) { - barWindow.state.bindAnswerMessage(finalAIMessage) + notchState.bindAnswerMessage(finalAIMessage) } // Cancel the messages subscription now that streaming is done. // Leaving it alive lets later sidebar mutations overwrite the floating bar display. @@ -4495,15 +4170,15 @@ class FloatingControlBarManager { chatCancellable = nil // Handle errors after sendMessage completes - barWindow.state.isAILoading = false + notchState.isAILoading = false if journalAccepted == false, providerResponse != nil { - appendJournalSaveWarning(in: barWindow, provider: provider) + appendJournalSaveWarning(provider: provider) } else if let errorText = provider.displayErrorMessage { // Provider reported an error (timeout, bridge crash, etc.). // Prefer mutating the provider-backed answer in place; only use // localAnswerOverride when there is no provider message to update. - if let existing = barWindow.state.currentAIMessage(from: provider), + if let existing = notchState.currentAIMessage(from: provider), let index = provider.messages.firstIndex(where: { $0.id == existing.id }) { let existingText = provider.messages[index].text @@ -4512,31 +4187,30 @@ class FloatingControlBarManager { ? "⚠️ \(errorText)" : existingText + "\n\n⚠️ \(errorText)" provider.messages[index].isStreaming = false - barWindow.state.bindAnswerMessage(provider.messages[index]) + notchState.bindAnswerMessage(provider.messages[index]) } else { - barWindow.state.setLocalAnswerOverride(ChatMessage(text: "⚠️ \(errorText)", sender: .ai)) + notchState.setLocalAnswerOverride(ChatMessage(text: "⚠️ \(errorText)", sender: .ai)) } - } else if barWindow.state.shouldPresentEmptyResponseFailure(from: provider) { + } else if notchState.shouldPresentEmptyResponseFailure(from: provider) { // No error and no provider-backed answer content (text/blocks/resources). // Never call setLocalAnswerOverride when an answerMessageId is already // bound — that would clear the provider answer (including block-only). - barWindow.state.setLocalAnswerOverride( + notchState.setLocalAnswerOverride( ChatMessage(text: "Failed to get a response. Please try again.", sender: .ai) ) } - // Ensure the response view is visible and resized (handles the case where - // the sink never fired because no streaming data arrived before the error) - if !barWindow.state.showingAIResponse { + // Ensure the response state is visible (handles the case where the sink + // never fired because no streaming data arrived before the error). + if !notchState.showingAIResponse { OmiMotion.withGated(.spring(response: 0.24, dampingFraction: 0.9)) { - barWindow.state.present(.mainResponse) + notchState.present(.mainResponse) } - barWindow.resizeToResponseHeightPublic(animated: true) } if shouldPlayVoice { FloatingBarVoicePlaybackService.shared.updateStreamingResponseIfEnabled( - barWindow.state.currentAIMessage(from: provider), + notchState.currentAIMessage(from: provider), isFinal: true ) } @@ -4544,7 +4218,6 @@ class FloatingControlBarManager { private func sendVoiceOnlyQuery( _ message: String, - barWindow: FloatingControlBarWindow, provider: ChatProvider, voiceTurnID: VoiceTurnID ) async { @@ -4566,7 +4239,7 @@ class FloatingControlBarManager { } } - barWindow.state.currentQueryFromVoice = true + notchState.currentQueryFromVoice = true if let turnID = VoiceTurnCoordinator.shared.activeTurnID { VoiceTurnCoordinator.shared.publish(.clearPresentation(turnID: turnID)) } @@ -4642,7 +4315,7 @@ class FloatingControlBarManager { voiceCompletionOutcome = journalAccepted == true ? .journalAccepted : .journalFailed } - if await dispatchPendingQueryIfNeeded(barWindow: barWindow, provider: provider) { + if await dispatchPendingQueryIfNeeded(provider: provider) { return } @@ -4652,7 +4325,7 @@ class FloatingControlBarManager { }) { FloatingBarVoicePlaybackService.shared.updateStreamingResponseIfEnabled(finalAIMessage, isFinal: true) if journalAccepted == false { - appendJournalSaveWarning(in: barWindow, provider: provider) + appendJournalSaveWarning(provider: provider) } } else if let errorText = provider.displayErrorMessage, !errorText.isEmpty { FloatingBarVoicePlaybackService.shared.speakOneShot(errorText) diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/GlobalShortcutManager.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/GlobalShortcutManager.swift index a14a255873a..b74abe6c967 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/GlobalShortcutManager.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/GlobalShortcutManager.swift @@ -296,9 +296,9 @@ class GlobalShortcutManager: @unchecked Sendable { private func openOmiFromShortcut() { NSLog("GlobalShortcutManager: Open Omi shortcut detected") DispatchQueue.main.async { - // Typing moved to the main app: the shortcut opens Omi itself - // instead of the floating bar's typed input panel. - (NSApp.delegate as? AppDelegate)?.openMainAppWindow() + // Chat-first notch: the shortcut opens the notch chat with the + // composer focused, on the screen under the mouse. + FloatingControlBarManager.shared.notchScreenManager?.openPrimary(tab: .chat) } } diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/MainWindowReveal.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/MainWindowReveal.swift new file mode 100644 index 00000000000..611c83c029f --- /dev/null +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/MainWindowReveal.swift @@ -0,0 +1,85 @@ +import AppKit +import SwiftUI + +private final class MainWindowSendableBox: @unchecked Sendable { + var value: Value + init(_ value: Value) { self.value = value } +} + +/// Activating the app's real main window from the notch (settings gear, +/// notification click-through). Waits for the window-key event instead of +/// guessing fixed delays, so navigate receivers are mounted before posts fire. +@MainActor +enum MainWindowReveal { + /// Open the main window and navigate to the Floating Bar settings section. + static func openSettings() { + activate() + runWhenMainWindowKey { + NotificationCenter.default.post(name: .navigateToFloatingBarSettings, object: nil) + } + } + + static func activate() { + NSApp.activate() + if reveal() { return } + // No existing window — open one and reveal it the moment it becomes key. + AppDelegate.openMainWindow?() + runWhenMainWindowKey { + NSApp.activate() + reveal() + } + } + + /// True for the app's real main window (not a panel or menu-bar popover). + private static func isRealMainWindow(_ window: NSWindow) -> Bool { + !(window is NSPanel) + && window.frame.width > 300 + && window.frame.height > 200 + && !window.title.hasPrefix("Item-") + } + + /// Run `action` once the main window is key — immediately if one already is, + /// otherwise on the next didBecomeKeyNotification for a real main window. + static func runWhenMainWindowKey(_ action: @escaping () -> Void) { + let actionBox = MainWindowSendableBox(action) + if let key = NSApp.keyWindow, isRealMainWindow(key) { + // One runloop hop so a freshly-keyed window's content (e.g. the + // navigate receiver) is mounted before we act. + DispatchQueue.main.async { actionBox.value() } + return + } + let tokenBox = MainWindowSendableBox(nil) + let removeObserver: @Sendable () -> Void = { + if let token = tokenBox.value { NotificationCenter.default.removeObserver(token) } + } + tokenBox.value = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, object: nil, queue: .main + ) { note in + let noteBox = MainWindowSendableBox(note) + MainActor.assumeIsolated { + guard let window = noteBox.value.object as? NSWindow, isRealMainWindow(window) else { + return + } + removeObserver() + DispatchQueue.main.async { actionBox.value() } + } + } + // Safety net: drop the observer after a bounded delay so it can't linger + // if no real main window ever becomes key. + Timer.scheduledTimer(withTimeInterval: 10, repeats: false) { _ in removeObserver() } + } + + @discardableResult + private static func reveal() -> Bool { + guard + let window = NSApp.windows.first(where: { isRealMainWindow($0) && !$0.isMiniaturized }) + ?? NSApp.windows.first(where: { isRealMainWindow($0) }) + else { + return false + } + window.deminiaturize(nil) + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + return true + } +} diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchAgentsView.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchAgentsView.swift new file mode 100644 index 00000000000..74642b04424 --- /dev/null +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchAgentsView.swift @@ -0,0 +1,147 @@ +import SwiftUI + +/// The agents tab: running/recent agent sessions from AgentPillsManager. +/// Tapping a row drills into that agent's conversation (rendered by the same +/// shared ChatMessagesView); back returns to the list. +struct NotchAgentsView: View { + @ObservedObject var vm: NotchViewModel + @ObservedObject var manager: AgentPillsManager + + var body: some View { + if let pillID = vm.openAgentPillID, + let pill = manager.pills.first(where: { $0.id == pillID }) + { + NotchAgentChatView(vm: vm, pill: pill) + } else { + agentList + } + } + + @ViewBuilder + private var agentList: some View { + if manager.pills.isEmpty { + VStack(spacing: 8) { + Image(systemName: "circle.hexagongrid") + .font(.system(size: 20, weight: .light)) + .foregroundStyle(.white.opacity(0.4)) + Text("No agents yet") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.white.opacity(0.6)) + Text("Ask Omi to work on something in the background") + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.4)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 2) { + ForEach(manager.pills) { pill in + NotchAgentRow(pill: pill) { + manager.markViewed(pillID: pill.id) + vm.openAgentPillID = pill.id + } + } + } + .padding(.vertical, 6) + } + } + } +} + +private struct NotchAgentRow: View { + @ObservedObject var pill: AgentPill + let onOpen: () -> Void + + @State private var isHovering = false + + var body: some View { + Button(action: onOpen) { + HStack(spacing: 10) { + Circle() + .fill(pill.status.tintColor) + .frame(width: 7, height: 7) + VStack(alignment: .leading, spacing: 1) { + Text(pill.title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white.opacity(0.94)) + .lineLimit(1) + .truncationMode(.tail) + Text(pill.latestActivity) + .font(.system(size: 10)) + .foregroundStyle(.white.opacity(0.5)) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.white.opacity(isHovering ? 0.6 : 0.25)) + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.white.opacity(isHovering ? 0.1 : 0)) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { isHovering = $0 } + } +} + +/// Read-only agent transcript with a back affordance. +/// ponytail: no in-notch agent follow-up composer yet — the tray stays bound +/// to main chat; add an agent tray mode if follow-ups from the notch matter. +private struct NotchAgentChatView: View { + @ObservedObject var vm: NotchViewModel + @ObservedObject var pill: AgentPill + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 8) { + Button { + vm.openAgentPillID = nil + } label: { + HStack(spacing: 3) { + Image(systemName: "chevron.left") + .font(.system(size: 9, weight: .semibold)) + Text("Agents") + .font(.system(size: 11, weight: .medium)) + } + .foregroundStyle(.white.opacity(0.6)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + Spacer() + Circle() + .fill(pill.status.tintColor) + .frame(width: 6, height: 6) + Text(pill.title) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white.opacity(0.85)) + .lineLimit(1) + } + .padding(.horizontal, 6) + .padding(.bottom, 6) + + ChatMessagesView( + messages: pill.conversationMessages, + isSending: false, + hasMoreMessages: false, + isLoadingMoreMessages: false, + isLoadingInitial: false, + app: nil, + onLoadMore: {}, + onRate: { _, _ in }, + horizontalContentPadding: 6, + welcomeContent: { + Text(pill.latestActivity) + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.5)) + .padding(.vertical, 14) + } + ) + } + } +} diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchChatView.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchChatView.swift new file mode 100644 index 00000000000..f409eba37bb --- /dev/null +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchChatView.swift @@ -0,0 +1,130 @@ +import SwiftUI + +/// The chat tab: the SAME timeline as the main chat window, rendered by the +/// shared ChatMessagesView over ChatProvider.mainInstance (INV-6: the notch is +/// an I/O device over the one kernel transcript, never a second store). Also +/// owns the height measure loop that lets the panel grow to fit the answer. +struct NotchChatView: View { + @ObservedObject var chatProvider: ChatProvider + @EnvironmentObject var barState: FloatingControlBarState + /// Reports the transcript's measured content height; NotchView filters + /// sub-4pt jitter before writing it into the view model. + let onBodyHeightChange: (CGFloat) -> Void + + @State private var transcriptHeight: CGFloat = 0 + @State private var liveStripHeight: CGFloat = 0 + + var body: some View { + VStack(spacing: 0) { + ChatMessagesView( + messages: chatProvider.messages, + isSending: chatProvider.isSending, + hasMoreMessages: chatProvider.hasMoreMessages, + isLoadingMoreMessages: chatProvider.isLoadingMoreMessages, + isLoadingInitial: (chatProvider.isLoading || chatProvider.isLoadingSessions) + && !chatProvider.isClearing, + app: nil, + onLoadMore: { await chatProvider.loadMoreMessages() }, + onRate: { messageId, rating in + Task { await chatProvider.rateMessage(messageId, rating: rating) } + }, + localSendToken: chatProvider.localSendToken, + onCancelTurn: { [weak chatProvider] in chatProvider?.stopAgent(owner: .mainChat) }, + onOpenAgent: { agentID, completion in + FloatingControlBarManager.shared.openAgentChatFromTimeline(agentID: agentID, completion: completion) + }, + onOpenAgentRef: { ref, completion in + FloatingControlBarManager.shared.openAgentChatFromTimeline(ref: ref, completion: completion) + }, + horizontalContentPadding: 10, + onContentHeightChange: { height in + transcriptHeight = height + onBodyHeightChange(height + liveStripHeight) + }, + welcomeContent: { welcome } + ) + + if showsLiveVoiceTurn { + liveVoiceStrip + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height + } action: { height in + liveStripHeight = height + onBodyHeightChange(transcriptHeight + height) + } + } + } + .onChange(of: showsLiveVoiceTurn) { _, shows in + if !shows { + liveStripHeight = 0 + onBodyHeightChange(transcriptHeight) + } + } + } + + // MARK: - Live voice turn + + /// Hub voice turns journal their exchange only at turn end; this strip + /// renders the in-flight question + streaming reply until the journaled + /// pair lands on the shared timeline. + private var showsLiveVoiceTurn: Bool { + guard barState.isVoicePresentationActive else { return false } + guard !barState.liveVoiceUserText.isEmpty || !barState.liveVoiceAssistantText.isEmpty else { + return false + } + // Once the journaled pair is on the timeline, the strip would duplicate it. + if let lastUser = chatProvider.messages.last(where: { $0.sender == .user }), + lastUser.text == barState.liveVoiceUserText + { + return false + } + return true + } + + private var liveVoiceStrip: some View { + VStack(alignment: .leading, spacing: 8) { + if !barState.liveVoiceUserText.isEmpty { + HStack { + Spacer(minLength: 40) + Text(barState.liveVoiceUserText) + .font(.system(size: 12)) + .foregroundStyle(.white.opacity(0.92)) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(Capsule().fill(Color.white.opacity(0.14))) + } + } + HStack(alignment: .top, spacing: 8) { + if barState.liveVoiceAssistantText.isEmpty { + OmiThinkingMark() + .frame(width: 16, height: 16) + Text(barState.isVoiceListening ? "Listening…" : "Thinking…") + .font(.system(size: 12)) + .foregroundStyle(.white.opacity(0.5)) + } else { + Text(barState.liveVoiceAssistantText) + .font(.system(size: 12.5)) + .foregroundStyle(.white.opacity(0.92)) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .transition(.opacity) + } + + private var welcome: some View { + VStack(spacing: 6) { + Text("Ask Omi anything") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white.opacity(0.9)) + Text("Your conversation continues in the main window") + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.5)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + } +} diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchMetrics.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchMetrics.swift new file mode 100644 index 00000000000..a59946d672f --- /dev/null +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchMetrics.swift @@ -0,0 +1,141 @@ +import AppKit +import SwiftUI + +/// The notch's animation springs — single source of truth so every call site +/// stays in step. The morph (open/close/tab) rides these; continuous height +/// growth rides its own `.smooth` timeline in NotchView and must never merge +/// with them. +enum NotchAnimation { + static let open = Animation.spring(response: 0.40, dampingFraction: 0.78) + static let close = Animation.spring(response: 0.34, dampingFraction: 0.9) + static let tab = Animation.spring(response: 0.36, dampingFraction: 0.82) +} + +/// Single source of truth for the notch's fixed geometry. +enum NotchMetrics { + /// Notch size on displays without a physical notch. + static let fallbackClosedSize = CGSize(width: 240, height: 34) + /// Fallback camera dead zone when the auxiliary areas can't be measured. + static let fallbackHiddenCenterWidth: CGFloat = 172 + /// Padding added around the measured camera gap so chrome never touches it. + static let hiddenCenterSafetyPadding: CGFloat = 34 + /// Width of each chrome lobe flanking the camera: omi logo (left), settings + /// gear (right) — always visible in the closed state. + static let closedSideWidth: CGFloat = 30 + /// Central void reserved for the camera in the open panel's header row. + static let headerCameraReserve: CGFloat = 28 + /// Size deltas for the compact voice states (waveform / thinking mark). + static let listeningExtraWidth: CGFloat = 100 + static let listeningExtraHeight: CGFloat = 26 + static let thinkingExtraWidth: CGFloat = 24 + /// Readable status strip under the chrome for too-short PTT / mic errors. + static let hintRowHeight: CGFloat = 30 + /// Proactive notification card shown below the closed chrome. + static let notificationSize = CGSize(width: 430, height: 108) + static let notificationSpacing: CGFloat = 8 + /// Slack around the content so the fixed window can hold glow bleed + shadow. + static let shadowPadding: CGFloat = 22 + /// The floating composer tray lives BELOW the black body: gap between the + /// body's bottom edge and the tray, the tray's reserve height (composer grown + /// to its multi-line maximum), and breathing room. The window reserves all of + /// it so a max-height answer plus the tray still fits in the fixed panel. + static let trayGap: CGFloat = 10 + static let trayHeight: CGFloat = 84 + static let trayReserve: CGFloat = trayGap + trayHeight + 16 + /// Corner radii: (top, bottom) for the closed notch and the open panel. + static let cornerClosed: (top: CGFloat, bottom: CGFloat) = (6, 14) + static let cornerOpen: (top: CGFloat, bottom: CGFloat) = (20, 26) + + // MARK: - Closed-size math (pure, injectable for tests) + + /// Whether a display should render the hardware-notch presentation. + /// Testing hooks OMI_FORCE_NOTCH / OMI_FORCE_NO_NOTCH mirror the legacy + /// behavior; NO_NOTCH wins when both are set. + static func screenHasCameraHousing(_ screen: NSScreen?) -> Bool { + if let forced = getenv("OMI_FORCE_NO_NOTCH"), String(cString: forced) == "1" { return false } + if let forced = getenv("OMI_FORCE_NOTCH"), String(cString: forced) == "1" { return true } + guard let screen else { return false } + if let leftArea = screen.auxiliaryTopLeftArea, + let rightArea = screen.auxiliaryTopRightArea, + !leftArea.isEmpty, + !rightArea.isEmpty + { + return true + } + return screen.safeAreaInsets.top > 0 + } + + /// The physical camera housing width alone (no safety padding) — what the + /// chrome icons visually hug. + static func cameraWidth(auxiliaryTopLeftArea: NSRect?, auxiliaryTopRightArea: NSRect?) -> CGFloat { + if let leftArea = auxiliaryTopLeftArea, + let rightArea = auxiliaryTopRightArea, + !leftArea.isEmpty, + !rightArea.isEmpty + { + let measuredGap = rightArea.minX - leftArea.maxX + if measuredGap > 0 { return measuredGap } + } + return fallbackHiddenCenterWidth + } + + /// The camera dead zone the chrome must straddle: the measured gap between + /// the two auxiliary top areas plus safety padding. + static func hiddenCenterWidth(auxiliaryTopLeftArea: NSRect?, auxiliaryTopRightArea: NSRect?) -> CGFloat { + if let leftArea = auxiliaryTopLeftArea, + let rightArea = auxiliaryTopRightArea, + !leftArea.isEmpty, + !rightArea.isEmpty + { + let measuredGap = rightArea.minX - leftArea.maxX + if measuredGap > 0 { + return max(fallbackHiddenCenterWidth + hiddenCenterSafetyPadding, measuredGap + hiddenCenterSafetyPadding) + } + } + return fallbackHiddenCenterWidth + hiddenCenterSafetyPadding + } + + /// Closed chrome height: the physical notch height when present, else the + /// menu-bar strip height, floored at the fallback. + static func closedHeight(topSafeAreaInset: CGFloat, frameMaxY: CGFloat, visibleFrameMaxY: CGFloat) -> CGFloat { + if topSafeAreaInset > 0 { return topSafeAreaInset } + return max(fallbackClosedSize.height, frameMaxY - visibleFrameMaxY - 1) + } + + /// The closed notch: camera dead zone flanked by the two always-visible + /// chrome lobes (logo / gear). + static func closedSize( + hasCameraHousing: Bool, + auxiliaryTopLeftArea: NSRect?, + auxiliaryTopRightArea: NSRect?, + topSafeAreaInset: CGFloat, + frameMaxY: CGFloat, + visibleFrameMaxY: CGFloat + ) -> CGSize { + let height = closedHeight( + topSafeAreaInset: topSafeAreaInset, frameMaxY: frameMaxY, visibleFrameMaxY: visibleFrameMaxY) + guard hasCameraHousing else { + return CGSize(width: fallbackClosedSize.width, height: height) + } + let center = hiddenCenterWidth( + auxiliaryTopLeftArea: auxiliaryTopLeftArea, auxiliaryTopRightArea: auxiliaryTopRightArea) + return CGSize(width: center + closedSideWidth * 2, height: height) + } + + static func closedSize(for screen: NSScreen) -> CGSize { + closedSize( + hasCameraHousing: screenHasCameraHousing(screen), + auxiliaryTopLeftArea: screen.auxiliaryTopLeftArea, + auxiliaryTopRightArea: screen.auxiliaryTopRightArea, + topSafeAreaInset: screen.safeAreaInsets.top, + frameMaxY: screen.frame.maxY, + visibleFrameMaxY: screen.visibleFrame.maxY + ) + } +} + +extension NSScreen { + var omiDisplayID: CGDirectDisplayID { + (deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0 + } +} diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchNotificationCard.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchNotificationCard.swift new file mode 100644 index 00000000000..955a27292f2 --- /dev/null +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchNotificationCard.swift @@ -0,0 +1,163 @@ +import OmiTheme +import SwiftUI + +/// Proactive notification card shown below the closed chrome inside the notch +/// body. The entire card opens the chat; dismiss (X) and Execute keep their +/// own hit regions in an overlay. +struct NotchNotificationCard: View { + let notification: FloatingBarNotification + + var body: some View { + if notification.assistantId == "reach_error" { + reachErrorCard + } else { + notificationCard + } + } + + private var notificationCard: some View { + Button { + FloatingControlBarManager.shared.openNotificationAsChat(notification) + } label: { + 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) + } + + VStack(alignment: .leading, spacing: OmiSpacing.xxs) { + Text(notification.title) + .scaledFont(size: OmiType.body, weight: .semibold) + .foregroundColor(.white) + .lineLimit(1) + + Text(notification.message) + .scaledFont(size: 12) + .foregroundColor(.white.opacity(0.72)) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 0) + + // Reserve space so text never runs under the overlaid action buttons. + Color.clear + .frame(width: notification.assistantId == "task" ? 90 : 36, height: 18) + } + .padding(.horizontal, OmiSpacing.md) + .padding(.vertical, OmiSpacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .overlay(alignment: .topTrailing) { + HStack(spacing: OmiSpacing.xs) { + // Execute is only meaningful for actionable (task) notifications. + if notification.assistantId == "task" { + Button { + let model = + ShortcutSettings.shared.selectedModel.isEmpty + ? ModelQoS.Claude.defaultSelection + : ShortcutSettings.shared.selectedModel + let query = ProactiveTaskExecute.buildQuery( + title: notification.title, + message: notification.message + ) + _ = AgentPillsManager.shared.spawn( + query: query, + model: model, + originSurface: .floatingBar, + systemPromptSuffix: ProactiveTaskExecute.systemPromptSuffix + ) + FloatingControlBarManager.shared.dismissCurrentNotification() + } label: { + HStack(spacing: OmiSpacing.xxs) { + Image(systemName: "sparkles") + .font(.system(size: 9, weight: .bold)) + Text("Execute") + .scaledFont(size: OmiType.micro, weight: .semibold) + } + .foregroundColor(.white) + .padding(.horizontal, OmiSpacing.sm) + .padding(.vertical, OmiSpacing.xxs) + .background(Color.white.opacity(0.18)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + .help("Spawn an agent to handle this") + } + + 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) + } + .padding(.horizontal, OmiSpacing.md) + .padding(.vertical, OmiSpacing.md) + } + } + + /// Hard reach failure (retries exhausted). Persists until the user picks + /// Retry (re-runs the query, restarting backoff) or Skip (back to idle). + private var reachErrorCard: some View { + HStack(alignment: .center, spacing: OmiSpacing.sm) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.white.opacity(0.9)) + + VStack(alignment: .leading, spacing: 1) { + Text(notification.title) + .scaledFont(size: OmiType.body, weight: .semibold) + .foregroundColor(.white) + .lineLimit(1) + if !notification.message.isEmpty { + Text(notification.message) + .scaledFont(size: 11) + .foregroundColor(.white.opacity(0.7)) + .lineLimit(1) + } + } + + Spacer(minLength: OmiSpacing.sm) + + Button { + FloatingControlBarManager.shared.retryReachError() + } label: { + Text("Retry") + .scaledFont(size: 12, weight: .semibold) + .foregroundColor(.white) + .padding(.horizontal, OmiSpacing.sm) + .padding(.vertical, OmiSpacing.xxs) + .background(Color.white.opacity(0.18)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + + Button { + FloatingControlBarManager.shared.dismissReachError() + } label: { + Text("Skip") + .scaledFont(size: 12, weight: .semibold) + .foregroundColor(.white.opacity(0.6)) + .padding(.horizontal, OmiSpacing.xs) + .padding(.vertical, OmiSpacing.xxs) + } + .buttonStyle(.plain) + } + .padding(.horizontal, OmiSpacing.md) + .padding(.vertical, OmiSpacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchOmiMarkView.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchOmiMarkView.swift new file mode 100644 index 00000000000..22e8fe69f35 --- /dev/null +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchOmiMarkView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +/// The Omi dotted-ring mark shown in the notch chrome. Dot colors default to +/// white; agent surfaces tint them by agent status. +struct NotchOmiMark: View { + var dotColors: [Color] = [] + + private static let dotCount = 8 + private static let dotDiameterRatio: CGFloat = 0.18 + private static let ringRadiusRatio: CGFloat = 0.33 + + var body: some View { + GeometryReader { geometry in + let size = min(geometry.size.width, geometry.size.height) + let center = CGPoint( + x: geometry.size.width / 2, + y: geometry.size.height / 2 + ) + let dotDiameter = size * Self.dotDiameterRatio + let ringRadius = size * Self.ringRadiusRatio + + ZStack { + ForEach(0.. listening > thinking > hint > notification > idle. +enum NotchPresentation: Equatable { + case open(NotchTab) + case listening + case thinking + case hint(String) + case notification(UUID) + case idle + + /// Whether the black body should cast its open-state shadow / glow. + var isExpandedSurface: Bool { + switch self { + case .open, .notification: return true + case .listening, .thinking, .hint, .idle: return false + } + } + + /// The priority ladder: a user-opened panel always wins, voice states beat + /// passive surfaces, and a notification only shows on an otherwise idle + /// notch. Pure so the ordering is unit-testable. + static func derive( + isOpen: Bool, + tab: NotchTab, + isVoiceListening: Bool, + isThinking: Bool, + hintText: String, + notificationID: UUID? + ) -> NotchPresentation { + if isOpen { return .open(tab) } + if isVoiceListening { return .listening } + if isThinking { return .thinking } + if !hintText.isEmpty { return .hint(hintText) } + if let notificationID { return .notification(notificationID) } + return .idle + } +} diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchScreenManager.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchScreenManager.swift new file mode 100644 index 00000000000..3865716b559 --- /dev/null +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchScreenManager.swift @@ -0,0 +1,462 @@ +import AppKit +import SwiftUI + +/// Owns one notch panel per display. The notch is visible on every screen but +/// only the panel under the mouse ever expands. Also the single auto-close +/// authority: pointer monitors are installed only while a panel is open, so +/// idle CPU stays at ~0%. +@MainActor +final class NotchScreenManager { + private struct Panel { + let window: NotchWindow + let vm: NotchViewModel + } + + private var panels: [CGDirectDisplayID: Panel] = [:] + private var screenObserver: NSObjectProtocol? + private var appActivationObserver: NSObjectProtocol? + private var rebuildTask: Task? + private var mouseMonitors: [Any] = [] + /// Per-panel timestamp of when the pointer first left its close zone; reset + /// whenever the pointer is back inside (or aiming toward it). + private var outsideSince: [CGDirectDisplayID: Date] = [:] + /// Previous pointer sample per panel, so we can tell whether the pointer is + /// moving TOWARD the panel (intent) versus drifting away. + private var lastPointer: [CGDirectDisplayID: CGPoint] = [:] + /// Fires a delayed pointerMoved so a stationary-while-outside pointer still + /// triggers the close. One pending task at a time. + private var outsideRecheckTask: Task? + + /// Moving the pointer off the panel doesn't close it quickly — the panel is + /// "sticky, but dismissable" (click outside or Esc). This dwell is the + /// safety net for "the user walked away entirely". + private static let outsideGrace: TimeInterval = 10 + + private weak var barState: FloatingControlBarState? + private weak var chatProvider: ChatProvider? + + /// Whether panels should be on screen. Panels are created hidden; only + /// `showAll` (reached through show / snooze-clear / temporary-notification) + /// reveals them, so a disabled or snoozed launch never flashes the notch. + /// Consulted on display hot-plug so a newly attached screen matches the + /// current visibility instead of always ordering front. + private var panelsVisible = false + + /// Lock-free-enough throttle usable from the monitor callback thread. + private final class PointerThrottle: @unchecked Sendable { + private let lock = NSLock() + private var last = Date.distantPast + func shouldFire() -> Bool { + lock.withLock { + guard Date.now.timeIntervalSince(last) > 0.08 else { return false } + last = .now + return true + } + } + } + + /// System agents that present permission/auth dialogs. While one of these is + /// frontmost, panels drop below dialog level so the prompt is never hidden + /// behind the notch. Lowercased so the membership check can't be broken by a + /// casing mismatch. + private nonisolated static let systemDialogAgents: Set = [ + "com.apple.usernotificationcenter", // TCC permission alerts + "com.apple.securityagent", // keychain / authorization + "com.apple.coreservices.uiagent", // gatekeeper & consent prompts + "com.apple.corelocationagent", + "com.apple.universalaccessauthwarn", // accessibility "control this computer" prompt + ] + + func start(barState: FloatingControlBarState, chatProvider: ChatProvider) { + self.barState = barState + self.chatProvider = chatProvider + rebuild() + + screenObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in self?.scheduleRebuild() } + } + + appActivationObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, + object: nil, + queue: .main + ) { [weak self] note in + let bundleID = (note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication)? + .bundleIdentifier + let yields = bundleID.map { Self.systemDialogAgents.contains($0.lowercased()) } ?? false + Task { @MainActor in + guard let self else { return } + for (_, panel) in self.panels { + panel.window.setYieldsToSystemDialog(yields) + } + } + } + } + + func stop() { + rebuildTask?.cancel() + outsideRecheckTask?.cancel() + if let screenObserver { + NotificationCenter.default.removeObserver(screenObserver) + } + if let appActivationObserver { + NSWorkspace.shared.notificationCenter.removeObserver(appActivationObserver) + } + removeMouseMonitors() + for (_, panel) in panels { + panel.vm.cancelHoverTasks() + panel.window.orderOut(nil) + } + panels.removeAll() + } + + /// Opens the panel on the screen containing the mouse (else the main + /// display). Used by the Ask-Omi hotkey and notification click-through. + func openPrimary(tab: NotchTab = .chat) { + let pointer = NSEvent.mouseLocation + let target = + panels.values.first { $0.vm.screenFrame.contains(pointer) } + ?? panels[CGMainDisplayID()] + ?? panels.values.first + guard let target else { return } + withAnimation(NotchAnimation.open) { target.vm.open(tab: tab) } + } + + /// Opens the agents tab drilled into a specific agent (timeline click, + /// agent-completion click-through). + func openAgent(pillID: UUID) { + let pointer = NSEvent.mouseLocation + let target = + panels.values.first { $0.vm.screenFrame.contains(pointer) } + ?? panels[CGMainDisplayID()] + ?? panels.values.first + guard let target else { return } + target.vm.openAgentPillID = pillID + withAnimation(NotchAnimation.open) { target.vm.open(tab: .agents) } + } + + func closeAll() { + for (_, panel) in panels where panel.vm.state == .open { + withAnimation(NotchAnimation.close) { panel.vm.close() } + } + } + + var hasOpenPanel: Bool { + panels.values.contains { $0.vm.state == .open } + } + + /// Count of panels currently ordered on screen. The regression guard for the + /// disabled/snoozed launch path: panels must stay at 0 until `showAll`. + var visibleWindowCount: Int { + panels.values.filter { $0.window.isVisible }.count + } + + /// True while an open panel's composer (or any text field inside it) holds + /// keyboard focus. Automation's proxy for "the conversation is focused". + var anyPanelKeyboardFocused: Bool { + panels.values.contains { $0.window.firstResponder is NSTextView } + } + + /// The frame of the panel `openPrimary` would target. nil before any panel exists. + var primaryPanelFrame: NSRect? { + let pointer = NSEvent.mouseLocation + let target = + panels.values.first { $0.vm.screenFrame.contains(pointer) } + ?? panels[CGMainDisplayID()] + ?? panels.values.first + return target?.window.frame + } + + /// Order every panel back on screen (show / clearing snooze). + func showAll() { + panelsVisible = true + applyPanelVisibility() + } + + /// Order every panel off screen (hide / snooze / disable). + func hideAll() { + panelsVisible = false + for (_, panel) in panels { panel.vm.close() } + applyPanelVisibility() + } + + /// A panel belongs on screen when the notch is passively visible OR that + /// panel is explicitly open (Ask Omi summon / notification click-through). So + /// an explicit open surfaces even while the passive bar is disabled or + /// snoozed, and closing it hides the panel again — matching the pre-notch + /// "show temporarily, hide on close" behavior. Re-applied on every open/close. + private func applyPanelVisibility() { + for panel in panels.values { + if panelsVisible || panel.vm.state == .open { + panel.window.orderFrontRegardless() + } else { + panel.window.orderOut(nil) + } + } + } + + /// Clear the agents-tab drill-in on any panel showing this pill, so a + /// dismissed pill doesn't leave the agents tab pointed at a removed agent. + func clearAgentDrillIn(pillID: UUID) { + for (_, panel) in panels where panel.vm.openAgentPillID == pillID { + panel.vm.openAgentPillID = nil + } + } + + // MARK: - Panel lifecycle + + /// Display hot-plug/sleep fires bursts of change notifications; settle first. + private func scheduleRebuild() { + rebuildTask?.cancel() + rebuildTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(0.5)) + guard !Task.isCancelled else { return } + self?.rebuild() + } + } + + private func rebuild() { + var seen: Set = [] + + for screen in NSScreen.screens { + let id = screen.omiDisplayID + seen.insert(id) + if let panel = panels[id] { + panel.vm.refresh(for: screen) + } else { + panels[id] = makePanel(for: screen) + } + } + + for (id, panel) in panels where !seen.contains(id) { + panel.vm.cancelHoverTasks() + panel.window.orderOut(nil) + panels.removeValue(forKey: id) + outsideSince.removeValue(forKey: id) + lastPointer.removeValue(forKey: id) + } + + // Newly created panels start hidden; a screen attached while the notch is + // visible (or with an open panel) is surfaced here. + applyPanelVisibility() + } + + private func makePanel(for screen: NSScreen) -> Panel { + let vm = NotchViewModel(screen: screen) + let window = NotchWindow( + contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel, .utilityWindow, .hudWindow], + backing: .buffered, + defer: false + ) + let root = NotchView(vm: vm, chatProvider: chatProvider) + .environmentObject(barState ?? FloatingControlBarState()) + window.contentView = NotchHostingView(rootView: AnyView(root)) + window.onEscape = { [weak vm] in + guard let vm, vm.state == .open else { return } + withAnimation(NotchAnimation.close) { vm.close() } + } + vm.attach(window: window) + vm.onStateChange = { [weak self] in + self?.updateMouseMonitors() + // An explicit open must surface the panel even when the passive bar is + // hidden; closing it re-hides when the bar is disabled/snoozed. + self?.applyPanelVisibility() + } + // Created hidden. Visibility is owned by applyPanelVisibility so the + // persisted enabled/snoozed/deferred launch state is respected. + return Panel(window: window, vm: vm) + } + + // MARK: - Pointer tracking (auto-close authority) + + /// The pointer monitors drive ONE thing — auto-close — which only matters + /// while a panel is open. Hover-to-open is handled by SwiftUI, so closed = + /// no mouse tracking. Installed on the first open, removed when the last + /// panel closes (via each view-model's onStateChange). + private func installMouseMonitors() { + guard mouseMonitors.isEmpty else { return } + // SwiftUI onHover can miss exit events during animated resizes, so the + // real pointer position is the authoritative close signal. Throttle before + // the actor hop — this fires for every pointer move on screen. + let throttle = PointerThrottle() + if let global = NSEvent.addGlobalMonitorForEvents( + matching: .mouseMoved, + handler: { [weak self] _ in + guard throttle.shouldFire() else { return } + Task { @MainActor in self?.pointerMoved() } + }) + { + mouseMonitors.append(global) + } + if let local = NSEvent.addLocalMonitorForEvents( + matching: .mouseMoved, + handler: { [weak self] event in + // Hop off the handler stack: closing the last panel removes these + // monitors synchronously, and removing a monitor from inside its own + // handler is unsafe. + Task { @MainActor in self?.pointerMoved() } + return event + }) + { + mouseMonitors.append(local) + } + // Click-away dismiss: a click landing in another app / the desktop arrives + // as a GLOBAL mouseDown (clicks inside the panel are local and never reach + // this). Closes immediately — the deliberate counterpart to the long + // mouse-leave grace. + if let clicks = NSEvent.addGlobalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown], + handler: { [weak self] _ in + Task { @MainActor in self?.pointerClickedOutside() } + }) + { + mouseMonitors.append(clicks) + } + } + + private func removeMouseMonitors() { + for monitor in mouseMonitors { NSEvent.removeMonitor(monitor) } + mouseMonitors.removeAll() + outsideRecheckTask?.cancel() + outsideSince.removeAll() + lastPointer.removeAll() + } + + /// Keep the monitors installed exactly while at least one panel is open, and + /// hand the keyboard to whichever panel is open (composer typing) — + /// releasing it back to the user's app when all panels close. + private func updateMouseMonitors() { + if panels.values.contains(where: { $0.vm.state == .open }) { + installMouseMonitors() + } else { + removeMouseMonitors() + } + for panel in panels.values { + panel.window.keyboardCaptureAllowed = (panel.vm.state == .open) + } + } + + /// The single auto-close authority. The close zone is the SOLID region from + /// the notch body's top down to the floating tray's bottom (body ∪ tray, + /// generously inset) — so moving onto the composer never reads as "left the + /// panel". While the pointer is aiming toward that zone, the grace dwell is + /// held; only a clear move away arms the close. + private func pointerMoved() { + let pointer = NSEvent.mouseLocation + let now = Date.now + var awaitingClose = false + + for (id, panel) in panels where panel.vm.state == .open { + let zone = Self.closeZone(body: panel.vm.visibleRect(open: true), tray: panel.vm.trayRect(open: true)) + if zone.contains(pointer) { + outsideSince[id] = nil + lastPointer[id] = pointer + continue + } + + let prev = lastPointer[id] ?? pointer + lastPointer[id] = pointer + + // Intent: still heading toward the panel -> hold the close, but keep + // re-checking since the pointer may stop. + if Self.isAiming(toward: zone, from: prev, to: pointer) { + outsideSince[id] = nil + awaitingClose = true + continue + } + + let since = outsideSince[id] ?? now + outsideSince[id] = since + + if now.timeIntervalSince(since) >= Self.outsideGrace, canAutoClose(panel) { + outsideSince[id] = nil + withAnimation(NotchAnimation.close) { panel.vm.close() } + } else { + // Dwell not yet elapsed, or a guard is holding it open; the pointer + // may now be stationary, so re-check on a timer. + awaitingClose = true + } + } + + if awaitingClose { + scheduleOutsideRecheck() + } else { + outsideRecheckTask?.cancel() + } + } + + /// A click outside every open panel's zone dismisses it (respecting the same + /// guards as the hover close — never mid-response or mid-voice). + private func pointerClickedOutside() { + let pointer = NSEvent.mouseLocation + for (id, panel) in panels where panel.vm.state == .open { + // Tighter than the hover zone: a deliberate click just needs to be + // clearly off the panel, not the generous "still aiming" margin. + let zone = panel.vm.visibleRect(open: true) + .union(panel.vm.trayRect(open: true)) + .insetBy(dx: -8, dy: -8) + guard !zone.contains(pointer), canAutoClose(panel, allowPressedButton: true) else { continue } + outsideSince[id] = nil + withAnimation(NotchAnimation.close) { panel.vm.close() } + } + } + + /// The pointer can stop moving while outside a panel; without a timed + /// re-check the dwell (or a lifted guard) would never be observed. + private func scheduleOutsideRecheck() { + outsideRecheckTask?.cancel() + outsideRecheckTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(0.25)) + guard !Task.isCancelled else { return } + self?.pointerMoved() + } + } + + /// Pointer-tracking close is allowed only when the view-model's own grace + /// (state / holdOpen / 0.6s-since-open) passes AND nothing here demands the + /// panel stay up: an in-flight response, an active voice turn, or a held + /// mouse button (drag / text selection in progress). + private func canAutoClose(_ panel: Panel, allowPressedButton: Bool = false) -> Bool { + guard panel.vm.canAutoClose else { return false } + if chatProvider?.isSending == true { return false } + if barState?.isVoicePresentationActive == true { return false } + // The pressed-button guard protects the HOVER path from closing mid-drag + // or mid-text-selection. A deliberate click-away is itself a press, so + // that path opts out — else it could never close. + if !allowPressedButton, NSEvent.pressedMouseButtons != 0 { return false } + return true + } + + // MARK: - Pure zone math (tested) + + /// The solid body∪tray region, generously inset so the body↔tray gap and + /// small overshoots stay "inside". + static func closeZone(body: CGRect, tray: CGRect) -> CGRect { + body.union(tray).insetBy(dx: -48, dy: -44) + } + + /// Apple's "menu aim" intent, in velocity-cone form: the pointer counts as + /// aiming when it moved closer to the zone since the last sample AND its + /// movement points into the zone (within ~60° of straight-at-it). A + /// stationary or receding pointer is not aiming, so the dwell can run. + static func isAiming(toward zone: CGRect, from prev: CGPoint, to cur: CGPoint) -> Bool { + func distance(_ p: CGPoint) -> CGFloat { + let dx = max(zone.minX - p.x, 0, p.x - zone.maxX) + let dy = max(zone.minY - p.y, 0, p.y - zone.maxY) + return (dx * dx + dy * dy).squareRoot() + } + guard distance(cur) < distance(prev) - 0.5 else { return false } + let move = CGVector(dx: cur.x - prev.x, dy: cur.y - prev.y) + let toZone = CGVector(dx: zone.midX - prev.x, dy: zone.midY - prev.y) + let moveMag = (move.dx * move.dx + move.dy * move.dy).squareRoot() + let zoneMag = (toZone.dx * toZone.dx + toZone.dy * toZone.dy).squareRoot() + guard moveMag > 0.5, zoneMag > 0.5 else { return false } + let cosine = (move.dx * toZone.dx + move.dy * toZone.dy) / (moveMag * zoneMag) + return cosine > 0.5 + } +} diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchShape.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchShape.swift new file mode 100644 index 00000000000..eff92909ac4 --- /dev/null +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchShape.swift @@ -0,0 +1,66 @@ +import SwiftUI + +/// The notch silhouette: top corners curve inward from the screen edge (small +/// radius), bottom corners flare outward (large radius). Both radii animate +/// independently so the shape morphs smoothly between closed and open. +struct NotchShape: Shape { + private var topCornerRadius: CGFloat + private var bottomCornerRadius: CGFloat + + init( + topCornerRadius: CGFloat = NotchMetrics.cornerClosed.top, + bottomCornerRadius: CGFloat = NotchMetrics.cornerClosed.bottom + ) { + self.topCornerRadius = topCornerRadius + self.bottomCornerRadius = bottomCornerRadius + } + + var animatableData: AnimatablePair { + get { .init(topCornerRadius, bottomCornerRadius) } + set { + topCornerRadius = newValue.first + bottomCornerRadius = newValue.second + } + } + + func path(in rect: CGRect) -> Path { + var path = Path() + + path.move(to: CGPoint(x: rect.minX, y: rect.minY)) + + path.addQuadCurve( + to: CGPoint(x: rect.minX + topCornerRadius, y: rect.minY + topCornerRadius), + control: CGPoint(x: rect.minX + topCornerRadius, y: rect.minY) + ) + + path.addLine( + to: CGPoint(x: rect.minX + topCornerRadius, y: rect.maxY - bottomCornerRadius) + ) + + path.addQuadCurve( + to: CGPoint(x: rect.minX + topCornerRadius + bottomCornerRadius, y: rect.maxY), + control: CGPoint(x: rect.minX + topCornerRadius, y: rect.maxY) + ) + + path.addLine( + to: CGPoint(x: rect.maxX - topCornerRadius - bottomCornerRadius, y: rect.maxY) + ) + + path.addQuadCurve( + to: CGPoint(x: rect.maxX - topCornerRadius, y: rect.maxY - bottomCornerRadius), + control: CGPoint(x: rect.maxX - topCornerRadius, y: rect.maxY) + ) + + path.addLine( + to: CGPoint(x: rect.maxX - topCornerRadius, y: rect.minY + topCornerRadius) + ) + + path.addQuadCurve( + to: CGPoint(x: rect.maxX, y: rect.minY), + control: CGPoint(x: rect.maxX - topCornerRadius, y: rect.minY) + ) + + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY)) + return path + } +} diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchTrayView.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchTrayView.swift new file mode 100644 index 00000000000..5c034df4c6e --- /dev/null +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchTrayView.swift @@ -0,0 +1,242 @@ +import SwiftUI +import UniformTypeIdentifiers + +/// The floating glass pill below the notch body. One slot, two contents: the +/// composer (attach + growing text field + send) and the Stop pill while a +/// response streams. Bound to the shared provider: one draft, one timeline. +struct NotchTrayView: View { + @ObservedObject var chatProvider: ChatProvider + @EnvironmentObject var barState: FloatingControlBarState + + @FocusState private var inputFocused: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var stopDimmed = false + + var body: some View { + Group { + if barState.isVoiceListening { + listeningPill + } else if chatProvider.isSending { + stopPill + } else { + composer + } + } + .frame(maxWidth: .infinity) + .animation(.snappy, value: chatProvider.isSending) + .animation(.snappy, value: barState.isVoiceListening) + } + + // MARK: - Listening (the composer becomes the live waveform during PTT) + + private var listeningPill: some View { + HStack(spacing: 10) { + VoiceWaveformBars(isActive: true) + Text(barState.liveVoiceUserText.isEmpty ? "Listening…" : barState.liveVoiceUserText) + .font(.system(size: 12)) + .foregroundStyle(.white.opacity(barState.liveVoiceUserText.isEmpty ? 0.5 : 0.85)) + .lineLimit(1) + .truncationMode(.head) + Spacer(minLength: 0) + Image(systemName: "mic.fill") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Color(red: 1.0, green: 0.45, blue: 0.45)) + Button(action: { PushToTalkManager.shared.cancelListening() }) { + Image(systemName: "stop.fill") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.black) + .frame(width: 26, height: 26) + .background(Circle().fill(.white)) + } + .buttonStyle(.plain) + .help("Stop listening") + .accessibilityLabel("Stop listening") + } + .padding(.leading, 16) + .padding(.trailing, 6) + .padding(.vertical, 9) + .trayGlass() + } + + // MARK: - Composer + + private var composer: some View { + VStack(spacing: 6) { + if !chatProvider.pendingAttachments.isEmpty { + attachmentChips + } + + HStack(spacing: 10) { + Button(action: pickAttachments) { + Image(systemName: "paperclip") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 26, height: 26) + } + .buttonStyle(.plain) + .help("Attach files") + .accessibilityLabel("Attach files") + + TextField( + "", + text: $chatProvider.draftText, + prompt: Text("Ask Omi…").foregroundStyle(.secondary), + axis: .vertical + ) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .lineLimit(1...3) + .focused($inputFocused) + // Return sends; Shift+Return inserts a newline (the field grows). + // No .onSubmit — it would also fire on Shift+Return and send. + .onKeyPress(.return) { + if NSEvent.modifierFlags.contains(.shift) { return .ignored } + send() + return .handled + } + .frame(maxWidth: .infinity) + + Button(action: send) { + Image(systemName: "arrow.up") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.black) + .frame(width: 26, height: 26) + .background(Circle().fill(canSend ? .white : .white.opacity(0.3))) + } + .buttonStyle(.plain) + .disabled(!canSend) + .accessibilityLabel("Send message") + } + } + .padding(.leading, 16) + .padding(.trailing, 6) + .padding(.vertical, 6) + .trayGlass() + .onAppear { inputFocused = true } + .onDrop(of: [UTType.fileURL], isTargeted: nil) { providers in + handleDrop(providers) + } + } + + private var canSend: Bool { + !chatProvider.draftText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !chatProvider.pendingAttachments.isEmpty + } + + private func send() { + let text = chatProvider.draftText + guard canSend else { return } + AnalyticsManager.shared.chatMessageSent( + messageLength: text.count, hasSelectedAppContext: false, source: "notch_chat") + // Empty the field in this run loop so it repaints immediately; sendMainDraft + // restores the text if the send never enters the timeline. + chatProvider.draftText = "" + inputFocused = true + Task { await chatProvider.sendMainDraft(text) } + } + + // MARK: - Attachments + + private var attachmentChips: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(chatProvider.pendingAttachments, id: \.id) { attachment in + HStack(spacing: 4) { + Image(systemName: "doc.fill") + .font(.system(size: 9)) + Text(attachment.fileName) + .font(.system(size: 10, weight: .medium)) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 120) + Button { + chatProvider.removePendingAttachment(id: attachment.id) + } label: { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .bold)) + } + .buttonStyle(.plain) + } + .foregroundStyle(.white.opacity(0.8)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Capsule().fill(Color.white.opacity(0.12))) + } + } + } + .padding(.top, 4) + } + + private func pickAttachments() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = false + panel.begin { response in + guard response == .OK else { return } + let attachments = panel.urls.compactMap { ChatAttachment.from(url: $0) } + Task { @MainActor in chatProvider.addAttachments(attachments) } + } + } + + private func handleDrop(_ providers: [NSItemProvider]) -> Bool { + var handled = false + for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { + handled = true + _ = provider.loadObject(ofClass: URL.self) { url, _ in + guard let url, let attachment = ChatAttachment.from(url: url) else { return } + Task { @MainActor in chatProvider.addAttachments([attachment]) } + } + } + return handled + } + + // MARK: - Stop + + private var stopPill: some View { + Button { + chatProvider.stopAgent(owner: .mainChat) + } label: { + HStack(spacing: 6) { + Image(systemName: "stop.fill") + .font(.system(size: 10, weight: .semibold)) + Text("Stop") + .font(.system(size: 11, weight: .medium)) + } + .foregroundStyle(.white.opacity(0.9)) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .trayGlass() + .opacity(stopDimmed ? 0.6 : 1) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel("Stop response") + .onAppear { + guard !reduceMotion else { return } + withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) { + stopDimmed = true + } + } + } +} + +extension View { + /// Liquid-Glass capsule with a crisp rim + soft float shadow so the pill + /// reads clearly over any desktop; HUD blur below macOS 26. + @ViewBuilder + fileprivate func trayGlass() -> some View { + if #available(macOS 26.0, *) { + glassEffect(.regular, in: .capsule) + .overlay(Capsule().strokeBorder(.white.opacity(0.22), lineWidth: 0.75)) + .shadow(color: .black.opacity(0.32), radius: 12, y: 5) + } else { + background( + VisualEffectView(material: .hudWindow, blendingMode: .behindWindow, alphaValue: 1) + .clipShape(Capsule()) + ) + .background(Capsule().fill(Color.black.opacity(0.3))) + .overlay(Capsule().strokeBorder(.white.opacity(0.22), lineWidth: 0.75)) + .shadow(color: .black.opacity(0.32), radius: 12, y: 5) + } + } +} diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchView.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchView.swift new file mode 100644 index 00000000000..3f626247372 --- /dev/null +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchView.swift @@ -0,0 +1,368 @@ +import SwiftUI + +/// Root view for one notch panel. The window frame is fixed; this view derives +/// a `NotchPresentation` and animates ONLY the inner content frame + corner +/// radii, anchored `.top` so every expansion grows out of the notch. +struct NotchView: View { + @ObservedObject var vm: NotchViewModel + /// The shared main-chat provider; the notch renders its timeline directly. + var chatProvider: ChatProvider? + @EnvironmentObject var barState: FloatingControlBarState + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + @State private var isHovering = false + + // MARK: - Presentation ladder + + /// Single value that both the panel size and the rendered content derive + /// from. Priority: open > listening > thinking > hint > notification > idle. + private var presentation: NotchPresentation { + NotchPresentation.derive( + isOpen: vm.state == .open, + tab: vm.selectedTab, + isVoiceListening: barState.isVoiceListening, + isThinking: barState.isThinking, + hintText: barState.pttHintText.isEmpty ? barState.transientHintText : barState.pttHintText, + notificationID: barState.currentNotification?.id + ) + } + + // MARK: - Animations (two isolated timelines) + + /// Discrete morphs: open/close/tab/voice/notification. Springs. + private var morphAnimation: Animation { + if reduceMotion { return .easeInOut(duration: 0.25) } + switch presentation { + case .open: return NotchAnimation.open + case .idle, .listening, .thinking, .hint, .notification: return NotchAnimation.close + } + } + + /// Continuous auto-grow while an answer streams. Calm, no spring bounce. + /// Deliberately isolated from the morph timeline: keying the morph spring on + /// the measured height makes the measure->resize->remeasure loop oscillate. + private var heightAnimation: Animation { + reduceMotion ? .easeInOut(duration: 0.25) : .smooth(duration: 0.35) + } + + private var contentTransition: AnyTransition { + reduceMotion + ? .opacity + : .scale(scale: 0.94, anchor: .top).combined(with: .opacity) + } + + private var displayedSize: CGSize { vm.size(for: presentation) } + + private var topCornerRadius: CGFloat { + presentation.isExpandedSurface ? NotchMetrics.cornerOpen.top : NotchMetrics.cornerClosed.top + } + + private var bottomCornerRadius: CGFloat { + presentation.isExpandedSurface ? NotchMetrics.cornerOpen.bottom : NotchMetrics.cornerClosed.bottom + } + + // MARK: - Body + + var body: some View { + ZStack(alignment: .top) { + tray + notchBody + } + .frame(width: vm.windowSize.width, height: vm.windowSize.height, alignment: .top) + .animation(morphAnimation, value: presentation) + .animation(heightAnimation, value: vm.chatBodyHeight) + } + + /// The floating composer glued below the body's bottom edge: it offsets by + /// the displayed height, so it rides both animation timelines with the body. + @ViewBuilder + private var tray: some View { + // The composer is bound to main chat; it hides on the agents tab so a + // send can't look like an agent follow-up while routing to main chat. + if vm.state == .open, vm.selectedTab == .chat, let chatProvider { + NotchTrayView(chatProvider: chatProvider) + .frame(width: min(displayedSize.width - 40, 420)) + .offset(y: displayedSize.height + NotchMetrics.trayGap) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + + /// The black island is its OWN stable layer: a NotchShape fill whose frame + /// always interpolates (no conditional content inside, so its identity can + /// never break). The presentation-switched content crossfades ON TOP, + /// clipped to the same shape — the Dynamic Island grammar: the black mass + /// grows, the content swaps inside it. + private var notchBody: some View { + ZStack(alignment: .top) { + NotchShape(topCornerRadius: topCornerRadius, bottomCornerRadius: bottomCornerRadius) + .fill(Color.black) + .frame(width: displayedSize.width, height: displayedSize.height) + // 1pt seam hider: the top fillets must never reveal a hairline gap + // against the physical black notch / bezel. + .overlay(alignment: .top) { + Rectangle() + .fill(.black) + .frame(height: 1) + .padding(.horizontal, topCornerRadius) + } + .shadow( + color: (vm.state == .open || isHovering) ? .black.opacity(0.7) : .clear, + radius: 8 + ) + bodyContent + .frame(width: displayedSize.width, height: displayedSize.height, alignment: .top) + .clipShape(NotchShape(topCornerRadius: topCornerRadius, bottomCornerRadius: bottomCornerRadius)) + } + .animation(morphAnimation, value: presentation) + .animation(heightAnimation, value: vm.chatBodyHeight) + .contentShape(NotchShape(topCornerRadius: topCornerRadius, bottomCornerRadius: bottomCornerRadius)) + .onHover(perform: handleHover) + .onTapGesture(perform: handleTap) + .onExitCommand { + guard vm.state == .open else { return } + withAnimation(NotchAnimation.close) { vm.close() } + } + // A voice answer streaming while closed opens the panel under the mouse + // so the reply lands in view (chat-first: the answer IS the chat). The + // text mirror usually fires first (first token); the glow covers turns + // that produce audio before any text. + .onChange(of: barState.isVoiceResponseGlowActive) { _, active in + guard active else { return } + openForVoiceAnswerIfClosed() + } + .onChange(of: barState.liveVoiceAssistantText.isEmpty) { _, isEmpty in + guard !isEmpty else { return } + openForVoiceAnswerIfClosed() + } + } + + private func openForVoiceAnswerIfClosed() { + guard vm.state == .closed, vm.screenFrame.contains(NSEvent.mouseLocation) else { return } + withAnimation(NotchAnimation.open) { vm.open(tab: .chat) } + } + + @ViewBuilder + private var bodyContent: some View { + switch presentation { + case .open(let tab): + VStack(spacing: 0) { + headerRow + openContent(for: tab) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.horizontal, 20) + .padding(.top, 4) + .padding(.bottom, 14) + } + .clipped() + .transition(contentTransition) + case .listening: + VStack(spacing: 2) { + voiceChrome { + VoiceWaveformBars(isActive: true) + .scaleEffect(0.72) + .frame(width: 28, height: 15) + } + Text(barState.displayedQuery.isEmpty ? "Listening…" : barState.displayedQuery) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.white.opacity(0.7)) + .lineLimit(1) + .truncationMode(.head) + .padding(.horizontal, 14) + } + .transition(contentTransition) + case .thinking: + voiceChrome { + OmiThinkingMark() + .frame(width: 22, height: 22) + } + .transition(contentTransition) + case .hint(let text): + VStack(spacing: 2) { + closedChrome + Text(text) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.white.opacity(0.75)) + .lineLimit(1) + .padding(.horizontal, 14) + } + .transition(contentTransition) + case .notification(let id): + VStack(spacing: NotchMetrics.notificationSpacing) { + closedChrome + if let notification = barState.currentNotification, notification.id == id { + NotchNotificationCard(notification: notification) + } + } + .transition(contentTransition) + case .idle: + closedChrome + } + } + + /// Compact voice chrome: the state indicator replaces the logo, still + /// hugging the camera module like the closed chrome. + private func voiceChrome(@ViewBuilder indicator: () -> Indicator) -> some View { + HStack(spacing: 0) { + Spacer(minLength: 0) + indicator() + .frame( + width: NotchMetrics.closedSideWidth + 10, height: vm.closedNotchSize.height, + alignment: .trailing) + Color.clear + .frame(width: cameraGap) + settingsButton + .frame( + width: NotchMetrics.closedSideWidth + 10, height: vm.closedNotchSize.height, + alignment: .leading) + Spacer(minLength: 0) + } + .frame(height: vm.closedNotchSize.height) + } + + // MARK: - Closed chrome (always-visible Omi identity) + + /// The gap the icons visually straddle: the camera housing plus a small + /// margin on a real notch, a modest fixed gap on the fake notch. + private var cameraGap: CGFloat { + vm.hasPhysicalNotch ? vm.cameraWidth + 8 : 56 + } + + /// Logo and gear hug the camera module: [logo][camera][gear] centered as a + /// cluster, outer space breathes. + private var closedChrome: some View { + HStack(spacing: 0) { + Spacer(minLength: 0) + Button { + withAnimation(NotchAnimation.open) { vm.open(tab: .agents) } + } label: { + NotchOmiMark() + .frame(width: 24, height: 24) + // Recording dot: transcription is running (legacy bar indicator). + .overlay(alignment: .topTrailing) { + if barState.isRecording { + Circle() + .fill(Color.red.opacity(0.9)) + .frame(width: 5, height: 5) + } + } + .frame( + width: NotchMetrics.closedSideWidth, height: vm.closedNotchSize.height, + alignment: .trailing + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Omi agents") + Color.clear + .frame(width: cameraGap) + settingsButton + .frame( + width: NotchMetrics.closedSideWidth, height: vm.closedNotchSize.height, + alignment: .leading + ) + Spacer(minLength: 0) + } + .frame(height: vm.closedNotchSize.height) + } + + private var settingsButton: some View { + Button(action: openSettings) { + Image(systemName: "gearshape.fill") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white.opacity(isHovering ? 0.95 : 0.7)) + } + .buttonStyle(.plain) + .accessibilityLabel("Omi settings") + } + + // MARK: - Open header + + /// Jarvis grammar: the tab cluster and the gear FLANK the camera module at + /// fixed offsets from center — controls stay clustered around the notch + /// they grew out of, not at the panel edges. + private var headerRow: some View { + HStack(spacing: 0) { + Spacer(minLength: 0) + HStack(spacing: 6) { + ForEach(NotchTab.allCases) { tab in + tabButton(tab) + } + } + // Camera void: header controls must never render under the physical + // notch. + Color.clear + .frame(width: cameraGap) + HStack(spacing: 6) { + settingsButton + } + // Mirror the two-tab cluster's width so the camera void stays exactly + // screen-centered (the gear alone is narrower than two tabs). + .frame(width: 66, alignment: .leading) + Spacer(minLength: 0) + } + .frame(height: vm.closedNotchSize.height) + .padding(.top, 2) + } + + private func tabButton(_ tab: NotchTab) -> some View { + Button { + withAnimation(reduceMotion ? .easeInOut(duration: 0.2) : NotchAnimation.tab) { + vm.selectedTab = tab + } + } label: { + Image(systemName: tab.symbol) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(vm.selectedTab == tab ? .white : .white.opacity(0.55)) + .frame(width: 30, height: 22) + .background( + Capsule().fill(vm.selectedTab == tab ? Color.white.opacity(0.14) : .clear) + ) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .help(tab.label) + .accessibilityLabel(tab.label) + } + + @ViewBuilder + private func openContent(for tab: NotchTab) -> some View { + switch tab { + case .chat: + if let chatProvider { + NotchChatView(chatProvider: chatProvider) { height in + // 4pt jitter filter: sub-pixel measurement noise must not feed the + // height animation or the measure loop oscillates. + if abs((vm.chatBodyHeight ?? 0) - height) > 4 { + vm.chatBodyHeight = height + } + } + } else { + Color.clear + } + case .agents: + NotchAgentsView(vm: vm, manager: AgentPillsManager.shared) + } + } + + // MARK: - Interactions + + private func handleHover(_ hovering: Bool) { + isHovering = hovering + if hovering { + vm.hoverEntered() + } else { + vm.hoverExited() + } + } + + /// Chat-first: any click on the closed chrome opens chat, except the logo + /// which opens the agents list (and the gear, which is its own button). + private func handleTap() { + guard vm.state == .closed else { return } + withAnimation(NotchAnimation.open) { vm.open(tab: .chat) } + } + + private func openSettings() { + MainWindowReveal.openSettings() + } +} diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchViewModel.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchViewModel.swift new file mode 100644 index 00000000000..18e2a09ebe2 --- /dev/null +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchViewModel.swift @@ -0,0 +1,290 @@ +import AppKit +import Combine +import SwiftUI + +/// Per-display source of truth for one notch panel: the authoritative +/// closed/open toggle, tab selection, dynamic chat height, and all sizing. +/// The window frame is fixed (sized to the largest any presentation can need); +/// only the inner content resizes, so every expansion visually originates from +/// the notch. +@MainActor +final class NotchViewModel: ObservableObject { + enum State: Equatable { + case closed + case open + } + + @Published private(set) var state: State = .closed + @Published var selectedTab: NotchTab = .chat + @Published private(set) var closedNotchSize: CGSize + /// Physical camera housing width (no padding) — chrome icons hug its edges. + @Published private(set) var cameraWidth: CGFloat + @Published private(set) var screenFrame: CGRect + /// Chat body height as reported by the chat view's measure loop. Drives the + /// dynamic panel height: the notch grows just enough to fit the current + /// answer, capped at half the screen. Deliberately NOT part of + /// `NotchPresentation` — it rides its own animation timeline. + @Published var chatBodyHeight: CGFloat? + /// Agent drill-in within the agents tab (nil = the list). + @Published var openAgentPillID: UUID? + /// True while something must stay on screen regardless of the pointer. + var holdOpen = false + + let displayID: CGDirectDisplayID + private(set) var hasPhysicalNotch: Bool + + private weak var window: NSWindow? + private var openedAt: Date? + private var hoverOpenTask: Task? + /// Fired after every open/close so the screen manager can install pointer + /// monitors only while a panel is open (zero mouse tracking while idle). + var onStateChange: (() -> Void)? + + /// Injected time sources so the hover dwell and the post-open grace are + /// testable without wall-clock sleeps. + private let now: () -> Date + private let sleep: (TimeInterval) async -> Void + private let defaults: UserDefaults + + private static let chatBodyHeightKey = "notch.chatBodyHeight" + + init( + displayID: CGDirectDisplayID, + screenFrame: CGRect, + hasPhysicalNotch: Bool, + closedNotchSize: CGSize, + cameraWidth: CGFloat = NotchMetrics.fallbackHiddenCenterWidth, + now: @escaping () -> Date = { Date() }, + sleep: @escaping (TimeInterval) async -> Void = { try? await Task.sleep(for: .seconds($0)) }, + defaults: UserDefaults = .standard + ) { + self.now = now + self.sleep = sleep + self.defaults = defaults + self.displayID = displayID + self.screenFrame = screenFrame + self.hasPhysicalNotch = hasPhysicalNotch + self.closedNotchSize = closedNotchSize + self.cameraWidth = cameraWidth + // Seed the last-known chat height so the first open after launch morphs + // straight to the right size instead of jumping min -> measured. The chat + // view's measure loop corrects it on mount if the answer differs. + if let saved = defaults.object(forKey: Self.chatBodyHeightKey) as? Double { + chatBodyHeight = CGFloat(saved) + } + } + + convenience init( + screen: NSScreen, + now: @escaping () -> Date = { Date() }, + sleep: @escaping (TimeInterval) async -> Void = { try? await Task.sleep(for: .seconds($0)) } + ) { + self.init( + displayID: screen.omiDisplayID, + screenFrame: screen.frame, + hasPhysicalNotch: NotchMetrics.screenHasCameraHousing(screen), + closedNotchSize: NotchMetrics.closedSize(for: screen), + cameraWidth: NotchMetrics.cameraWidth( + auxiliaryTopLeftArea: screen.auxiliaryTopLeftArea, + auxiliaryTopRightArea: screen.auxiliaryTopRightArea + ), + now: now, + sleep: sleep + ) + } + + // MARK: - Sizing + + /// Chrome above the chat body: header row (closed-chrome height) + paddings. + private var chatChromeHeight: CGFloat { closedNotchSize.height + 8 + 16 } + + var chatMinHeight: CGFloat { clampValue(screenFrame.height * 0.09, 96, 124) } + var chatMaxHeight: CGFloat { screenFrame.height * 0.5 } + + func openContentSize(for tab: NotchTab) -> CGSize { + let width = clampValue(screenFrame.width * 0.32, 440, 540) + switch tab { + case .chat: + let height = + chatBodyHeight.map { clampValue($0 + chatChromeHeight, chatMinHeight, chatMaxHeight) } + ?? chatMinHeight + return CGSize(width: width, height: height) + case .agents: + return CGSize(width: width, height: clampValue(screenFrame.height * 0.34, 240, 400)) + } + } + + var currentOpenContentSize: CGSize { openContentSize(for: selectedTab) } + + var listeningSize: CGSize { + CGSize( + width: clampValue(closedNotchSize.width + NotchMetrics.listeningExtraWidth, 280, 380), + height: closedNotchSize.height + NotchMetrics.listeningExtraHeight) + } + + var thinkingSize: CGSize { + CGSize(width: closedNotchSize.width + NotchMetrics.thinkingExtraWidth, height: closedNotchSize.height) + } + + var hintSize: CGSize { + CGSize(width: listeningSize.width, height: closedNotchSize.height + NotchMetrics.hintRowHeight) + } + + var notificationSize: CGSize { + CGSize( + width: max(closedNotchSize.width, NotchMetrics.notificationSize.width), + height: closedNotchSize.height + NotchMetrics.notificationSpacing + NotchMetrics.notificationSize.height) + } + + /// The panel size for a presentation — the single sizing authority. Content + /// (in NotchView) switches on the same value, so size and content stay locked. + func size(for presentation: NotchPresentation) -> CGSize { + switch presentation { + case .open(let tab): return openContentSize(for: tab) + case .listening: return listeningSize + case .thinking: return thinkingSize + case .hint: return hintSize + case .notification: return notificationSize + case .idle: return closedNotchSize + } + } + + /// The window is fixed at the largest any presentation can ever need; only + /// the inner content scales, so expansion always originates from the notch. + private var maxContentSize: CGSize { + var size = CGSize(width: 0, height: chatMaxHeight) + for tab in NotchTab.allCases { + let tabSize = openContentSize(for: tab) + size.width = max(size.width, tabSize.width) + size.height = max(size.height, tabSize.height) + } + size.width = max(size.width, notificationSize.width) + size.height = max(size.height, notificationSize.height) + return size + } + + var windowSize: CGSize { + CGSize( + width: maxContentSize.width + NotchMetrics.shadowPadding * 2, + height: maxContentSize.height + NotchMetrics.trayReserve + NotchMetrics.shadowPadding + ) + } + + /// The visible black region in screen coordinates for the current state — + /// used for authoritative hover hit-testing (the window itself is larger). + func visibleRect(open: Bool) -> CGRect { + let size = open ? currentOpenContentSize : closedNotchSize + return CGRect( + x: screenFrame.midX - size.width / 2, + y: screenFrame.maxY - size.height, + width: size.width, + height: size.height + ) + } + + /// The floating composer tray's region: directly below the body. Union'd + /// with `visibleRect` for the auto-close hit region so moving onto the + /// composer never reads as "left the panel". + func trayRect(open: Bool) -> CGRect { + let body = visibleRect(open: open) + return CGRect( + x: body.minX, + y: body.minY - NotchMetrics.trayGap - NotchMetrics.trayHeight, + width: body.width, + height: NotchMetrics.trayHeight + ) + } + + // MARK: - Window coordination + + func attach(window: NSWindow) { + self.window = window + positionWindow() + } + + func refresh(for screen: NSScreen) { + screenFrame = screen.frame + hasPhysicalNotch = NotchMetrics.screenHasCameraHousing(screen) + closedNotchSize = NotchMetrics.closedSize(for: screen) + cameraWidth = NotchMetrics.cameraWidth( + auxiliaryTopLeftArea: screen.auxiliaryTopLeftArea, + auxiliaryTopRightArea: screen.auxiliaryTopRightArea + ) + positionWindow() + } + + /// Fixed geometry: centered horizontally, pinned to the top. Never animated. + private func positionWindow() { + guard let window else { return } + let size = windowSize + let origin = NSPoint( + x: screenFrame.midX - size.width / 2, + y: screenFrame.maxY - size.height + ) + window.setFrame(NSRect(origin: origin, size: size), display: true) + } + + // MARK: - Open / close + + /// Chat-first: every open lands on chat unless the caller asks for another + /// tab (logo click opens agents). + func open(tab: NotchTab = .chat) { + cancelHoverTasks() + selectedTab = tab + guard state != .open else { return } + state = .open + openedAt = now() + onStateChange?() + } + + func close() { + cancelHoverTasks() + guard state != .closed else { return } + state = .closed + openedAt = nil + openAgentPillID = nil + // Persist the settled chat height to seed the next launch's first open. + if let chatBodyHeight { + defaults.set(Double(chatBodyHeight), forKey: Self.chatBodyHeightKey) + } + onStateChange?() + } + + /// Grace period so the panel can't slam shut right after opening. + var canAutoClose: Bool { + guard state == .open, !holdOpen else { return false } + guard let openedAt else { return true } + return now().timeIntervalSince(openedAt) > 0.6 + } + + func hoverEntered(delay: TimeInterval = 0.28) { + guard state == .closed else { return } + hoverOpenTask?.cancel() + hoverOpenTask = Task { [weak self, sleep] in + await sleep(delay) + guard !Task.isCancelled else { return } + // The dwell task runs outside any SwiftUI transaction — wrap the open + // so the frame morph rides the spring instead of snapping. + withAnimation(NotchAnimation.open) { self?.open() } + } + } + + /// Hover only ever OPENS. Auto-close is owned solely by + /// NotchScreenManager's pointer tracking, so leaving the hover region here + /// just cancels a still-pending open — it never closes. + func hoverExited() { + hoverOpenTask?.cancel() + } + + func cancelHoverTasks() { + hoverOpenTask?.cancel() + } + + deinit { + hoverOpenTask?.cancel() + } +} + +private func clampValue(_ value: CGFloat, _ low: CGFloat, _ high: CGFloat) -> CGFloat { + min(max(value, low), high) +} diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchWindow.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchWindow.swift new file mode 100644 index 00000000000..a0d690f2cd0 --- /dev/null +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchWindow.swift @@ -0,0 +1,142 @@ +import AppKit +import SwiftUI + +/// The per-display notch panel. Non-activating so opening/typing never +/// surfaces the main window or deactivates the user's frontmost app; the +/// window frame is fixed (sized by NotchViewModel) and never animates. +final class NotchWindow: NSPanel { + /// The bar must never be buried under third-party overlay apps: notch + /// companions (e.g. Clicky) park windows at .popUpMenu (101) and full-screen + /// overlays at .screenSaver (1000), so .statusBar (25) lost the notch to + /// them. Assistive-tech-high (1500) beats every common overlay level while + /// staying below the system cursor and the screen-lock shield. It also + /// covers the menu-bar strip, which the notch body must do. + static let normalLevel = NSWindow.Level( + rawValue: Int(CGWindowLevelForKey(.assistiveTechHighWindow)) + ) + + /// In-process NSMenus (context menus, pickers) render at .popUpMenu (101); + /// while one is tracking, the panel drops to that level so the notch cannot + /// occlude its own menus. Depth-counted because nested submenus emit their + /// own begin/end tracking notifications. + private var menuTrackingDepth = 0 + /// Notification tokens live in their own bag so removal can happen from the + /// bag's nonisolated deinit when the window deallocates. + private final class ObserverBag: @unchecked Sendable { + var tokens: [NSObjectProtocol] = [] + deinit { tokens.forEach(NotificationCenter.default.removeObserver) } + } + private let menuTrackingObservers = ObserverBag() + /// While a system permission/auth dialog is frontmost, drop below it so the + /// user can actually see and answer it — at 1500 the notch would otherwise + /// cover TCC prompts. + private var yieldsToSystemDialog = false + + /// Escape pressed while the panel has the keyboard. + var onEscape: (() -> Void)? + + override init( + contentRect: NSRect, + styleMask: NSWindow.StyleMask, + backing: NSWindow.BackingStoreType, + defer flag: Bool + ) { + super.init(contentRect: contentRect, styleMask: styleMask, backing: backing, defer: flag) + + isFloatingPanel = true + isOpaque = false + titleVisibility = .hidden + titlebarAppearsTransparent = true + backgroundColor = .clear + isMovable = false + hasShadow = false + isReleasedWhenClosed = false + hidesOnDeactivate = false + appearance = NSAppearance(named: .vibrantDark) + level = Self.normalLevel + collectionBehavior = [ + .fullScreenAuxiliary, + .stationary, + .canJoinAllSpaces, + .ignoresCycle, + ] + registerMenuTrackingObservers() + } + + func setYieldsToSystemDialog(_ yields: Bool) { + yieldsToSystemDialog = yields + applySurfaceLevel() + } + + private func applySurfaceLevel() { + if menuTrackingDepth > 0 { + level = .popUpMenu + } else if yieldsToSystemDialog { + level = .floating + } else { + level = Self.normalLevel + } + } + + private func registerMenuTrackingObservers() { + let center = NotificationCenter.default + menuTrackingObservers.tokens.append( + center.addObserver(forName: NSMenu.didBeginTrackingNotification, object: nil, queue: .main) { + [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + self.menuTrackingDepth += 1 + self.applySurfaceLevel() + } + }) + menuTrackingObservers.tokens.append( + center.addObserver(forName: NSMenu.didEndTrackingNotification, object: nil, queue: .main) { + [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + self.menuTrackingDepth = max(0, self.menuTrackingDepth - 1) + self.applySurfaceLevel() + } + }) + } + + /// The panel takes the keyboard only while it's expanded (see + /// NotchScreenManager.updateMouseMonitors): opening focuses the composer so + /// you can type straight in; closing hands the keyboard back to your app. + /// .nonactivatingPanel keeps the underlying app active throughout. + var keyboardCaptureAllowed = false { + didSet { + guard oldValue != keyboardCaptureAllowed else { return } + if keyboardCaptureAllowed { + makeKey() + } else if isKeyWindow { + // Give up key + first responder so the frontmost app's window + // reclaims the keyboard (the notch stays on top at its level). + makeFirstResponder(nil) + resignKey() + } + } + } + override var canBecomeKey: Bool { keyboardCaptureAllowed } + override var canBecomeMain: Bool { false } + + /// SwiftUI's onExitCommand only reaches focused views; the panel-level + /// fallback guarantees Esc always closes the notch. + override func keyDown(with event: NSEvent) { + if event.keyCode == 53 { + onEscape?() + return + } + super.keyDown(with: event) + } + + override func cancelOperation(_ sender: Any?) { + onEscape?() + } +} + +/// The panel never becomes key while closed, so views must accept the first +/// mouse click or taps get swallowed by window activation. +final class NotchHostingView: NSHostingView { + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } +} diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/PushToTalkManager.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/PushToTalkManager.swift index 3a688bdb4d0..c00eb0bd1b6 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/PushToTalkManager.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/PushToTalkManager.swift @@ -610,8 +610,8 @@ class PushToTalkManager: ObservableObject { private func isBlockedByUsageLimit() -> Bool { guard !APIKeyService.isByokActive, FloatingBarUsageLimiter.shared.isLimitReached else { return false } log("PushToTalkManager: PTT blocked — monthly free-tier chat limit reached") - NotificationCenter.default.post( - name: .showUsageLimitPopup, object: nil, userInfo: ["reason": "ptt"]) + barState?.flashHint("Monthly free chat limit reached. Upgrade to keep using voice") + NotificationCenter.default.post(name: .showUsageLimitPopup, object: nil, userInfo: ["reason": "ptt"]) return true } diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+SessionDelegate.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+SessionDelegate.swift index 1f19bddfdf9..30601b2c027 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+SessionDelegate.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubController+SessionDelegate.swift @@ -663,8 +663,23 @@ extension RealtimeHubController { providerTranscriptFinalized = !turnTranscript.trimmingCharacters( in: .whitespacesAndNewlines ).isEmpty + // Surface the settled question in the notch chat's live strip so the + // user message renders before the streaming reply (live partials stay + // hidden per the noise policy below). + if providerTranscriptFinalized { + FloatingControlBarManager.shared.barState?.liveVoiceUserText = turnTranscript + } } else { turnTranscript += text + // Surface the growing partial in the notch's listening pill so the user + // sees their words as they speak. Ephemeral: the settled final above + // replaces it, and this never reaches the committed chat transcript. + // ponytail: partials can catch background noise on a near-silent hold; the + // final is authoritative, so the transient pill text is an acceptable cost. + let partial = turnTranscript.trimmingCharacters(in: .whitespacesAndNewlines) + if !partial.isEmpty { + FloatingControlBarManager.shared.barState?.liveVoiceUserText = partial + } } if !text.isEmpty { lastInputTranscriptUpdateAt = Date() } // Don't surface Gemini's LIVE partial transcript on the bar: on a quiet/near-silent @@ -761,6 +776,16 @@ extension RealtimeHubController { else { return } if !text.isEmpty { assistantText += text + // Mirror the streaming reply so the notch chat can render it live (the + // journaled exchange only lands on the timeline at turn end). If the + // transcript-final event was missed, backfill the question here so the + // user message always renders above the reply. + if let barState = FloatingControlBarManager.shared.barState { + if barState.liveVoiceUserText.isEmpty, !turnTranscript.isEmpty { + barState.liveVoiceUserText = turnTranscript + } + barState.liveVoiceAssistantText = assistantText + } if let turnID = VoiceTurnCoordinator.shared.activeTurnID, let providerIdentity = VoiceTurnCoordinator.shared.activeTurn?.providerEffectIdentity { diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift index 03dfb26ceb1..6c4b64fbf35 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift @@ -677,6 +677,10 @@ struct AgentSpawnCard: View { } .frame(maxWidth: .infinity, alignment: .leading) .omiControlSurface(fill: OmiColors.backgroundTertiary.opacity(0.88), radius: 16) + // The whole card opens the agent (the corner icon is just an affordance). + // A spawn card has no competing tap action, so this is safe on every surface. + .contentShape(Rectangle()) + .onTapGesture { openAgent() } } private func openAgent() { diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift index 7be0b257ab3..aac33ce88e4 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift @@ -91,6 +91,10 @@ struct ChatMessagesView: View { /// Horizontal inset of the message column. Home passes 0 so bubbles align /// exactly with the ask bar's edges; other surfaces keep the default gutter. var horizontalContentPadding: CGFloat = OmiSpacing.xxl + /// Reports the transcript content's measured height (the LazyVStack inside + /// the scroll view). The notch uses this to grow its panel to fit the + /// conversation; nil everywhere else. + var onContentHeightChange: ((CGFloat) -> Void)? = nil @ViewBuilder var welcomeContent: () -> WelcomeContent /// IDs of messages that are near-duplicates of an earlier message in the same session. @@ -176,6 +180,11 @@ struct ChatMessagesView: View { // chrome Text (agent card headers, tool summaries, timestamps) can peg the // main thread in GraphHost layout. Message bodies opt in via SelectableMarkdown. .background(scrollDetectors) + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height + } action: { height in + onContentHeightChange?(height) + } // Invisible anchor lives OUTSIDE the LazyVStack so it is always // eagerly rendered. Inside LazyVStack it may not exist in the view diff --git a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift index c1736c40661..8d4680d9a85 100644 --- a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift +++ b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift @@ -4989,16 +4989,20 @@ class ChatProvider: ObservableObject { /// and typing a new draft while acceptance is pending is never overwritten. @discardableResult func sendMainDraft(_ text: String) async -> String? { - let submittedRevision = draftRevision - return await sendMessage( + // Clear the composer synchronously, in the same run loop as the send, so the + // sent text can't linger in the field or resurface when the chat context + // drifts to a new session key mid-turn. Clearing here also writes the empty + // draft to the store under the key it was typed against. Restore the text + // only if the send never enters the timeline (usage limit, offline, busy). + draftText = "" + var accepted = false + let result = await sendMessage( text, - onAccepted: { [weak self] in - guard let self, - self.draftRevision == submittedRevision, - self.draftText == text - else { return } - self.draftText = "" - }) + onAccepted: { accepted = true }) + if !accepted, draftText.isEmpty { + draftText = text + } + return result } nonisolated static func chatTelemetrySurface( diff --git a/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift b/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift index a461fac1801..66f6910cae0 100644 --- a/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift +++ b/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift @@ -293,21 +293,17 @@ import XCTest XCTAssertTrue(windowSource.contains("kernelTurnProjection.appendAgentCompletion(")) XCTAssertFalse(windowSource.contains("func recordAgentArtifactCompletion(")) XCTAssertFalse(windowSource.contains("pill_completion")) - XCTAssertTrue(windowSource.contains("deliverAgentArtifactCompletionToFloatingSurface")) + // Completion surfaces by projecting the canonical journal turn into the + // shared provider timeline the notch renders — the legacy window-bound + // delivery is removed, not resurrected (INV-6: no second message store). + XCTAssertTrue(windowSource.contains("provider.projectJournalTurn(updated)")) + XCTAssertFalse(windowSource.contains("deliverAgentArtifactCompletionToFloatingSurface")) + XCTAssertFalse(windowSource.contains(".chatHistory.append")) } - func testArtifactDeliveryClearsFloatingTypingState() throws { - let windowSource = try floatingControlBarWindowSource() + func testFinishedArtifactResponseViewHidesLoadingWhenResourcesEmpty() throws { let responseSource = try aiResponseViewSource() - XCTAssertTrue(windowSource.contains("chatCancellable?.cancel()")) - XCTAssertTrue(windowSource.contains("completedMessage.isStreaming = false")) - XCTAssertTrue(windowSource.contains("window.state.archiveCurrentExchange(using: self.historyChatProvider)")) - XCTAssertTrue( - windowSource.contains("window.state.bindAnswerMessage(completedMessage)") - || windowSource.contains("window.state.setLocalAnswerOverride(completedMessage)") - ) - XCTAssertFalse(windowSource.contains("window.state.chatHistory.append")) XCTAssertTrue(responseSource.contains("} else if isLoading {")) XCTAssertTrue(responseSource.contains("&& message.displayResources.isEmpty\n {")) } @@ -567,8 +563,8 @@ import XCTest XCTAssertTrue(windowSource.contains("state.isNotchHoverMenuVisible ? .openMenuRetention : .activationOnly")) XCTAssertTrue(source.contains("isChatChromePinned || shouldShowNotchHoverMenu")) XCTAssertTrue(source.contains("static let maxAgents = FloatingControlBarWindow.notchAgentListMaxVisibleAgents")) - XCTAssertTrue(source.contains("static let dotDiameterRatio: CGFloat = 0.18")) - XCTAssertTrue(source.contains("static let ringRadiusRatio: CGFloat = 0.33")) + // The Omi dot-ring mark itself moved to the shared + // Notch/NotchOmiMarkView.swift (used by legacy and notch v2 chrome). XCTAssertTrue(source.contains("NotchAgentOmiIndicatorView(pills: stackedPills)")) XCTAssertTrue(source.contains("NotchOmiMark(dotColors: visiblePills.map")) XCTAssertTrue(source.contains("NotchAgentMorphField(")) @@ -926,10 +922,10 @@ import XCTest let inputSource = String(viewSource[inputRange.lowerBound.. Void")) diff --git a/desktop/macos/Desktop/Tests/ChatErrorStateTests.swift b/desktop/macos/Desktop/Tests/ChatErrorStateTests.swift index 03af4927bd9..7bf93b3ef98 100644 --- a/desktop/macos/Desktop/Tests/ChatErrorStateTests.swift +++ b/desktop/macos/Desktop/Tests/ChatErrorStateTests.swift @@ -248,12 +248,17 @@ final class ChatErrorStateTests: XCTestCase { XCTAssertFalse(snippet.contains("\"AI not available: Please sign in")) } - func testSendPreservesDraftUntilTurnIsAccepted() throws { + // omi-test-quality: source-inspection -- static contract: sendMainDraft's + // clear/restore ordering runs through sendMessage's live bridge and can't be + // driven hermetically; this pins the contract shape. + func testSendClearsDraftAndRestoresOnlyIfNotAccepted() throws { let source = try sourceFile("Providers/ChatProvider.swift") XCTAssertTrue(source.contains("onAccepted: (@MainActor () -> Void)? = nil")) XCTAssertTrue(source.contains("onAccepted?()")) - XCTAssertTrue(source.contains("self.draftRevision == submittedRevision")) - XCTAssertTrue(source.contains("self.draftText == text\n else { return }")) + // The draft is cleared up front (so it can't linger or resurface) and put + // back only if the send never enters the timeline and no new draft was typed. + XCTAssertTrue(source.contains("onAccepted: { accepted = true }")) + XCTAssertTrue(source.contains("if !accepted, draftText.isEmpty")) XCTAssertFalse(source.contains("draftText = trimmedText")) } diff --git a/desktop/macos/Desktop/Tests/NotchAutoCloseZoneTests.swift b/desktop/macos/Desktop/Tests/NotchAutoCloseZoneTests.swift new file mode 100644 index 00000000000..39b06d1b1a9 --- /dev/null +++ b/desktop/macos/Desktop/Tests/NotchAutoCloseZoneTests.swift @@ -0,0 +1,45 @@ +import XCTest + +@testable import Omi_Computer + +@MainActor +final class NotchAutoCloseZoneTests: XCTestCase { + private let body = CGRect(x: 500, y: 900, width: 480, height: 200) + private let tray = CGRect(x: 500, y: 806, width: 480, height: 84) + + func testCloseZoneCoversBodyTrayAndTheGapBetween() { + let zone = NotchScreenManager.closeZone(body: body, tray: tray) + XCTAssertTrue(zone.contains(CGPoint(x: 740, y: 1000)), "inside body") + XCTAssertTrue(zone.contains(CGPoint(x: 740, y: 850)), "inside tray") + XCTAssertTrue(zone.contains(CGPoint(x: 740, y: 895)), "the body-tray gap") + XCTAssertTrue(zone.contains(CGPoint(x: 470, y: 1000)), "generous horizontal inset") + XCTAssertFalse(zone.contains(CGPoint(x: 200, y: 500)), "clearly outside") + } + + func testAimingTowardZoneHoldsClose() { + let zone = NotchScreenManager.closeZone(body: body, tray: tray) + // Moving straight toward the zone center from below-left. + XCTAssertTrue( + NotchScreenManager.isAiming(toward: zone, from: CGPoint(x: 100, y: 100), to: CGPoint(x: 150, y: 170))) + } + + func testRecedingPointerIsNotAiming() { + let zone = NotchScreenManager.closeZone(body: body, tray: tray) + XCTAssertFalse( + NotchScreenManager.isAiming(toward: zone, from: CGPoint(x: 150, y: 170), to: CGPoint(x: 100, y: 100))) + } + + func testStationaryPointerIsNotAiming() { + let zone = NotchScreenManager.closeZone(body: body, tray: tray) + XCTAssertFalse( + NotchScreenManager.isAiming(toward: zone, from: CGPoint(x: 100, y: 100), to: CGPoint(x: 100, y: 100))) + } + + func testPerpendicularMovementIsNotAiming() { + let zone = NotchScreenManager.closeZone(body: body, tray: tray) + // Skimming sideways far below the zone: distance barely changes and the + // movement vector points away from the zone center's direction cone. + XCTAssertFalse( + NotchScreenManager.isAiming(toward: zone, from: CGPoint(x: 740, y: 100), to: CGPoint(x: 741, y: 40))) + } +} diff --git a/desktop/macos/Desktop/Tests/NotchGeometryTests.swift b/desktop/macos/Desktop/Tests/NotchGeometryTests.swift new file mode 100644 index 00000000000..e8730ad0d30 --- /dev/null +++ b/desktop/macos/Desktop/Tests/NotchGeometryTests.swift @@ -0,0 +1,75 @@ +import XCTest + +@testable import Omi_Computer + +final class NotchGeometryTests: XCTestCase { + func testHiddenCenterWidthFromMeasuredAuxiliaryAreas() { + // 1512pt screen: left aux ends at 640, right aux starts at 872 -> 232pt gap. + let width = NotchMetrics.hiddenCenterWidth( + auxiliaryTopLeftArea: NSRect(x: 0, y: 0, width: 640, height: 38), + auxiliaryTopRightArea: NSRect(x: 872, y: 0, width: 640, height: 38) + ) + XCTAssertEqual(width, 232 + NotchMetrics.hiddenCenterSafetyPadding) + } + + func testHiddenCenterWidthFallsBackWithoutAuxiliaryAreas() { + let expected = NotchMetrics.fallbackHiddenCenterWidth + NotchMetrics.hiddenCenterSafetyPadding + XCTAssertEqual( + NotchMetrics.hiddenCenterWidth(auxiliaryTopLeftArea: nil, auxiliaryTopRightArea: nil), expected) + XCTAssertEqual( + NotchMetrics.hiddenCenterWidth( + auxiliaryTopLeftArea: .zero, auxiliaryTopRightArea: NSRect(x: 1, y: 0, width: 1, height: 1)), + expected) + } + + func testHiddenCenterWidthNeverShrinksBelowFallback() { + // A tiny measured gap must not produce chrome narrower than the fallback. + let width = NotchMetrics.hiddenCenterWidth( + auxiliaryTopLeftArea: NSRect(x: 0, y: 0, width: 750, height: 38), + auxiliaryTopRightArea: NSRect(x: 760, y: 0, width: 750, height: 38) + ) + XCTAssertEqual(width, NotchMetrics.fallbackHiddenCenterWidth + NotchMetrics.hiddenCenterSafetyPadding) + } + + func testClosedHeightUsesPhysicalNotchWhenPresent() { + let height = NotchMetrics.closedHeight(topSafeAreaInset: 38, frameMaxY: 982, visibleFrameMaxY: 944) + XCTAssertEqual(height, 38) + } + + func testClosedHeightFallsBackToMenuBarStrip() { + let height = NotchMetrics.closedHeight(topSafeAreaInset: 0, frameMaxY: 1080, visibleFrameMaxY: 1043) + XCTAssertEqual(height, 36) + } + + func testClosedHeightNeverBelowFallback() { + let height = NotchMetrics.closedHeight(topSafeAreaInset: 0, frameMaxY: 1080, visibleFrameMaxY: 1079) + XCTAssertEqual(height, NotchMetrics.fallbackClosedSize.height) + } + + func testClosedSizeOnNotchedDisplayStraddlesCameraWithSideLobes() { + let size = NotchMetrics.closedSize( + hasCameraHousing: true, + auxiliaryTopLeftArea: NSRect(x: 0, y: 0, width: 640, height: 38), + auxiliaryTopRightArea: NSRect(x: 872, y: 0, width: 640, height: 38), + topSafeAreaInset: 38, + frameMaxY: 982, + visibleFrameMaxY: 944 + ) + XCTAssertEqual( + size.width, 232 + NotchMetrics.hiddenCenterSafetyPadding + NotchMetrics.closedSideWidth * 2) + XCTAssertEqual(size.height, 38) + } + + func testClosedSizeOnPlainDisplayUsesFallbackWidth() { + let size = NotchMetrics.closedSize( + hasCameraHousing: false, + auxiliaryTopLeftArea: nil, + auxiliaryTopRightArea: nil, + topSafeAreaInset: 0, + frameMaxY: 1080, + visibleFrameMaxY: 1043 + ) + XCTAssertEqual(size.width, NotchMetrics.fallbackClosedSize.width) + XCTAssertEqual(size.height, 36) + } +} diff --git a/desktop/macos/Desktop/Tests/NotchPresentationLadderTests.swift b/desktop/macos/Desktop/Tests/NotchPresentationLadderTests.swift new file mode 100644 index 00000000000..c9ed033df8a --- /dev/null +++ b/desktop/macos/Desktop/Tests/NotchPresentationLadderTests.swift @@ -0,0 +1,63 @@ +import XCTest + +@testable import Omi_Computer + +/// The ladder ordering is a product contract: an open panel always wins, voice +/// beats passive surfaces, notifications only show on an idle notch. +final class NotchPresentationLadderTests: XCTestCase { + private let noteID = UUID() + + private func derive( + isOpen: Bool = false, + tab: NotchTab = .chat, + listening: Bool = false, + thinking: Bool = false, + hint: String = "", + notification: UUID? = nil + ) -> NotchPresentation { + NotchPresentation.derive( + isOpen: isOpen, + tab: tab, + isVoiceListening: listening, + isThinking: thinking, + hintText: hint, + notificationID: notification + ) + } + + func testIdleWhenNothingActive() { + XCTAssertEqual(derive(), .idle) + } + + func testOpenBeatsEverything() { + XCTAssertEqual( + derive(isOpen: true, tab: .agents, listening: true, thinking: true, hint: "x", notification: noteID), + .open(.agents)) + } + + func testListeningBeatsThinkingHintAndNotification() { + XCTAssertEqual( + derive(listening: true, thinking: true, hint: "x", notification: noteID), .listening) + } + + func testThinkingBeatsHintAndNotification() { + XCTAssertEqual(derive(thinking: true, hint: "x", notification: noteID), .thinking) + } + + func testHintBeatsNotification() { + XCTAssertEqual(derive(hint: "Too short", notification: noteID), .hint("Too short")) + } + + func testNotificationOnlyOnIdleNotch() { + XCTAssertEqual(derive(notification: noteID), .notification(noteID)) + } + + func testExpandedSurfaceFlags() { + XCTAssertTrue(NotchPresentation.open(.chat).isExpandedSurface) + XCTAssertTrue(NotchPresentation.notification(noteID).isExpandedSurface) + XCTAssertFalse(NotchPresentation.listening.isExpandedSurface) + XCTAssertFalse(NotchPresentation.thinking.isExpandedSurface) + XCTAssertFalse(NotchPresentation.hint("x").isExpandedSurface) + XCTAssertFalse(NotchPresentation.idle.isExpandedSurface) + } +} diff --git a/desktop/macos/Desktop/Tests/NotchScreenManagerVisibilityTests.swift b/desktop/macos/Desktop/Tests/NotchScreenManagerVisibilityTests.swift new file mode 100644 index 00000000000..67b6f868303 --- /dev/null +++ b/desktop/macos/Desktop/Tests/NotchScreenManagerVisibilityTests.swift @@ -0,0 +1,70 @@ +import AppKit +import XCTest + +@testable import Omi_Computer + +/// Regression guard for the disabled/snoozed/deferred launch path: panels must +/// be created hidden and only revealed through showAll (reached via +/// show / snooze-clear / temporary-notification). The bug this pins down is +/// makePanel ordering every panel front on creation, which let the notch appear +/// on launch even when the user had disabled or snoozed the floating bar. +@MainActor final class NotchScreenManagerVisibilityTests: XCTestCase { + func testPanelsAreCreatedHiddenUntilShowAll() throws { + try XCTSkipIf(NSScreen.screens.isEmpty, "requires at least one display") + + let manager = NotchScreenManager() + manager.start(barState: FloatingControlBarState(), chatProvider: ChatProvider()) + defer { manager.stop() } + + XCTAssertEqual( + manager.visibleWindowCount, 0, + "panels must not order front on creation — a disabled/snoozed launch would leak the notch") + + manager.showAll() + XCTAssertEqual( + manager.visibleWindowCount, NSScreen.screens.count, + "showAll must reveal one panel per display") + + manager.hideAll() + XCTAssertEqual(manager.visibleWindowCount, 0, "hideAll must order every panel off screen") + } + + /// The mechanism the ported `closeAskOmiForAutomation` (bridge action + /// `close_ask_omi`) relies on: an open panel is dismissed by closeAll. Before + /// the port this ran against the always-nil legacy window and no-oped. + func testCloseAllDismissesOpenPanel() throws { + try XCTSkipIf(NSScreen.screens.isEmpty, "requires at least one display") + + let manager = NotchScreenManager() + manager.start(barState: FloatingControlBarState(), chatProvider: ChatProvider()) + defer { manager.stop() } + + manager.openPrimary() + XCTAssertTrue(manager.hasOpenPanel, "openPrimary must open a panel to close") + + manager.closeAll() + XCTAssertFalse(manager.hasOpenPanel, "close_ask_omi path must dismiss the open panel") + } + + /// Explicit summon (Ask Omi hotkey / notification) must surface the panel even + /// while the passive bar is hidden (disabled/snoozed), and closing it must + /// re-hide — the pre-notch "show temporarily, hide on close" contract. + func testExplicitOpenSurfacesWhilePassivelyHiddenThenHidesOnClose() throws { + try XCTSkipIf(NSScreen.screens.isEmpty, "requires at least one display") + + let manager = NotchScreenManager() + manager.start(barState: FloatingControlBarState(), chatProvider: ChatProvider()) + defer { manager.stop() } + + // Passive bar hidden (disabled/snoozed launch). + XCTAssertEqual(manager.visibleWindowCount, 0) + + manager.openPrimary() + XCTAssertGreaterThan( + manager.visibleWindowCount, 0, "an explicit open must surface even while the passive bar is hidden") + + manager.closeAll() + XCTAssertEqual( + manager.visibleWindowCount, 0, "closing an explicitly-summoned panel must re-hide it while disabled") + } +} diff --git a/desktop/macos/Desktop/Tests/NotchShapeTests.swift b/desktop/macos/Desktop/Tests/NotchShapeTests.swift new file mode 100644 index 00000000000..e5146992685 --- /dev/null +++ b/desktop/macos/Desktop/Tests/NotchShapeTests.swift @@ -0,0 +1,38 @@ +import SwiftUI +import XCTest + +@testable import Omi_Computer + +final class NotchShapeTests: XCTestCase { + func testCornerRadiiAreAnimatable() { + var shape = NotchShape(topCornerRadius: 6, bottomCornerRadius: 14) + XCTAssertEqual(shape.animatableData.first, 6) + XCTAssertEqual(shape.animatableData.second, 14) + shape.animatableData = AnimatablePair(20, 26) + XCTAssertEqual(shape.animatableData.first, 20) + XCTAssertEqual(shape.animatableData.second, 26) + } + + func testPathSpansFullRectAndHugsTopEdge() { + let rect = CGRect(x: 0, y: 0, width: 300, height: 40) + let path = NotchShape(topCornerRadius: 6, bottomCornerRadius: 14).path(in: rect) + let bounds = path.boundingRect + // The silhouette must reach every edge of the rect: flush with the screen + // edge on top, flared corners at the bottom. + XCTAssertEqual(bounds.minX, rect.minX, accuracy: 0.001) + XCTAssertEqual(bounds.maxX, rect.maxX, accuracy: 0.001) + XCTAssertEqual(bounds.minY, rect.minY, accuracy: 0.001) + XCTAssertEqual(bounds.maxY, rect.maxY, accuracy: 0.001) + } + + func testBodyWallsSitInsetByTopRadius() { + // The side walls are inset by the top radius: the body is narrower than the + // top edge, which is what lets the top corners curve inward into the bezel. + let rect = CGRect(x: 0, y: 0, width: 300, height: 40) + let path = NotchShape(topCornerRadius: 10, bottomCornerRadius: 14).path(in: rect) + XCTAssertFalse(path.contains(CGPoint(x: 5, y: 20)), "outside the left wall") + XCTAssertFalse(path.contains(CGPoint(x: 295, y: 20)), "outside the right wall") + XCTAssertTrue(path.contains(CGPoint(x: 15, y: 20)), "inside the body") + XCTAssertTrue(path.contains(CGPoint(x: 150, y: 30)), "center bottom inside") + } +} diff --git a/desktop/macos/Desktop/Tests/NotchViewModelTests.swift b/desktop/macos/Desktop/Tests/NotchViewModelTests.swift new file mode 100644 index 00000000000..26ec00b4f8c --- /dev/null +++ b/desktop/macos/Desktop/Tests/NotchViewModelTests.swift @@ -0,0 +1,207 @@ +import XCTest + +@testable import Omi_Computer + +/// Deterministic sleeper: parks every sleep call until the test resumes it. +private final class ManualSleeper: @unchecked Sendable { + private var continuations: [CheckedContinuation] = [] + private let lock = NSLock() + + var pendingCount: Int { + lock.lock() + defer { lock.unlock() } + return continuations.count + } + + func sleep(_: TimeInterval) async { + await withCheckedContinuation { continuation in + lock.lock() + continuations.append(continuation) + lock.unlock() + } + } + + func resumeAll() { + lock.lock() + let pending = continuations + continuations = [] + lock.unlock() + pending.forEach { $0.resume() } + } +} + +@MainActor +final class NotchViewModelTests: XCTestCase { + private var defaults = UserDefaults.standard + private var suiteName = "" + + override func setUp() async throws { + suiteName = "NotchViewModelTests-\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) ?? .standard + } + + override func tearDown() async throws { + defaults.removePersistentDomain(forName: suiteName) + } + + private func makeModel( + now: @escaping () -> Date = { Date(timeIntervalSinceReferenceDate: 0) }, + sleep: @escaping (TimeInterval) async -> Void = { _ in } + ) -> NotchViewModel { + NotchViewModel( + displayID: 1, + screenFrame: CGRect(x: 0, y: 0, width: 1512, height: 982), + hasPhysicalNotch: true, + closedNotchSize: CGSize(width: 266, height: 38), + now: now, + sleep: sleep, + defaults: defaults + ) + } + + private func waitUntil(_ condition: () -> Bool) async { + for _ in 0..<1_000 where !condition() { + await Task.yield() + } + } + + // MARK: - Open / close + + func testOpenDefaultsToChatTab() { + let model = makeModel() + model.selectedTab = .agents + model.open() + XCTAssertEqual(model.state, .open) + XCTAssertEqual(model.selectedTab, .chat) + } + + func testOpenWithExplicitTab() { + let model = makeModel() + model.open(tab: .agents) + XCTAssertEqual(model.state, .open) + XCTAssertEqual(model.selectedTab, .agents) + } + + func testStateChangeCallbackFiresOnOpenAndClose() { + let model = makeModel() + var changes = 0 + model.onStateChange = { changes += 1 } + model.open() + model.close() + XCTAssertEqual(changes, 2) + // Redundant transitions must not re-fire. + model.close() + XCTAssertEqual(changes, 2) + } + + // MARK: - Hover dwell + + func testHoverDwellOpensAfterDelayElapses() async { + let sleeper = ManualSleeper() + let model = makeModel(sleep: { await sleeper.sleep($0) }) + model.hoverEntered() + await waitUntil { sleeper.pendingCount == 1 } + XCTAssertEqual(model.state, .closed) + sleeper.resumeAll() + await waitUntil { model.state == .open } + XCTAssertEqual(model.state, .open) + } + + func testHoverExitCancelsPendingOpen() async { + let sleeper = ManualSleeper() + let model = makeModel(sleep: { await sleeper.sleep($0) }) + model.hoverEntered() + await waitUntil { sleeper.pendingCount == 1 } + model.hoverExited() + sleeper.resumeAll() + for _ in 0..<50 { await Task.yield() } + XCTAssertEqual(model.state, .closed) + } + + func testHoverWhileOpenIsIgnored() { + let sleeper = ManualSleeper() + let model = makeModel(sleep: { await sleeper.sleep($0) }) + model.open() + model.hoverEntered() + XCTAssertEqual(sleeper.pendingCount, 0) + } + + // MARK: - Auto-close grace + + func testCanAutoCloseHonorsGracePeriodAndHold() { + var currentTime = Date(timeIntervalSinceReferenceDate: 0) + let model = makeModel(now: { currentTime }) + XCTAssertFalse(model.canAutoClose, "closed panel never auto-closes") + model.open() + XCTAssertFalse(model.canAutoClose, "within the 0.6s post-open grace") + currentTime = currentTime.addingTimeInterval(0.7) + XCTAssertTrue(model.canAutoClose) + model.holdOpen = true + XCTAssertFalse(model.canAutoClose) + model.holdOpen = false + XCTAssertTrue(model.canAutoClose) + } + + // MARK: - Dynamic height + + func testChatHeightClampsBetweenMinAndHalfScreen() { + let model = makeModel() + model.chatBodyHeight = 10 + XCTAssertEqual(model.openContentSize(for: .chat).height, model.chatMinHeight) + model.chatBodyHeight = 10_000 + XCTAssertEqual(model.openContentSize(for: .chat).height, model.chatMaxHeight) + XCTAssertEqual(model.chatMaxHeight, 982 * 0.5) + } + + func testChatHeightPersistsAcrossInstances() { + let model = makeModel() + model.open() + model.chatBodyHeight = 333 + model.close() + let reborn = makeModel() + XCTAssertEqual(reborn.chatBodyHeight, 333) + } + + // MARK: - Sizing authority + + func testSizeForPresentationMapping() { + let model = makeModel() + XCTAssertEqual(model.size(for: .idle), model.closedNotchSize) + XCTAssertEqual(model.size(for: .open(.chat)), model.openContentSize(for: .chat)) + XCTAssertEqual(model.size(for: .open(.agents)), model.openContentSize(for: .agents)) + XCTAssertEqual(model.size(for: .listening), model.listeningSize) + XCTAssertEqual(model.size(for: .thinking), model.thinkingSize) + XCTAssertEqual(model.size(for: .hint("too short")), model.hintSize) + XCTAssertEqual(model.size(for: .notification(UUID())), model.notificationSize) + } + + func testWindowSizeCoversEveryPresentationPlusTray() { + let model = makeModel() + model.chatBodyHeight = 10_000 + let window = model.windowSize + let presentations: [NotchPresentation] = [ + .idle, .open(.chat), .open(.agents), .listening, .thinking, .hint("x"), .notification(UUID()), + ] + for presentation in presentations { + let size = model.size(for: presentation) + XCTAssertGreaterThanOrEqual(window.width, size.width, "\(presentation)") + XCTAssertGreaterThanOrEqual( + window.height, size.height + NotchMetrics.trayReserve, "\(presentation)") + } + } + + func testVisibleRectAndTrayRectGeometry() { + let model = makeModel() + let closed = model.visibleRect(open: false) + XCTAssertEqual(closed.midX, 1512 / 2, accuracy: 0.5) + XCTAssertEqual(closed.maxY, 982) + XCTAssertEqual(closed.size, model.closedNotchSize) + + model.open() + let body = model.visibleRect(open: true) + let tray = model.trayRect(open: true) + XCTAssertEqual(tray.maxY, body.minY - NotchMetrics.trayGap) + XCTAssertEqual(tray.width, body.width) + XCTAssertEqual(tray.height, NotchMetrics.trayHeight) + } +} diff --git a/desktop/macos/Desktop/Tests/NotchWindowLevelTests.swift b/desktop/macos/Desktop/Tests/NotchWindowLevelTests.swift new file mode 100644 index 00000000000..487fbcd1b27 --- /dev/null +++ b/desktop/macos/Desktop/Tests/NotchWindowLevelTests.swift @@ -0,0 +1,24 @@ +import AppKit +import XCTest + +@testable import Omi_Computer + +/// Regression guard for the notch's always-on-top contract: third-party notch +/// companions (e.g. Clicky) park windows at .popUpMenu (101) and full-screen +/// overlays at .screenSaver (1000). A notch buried at .statusBar (25) is the +/// bug this pins down — its level must clear every common overlay level. +@MainActor final class NotchWindowLevelTests: XCTestCase { + func testNotchLevelClearsCommonOverlayLevels() { + let level = NotchWindow.normalLevel + XCTAssertGreaterThan(level.rawValue, NSWindow.Level.statusBar.rawValue) + XCTAssertGreaterThan(level.rawValue, NSWindow.Level.popUpMenu.rawValue) + XCTAssertGreaterThan(level.rawValue, NSWindow.Level.screenSaver.rawValue) + XCTAssertGreaterThan(level.rawValue, NSWindow.Level.mainMenu.rawValue, "must cover the menu-bar strip") + } + + func testNotchLevelStaysBelowCursorAndShield() { + let level = NotchWindow.normalLevel + XCTAssertLessThan(level.rawValue, Int(CGWindowLevelForKey(.cursorWindow))) + XCTAssertLessThan(level.rawValue, Int(CGShieldingWindowLevel())) + } +} diff --git a/desktop/macos/changelog/unreleased/20260721-notch-dynamic-island-chat.json b/desktop/macos/changelog/unreleased/20260721-notch-dynamic-island-chat.json new file mode 100644 index 00000000000..92a56e8bc57 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260721-notch-dynamic-island-chat.json @@ -0,0 +1,3 @@ +{ + "change": "Redesigned the notch into a chat-first dynamic island: hover to open the full conversation with a floating composer, watch voice replies stream live, and see the notch grow smoothly to fit answers" +} diff --git a/desktop/macos/e2e/flows/floating-bar-functional.yaml b/desktop/macos/e2e/flows/floating-bar-functional.yaml index 65f0f089fc7..43d7e621d49 100644 --- a/desktop/macos/e2e/flows/floating-bar-functional.yaml +++ b/desktop/macos/e2e/flows/floating-bar-functional.yaml @@ -4,6 +4,19 @@ tier: 2 description: Ask Omi open + stubbed typed turn on the floating bar (distinct from perf benchmarks) app: non-prod covers: + - desktop/macos/Desktop/Sources/FloatingControlBar/Notch/MainWindowReveal.swift + - desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchAgentsView.swift + - desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchChatView.swift + - desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchMetrics.swift + - desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchNotificationCard.swift + - desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchOmiMarkView.swift + - desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchPresentation.swift + - desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchScreenManager.swift + - desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchShape.swift + - desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchTrayView.swift + - desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchView.swift + - desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchViewModel.swift + - desktop/macos/Desktop/Sources/FloatingControlBar/Notch/NotchWindow.swift - desktop/macos/Desktop/Sources/Chat/ChatDraftStore.swift - desktop/macos/Desktop/Sources/FloatingControlBar/AIResponseView.swift - desktop/macos/Desktop/Sources/MainWindow/ClickThroughView.swift