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..c4e7febb609 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,16 +1,16 @@ { "files": { - "desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift": 1926, + "desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift": 1952, "desktop/macos/Desktop/Sources/MainWindow/Pages/AppsPage.swift": 3643, - "desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift": 4543, + "desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift": 4584, "desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift": 3298, "desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift": 5518, "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": "Metadata-row reveal now honors keyboard focus (shared FocusState + testable ChatBubbleMetadataReveal) so Full Keyboard Access never lands on invisible actions.", "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/DashboardPage.swift": "Home redesign bug fixes: hub-safe collapse-catcher predicate, Continue-in-Omi chat landing consume, and a lifecycle chain split for the type-checker; merged with main's DashboardPage changes; component split remains 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.", "desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift": "The Load-more-tasks action reads displayTasks.last at execution time via loadMoreTapped() instead of force-unwrapping (an emptied list no longer crashes); merged with main binding first-load/loading/error/retry state to the selected To Do or Done view.", "desktop/macos/Desktop/Sources/MainWindow/SidebarView.swift": "Strict concurrency imports annotate legacy AppKit image usage without changing runtime behavior." 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..ab6b8525f8f 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 @@ -4,13 +4,13 @@ "desktop/macos/Desktop/Sources/CloudConnectorFormAutomation.swift": 1678, "desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift": 4238, "desktop/macos/Desktop/Sources/MemoryExportService.swift": 1578, - "desktop/macos/Desktop/Sources/OmiApp.swift": 1638 + "desktop/macos/Desktop/Sources/OmiApp.swift": 1646 }, "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/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" + "desktop/macos/Desktop/Sources/OmiApp.swift": "openMainAppChat() gives the floating bar Continue-in-Omi affordances a chat-landing entry next to openMainAppWindow()." }, "threshold": 1500 } diff --git a/app/lib/pages/conversation_detail/conversation_detail_provider.dart b/app/lib/pages/conversation_detail/conversation_detail_provider.dart index 3de2502d77a..753ffec1130 100644 --- a/app/lib/pages/conversation_detail/conversation_detail_provider.dart +++ b/app/lib/pages/conversation_detail/conversation_detail_provider.dart @@ -9,7 +9,8 @@ import 'package:omi/backend/http/api/apps.dart'; import 'package:omi/backend/http/api/audio.dart'; import 'package:omi/backend/http/api/conversations.dart' hide unlinkCalendarEvent, autoLinkCalendarEvent, linkCalendarEvent; -import 'package:omi/backend/http/api/conversations.dart' as conv_api +import 'package:omi/backend/http/api/conversations.dart' + as conv_api show unlinkCalendarEvent, autoLinkCalendarEvent, linkCalendarEvent; import 'package:omi/backend/http/api/users.dart'; import 'package:omi/backend/preferences.dart'; @@ -462,8 +463,9 @@ class ConversationDetailProvider extends ChangeNotifier with MessageNotifierMixi if (_isDisposed) return; // Preserve locally added apps that aren't in the API response yet - final locallyAddedApps = - _cachedEnabledConversationApps.where((app) => _locallyAddedAppIds.contains(app.id)).toList(); + final locallyAddedApps = _cachedEnabledConversationApps + .where((app) => _locallyAddedAppIds.contains(app.id)) + .toList(); _cachedEnabledConversationApps.clear(); _cachedEnabledConversationApps.addAll(apps); diff --git a/app/lib/services/capture/capture_controller.dart b/app/lib/services/capture/capture_controller.dart index 38d1f32326d..6b3b1337311 100644 --- a/app/lib/services/capture/capture_controller.dart +++ b/app/lib/services/capture/capture_controller.dart @@ -154,7 +154,7 @@ class CaptureController extends ChangeNotifier } CaptureController({CaptureExternalActions? externalActions}) - : externalActions = externalActions ?? const NoopCaptureExternalActions() { + : externalActions = externalActions ?? const NoopCaptureExternalActions() { // Restore a persisted device mute so it survives an app kill/restart. When // the device reconnects, streamDeviceRecording() reads _isPaused as // `wasPaused` and re-applies the mute instead of silently resuming. @@ -215,30 +215,30 @@ class CaptureController extends ChangeNotifier _activeSource = PhoneMicSource(); _phoneMicWalActive = true; await ServiceManager.instance().phoneMic.start( - onByteReceived: (bytes) { - final frames = _activeSource?.processBytes(bytes) ?? []; - for (final frame in frames) { - _wal.getSyncs().phone.onFrameCaptured(frame); - if (_socket?.state == SocketServiceState.connected) { - _socket?.send(frame.payload); - _wal.getSyncs().phone.markFrameSynced(frame.syncKey); - } - } - }, - onRecording: () { - updateRecordingState(RecordingState.record); - }, - onStop: () { - if (!_micInterrupted) { - updateRecordingState(RecordingState.stop); - } - }, - onInitializing: () { - updateRecordingState(RecordingState.initialising); - }, - onStalled: _onMicStalled, - onInterruption: _onMicInterruption, - ); + onByteReceived: (bytes) { + final frames = _activeSource?.processBytes(bytes) ?? []; + for (final frame in frames) { + _wal.getSyncs().phone.onFrameCaptured(frame); + if (_socket?.state == SocketServiceState.connected) { + _socket?.send(frame.payload); + _wal.getSyncs().phone.markFrameSynced(frame.syncKey); + } + } + }, + onRecording: () { + updateRecordingState(RecordingState.record); + }, + onStop: () { + if (!_micInterrupted) { + updateRecordingState(RecordingState.stop); + } + }, + onInitializing: () { + updateRecordingState(RecordingState.initialising); + }, + onStalled: _onMicStalled, + onInterruption: _onMicInterruption, + ); } void _onMicStalled() { @@ -634,8 +634,9 @@ class CaptureController extends ChangeNotifier Logger.debug('Initiating WebSocket with: codec=$codec, sampleRate=$sampleRate, channels=$channels, isPcm=$isPcm'); // Get language and custom STT config - String language = - SharedPreferencesUtil().hasSetPrimaryLanguage ? SharedPreferencesUtil().userPrimaryLanguage : "multi"; + String language = SharedPreferencesUtil().hasSetPrimaryLanguage + ? SharedPreferencesUtil().userPrimaryLanguage + : "multi"; final customSttConfig = SharedPreferencesUtil().customSttConfig; Logger.debug('Custom STT enabled: ${customSttConfig.isEnabled}, provider: ${customSttConfig.provider}'); @@ -649,13 +650,13 @@ class CaptureController extends ChangeNotifier // Connect to the transcript socket _socket = await ServiceManager.instance().socket.conversation( - codec: codec, - sampleRate: sampleRate, - language: language, - force: force, - source: source, - customSttConfig: effectiveConfig, - ); + codec: codec, + sampleRate: sampleRate, + language: language, + force: force, + source: source, + customSttConfig: effectiveConfig, + ); if (_socket == null) { _startKeepAliveServices(); Logger.debug("Can not create new conversation socket"); @@ -772,20 +773,24 @@ class CaptureController extends ChangeNotifier _isProcessingButtonEvent = true; if (_isPaused) { PlatformManager.instance.analytics.omiDoubleTap(feature: 'unmute'); - resumeDeviceRecording().then((_) { - _isProcessingButtonEvent = false; - }).catchError((e) { - Logger.debug("Error resuming device recording: $e"); - _isProcessingButtonEvent = false; - }); + resumeDeviceRecording() + .then((_) { + _isProcessingButtonEvent = false; + }) + .catchError((e) { + Logger.debug("Error resuming device recording: $e"); + _isProcessingButtonEvent = false; + }); } else { PlatformManager.instance.analytics.omiDoubleTap(feature: 'mute'); - pauseDeviceRecording().then((_) { - _isProcessingButtonEvent = false; - }).catchError((e) { - Logger.debug("Error pausing device recording: $e"); - _isProcessingButtonEvent = false; - }); + pauseDeviceRecording() + .then((_) { + _isProcessingButtonEvent = false; + }) + .catchError((e) { + Logger.debug("Error pausing device recording: $e"); + _isProcessingButtonEvent = false; + }); } } else if (doubleTapAction == 2) { // Star ongoing conversation (doesn't end it) @@ -879,7 +884,8 @@ class CaptureController extends ChangeNotifier // Local storage syncs. In batch mode the native layer owns writing the // .bin files, so the Dart WAL writer must stay off to avoid double-writes. - var checkWalSupported = !SharedPreferencesUtil().batchModeEnabled && + var checkWalSupported = + !SharedPreferencesUtil().batchModeEnabled && (_recordingDevice?.type == DeviceType.omi || _recordingDevice?.type == DeviceType.openglass) && codec.isOpusSupported() && (_socket?.state != SocketServiceState.connected || SharedPreferencesUtil().unlimitedLocalStorageEnabled); @@ -987,8 +993,9 @@ class CaptureController extends ChangeNotifier return; } BleAudioCodec codec = await _getAudioCodec(_recordingDevice!.id); - var language = - SharedPreferencesUtil().hasSetPrimaryLanguage ? SharedPreferencesUtil().userPrimaryLanguage : "multi"; + var language = SharedPreferencesUtil().hasSetPrimaryLanguage + ? SharedPreferencesUtil().userPrimaryLanguage + : "multi"; final customSttConfig = SharedPreferencesUtil().customSttConfig; final sttConfigId = customSttConfig.sttConfigId; @@ -1355,33 +1362,33 @@ class CaptureController extends ChangeNotifier // record try { await ServiceManager.instance().phoneMic.start( - onByteReceived: (bytes) { - // Process through AudioSource for frame splitting and sync key generation - final frames = _activeSource?.processBytes(bytes) ?? []; - - for (final frame in frames) { - _wal.getSyncs().phone.onFrameCaptured(frame); - - if (_socket?.state == SocketServiceState.connected) { - _socket?.send(frame.payload); - _wal.getSyncs().phone.markFrameSynced(frame.syncKey); - } - } - }, - onRecording: () { - updateRecordingState(RecordingState.record); - }, - onStop: () { - if (!_micInterrupted) { - updateRecordingState(RecordingState.stop); - } - }, - onInitializing: () { - updateRecordingState(RecordingState.initialising); - }, - onStalled: _onMicStalled, - onInterruption: _onMicInterruption, - ); + onByteReceived: (bytes) { + // Process through AudioSource for frame splitting and sync key generation + final frames = _activeSource?.processBytes(bytes) ?? []; + + for (final frame in frames) { + _wal.getSyncs().phone.onFrameCaptured(frame); + + if (_socket?.state == SocketServiceState.connected) { + _socket?.send(frame.payload); + _wal.getSyncs().phone.markFrameSynced(frame.syncKey); + } + } + }, + onRecording: () { + updateRecordingState(RecordingState.record); + }, + onStop: () { + if (!_micInterrupted) { + updateRecordingState(RecordingState.stop); + } + }, + onInitializing: () { + updateRecordingState(RecordingState.initialising); + }, + onStalled: _onMicStalled, + onInterruption: _onMicInterruption, + ); } catch (e, st) { // Typed native failures (permission_denied, engine_start_failed, ...) or // mic contention — fail visibly instead of recording silence. @@ -1459,15 +1466,15 @@ class CaptureController extends ChangeNotifier updateRecordingState(RecordingState.record); try { await ServiceManager.instance().phoneMic.startBatch( - onStop: () { - if (!_micInterrupted && !_phoneMicBatchRestartInFlight) { - updateRecordingState(RecordingState.stop); - } - }, - onInterruption: _onMicInterruption, - onBatchStalled: _onBatchStalled, - onError: _onBatchCaptureError, - ); + onStop: () { + if (!_micInterrupted && !_phoneMicBatchRestartInFlight) { + updateRecordingState(RecordingState.stop); + } + }, + onInterruption: _onMicInterruption, + onBatchStalled: _onBatchStalled, + onError: _onBatchCaptureError, + ); } catch (e, st) { // No socket to clean in batch — fail visibly instead of recording nothing. Logger.error('[CaptureProvider] phone mic batch start failed: $e\n$st'); diff --git a/app/lib/services/capture/conversation_location_capture.dart b/app/lib/services/capture/conversation_location_capture.dart index 59f546f1656..23ab8c571b4 100644 --- a/app/lib/services/capture/conversation_location_capture.dart +++ b/app/lib/services/capture/conversation_location_capture.dart @@ -23,11 +23,11 @@ class ConversationLocationCapture { GeolocationUploader? upload, this.currentPositionTimeout = const Duration(seconds: 1), this.totalTimeout = const Duration(seconds: 3), - }) : _isLocationServiceEnabled = isLocationServiceEnabled ?? Geolocator.isLocationServiceEnabled, - _checkPermission = checkPermission ?? Geolocator.checkPermission, - _getCurrentPosition = getCurrentPosition ?? Geolocator.getCurrentPosition, - _getLastKnownPosition = getLastKnownPosition ?? Geolocator.getLastKnownPosition, - _upload = upload ?? ((geolocation) => updateUserGeolocation(geolocation: geolocation)); + }) : _isLocationServiceEnabled = isLocationServiceEnabled ?? Geolocator.isLocationServiceEnabled, + _checkPermission = checkPermission ?? Geolocator.checkPermission, + _getCurrentPosition = getCurrentPosition ?? Geolocator.getCurrentPosition, + _getLastKnownPosition = getLastKnownPosition ?? Geolocator.getLastKnownPosition, + _upload = upload ?? ((geolocation) => updateUserGeolocation(geolocation: geolocation)); final LocationServiceEnabled _isLocationServiceEnabled; final LocationPermissionReader _checkPermission; diff --git a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift index 8fffc586e81..b53ca2355d5 100644 --- a/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift +++ b/desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift @@ -1691,7 +1691,7 @@ struct FloatingControlBarView: View { onEscape: onEscape, onOpenMainApp: { (window as? FloatingControlBarWindow)?.closeAIConversation() - (NSApp.delegate as? AppDelegate)?.openMainAppWindow() + (NSApp.delegate as? AppDelegate)?.openMainAppChat() }, onRate: onRate, onShareLink: onShareLink, @@ -2295,7 +2295,7 @@ private struct AgentMainChatView: View { HStack(spacing: OmiSpacing.xs) { Button { onEscape() - (NSApp.delegate as? AppDelegate)?.openMainAppWindow() + (NSApp.delegate as? AppDelegate)?.openMainAppChat() } label: { HStack(spacing: OmiSpacing.xs) { Text("Continue in Omi") diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift index 03dfb26ceb1..ea8f9428300 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift @@ -25,6 +25,10 @@ struct ChatBubble: View { @State private var showRatingFeedback = false @State private var showInfoPopover = false @State private var lastSubmittedRating: Int? + // Shared across every metadata control: true while any of them holds + // keyboard focus, so Tab / Full Keyboard Access never lands on an + // invisible button. + @FocusState private var isMetadataControlFocused: Bool init( message: ChatMessage, app: OmiApp?, onRate: @escaping (Int?) -> Void, @@ -329,10 +333,18 @@ struct ChatBubble: View { } } // Quiet timeline: actions and timestamps only surface while the reader - // is on the message (or mid-interaction with them). - .opacity(isRowHovering || showRatingFeedback || showCopied || showInfoPopover ? 1 : 0) + // is on the message — by pointer hover or keyboard focus — or + // mid-interaction with them. + .opacity( + ChatBubbleMetadataReveal.isVisible( + hovering: isRowHovering, + controlFocused: isMetadataControlFocused, + transientFeedback: showRatingFeedback || showCopied || showInfoPopover + ) ? 1 : 0 + ) .omiAnimation(.easeInOut(duration: 0.12), value: isTimestampHovering) .omiAnimation(.easeInOut(duration: 0.15), value: isRowHovering) + .omiAnimation(.easeInOut(duration: 0.15), value: isMetadataControlFocused) } @ViewBuilder @@ -351,6 +363,7 @@ struct ChatBubble: View { .foregroundColor(message.rating == 1 ? OmiColors.accent : OmiColors.textTertiary) } .buttonStyle(.plain) + .focused($isMetadataControlFocused) .help("Helpful response") // Thumbs down @@ -366,6 +379,7 @@ struct ChatBubble: View { .foregroundColor(message.rating == -1 ? .red : OmiColors.textTertiary) } .buttonStyle(.plain) + .focused($isMetadataControlFocused) .help("Not helpful") if showRatingFeedback { @@ -408,6 +422,7 @@ struct ChatBubble: View { .foregroundColor(showCopied ? .green : OmiColors.textTertiary) } .buttonStyle(.plain) + .focused($isMetadataControlFocused) .help("Copy message") } @@ -422,6 +437,7 @@ struct ChatBubble: View { .foregroundColor(showInfoPopover ? OmiColors.textPrimary : OmiColors.textTertiary) } .buttonStyle(.plain) + .focused($isMetadataControlFocused) .help("View response context") .popover(isPresented: $showInfoPopover, arrowEdge: .bottom) { if let metadata = message.metadata { @@ -431,6 +447,16 @@ struct ChatBubble: View { } } +/// Visibility rule for the quiet timeline's per-message metadata row +/// (rating / copy / info / timestamp). Keyboard parity is part of the +/// contract: focus on any metadata control must reveal the row, otherwise +/// Tab / Full Keyboard Access ends up on an invisible button. +enum ChatBubbleMetadataReveal { + static func isVisible(hovering: Bool, controlFocused: Bool, transientFeedback: Bool) -> Bool { + hovering || controlFocused || transientFeedback + } +} + struct BackgroundAgentSummary: Equatable { let agentID: UUID? let prompt: String diff --git a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeKnowsComposer.swift b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeKnowsComposer.swift index 64ad05fd7cb..1cb453ed690 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeKnowsComposer.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeKnowsComposer.swift @@ -54,10 +54,13 @@ enum HomeKnowsListComposer { rows.append(HomeKnowsRow(kind: .task(id: task.id), text: task.text)) } + // Question rows are identified by their text, so a repeated suggestion + // would collide as a ForEach ID — keep only the first occurrence. + var seenQuestions = Set() let cleanQuestions = questions .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } + .filter { !$0.isEmpty && seenQuestions.insert($0).inserted } // Insights fill the middle; one slot stays reserved for a question so the // list always ends with something the user can ask. diff --git a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeStageHistoryAutoOpenPolicy.swift b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeStageHistoryAutoOpenPolicy.swift index ab014617dfa..ef9ccb9b945 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeStageHistoryAutoOpenPolicy.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Dashboard/HomeStageHistoryAutoOpenPolicy.swift @@ -10,4 +10,4 @@ struct HomeStageHistoryAutoOpenPolicy: Equatable { mutating func suppressAutoOpenForExplicitHubClose() { didAutoOpen = true } -} \ No newline at end of file +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index c1f9684c170..6ebc0cb28a8 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -486,6 +486,11 @@ struct DesktopHomeView: View { notification in handleAutomationNavigation(notification) } + // "Continue in Omi" from the floating bar: switch to the Home tab; the + // dashboard consumes the pending request and opens the chat panel. + .onReceive(NotificationCenter.default.publisher(for: .openMainChatRequested)) { _ in + selectedIndex = SidebarNavItem.dashboard.rawValue + } } private func enforceMainWindowMinimumSize() { diff --git a/desktop/macos/Desktop/Sources/MainWindow/MainChatNavigationRequest.swift b/desktop/macos/Desktop/Sources/MainWindow/MainChatNavigationRequest.swift new file mode 100644 index 00000000000..3b871fe134e --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/MainChatNavigationRequest.swift @@ -0,0 +1,32 @@ +import Foundation + +/// One-shot "open the main chat" request raised by surfaces outside the main +/// window (the floating bar's "Continue in Omi" affordances). Revealing the +/// window alone is not enough: the main window may be resting on any tab, so +/// the conversation the user asked to continue would be nowhere in sight. +/// +/// Flow: the raiser calls `request()` (which also posts +/// `.openMainChatRequested`); `DesktopHomeView` switches to the Home tab on +/// the notification, and `DashboardPage` consumes the pending request when it +/// is (or becomes) visible and opens the chat panel. +@MainActor +final class MainChatNavigationRequestStore { + static let shared = MainChatNavigationRequestStore() + + private(set) var isPending = false + + func request() { + isPending = true + NotificationCenter.default.post(name: .openMainChatRequested, object: nil) + } + + /// Returns whether a request was pending, and clears it. + func consume() -> Bool { + defer { isPending = false } + return isPending + } +} + +extension Notification.Name { + static let openMainChatRequested = Notification.Name("openMainChatRequested") +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift index 575e4d811a5..8be7741f2ed 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift @@ -461,7 +461,13 @@ struct DashboardPage: View { } } + // Split in two (`applyHomeLifecycle` → `applyHomeStageObservers`) so each + // modifier chain stays within the type-checker's budget. private func applyHomeLifecycle(to content: Content) -> some View { + applyHomeStageObservers(to: applyHomeLifecycleCore(to: content)) + } + + private func applyHomeLifecycleCore(to content: Content) -> some View { content .onAppear { if PostOnboardingPromptSuggestions.shouldShowPopup && !postOnboardingSuggestions.isEmpty { @@ -469,6 +475,7 @@ struct DashboardPage: View { } syncCaptureState() autoOpenChatForExistingHistoryIfNeeded() + consumePendingMainChatOpenRequest() reportHomeAutomationMode() intelligenceStore.setRecommendationActionHandler { recommendation in await openRecommendation(recommendation) @@ -516,6 +523,15 @@ struct DashboardPage: View { .onReceive(NotificationCenter.default.publisher(for: .screenCaptureKitBroken)) { _ in syncCaptureState() } + } + + private func applyHomeStageObservers(to content: Content) -> some View { + content + // "Continue in Omi" while the dashboard is already mounted; the + // not-yet-mounted case is covered by the consume in onAppear. + .onReceive(NotificationCenter.default.publisher(for: .openMainChatRequested)) { _ in + consumePendingMainChatOpenRequest() + } // Chat history is the home surface: as soon as the (async) history // load shows prior messages, land on the chat panel, not the greeting. .onChange(of: chatProvider.messages.count) { _, _ in @@ -657,10 +673,12 @@ struct DashboardPage: View { HomeCanvasBackground() // Clicking anywhere outside the chat / connect panel collapses - // back to the hub (panels and the ask bar consume their own - // clicks above this catcher). When chat history exists, chat IS the - // resting Home surface, so no catcher is mounted over it. - if homeMode != homeRestingMode { + // back to the resting surface (panels and the ask bar consume their + // own clicks above this catcher). When chat history exists, chat IS + // the resting Home surface, so no catcher is mounted over it — and + // the hub is never an overlay, so no catcher is ever mounted over + // the hub either (a stray click must not throw the user into chat). + if HomeStageMode.collapseCatcherActive(mode: homeMode, resting: homeRestingMode) { Color.black.opacity(0.001) .ignoresSafeArea() .contentShape(Rectangle()) @@ -703,8 +721,10 @@ struct DashboardPage: View { // Esc collapses the connect tray (and, with no chat history, the // inline chat) back to the resting surface — but only while no modal // overlay owns the key. Chat with history is Home itself and cannot - // be escaped. - if homeMode != homeRestingMode && !isHomeModalPresented { + // be escaped; the hub is likewise never escaped *into* a panel. + if HomeStageMode.collapseCatcherActive(mode: homeMode, resting: homeRestingMode) + && !isHomeModalPresented + { OverlayModalEscapeCatcher { collapseHomeStagePanel() } @@ -1149,16 +1169,28 @@ struct DashboardPage: View { } } - /// Chat with history is the default Home surface. An explicit hub close consumes - /// the one-shot so a late history update cannot immediately undo that action. + /// Chat with history is the default Home surface: whenever prior messages + /// exist, the hub greeting yields to the chat panel. Runs once per page + /// visit so the automation bridge's `home_close_panel` hub jump is not + /// immediately overridden by the next `messages.count` change. (There is no + /// user-facing path back to the hub once history exists — chat is Home.) private func autoOpenChatForExistingHistoryIfNeeded() { - guard homeHistoryAutoOpenPolicy.shouldAutoOpen( - isLegacy: useLegacyHomeDesign, mode: homeMode, hasMessages: !chatProvider.messages.isEmpty) + guard + homeHistoryAutoOpenPolicy.shouldAutoOpen( + isLegacy: useLegacyHomeDesign, mode: homeMode, hasMessages: !chatProvider.messages.isEmpty) else { return } homeMode = .chat reportHomeAutomationMode() } + /// Floating-bar "Continue in Omi": land directly on the chat panel instead + /// of whatever surface Home was resting on. + private func consumePendingMainChatOpenRequest() { + guard MainChatNavigationRequestStore.shared.consume() else { return } + guard !useLegacyHomeDesign else { return } + openHomeChat() + } + private func openHomeChat(focusInput: Bool = true) { guard homeMode != .chat else { return } OmiMotion.withGated(Self.homeStageAnimation) { @@ -2128,6 +2160,15 @@ enum HomeStageMode: Equatable { case chat case connect + /// Whether the user-facing collapse catchers (click-outside + Esc) mount. + /// Only a panel that can collapse to a *different* resting surface gets a + /// catcher. The hub is the base surface, never an overlay: mounting a + /// catcher over hub-with-history would invert the gesture and make a stray + /// click or Esc *open* the chat. + static func collapseCatcherActive(mode: HomeStageMode, resting: HomeStageMode) -> Bool { + mode != resting && mode != .hub + } + var automationLabel: String { switch self { case .hub: return "hub" diff --git a/desktop/macos/Desktop/Sources/OmiApp.swift b/desktop/macos/Desktop/Sources/OmiApp.swift index f9ba1116ebb..c70c1d63374 100644 --- a/desktop/macos/Desktop/Sources/OmiApp.swift +++ b/desktop/macos/Desktop/Sources/OmiApp.swift @@ -1037,6 +1037,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, @unchecked S openMainAppWindow() } + /// "Continue in Omi": bring the main window forward *and* land on the chat + /// timeline, wherever the window was last resting. The pending request + /// survives window creation, so a freshly created window also lands on chat. + @MainActor func openMainAppChat() { + MainChatNavigationRequestStore.shared.request() + openMainAppWindow() + } + /// Bring the main Omi window to the front, creating it if needed. Shared by /// the menu-bar "Open Omi" item, the global Open Omi (formerly Ask Omi) /// shortcut, and the floating bar's "Continue in Omi" affordance. diff --git a/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift b/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift index a461fac1801..eb7b6686064 100644 --- a/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift +++ b/desktop/macos/Desktop/Tests/AgentPillLifecycleTests.swift @@ -71,9 +71,9 @@ import XCTest } func testMainChatSpawnReceiptProjectsTheExistingFloatingPill() throws { + let pillSource = try agentPillSource() let providerSource = try chatProviderSource() let viewSource = try floatingControlBarViewSource() - let pillSource = try agentPillSource() XCTAssertTrue(providerSource.contains("AgentPillsManager.shared.upsertSpawnedPill(")) XCTAssertTrue(providerSource.contains("producingJournalSurface: mainChatSurfaceReference()")) @@ -373,9 +373,9 @@ import XCTest XCTAssertFalse(windowSource.contains("resolveDelegationAndDispatch")) XCTAssertTrue(windowSource.contains("await dispatchChatQuery(")) XCTAssertFalse(source.contains("AgentPillFollowUpRoutingPolicy")) - // The bar has no typed pill composer since typing moved to the app's chat: - // pill steering routes to the main app instead of parsing wording locally. - XCTAssertTrue(source.contains("(NSApp.delegate as? AppDelegate)?.openMainAppWindow()")) + // The bar's typed follow-up composer was retired (#10181): its "Continue + // in Omi" affordance routes to the main chat instead of spawning. + XCTAssertTrue(source.contains("openMainAppChat()")) } func testSubagentChatRendersMarkdownAndLargeBackHitTarget() throws { @@ -835,13 +835,14 @@ import XCTest } func testSubagentFollowUpsOnlyContinueTheCanonicalSession() throws { - // omi-test-quality: source-inspection -- static contract: the bar's typed composer is gone; follow-ups continue only through the manager bound to the pill's canonical session. + // omi-test-quality: source-inspection -- static contract: the bar's typed composer is gone; the pill's only affordance opens the main chat. let viewSource = try floatingControlBarViewSource() - let pillSource = try agentPillSource() XCTAssertFalse(viewSource.contains("AgentPillFollowUpRoutingPolicy")) - XCTAssertTrue(pillSource.contains("guard pill.canonicalSessionId == sessionId else { return }")) - XCTAssertTrue(pillSource.contains("DesktopCoordinatorService.shared.continueAgent(")) + // Typed steering from the pill was retired (#10181): the composer's only + // affordance opens the main chat, so no second send path can exist. + XCTAssertFalse(viewSource.contains("manager.continueAgent(from:")) + XCTAssertTrue(viewSource.contains("openMainAppChat()")) } func testSpawnAgentToolCallOpensSubagentChat() throws { @@ -926,10 +927,10 @@ import XCTest let inputSource = String(viewSource[inputRange.lowerBound..