From 775c7dd562847274a47d0a6fac24ed6360bb0021 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:04:44 -0400 Subject: [PATCH 01/31] Stop Home refetching status data on every visit --- .../Sources/MainWindow/DesktopHomeView.swift | 2 + .../MainWindow/Pages/DashboardPage.swift | 116 +++-------------- .../Sources/Stores/HomeStatusStore.swift | 119 ++++++++++++++++++ .../macos/Desktop/Sources/ViewExporter.swift | 4 +- .../Desktop/Sources/ViewModelContainer.swift | 4 + .../Tests/DashboardCaptureStateTests.swift | 68 +++++----- 6 files changed, 183 insertions(+), 130 deletions(-) create mode 100644 desktop/macos/Desktop/Sources/Stores/HomeStatusStore.swift diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index 99494d3feab..74429c7bec1 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -1093,6 +1093,7 @@ private struct PageContentView: View { appProvider: viewModelContainer.appProvider, chatProvider: viewModelContainer.chatProvider, memoriesViewModel: viewModelContainer.memoriesViewModel, + homeStatus: viewModelContainer.homeStatusStore, selectedIndex: $selectedTabIndex) case 1: ConversationsPageHost(appState: appState) @@ -1133,6 +1134,7 @@ private struct PageContentView: View { appProvider: viewModelContainer.appProvider, chatProvider: viewModelContainer.chatProvider, memoriesViewModel: viewModelContainer.memoriesViewModel, + homeStatus: viewModelContainer.homeStatusStore, selectedIndex: $selectedTabIndex) } } diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift index a59951c3cae..1cf49fed180 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift @@ -218,8 +218,8 @@ struct DashboardPage: View { @ObservedObject var appProvider: AppProvider @ObservedObject var chatProvider: ChatProvider @ObservedObject var memoriesViewModel: MemoriesViewModel + @ObservedObject var homeStatus: HomeStatusStore @ObservedObject private var deviceProvider = DeviceProvider.shared - @StateObject private var importConnectorStatusStore = ImportConnectorStatusStore() @Binding var selectedIndex: Int @State private var citedConversation: ServerConversation? = nil @State private var selectedCatalogApp: OmiApp? @@ -231,18 +231,6 @@ struct DashboardPage: View { @State private var appsPopupInitialSection: AppsCatalogInitialSection = .imports @State private var appsPopupPresentationID = UUID() @State private var isLoadingCitation = false - @State private var screenshotCount: Int? - // True totals for the "What omi knows" tiles. Without these the tiles showed - // only the loaded page (~50 conversations, ~100 memories), badly undercounting. - @State private var conversationCount: Int? - @State private var memoryCount: Int? - @State private var taskCount: Int? - // Wearable used on this account (any friend/omi-sourced conversation). - // Seeded from UserDefaults so the badge is instant on later launches. - @State private var accountHasOmiDeviceConversations = UserDefaults.standard.bool( - forKey: DashboardPage.omiDeviceHistoryDefaultsKey) - @State private var memoryExportStatuses: [MemoryExportDestination: MemoryExportStatus] = [:] - @State private var lastHomeStatusRefreshAt = Date.distantPast @State private var isCaptureMonitoring = false @State private var isTogglingCapture = false @State private var isTogglingListening = false @@ -291,7 +279,6 @@ struct DashboardPage: View { } } - private static let omiDeviceHistoryDefaultsKey = "home-omi-device-account-history" private static let homeStageMaxWidth: CGFloat = 1120 private static let homeStageHorizontalPadding: CGFloat = 34 private static let homeAskBarMaxWidth: CGFloat = 640 @@ -344,23 +331,23 @@ struct DashboardPage: View { private var hasOmiDeviceHistory: Bool { deviceProvider.connectedDevice != nil || deviceProvider.pairedDevice != nil - || accountHasOmiDeviceConversations + || homeStatus.accountHasOmiDeviceConversations } /// Real persisted import-connector state (UserDefaults-backed via ImportConnectorStatusStore). private func isImportConnectorConnected(_ connectorID: String) -> Bool { guard let connector = ImportConnector.all.first(where: { $0.id == connectorID }) else { return false } - return importConnectorStatusStore.snapshot(for: connector).isConnected + return homeStatus.importConnectorStatusStore.snapshot(for: connector).isConnected } private func isMCPDestinationConnected(_ destination: MemoryExportDestination) -> Bool { switch destination { case .claude, .claudeCode: - return [.claude, .claudeCode].contains { memoryExportStatuses[$0]?.hasConnection == true } + return [.claude, .claudeCode].contains { homeStatus.memoryExportStatuses[$0]?.hasConnection == true } case .chatgpt, .codex: - return [.chatgpt, .codex].contains { memoryExportStatuses[$0]?.hasConnection == true } + return [.chatgpt, .codex].contains { homeStatus.memoryExportStatuses[$0]?.hasConnection == true } default: - return memoryExportStatuses[destination]?.hasConnection == true + return homeStatus.memoryExportStatuses[destination]?.hasConnection == true } } @@ -394,7 +381,7 @@ struct DashboardPage: View { ImportConnectorSheet( connector: connector, appState: appState, - statusStore: importConnectorStatusStore, + statusStore: homeStatus.importConnectorStatusStore, onDismiss: { selectedImportConnector = nil } @@ -404,7 +391,7 @@ struct DashboardPage: View { .dismissableSheet(item: legacySelectedExportDestination) { destination in ConnectDestinationSheet( destination: destination, - statuses: $memoryExportStatuses, + statuses: $homeStatus.memoryExportStatuses, onDismiss: { selectedExportDestination = nil } @@ -433,13 +420,13 @@ struct DashboardPage: View { } syncCaptureState() reportHomeAutomationMode() - Task { await refreshHomeStatusData(force: true) } + Task { await homeStatus.refresh(force: false) } } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in viewModel.refreshGoals() appState.checkAllPermissions() syncCaptureState() - Task { await refreshHomeStatusData(force: false) } + Task { await homeStatus.refresh(force: false) } } .onReceive(NotificationCenter.default.publisher(for: .assistantMonitoringStateDidChange)) { _ in syncCaptureState() @@ -1105,7 +1092,7 @@ struct DashboardPage: View { ImportConnectorSheet( connector: connector, appState: appState, - statusStore: importConnectorStatusStore, + statusStore: homeStatus.importConnectorStatusStore, onDismiss: { dismissHomeConnectSheet() } @@ -1113,7 +1100,7 @@ struct DashboardPage: View { } else if let destination = selectedExportDestination { ConnectDestinationSheet( destination: destination, - statuses: $memoryExportStatuses, + statuses: $homeStatus.memoryExportStatuses, onDismiss: { dismissHomeConnectSheet() } @@ -1238,22 +1225,22 @@ struct DashboardPage: View { } private var conversationMetricValue: String { - formattedCount(conversationCount ?? appState.totalConversationsCount ?? appState.conversations.count) + formattedCount(homeStatus.conversationCount ?? appState.totalConversationsCount ?? appState.conversations.count) } private var taskMetricValue: String { - formattedCount(taskCount ?? incompleteTaskCount) + formattedCount(homeStatus.taskCount ?? incompleteTaskCount) } private var memoryMetricValue: String { - let count = memoryCount ?? (memoriesViewModel.totalMemoriesCount > 0 + let count = homeStatus.memoryCount ?? (memoriesViewModel.totalMemoriesCount > 0 ? memoriesViewModel.totalMemoriesCount : memoriesViewModel.memories.count) return formattedCount(count) } private var screenshotMetricValue: String { - screenshotCount.map(formattedCount) ?? "—" + homeStatus.screenshotCount.map(formattedCount) ?? "—" } private func navigate(to item: SidebarNavItem) { @@ -1428,75 +1415,6 @@ struct DashboardPage: View { isCaptureMonitoring = ProactiveAssistantsPlugin.shared.isMonitoring } - private func loadScreenshotCount() async { - let stats = await RewindIndexer.shared.getStats() - await MainActor.run { - screenshotCount = stats?.total - } - } - - /// Refreshes Home-only status tiles and connector rows. Forced loads run - /// when the Home view is recreated; activation-triggered loads share the - /// app-wide cooldown so Cmd-Tab bursts do not rescan configs or hit count - /// endpoints repeatedly while Home is still mounted. - private func refreshHomeStatusData(force: Bool) async { - let shouldRefresh = await MainActor.run { - let now = Date() - if !force, - !PollingConfig.shouldAllowActivationRefresh( - now: now, - lastRefresh: lastHomeStatusRefreshAt - ) { - return false - } - lastHomeStatusRefreshAt = now - return true - } - guard shouldRefresh else { return } - - async let importConnectorStatuses: Void = importConnectorStatusStore.refresh() - async let screenshots: Void = loadScreenshotCount() - async let knowledgeCounts: Void = loadKnowledgeCounts() - async let exportStatuses: Void = loadMemoryExportStatuses() - _ = await (importConnectorStatuses, screenshots, knowledgeCounts, exportStatuses) - } - - private func loadMemoryExportStatuses() async { - let statuses = await MemoryExportService.shared.allStatuses() - await MainActor.run { - memoryExportStatuses = statuses - } - } - - /// Load the true totals behind the "What omi knows" tiles. Conversations come - /// from the server count endpoint (not stored locally); memories and tasks are - /// counted from the synced local DB — the same totals the detail pages show. - private func loadKnowledgeCounts() async { - async let convos = try? APIClient.shared.getConversationsCount(includeDiscarded: false) - async let mems = try? MemoryStorage.shared.getLocalMemoriesCount() - // Open tasks only (matches the "Tasks" label and the old tile's intent — - // the old value just under-counted, capping each bucket at a 7-day window). - async let tasks = try? ActionItemStorage.shared.getLocalActionItemsCount(completed: false) - let shouldLoadDeviceHistory = await MainActor.run { !accountHasOmiDeviceConversations } - async let deviceHistory = shouldLoadDeviceHistory ? loadOmiDeviceHistory() : nil - let (c, m, t, d) = await (convos, mems, tasks, deviceHistory) - await MainActor.run { - if let c { conversationCount = c } - if let m { memoryCount = m } - if let t { taskCount = t } - // Sticky: device history never un-happens; keep the badge across - // launches and network failures once observed. - if d == true { - accountHasOmiDeviceConversations = true - UserDefaults.standard.set(true, forKey: Self.omiDeviceHistoryDefaultsKey) - } - } - } - - private func loadOmiDeviceHistory() async -> Bool? { - try? await APIClient.shared.hasOmiDeviceConversations() - } - private func formattedCount(_ count: Int) -> String { count.formatted() } @@ -4112,7 +4030,7 @@ private struct HomeAIButton: View { appState: AppState(), appProvider: AppProvider(), chatProvider: ChatProvider(), - memoriesViewModel: MemoriesViewModel(), + memoriesViewModel: MemoriesViewModel(), homeStatus: HomeStatusStore(), selectedIndex: .constant(0) ) .frame(width: 800, height: 600) diff --git a/desktop/macos/Desktop/Sources/Stores/HomeStatusStore.swift b/desktop/macos/Desktop/Sources/Stores/HomeStatusStore.swift new file mode 100644 index 00000000000..8d8a7481568 --- /dev/null +++ b/desktop/macos/Desktop/Sources/Stores/HomeStatusStore.swift @@ -0,0 +1,119 @@ +import Combine +import Foundation +import SwiftUI + +/// Persistent owner of the Home page's status data: knowledge counts, +/// import-connector and MCP-export statuses, and the refresh cooldown. +/// +/// Lives in `ViewModelContainer` so navigating away and back to Home renders +/// instantly from cached values instead of refetching — the page view itself +/// is recreated on every tab switch, so any state kept on the page (including +/// its refresh cooldown) dies with it and turns each visit into a refetch +/// storm (2 network calls + Rewind DB stats + local counts + connector and +/// MCP status scans). +@MainActor +final class HomeStatusStore: ObservableObject { + static let omiDeviceHistoryDefaultsKey = "home-omi-device-account-history" + + /// Import-connector (Gmail, Notes, …) status cache. Owned here so the + /// connected badges don't reset to unknown on every Home visit. + let importConnectorStatusStore = ImportConnectorStatusStore() + + // True totals for the "What omi knows" tiles. Without these the tiles + // showed only the loaded page (~50 conversations, ~100 memories), badly + // undercounting. + @Published var screenshotCount: Int? + @Published var conversationCount: Int? + @Published var memoryCount: Int? + @Published var taskCount: Int? + @Published var memoryExportStatuses: [MemoryExportDestination: MemoryExportStatus] = [:] + /// Wearable used on this account (any friend/omi-sourced conversation). + /// Seeded from UserDefaults so the badge is instant on later launches. + @Published var accountHasOmiDeviceConversations = UserDefaults.standard.bool( + forKey: HomeStatusStore.omiDeviceHistoryDefaultsKey) + + private var lastRefreshAt = Date.distantPast + private var isRefreshing = false + private var cancellables: Set = [] + + init() { + // The nested connector store publishes its own changes; forward them + // so views observing this store re-render connected badges. + importConnectorStatusStore.objectWillChange + .sink { [weak self] _ in self?.objectWillChange.send() } + .store(in: &cancellables) + } + + /// Refresh all Home status data. Non-forced calls share the app-wide + /// activation cooldown (so tab switches and Cmd-Tab bursts render from + /// cache), and calls are coalesced while a refresh is in flight. + func refresh(force: Bool) async { + let now = Date() + if !force, + !PollingConfig.shouldAllowActivationRefresh(now: now, lastRefresh: lastRefreshAt) + { + return + } + guard !isRefreshing else { return } + isRefreshing = true + lastRefreshAt = now + defer { isRefreshing = false } + + async let importConnectorStatuses: Void = importConnectorStatusStore.refresh() + async let screenshots: Void = loadScreenshotCount() + async let knowledgeCounts: Void = loadKnowledgeCounts() + async let exportStatuses: Void = loadMemoryExportStatuses() + _ = await (importConnectorStatuses, screenshots, knowledgeCounts, exportStatuses) + } + + /// Clear per-account data on sign-out/account switch. + func resetSessionState() { + screenshotCount = nil + conversationCount = nil + memoryCount = nil + taskCount = nil + memoryExportStatuses = [:] + accountHasOmiDeviceConversations = UserDefaults.standard.bool( + forKey: HomeStatusStore.omiDeviceHistoryDefaultsKey) + lastRefreshAt = .distantPast + } + + // MARK: - Loaders + + private func loadScreenshotCount() async { + let stats = await RewindIndexer.shared.getStats() + screenshotCount = stats?.total + } + + private func loadMemoryExportStatuses() async { + let statuses = await MemoryExportService.shared.allStatuses() + memoryExportStatuses = statuses + } + + /// Load the true totals behind the "What omi knows" tiles. Conversations come + /// from the server count endpoint (not stored locally); memories and tasks are + /// counted from the synced local DB — the same totals the detail pages show. + private func loadKnowledgeCounts() async { + async let convos = try? APIClient.shared.getConversationsCount(includeDiscarded: false) + async let mems = try? MemoryStorage.shared.getLocalMemoriesCount() + // Open tasks only (matches the "Tasks" label and the old tile's intent — + // the old value just under-counted, capping each bucket at a 7-day window). + async let tasks = try? ActionItemStorage.shared.getLocalActionItemsCount(completed: false) + let shouldLoadDeviceHistory = !accountHasOmiDeviceConversations + async let deviceHistory = shouldLoadDeviceHistory ? loadOmiDeviceHistory() : nil + let (c, m, t, d) = await (convos, mems, tasks, deviceHistory) + if let c { conversationCount = c } + if let m { memoryCount = m } + if let t { taskCount = t } + // Sticky: device history never un-happens; keep the badge across + // launches and network failures once observed. + if d == true { + accountHasOmiDeviceConversations = true + UserDefaults.standard.set(true, forKey: Self.omiDeviceHistoryDefaultsKey) + } + } + + private func loadOmiDeviceHistory() async -> Bool? { + try? await APIClient.shared.hasOmiDeviceConversations() + } +} diff --git a/desktop/macos/Desktop/Sources/ViewExporter.swift b/desktop/macos/Desktop/Sources/ViewExporter.swift index 2074644e2e4..5cabb6228b1 100644 --- a/desktop/macos/Desktop/Sources/ViewExporter.swift +++ b/desktop/macos/Desktop/Sources/ViewExporter.swift @@ -59,7 +59,7 @@ enum ViewExporter { appState: AppState(), appProvider: AppProvider(), chatProvider: ChatProvider(), - memoriesViewModel: MemoriesViewModel(), + memoriesViewModel: MemoriesViewModel(), homeStatus: HomeStatusStore(), selectedIndex: .constant(0))) }, CGSize(width: 900, height: 700) @@ -282,7 +282,7 @@ enum ViewExporter { appState: AppState(), appProvider: AppProvider(), chatProvider: ChatProvider(), - memoriesViewModel: previewMemoriesViewModel(), + memoriesViewModel: previewMemoriesViewModel(), homeStatus: HomeStatusStore(), selectedIndex: .constant(0))) } ), diff --git a/desktop/macos/Desktop/Sources/ViewModelContainer.swift b/desktop/macos/Desktop/Sources/ViewModelContainer.swift index d540852b1e9..6888ae66cee 100644 --- a/desktop/macos/Desktop/Sources/ViewModelContainer.swift +++ b/desktop/macos/Desktop/Sources/ViewModelContainer.swift @@ -12,6 +12,9 @@ class ViewModelContainer: ObservableObject { let tasksViewModel = TasksViewModel() let appProvider = AppProvider() let memoriesViewModel = MemoriesViewModel() + /// Home status data (counts, connector/MCP statuses) — persistent so Home + /// renders from cache on every visit instead of refetching. + let homeStatusStore = HomeStatusStore() let chatProvider: ChatProvider let taskChatCoordinator: TaskChatCoordinator private lazy var warmupCoordinator = StartupWarmupCoordinator( @@ -131,6 +134,7 @@ class ViewModelContainer: ObservableObject { dashboardViewModel.resetSessionState() memoriesViewModel.resetSessionState() appProvider.resetSessionState() + homeStatusStore.resetSessionState() isInitialLoadComplete = false isLoading = false databaseInitFailed = false diff --git a/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift b/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift index cc5c1e7ee1d..4fc7f2608a3 100644 --- a/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift +++ b/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift @@ -148,46 +148,48 @@ final class DashboardCaptureStateTests: XCTestCase { } func testHomeStatusRefreshUsesSharedActivationThrottle() throws { - let source = try dashboardSource() - let normalizedSource = normalizedWhitespace(source) - let method = try methodBody(named: "refreshHomeStatusData", in: source) - - XCTAssertTrue(source.contains("@State private var lastHomeStatusRefreshAt = Date.distantPast")) - XCTAssertTrue(normalizedSource.contains("syncCaptureState() reportHomeAutomationMode() Task { await refreshHomeStatusData(force: true) }")) - XCTAssertTrue( - normalizedSource.contains( - "viewModel.refreshGoals() appState.checkAllPermissions() syncCaptureState() Task { await refreshHomeStatusData(force: false) }" - ) - ) - XCTAssertTrue(method.contains("PollingConfig.shouldAllowActivationRefresh")) - XCTAssertTrue(method.contains("lastRefresh: lastHomeStatusRefreshAt")) - XCTAssertTrue(method.contains("lastHomeStatusRefreshAt = now")) - XCTAssertTrue(method.contains("async let importConnectorStatuses: Void = importConnectorStatusStore.refresh()")) - XCTAssertTrue(method.contains("async let screenshots: Void = loadScreenshotCount()")) - XCTAssertTrue(method.contains("async let knowledgeCounts: Void = loadKnowledgeCounts()")) - XCTAssertTrue(method.contains("async let exportStatuses: Void = loadMemoryExportStatuses()")) - XCTAssertFalse(source.contains("memoryExportStatusActiveRefreshThrottle")) - XCTAssertFalse(source.contains("lastMemoryExportStatusRefreshAt")) - XCTAssertFalse(source.contains("loadMemoryExportStatuses(force:")) + let dashboard = try dashboardSource() + let store = try homeStatusStoreSource() + let normalizedDashboard = normalizedWhitespace(dashboard) + let refresh = try methodBody(named: "refresh", in: store) + + // The cooldown lives on the session-persistent HomeStatusStore, not + // on page @State — page views are recreated on every tab switch, so + // view-held state can never throttle revisits (each visit refetched). + XCTAssertTrue(store.contains("private var lastRefreshAt = Date.distantPast")) + XCTAssertFalse(dashboard.contains("lastHomeStatusRefreshAt")) + // Home renders from the cached store; appear and app-activation both + // go through the throttled path — no forced refetch per visit. + XCTAssertTrue(normalizedDashboard.contains("Task { await homeStatus.refresh(force: false) }")) + XCTAssertFalse(dashboard.contains("refresh(force: true)")) + XCTAssertTrue(refresh.contains("PollingConfig.shouldAllowActivationRefresh")) + XCTAssertTrue(refresh.contains("lastRefreshAt = now")) + XCTAssertTrue(refresh.contains("guard !isRefreshing else { return }")) + XCTAssertTrue(refresh.contains("async let importConnectorStatuses: Void = importConnectorStatusStore.refresh()")) + XCTAssertTrue(refresh.contains("async let screenshots: Void = loadScreenshotCount()")) + XCTAssertTrue(refresh.contains("async let knowledgeCounts: Void = loadKnowledgeCounts()")) + XCTAssertTrue(refresh.contains("async let exportStatuses: Void = loadMemoryExportStatuses()")) + XCTAssertFalse(store.contains("memoryExportStatusActiveRefreshThrottle")) + XCTAssertFalse(store.contains("loadMemoryExportStatuses(force:")) } func testMemoryExportStatusesRefreshInsideHomeStatusGate() throws { - let source = try dashboardSource() - let method = try methodBody(named: "loadMemoryExportStatuses", in: source) + let store = try homeStatusStoreSource() + let method = try methodBody(named: "loadMemoryExportStatuses", in: store) XCTAssertTrue(method.contains("let statuses = await MemoryExportService.shared.allStatuses()")) XCTAssertTrue(method.contains("memoryExportStatuses = statuses")) XCTAssertFalse(method.contains("PollingConfig.shouldAllowActivationRefresh")) - XCTAssertFalse(method.contains("lastHomeStatusRefreshAt")) + XCTAssertFalse(method.contains("lastRefreshAt")) XCTAssertFalse(method.contains("memoryExportStatusActiveRefreshThrottle")) } func testOmiDeviceHistorySkipsNetworkAfterStickyFlag() throws { - let source = try dashboardSource() - let method = try methodBody(named: "loadKnowledgeCounts", in: source) - let helper = try methodBody(named: "loadOmiDeviceHistory", in: source) + let store = try homeStatusStoreSource() + let method = try methodBody(named: "loadKnowledgeCounts", in: store) + let helper = try methodBody(named: "loadOmiDeviceHistory", in: store) - XCTAssertTrue(method.contains("let shouldLoadDeviceHistory = await MainActor.run { !accountHasOmiDeviceConversations }")) + XCTAssertTrue(method.contains("let shouldLoadDeviceHistory = !accountHasOmiDeviceConversations")) XCTAssertTrue(method.contains("async let deviceHistory = shouldLoadDeviceHistory ? loadOmiDeviceHistory() : nil")) XCTAssertTrue(helper.contains("APIClient.shared.hasOmiDeviceConversations()")) XCTAssertTrue(method.contains("UserDefaults.standard.set(true, forKey: Self.omiDeviceHistoryDefaultsKey)")) @@ -309,6 +311,14 @@ final class DashboardCaptureStateTests: XCTestCase { return try String(contentsOf: dashboardURL, encoding: .utf8) } + private func homeStatusStoreSource() throws -> String { + let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + let storeURL = testsURL + .deletingLastPathComponent() + .appendingPathComponent("Sources/Stores/HomeStatusStore.swift") + return try String(contentsOf: storeURL, encoding: .utf8) + } + private func appsSource() throws -> String { try source(named: "AppsPage.swift") } @@ -322,7 +332,7 @@ final class DashboardCaptureStateTests: XCTestCase { } private func methodBody(named name: String, in source: String) throws -> String { - let pattern = #"private func \#(name)\([^\)]*\)[^{]*\{([\s\S]*?)\n \}"# + let pattern = #"(?:private )?func \#(name)\([^\)]*\)[^{]*\{([\s\S]*?)\n \}"# let regex = try NSRegularExpression(pattern: pattern) let range = NSRange(source.startIndex.. Date: Tue, 7 Jul 2026 11:05:02 -0400 Subject: [PATCH 02/31] Persist conversations search across page navigation --- .../Sources/MainWindow/DesktopHomeView.swift | 8 +- .../MainWindow/Pages/ConversationsPage.swift | 95 +++---------------- .../Stores/ConversationsSearchModel.swift | 89 +++++++++++++++++ .../macos/Desktop/Sources/ViewExporter.swift | 4 +- .../Desktop/Sources/ViewModelContainer.swift | 3 + 5 files changed, 115 insertions(+), 84 deletions(-) create mode 100644 desktop/macos/Desktop/Sources/Stores/ConversationsSearchModel.swift diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index 74429c7bec1..05c3e0ce075 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -1096,7 +1096,8 @@ private struct PageContentView: View { homeStatus: viewModelContainer.homeStatusStore, selectedIndex: $selectedTabIndex) case 1: - ConversationsPageHost(appState: appState) + ConversationsPageHost( + appState: appState, searchModel: viewModelContainer.conversationsSearchModel) case 2: ChatPage( appProvider: viewModelContainer.appProvider, chatProvider: viewModelContainer.chatProvider @@ -1145,10 +1146,13 @@ private struct PageContentView: View { /// so tapping a row navigates to the detail view. private struct ConversationsPageHost: View { let appState: AppState + let searchModel: ConversationsSearchModel @State private var selectedConversation: ServerConversation? = nil var body: some View { - ConversationsPage(appState: appState, selectedConversation: $selectedConversation) + ConversationsPage( + appState: appState, selectedConversation: $selectedConversation, + searchModel: searchModel) } } diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift index 4440bbca154..9d7d8b91b62 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift @@ -2,33 +2,13 @@ import Combine import SwiftUI import OmiTheme -// MARK: - Search Debouncer - -/// Debounces search queries to avoid excessive API calls -class SearchDebouncer: ObservableObject { - /// The input query (set immediately when user types) - @Published var inputQuery: String = "" - /// The debounced query (updated 250ms after user stops typing) - @Published var debouncedQuery: String = "" - private var cancellables = Set() - - init() { - // Observe input and debounce to output - $inputQuery - .debounce(for: .milliseconds(250), scheduler: DispatchQueue.main) - .removeDuplicates() - .sink { [weak self] value in - self?.debouncedQuery = value - } - .store(in: &cancellables) - } -} - // MARK: - Conversations Page struct ConversationsPage: View { @ObservedObject var appState: AppState @Binding var selectedConversation: ServerConversation? + /// Persistent search state — survives page recreation on tab switches. + @ObservedObject var searchModel: ConversationsSearchModel /// When true, renders without internal ScrollViews (for embedding in an outer ScrollView) var embedded: Bool = false @@ -36,12 +16,6 @@ struct ConversationsPage: View { // Compact view mode - persisted preference @AppStorage("conversationsCompactView") private var isCompactView = true - // Search state - @State private var searchQuery: String = "" - @State private var searchResults: [ServerConversation] = [] - @State private var isSearching: Bool = false - @State private var searchError: String? = nil - @StateObject private var searchDebouncer = SearchDebouncer() // Date picker state @State private var showDatePicker: Bool = false @@ -169,7 +143,7 @@ struct ConversationsPage: View { return } - if let conversation = searchResults.first(where: { $0.id == conversationId }) { + if let conversation = searchModel.results.first(where: { $0.id == conversationId }) { present(conversation) return } @@ -243,25 +217,14 @@ struct ConversationsPage: View { .scaledFont(size: 13) .foregroundColor(OmiColors.textTertiary) - TextField("Search conversations...", text: $searchQuery) + TextField("Search conversations...", text: $searchModel.query) .textFieldStyle(.plain) .scaledFont(size: 13) .foregroundColor(OmiColors.textPrimary) - .onChange(of: searchQuery) { _, newValue in - // Feed input to debouncer - searchDebouncer.inputQuery = newValue - } - .onChange(of: searchDebouncer.debouncedQuery) { _, newValue in - // Debounced value changed - perform search - performSearch(query: newValue) - } - if !searchQuery.isEmpty { + if !searchModel.query.isEmpty { Button(action: { - searchQuery = "" - searchDebouncer.inputQuery = "" - searchResults = [] - searchError = nil + searchModel.clear() }) { Image(systemName: "xmark.circle.fill") .scaledFont(size: 13) @@ -293,7 +256,7 @@ struct ConversationsPage: View { .padding(.bottom, 12) // List - show search results or regular conversations - if !searchQuery.isEmpty { + if !searchModel.query.isEmpty { // Search results view searchResultsView } else { @@ -344,7 +307,7 @@ struct ConversationsPage: View { private var searchResultsView: some View { Group { - if isSearching { + if searchModel.isSearching { VStack(spacing: 12) { ProgressView() Text("Searching...") @@ -352,7 +315,7 @@ struct ConversationsPage: View { .foregroundColor(OmiColors.textTertiary) } .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let error = searchError { + } else if let error = searchModel.searchError { VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle") .scaledFont(size: 32) @@ -364,7 +327,7 @@ struct ConversationsPage: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() - } else if searchResults.isEmpty { + } else if searchModel.results.isEmpty { VStack(spacing: 12) { Image(systemName: "magnifyingglass") .scaledFont(size: 32) @@ -394,7 +357,7 @@ struct ConversationsPage: View { @ViewBuilder private var searchResultsContent: some View { let content = LazyVStack(spacing: 8) { - ForEach(searchResults) { conversation in + ForEach(searchModel.results) { conversation in ConversationRowView( conversation: conversation, onTap: { @@ -433,38 +396,6 @@ struct ConversationsPage: View { // MARK: - Search - private func performSearch(query: String) { - guard !query.isEmpty else { - searchResults = [] - searchError = nil - return - } - - isSearching = true - searchError = nil - log("Search: Starting search for '\(query)'") - AnalyticsManager.shared.searchQueryEntered(query: query) - - Task { - do { - let result = try await APIClient.shared.searchConversations( - query: query, - page: 1, - perPage: 50, - includeDiscarded: false - ) - log("Search: Found \(result.items.count) results") - searchResults = result.items - isSearching = false - } catch { - logError("Search: Failed", error: error) - searchError = error.localizedDescription - searchResults = [] - isSearching = false - } - } - } - // MARK: - Filter Buttons private var filterButtonsRow: some View { @@ -794,7 +725,9 @@ private struct TranscriptNotesDivider: View { #if canImport(PreviewsMacros) #Preview { - ConversationsPage(appState: AppState(), selectedConversation: .constant(nil)) + ConversationsPage( + appState: AppState(), selectedConversation: .constant(nil), + searchModel: ConversationsSearchModel()) .frame(width: 600, height: 800) .background(OmiColors.backgroundSecondary) } diff --git a/desktop/macos/Desktop/Sources/Stores/ConversationsSearchModel.swift b/desktop/macos/Desktop/Sources/Stores/ConversationsSearchModel.swift new file mode 100644 index 00000000000..aedc2337792 --- /dev/null +++ b/desktop/macos/Desktop/Sources/Stores/ConversationsSearchModel.swift @@ -0,0 +1,89 @@ +import Combine +import Foundation + +/// Persistent owner of the Conversations page's search: the typed query, +/// debounced execution, and results. +/// +/// Lives in `ViewModelContainer` because the page view is recreated on every +/// tab switch — keeping this state on the page meant a typed search vanished +/// whenever the user navigated away and back. Owning the debounce + request +/// here also lets us cancel superseded in-flight searches instead of letting +/// slow responses race each other out of order. +@MainActor +final class ConversationsSearchModel: ObservableObject { + /// The text in the search field. Updates immediately as the user types; + /// execution is debounced internally. + @Published var query: String = "" + @Published private(set) var results: [ServerConversation] = [] + @Published private(set) var isSearching = false + @Published private(set) var searchError: String? = nil + + private var lastSearchedQuery: String? = nil + private var searchTask: Task? = nil + private var cancellables = Set() + + init() { + $query + .debounce(for: .milliseconds(250), scheduler: DispatchQueue.main) + .removeDuplicates() + .sink { [weak self] value in + self?.performSearch(query: value) + } + .store(in: &cancellables) + } + + func clear() { + searchTask?.cancel() + query = "" + results = [] + searchError = nil + isSearching = false + lastSearchedQuery = nil + } + + func resetSessionState() { + clear() + } + + private func performSearch(query: String) { + guard !query.isEmpty else { + searchTask?.cancel() + results = [] + searchError = nil + isSearching = false + lastSearchedQuery = nil + return + } + // The debounce pipeline already drops consecutive duplicates; this + // guards the clear-and-retype path from re-firing identical requests. + guard query != lastSearchedQuery else { return } + lastSearchedQuery = query + + isSearching = true + searchError = nil + log("Search: Starting search for '\(query)'") + AnalyticsManager.shared.searchQueryEntered(query: query) + + searchTask?.cancel() + searchTask = Task { + do { + let result = try await APIClient.shared.searchConversations( + query: query, + page: 1, + perPage: 50, + includeDiscarded: false + ) + guard !Task.isCancelled else { return } + log("Search: Found \(result.items.count) results") + results = result.items + isSearching = false + } catch { + guard !Task.isCancelled else { return } + logError("Search: Failed", error: error) + searchError = error.localizedDescription + results = [] + isSearching = false + } + } + } +} diff --git a/desktop/macos/Desktop/Sources/ViewExporter.swift b/desktop/macos/Desktop/Sources/ViewExporter.swift index 5cabb6228b1..85fcef8595a 100644 --- a/desktop/macos/Desktop/Sources/ViewExporter.swift +++ b/desktop/macos/Desktop/Sources/ViewExporter.swift @@ -73,7 +73,9 @@ enum ViewExporter { ( "04-conversations", - { AnyView(ConversationsPage(appState: AppState(), selectedConversation: .constant(nil))) }, + { AnyView(ConversationsPage( + appState: AppState(), selectedConversation: .constant(nil), + searchModel: ConversationsSearchModel())) }, CGSize(width: 900, height: 700) ), diff --git a/desktop/macos/Desktop/Sources/ViewModelContainer.swift b/desktop/macos/Desktop/Sources/ViewModelContainer.swift index 6888ae66cee..2f243426605 100644 --- a/desktop/macos/Desktop/Sources/ViewModelContainer.swift +++ b/desktop/macos/Desktop/Sources/ViewModelContainer.swift @@ -15,6 +15,8 @@ class ViewModelContainer: ObservableObject { /// Home status data (counts, connector/MCP statuses) — persistent so Home /// renders from cache on every visit instead of refetching. let homeStatusStore = HomeStatusStore() + /// Conversations search — persistent so a typed query survives navigation. + let conversationsSearchModel = ConversationsSearchModel() let chatProvider: ChatProvider let taskChatCoordinator: TaskChatCoordinator private lazy var warmupCoordinator = StartupWarmupCoordinator( @@ -135,6 +137,7 @@ class ViewModelContainer: ObservableObject { memoriesViewModel.resetSessionState() appProvider.resetSessionState() homeStatusStore.resetSessionState() + conversationsSearchModel.resetSessionState() isInitialLoadComplete = false isLoading = false databaseInitFailed = false From 31bd037644ec210b4ee8f6981e85eb18d9312856 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:05:21 -0400 Subject: [PATCH 03/31] Cache brain map layouts and skip rebuilds on unchanged graphs --- .../Sources/MainWindow/DesktopHomeView.swift | 4 +- .../MainWindow/Pages/MemoriesPage.swift | 3 +- .../MemoryGraph/ForceDirectedSimulation.swift | 150 +++++++++- .../Pages/MemoryGraph/MemoryGraphPage.swift | 262 +++++++++++++++--- .../macos/Desktop/Sources/ViewExporter.swift | 2 +- .../Desktop/Sources/ViewModelContainer.swift | 3 + .../Tests/MemoryGraphRevisitTests.swift | 78 ++++++ .../20260707-snappier-page-navigation.json | 3 + 8 files changed, 455 insertions(+), 50 deletions(-) create mode 100644 desktop/macos/Desktop/Tests/MemoryGraphRevisitTests.swift create mode 100644 desktop/macos/changelog/unreleased/20260707-snappier-page-navigation.json diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index 05c3e0ce075..ba3ff7168b0 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -1103,7 +1103,9 @@ private struct PageContentView: View { appProvider: viewModelContainer.appProvider, chatProvider: viewModelContainer.chatProvider ) case 3: - MemoriesPage(viewModel: viewModelContainer.memoriesViewModel) + MemoriesPage( + viewModel: viewModelContainer.memoriesViewModel, + graphViewModel: viewModelContainer.memoryGraphViewModel) case 4: TasksPage( viewModel: viewModelContainer.tasksViewModel, diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift index 837b5a2543d..1df3da87108 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift @@ -1356,6 +1356,7 @@ class MemoriesViewModel: ObservableObject { struct MemoriesPage: View { @ObservedObject var viewModel: MemoriesViewModel + let graphViewModel: MemoryGraphViewModel @State private var showCategoryFilter = false @State private var categorySearchText = "" @State private var pendingSelectedTags: Set = [] @@ -1954,7 +1955,7 @@ struct MemoriesPage: View { private var memoryList: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 14) { - MemoryGraphInlineCard() + MemoryGraphInlineCard(viewModel: graphViewModel) LazyVStack(spacing: 10) { ForEach(viewModel.filteredMemories) { memory in diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoryGraph/ForceDirectedSimulation.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoryGraph/ForceDirectedSimulation.swift index 4d75b15e905..190bea85425 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoryGraph/ForceDirectedSimulation.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoryGraph/ForceDirectedSimulation.swift @@ -68,6 +68,7 @@ class ForceDirectedSimulation { let maxSpeed: Float = 40 private var tickCount = 0 + private(set) var lastStepEnergy: Float = 0 private var stableFrameCount = 0 private let stableThreshold: Float = 0.2 private let stableFramesRequired = 10 @@ -269,11 +270,143 @@ class ForceDirectedSimulation { } /// Run multiple physics steps synchronously (for initial layout) - /// Bypasses tick counting for full-speed computation. + /// Bypasses tick counting for full-speed computation. `ticks` is a + /// budget, not a quota — the loop exits as soon as the layout settles + /// (total kinetic energy below threshold for a run of steps). + /// Bulk layout for the initial build. Same math as `runPhysicsStep`, + /// but batched over contiguous value arrays: in unoptimized dev builds + /// the per-property class access in the hot loop is dominated by ARC + /// retain/release traffic, and the array form sidesteps it entirely. + /// Exits early when total kinetic energy plateaus (no fixed threshold + /// to mis-tune — "stopped improving" is the criterion). func runSync(ticks: Int) { - for _ in 0.. 0 else { return } + + // Snapshot node state into contiguous buffers + var positions = [SIMD3](repeating: .zero, count: nodeCount) + var velocities = [SIMD3](repeating: .zero, count: nodeCount) + var forces = [SIMD3](repeating: .zero, count: nodeCount) + var fixed = [Bool](repeating: false, count: nodeCount) + var indexOf: [String: Int] = [:] + indexOf.reserveCapacity(nodeCount) + for (index, node) in nodes.enumerated() { + positions[index] = node.position + velocities[index] = node.velocity + fixed[index] = node.isFixed + indexOf[node.id] = index + } + let springs: [(source: Int, target: Int)] = edges.compactMap { edge in + guard edge.affectsPhysics, + let source = indexOf[edge.sourceId], + let target = indexOf[edge.targetId] else { return nil } + return (source, target) + } + + var previousEnergy = Float.greatestFiniteMagnitude + var plateauSteps = 0 + var executedSteps = 0 + + for step in 0.. 0 else { continue } + let force = (delta / dist) * ((dist - restLength) * attraction) + if !fixed[spring.source] { forces[spring.source] += force } + if !fixed[spring.target] { forces[spring.target] -= force } + } + + // 4. Center gravity + 5. integrate + var totalEnergy: Float = 0 + for i in 0.. maxSpeed { + velocities[i] = simd_normalize(velocities[i]) * maxSpeed + } + positions[i] += velocities[i] + totalEnergy += speed * speed + } + + // 6. Plateau detection: layout has stopped improving when energy + // change stays below 1% for a sustained run of steps. + let delta = abs(previousEnergy - totalEnergy) + if delta < previousEnergy * 0.01 { + plateauSteps += 1 + if plateauSteps >= 20 { + logPerf( + "MemoryGraph: layout converged after \(executedSteps)/\(ticks) ticks (energy \(totalEnergy))" + ) + break + } + } else { + plateauSteps = 0 + } + previousEnergy = totalEnergy + lastStepEnergy = totalEnergy + } + + if plateauSteps < 20 { + logPerf( + "MemoryGraph: layout budget exhausted (\(executedSteps) ticks, energy \(lastStepEnergy))" + ) + } + + // Write settled state back to the node objects + for (index, node) in nodes.enumerated() { + node.position = positions[index] + node.velocity = velocities[index] } + stableFrameCount = stableFramesRequired + } + + /// Snapshot of every node's settled position, for layout caching. + func layoutPositions() -> [String: SIMD3] { + var positions: [String: SIMD3] = [:] + for node in nodes { + positions[node.id] = node.position + } + return positions + } + + /// Restore a previously settled layout. Returns false (leaving positions + /// untouched for a fresh simulation) unless every node is covered. + func applyLayout(_ positions: [String: SIMD3]) -> Bool { + for node in nodes where !node.isFixed { + guard positions[node.id] != nil else { return false } + } + for node in nodes where !node.isFixed { + if let position = positions[node.id] { + node.position = position + node.velocity = .zero + } + } + stableFrameCount = stableFramesRequired + return true } /// One full physics step (no tick-skipping) @@ -330,6 +463,7 @@ class ForceDirectedSimulation { } // 5. Update velocities and positions + var totalEnergy: Float = 0 for node in nodes where !node.isFixed { node.velocity += node.force * dt node.velocity *= damping @@ -339,6 +473,16 @@ class ForceDirectedSimulation { node.velocity = simd_normalize(node.velocity) * maxSpeed } node.position += node.velocity + totalEnergy += speed * speed + } + + // 6. Stability tracking (shared with the animated tick path) so + // runSync can stop as soon as the layout has settled. + lastStepEnergy = totalEnergy + if totalEnergy < stableThreshold { + stableFrameCount += 1 + } else { + stableFrameCount = 0 } } diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoryGraph/MemoryGraphPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoryGraph/MemoryGraphPage.swift index 4991fba95dc..d4d89559f04 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoryGraph/MemoryGraphPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoryGraph/MemoryGraphPage.swift @@ -5,7 +5,7 @@ import OmiTheme // MARK: - Memory Graph Page struct MemoryGraphPage: View { - @StateObject private var viewModel = MemoryGraphViewModel() + @ObservedObject var viewModel: MemoryGraphViewModel @Environment(\.dismiss) private var dismiss var body: some View { @@ -70,7 +70,7 @@ struct MemoryGraphPage: View { } struct MemoryGraphInlineCard: View { - @StateObject private var viewModel = MemoryGraphViewModel() + @ObservedObject var viewModel: MemoryGraphViewModel var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -203,6 +203,14 @@ class MemoryGraphViewModel: ObservableObject { private var nodeSceneNodes: [String: SCNNode] = [:] private var edgeSceneNodes: [String: SCNNode] = [:] private var isAnimating = true + // Revisit guards: the VM is session-persistent (ViewModelContainer), so a + // page visit renders the existing scene instantly. Non-forced loads are + // TTL-throttled, single-flight, and skip the expensive re-simulation when + // the fetched graph is unchanged. + private var lastLoadedAt = Date.distantPast + private var isPreparing = false + private var hasRunEmptyBootstrap = false + private var loadedGraphSignature: Int? init() { setupCamera() @@ -243,8 +251,23 @@ class MemoryGraphViewModel: ObservableObject { // MARK: - Load Graph func prepareGraph() async { + guard !isPreparing else { return } + isPreparing = true + defer { isPreparing = false } + + // A rendered scene within the cooldown is served as-is — visiting the + // page must not refetch, re-run the force layout, or reset the camera. + if !isEmpty, + !PollingConfig.shouldAllowActivationRefresh(lastRefresh: lastLoadedAt) + { + return + } + await loadGraph() - if isEmpty { + if isEmpty && !hasRunEmptyBootstrap { + // First-session bootstrap for sparse accounts: ask the backend to build + // the graph, then poll for it. Run once per session — not per visit. + hasRunEmptyBootstrap = true await rebuildGraph() for _ in 1...10 { try? await Task.sleep(nanoseconds: 3_000_000_000) @@ -255,44 +278,142 @@ class MemoryGraphViewModel: ObservableObject { } func loadGraph() async { - isLoading = true - defer { isLoading = false } + // Only surface the spinner while there's no scene to show — freshness + // checks over a rendered graph stay invisible. + let showSpinner = isEmpty + if showSpinner { isLoading = true } + defer { if showSpinner { isLoading = false } } do { let response = try await fetchGraph() log("Knowledge graph: \(response.nodes.count) nodes, \(response.edges.count) edges") isEmpty = response.nodes.isEmpty + lastLoadedAt = Date() guard !isEmpty else { return } + // Same graph as last time → keep the settled scene. Re-simulating and + // recreating scene nodes for identical data is what made every page + // visit visibly "reload" the brain map. + let signature = Self.graphSignature(of: response) + if signature == loadedGraphSignature { + return + } + loadedGraphSignature = signature + // Populate simulation with user node at center let userName = AuthService.shared.displayName.isEmpty ? nil : AuthService.shared.givenName log("User name for center node: \(userName ?? "nil")") + let populateStart = CFAbsoluteTimeGetCurrent() simulation.populate(graphResponse: response, userNodeLabel: userName) log( "Simulation populated: \(simulation.nodes.count) nodes (including user), \(simulation.edges.count) edges" ) - - // Run initial layout off main thread for responsiveness - await Task.detached(priority: .userInitiated) { [simulation] in - simulation.runSync(ticks: 800) - }.value + logPerf( + "MemoryGraph: populate", duration: CFAbsoluteTimeGetCurrent() - populateStart) + + // A settled layout for this exact graph renders instantly — restore it + // and skip both the physics run and the visual settle animation. + let layoutStart = CFAbsoluteTimeGetCurrent() + let restoredLayout = + loadCachedLayout(signature: signature).map { simulation.applyLayout($0) } ?? false + if !restoredLayout { + // Run initial layout off main thread for responsiveness + await Task.detached(priority: .userInitiated) { [simulation] in + simulation.runSync(ticks: 800) + }.value + saveLayoutCache(signature: signature) + } + logPerf( + "MemoryGraph: layout (restored=\(restoredLayout))", + duration: CFAbsoluteTimeGetCurrent() - layoutStart) // Create scene nodes + let sceneStart = CFAbsoluteTimeGetCurrent() createSceneNodes() + logPerf("MemoryGraph: scene build", duration: CFAbsoluteTimeGetCurrent() - sceneStart) - // Brief animation to settle, then stop - isAnimating = true - Task { - try? await Task.sleep(nanoseconds: 3_000_000_000) // 3s of live physics - await MainActor.run { isAnimating = false } + if restoredLayout { + isAnimating = false + } else { + // Brief animation to settle, then stop + isAnimating = true + Task { + try? await Task.sleep(nanoseconds: 3_000_000_000) // 3s of live physics + await MainActor.run { isAnimating = false } + } } } catch { log("Failed to load knowledge graph: \(error.localizedDescription)") } } + private struct GraphLayoutCache: Codable { + let signature: Int + let positions: [String: [Float]] + } + + private static var layoutCacheURL: URL? { + guard + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) + .first + else { return nil } + let dir = base.appendingPathComponent( + Bundle.main.bundleIdentifier ?? "me.omi", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("memory-graph-layout.json") + } + + private func loadCachedLayout(signature: Int) -> [String: SIMD3]? { + guard let url = Self.layoutCacheURL, + let data = try? Data(contentsOf: url), + let cache = try? JSONDecoder().decode(GraphLayoutCache.self, from: data), + cache.signature == signature + else { return nil } + var positions: [String: SIMD3] = [:] + positions.reserveCapacity(cache.positions.count) + for (id, values) in cache.positions where values.count == 3 { + positions[id] = SIMD3(values[0], values[1], values[2]) + } + return positions + } + + private func saveLayoutCache(signature: Int) { + guard let url = Self.layoutCacheURL else { return } + var positions: [String: [Float]] = [:] + for (id, position) in simulation.layoutPositions() { + positions[id] = [position.x, position.y, position.z] + } + let cache = GraphLayoutCache(signature: signature, positions: positions) + if let data = try? JSONEncoder().encode(cache) { + try? data.write(to: url, options: .atomic) + } + } + + /// Stable across launches — Swift's `Hasher` is per-process seeded, which + /// would silently invalidate the on-disk layout cache on every restart. + private static func graphSignature(of response: KnowledgeGraphResponse) -> Int { + var hash: UInt64 = 0xcbf2_9ce4_8422_2325 // FNV-1a + func combine(_ string: String) { + for byte in string.utf8 { + hash ^= UInt64(byte) + hash = hash &* 0x0000_0100_0000_01b3 + } + hash ^= 0xff + hash = hash &* 0x0000_0100_0000_01b3 + } + combine(String(response.nodes.count)) + combine(String(response.edges.count)) + for id in response.nodes.map(\.id).sorted() { + combine(id) + } + for id in response.edges.map(\.id).sorted() { + combine(id) + } + return Int(bitPattern: UInt(truncatingIfNeeded: hash)) + } + private func fetchGraph() async throws -> KnowledgeGraphResponse { var response = await KnowledgeGraphStorage.shared.loadGraph() if !response.nodes.isEmpty { @@ -480,17 +601,35 @@ class MemoryGraphViewModel: ObservableObject { let billboardConstraint = SCNBillboardConstraint() billboardConstraint.freeAxes = [.X, .Y] + // Shared materials/geometry: nodes of the same type (and edges of the + // same type pair) are visually identical, so building one material per + // node/edge (~1,200 unique GPU objects for a mid-size graph) wasted both + // build time and draw-call batching. Cache by visual identity instead. + var edgeMaterialCache: [String: SCNMaterial] = [:] + var bodyMaterialCache: [String: SCNMaterial] = [:] + var glowMaterialCache: [String: SCNMaterial] = [:] + var sphereCache: [String: SCNSphere] = [:] + // Create edges first (behind nodes) for edge in simulation.edges { guard let source = simulation.nodeMap[edge.sourceId], let target = simulation.nodeMap[edge.targetId] else { continue } - let edgeColor = blendColors(source.nodeType.nsColor, target.nodeType.nsColor, alpha: 0.25) - let edgeMaterial = SCNMaterial() - edgeMaterial.diffuse.contents = edgeColor - edgeMaterial.emission.contents = edgeColor.withAlphaComponent(0.15) - edgeMaterial.lightingModel = .constant + let edgeKey = "\(source.nodeType.rawValue)|\(target.nodeType.rawValue)" + let edgeMaterial: SCNMaterial + if let cached = edgeMaterialCache[edgeKey] { + edgeMaterial = cached + } else { + let edgeColor = blendColors( + source.nodeType.nsColor, target.nodeType.nsColor, alpha: 0.25) + let material = SCNMaterial() + material.diffuse.contents = edgeColor + material.emission.contents = edgeColor.withAlphaComponent(0.15) + material.lightingModel = .constant + edgeMaterialCache[edgeKey] = material + edgeMaterial = material + } let edgeNode = createEdgeNode( from: source.position, to: target.position, material: edgeMaterial) @@ -506,34 +645,69 @@ class MemoryGraphViewModel: ObservableObject { containerNode.position = SCNVector3(node.position) containerNode.name = node.id + let materialKey = node.isFixed ? "fixed" : node.nodeType.rawValue + // Core sphere - let sphere = SCNSphere(radius: radius) - sphere.segmentCount = node.isFixed ? 24 : 16 - let mat = SCNMaterial() - if node.isFixed { - mat.diffuse.contents = NSColor.white - mat.emission.contents = NSColor.white.withAlphaComponent(0.8) + let bodyMaterial: SCNMaterial + if let cached = bodyMaterialCache[materialKey] { + bodyMaterial = cached } else { - mat.diffuse.contents = node.nodeType.nsColor - mat.emission.contents = node.nodeType.nsColor.withAlphaComponent(0.5) + let material = SCNMaterial() + if node.isFixed { + material.diffuse.contents = NSColor.white + material.emission.contents = NSColor.white.withAlphaComponent(0.8) + } else { + material.diffuse.contents = node.nodeType.nsColor + material.emission.contents = node.nodeType.nsColor.withAlphaComponent(0.5) + } + material.lightingModel = .constant + bodyMaterialCache[materialKey] = material + bodyMaterial = material + } + let segments = node.isFixed ? 24 : 16 + let sphereKey = "\(materialKey)|\(Int(radius * 10))|\(segments)" + let sphere: SCNSphere + if let cached = sphereCache[sphereKey] { + sphere = cached + } else { + let newSphere = SCNSphere(radius: radius) + newSphere.segmentCount = segments + newSphere.materials = [bodyMaterial] + sphereCache[sphereKey] = newSphere + sphere = newSphere } - mat.lightingModel = .constant - sphere.materials = [mat] let sphereNode = SCNNode(geometry: sphere) containerNode.addChildNode(sphereNode) - // Glow halo (larger semi-transparent sphere around node) + // Glow halo (larger semi-transparent sphere around node). The halo is + // an additive blur — 24 segments is visually identical to 48 at half + // the tessellation. let glowRadius = radius * 2.5 - let glowSphere = SCNSphere(radius: glowRadius) - glowSphere.segmentCount = 48 - let glowMat = SCNMaterial() - let glowColor = node.isFixed ? NSColor.white : node.nodeType.nsColor - glowMat.diffuse.contents = glowColor.withAlphaComponent(0.03) - glowMat.emission.contents = glowColor.withAlphaComponent(0.025) - glowMat.lightingModel = .constant - glowMat.isDoubleSided = true - glowMat.blendMode = .add - glowSphere.materials = [glowMat] + let glowMaterial: SCNMaterial + if let cached = glowMaterialCache[materialKey] { + glowMaterial = cached + } else { + let material = SCNMaterial() + let glowColor = node.isFixed ? NSColor.white : node.nodeType.nsColor + material.diffuse.contents = glowColor.withAlphaComponent(0.03) + material.emission.contents = glowColor.withAlphaComponent(0.025) + material.lightingModel = .constant + material.isDoubleSided = true + material.blendMode = .add + glowMaterialCache[materialKey] = material + glowMaterial = material + } + let glowKey = "glow|\(materialKey)|\(Int(glowRadius * 10))" + let glowSphere: SCNSphere + if let cached = sphereCache[glowKey] { + glowSphere = cached + } else { + let newSphere = SCNSphere(radius: glowRadius) + newSphere.segmentCount = 24 + newSphere.materials = [glowMaterial] + sphereCache[glowKey] = newSphere + glowSphere = newSphere + } let glowNode = SCNNode(geometry: glowSphere) containerNode.addChildNode(glowNode) @@ -554,9 +728,9 @@ class MemoryGraphViewModel: ObservableObject { private func createLabelNode(text: String, nodeRadius: CGFloat, isFixed: Bool) -> SCNNode { let truncated = text.count > 18 ? String(text.prefix(16)) + "..." : text let fontSize: CGFloat = isFixed ? 22 : 16 - let scnText = SCNText(string: truncated, extrusionDepth: 0.5) + let scnText = SCNText(string: truncated, extrusionDepth: 0) scnText.font = NSFont.systemFont(ofSize: fontSize, weight: isFixed ? .bold : .medium) - scnText.flatness = 0.3 + scnText.flatness = 0.6 scnText.alignmentMode = CATextLayerAlignmentMode.center.rawValue let textMat = SCNMaterial() @@ -749,7 +923,7 @@ extension SCNVector3 { #if canImport(PreviewsMacros) #Preview { - MemoryGraphPage() + MemoryGraphPage(viewModel: MemoryGraphViewModel()) .frame(width: 800, height: 600) } #endif diff --git a/desktop/macos/Desktop/Sources/ViewExporter.swift b/desktop/macos/Desktop/Sources/ViewExporter.swift index 85fcef8595a..4790566102b 100644 --- a/desktop/macos/Desktop/Sources/ViewExporter.swift +++ b/desktop/macos/Desktop/Sources/ViewExporter.swift @@ -292,7 +292,7 @@ enum ViewExporter { "full-ai-chat", 2, { AnyView(ChatPage(appProvider: AppProvider(), chatProvider: previewChatProvider())) } ), - ("full-memories", 3, { AnyView(MemoriesPage(viewModel: previewMemoriesViewModel())) }), + ("full-memories", 3, { AnyView(MemoriesPage(viewModel: previewMemoriesViewModel(), graphViewModel: MemoryGraphViewModel())) }), ( "full-tasks", 4, { diff --git a/desktop/macos/Desktop/Sources/ViewModelContainer.swift b/desktop/macos/Desktop/Sources/ViewModelContainer.swift index 2f243426605..abd1dd22ca0 100644 --- a/desktop/macos/Desktop/Sources/ViewModelContainer.swift +++ b/desktop/macos/Desktop/Sources/ViewModelContainer.swift @@ -17,6 +17,9 @@ class ViewModelContainer: ObservableObject { let homeStatusStore = HomeStatusStore() /// Conversations search — persistent so a typed query survives navigation. let conversationsSearchModel = ConversationsSearchModel() + /// Brain-map graph — persistent so the SceneKit scene, force layout, and + /// camera survive page navigation instead of rebuilding every visit. + let memoryGraphViewModel = MemoryGraphViewModel() let chatProvider: ChatProvider let taskChatCoordinator: TaskChatCoordinator private lazy var warmupCoordinator = StartupWarmupCoordinator( diff --git a/desktop/macos/Desktop/Tests/MemoryGraphRevisitTests.swift b/desktop/macos/Desktop/Tests/MemoryGraphRevisitTests.swift new file mode 100644 index 00000000000..a0ca7915901 --- /dev/null +++ b/desktop/macos/Desktop/Tests/MemoryGraphRevisitTests.swift @@ -0,0 +1,78 @@ +import XCTest + +/// Guards the brain-map revisit behavior: the graph view model is +/// session-persistent and page visits must not refetch, re-run the force +/// layout, or rebuild the SceneKit scene unless the graph actually changed. +/// (Regression: every Memories visit used to fetch the full graph, run 800 +/// physics ticks, rebuild the scene, and replay a 3s settle animation.) +final class MemoryGraphRevisitTests: XCTestCase { + func testGraphSurfacesUseInjectedPersistentViewModel() throws { + let source = try graphSource() + + XCTAssertFalse( + source.contains("@StateObject private var viewModel = MemoryGraphViewModel()"), + "Graph surfaces must not own their view model — page-local @StateObject dies on every tab switch and rebuilds the scene per visit" + ) + XCTAssertTrue(source.contains("@ObservedObject var viewModel: MemoryGraphViewModel")) + + let container = try containerSource() + XCTAssertTrue( + container.contains("let memoryGraphViewModel = MemoryGraphViewModel()"), + "The shared graph view model lives in ViewModelContainer so it survives navigation" + ) + } + + func testPrepareGraphIsThrottledAndSingleFlight() throws { + let source = try graphSource() + let method = try methodBody(named: "prepareGraph", in: source) + + XCTAssertTrue(method.contains("guard !isPreparing else { return }")) + XCTAssertTrue(method.contains("PollingConfig.shouldAllowActivationRefresh(lastRefresh: lastLoadedAt)")) + XCTAssertTrue( + method.contains("if isEmpty && !hasRunEmptyBootstrap {"), + "The empty-graph rebuild+poll bootstrap must run once per session, not on every visit" + ) + } + + func testLoadGraphSkipsResimulationForUnchangedGraph() throws { + let source = try graphSource() + let method = try methodBody(named: "loadGraph", in: source) + + XCTAssertTrue(method.contains("let signature = Self.graphSignature(of: response)")) + XCTAssertTrue( + method.contains("if signature == loadedGraphSignature {"), + "An unchanged graph must keep the settled scene — no repopulate, no runSync, no camera reset" + ) + XCTAssertTrue( + method.contains("let showSpinner = isEmpty"), + "Freshness checks over a rendered scene must not flash the loading spinner" + ) + } + + // MARK: - Helpers + + private func graphSource() throws -> String { + try source(at: "Sources/MainWindow/Pages/MemoryGraph/MemoryGraphPage.swift") + } + + private func containerSource() throws -> String { + try source(at: "Sources/ViewModelContainer.swift") + } + + private func source(at relativePath: String) throws -> String { + let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + let sourceURL = testsURL + .deletingLastPathComponent() + .appendingPathComponent(relativePath) + return try String(contentsOf: sourceURL, encoding: .utf8) + } + + private func methodBody(named name: String, in source: String) throws -> String { + let pattern = #"(?:private )?func \#(name)\([^\)]*\)[^{]*\{([\s\S]*?)\n \}"# + let regex = try NSRegularExpression(pattern: pattern) + let range = NSRange(source.startIndex.. Date: Tue, 7 Jul 2026 11:13:20 -0400 Subject: [PATCH 04/31] Extract Home header controls into shared page-header components --- .../CaptureListeningController.swift | 160 ++++++ .../Components/PageHeaderControls.swift | 313 ++++++++++++ .../Components/PageHeaderView.swift | 127 +++++ .../MainWindow/Pages/DashboardPage.swift | 476 +----------------- 4 files changed, 604 insertions(+), 472 deletions(-) create mode 100644 desktop/macos/Desktop/Sources/MainWindow/Components/CaptureListeningController.swift create mode 100644 desktop/macos/Desktop/Sources/MainWindow/Components/PageHeaderControls.swift create mode 100644 desktop/macos/Desktop/Sources/MainWindow/Components/PageHeaderView.swift diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/CaptureListeningController.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/CaptureListeningController.swift new file mode 100644 index 00000000000..13dc088e992 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/CaptureListeningController.swift @@ -0,0 +1,160 @@ +import AppKit +import SwiftUI + +/// Shared state + actions behind the Capture and Listening header pills. +/// One instance per `PageHeaderView`; the underlying truth lives in +/// `ProactiveAssistantsPlugin` / `AssistantSettings` / `AppState`, so +/// instances stay in sync via the monitoring notifications. +/// +/// Extracted from `DashboardPage` so every main page can host the same +/// control cluster without duplicating the toggle logic. +@MainActor +final class CaptureListeningController: ObservableObject { + @Published private(set) var isCaptureMonitoring = false + @Published private(set) var isTogglingCapture = false + @Published private(set) var isTogglingListening = false + + private let appState: AppState + private var observers: [NSObjectProtocol] = [] + + init(appState: AppState) { + self.appState = appState + syncCaptureState() + + let center = NotificationCenter.default + let syncNames: [Notification.Name] = [ + .assistantMonitoringStateDidChange, + .screenCapturePermissionLost, + .screenCaptureKitBroken, + ] + for name in syncNames { + observers.append( + center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + Task { @MainActor in self?.syncCaptureState() } + }) + } + } + + deinit { + for observer in observers { + NotificationCenter.default.removeObserver(observer) + } + } + + // MARK: - State + + var isCaptureLive: Bool { + isCaptureMonitoring || ProactiveAssistantsPlugin.shared.isMonitoring + } + + var captureStatus: HomeStatusState { + if appState.isScreenCaptureKitBroken || appState.isScreenRecordingStale + || !appState.hasScreenRecordingPermission + { + return .blocked + } + return isCaptureLive ? .active : .inactive + } + + var listeningCaptureMode: AssistantSettings.SystemAudioCaptureMode { + AssistantSettings.shared.systemAudioCaptureMode + } + + var listeningModeTitle: String { + switch listeningCaptureMode { + case .always: + return "Always" + case .onlyDuringMeetings: + return appState.isAwaitingMeeting ? "Meetings only" : "In meeting" + case .never: + return "Mic only" + } + } + + // MARK: - Actions + + func toggleListening() { + let enabled = !appState.isTranscribing + if enabled && !appState.hasMicrophonePermission { + appState.requestMicrophonePermission() + return + } + + isTogglingListening = true + UserDefaults.standard.set(enabled, forKey: "transcriptionEnabled") + AssistantSettings.shared.transcriptionEnabled = enabled + AnalyticsManager.shared.settingToggled(setting: "transcription", enabled: enabled) + NotificationCenter.default.post( + name: .toggleTranscriptionRequested, + object: nil, + userInfo: ["enabled": enabled] + ) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + self?.isTogglingListening = false + } + } + + func toggleListeningMode() { + let nextMode: AssistantSettings.SystemAudioCaptureMode = + listeningCaptureMode == .onlyDuringMeetings ? .always : .onlyDuringMeetings + UserDefaults.standard.set(nextMode.rawValue, forKey: "systemAudioCaptureMode") + AssistantSettings.shared.systemAudioCaptureMode = nextMode + AnalyticsManager.shared.settingToggled( + setting: "meetings_only_listening", + enabled: nextMode == .onlyDuringMeetings + ) + objectWillChange.send() + } + + func toggleCapture() { + syncCaptureState() + let enabled = !isCaptureLive + isTogglingCapture = true + + if enabled { + ProactiveAssistantsPlugin.shared.refreshScreenRecordingPermission() + guard ProactiveAssistantsPlugin.shared.hasScreenRecordingPermission else { + setScreenAnalysisEnabled(false) + isCaptureMonitoring = false + isTogglingCapture = false + ScreenCaptureService.requestScreenRecordingAccessAndOpenSettings() + return + } + } + + setScreenAnalysisEnabled(enabled) + AnalyticsManager.shared.settingToggled(setting: "monitoring", enabled: enabled) + + if enabled { + ProactiveAssistantsPlugin.shared.startMonitoring { [weak self] success, _ in + DispatchQueue.main.async { + guard let self else { return } + self.isTogglingCapture = false + self.isCaptureMonitoring = ProactiveAssistantsPlugin.shared.isMonitoring + if !success { + self.setScreenAnalysisEnabled(false) + self.isCaptureMonitoring = false + } + } + } + } else { + ProactiveAssistantsPlugin.shared.stopMonitoring() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + self?.isTogglingCapture = false + self?.isCaptureMonitoring = false + } + } + } + + func syncCaptureState() { + ProactiveAssistantsPlugin.shared.refreshScreenRecordingPermission() + isCaptureMonitoring = ProactiveAssistantsPlugin.shared.isMonitoring + objectWillChange.send() + } + + private func setScreenAnalysisEnabled(_ enabled: Bool) { + UserDefaults.standard.set(enabled, forKey: "screenAnalysisEnabled") + AssistantSettings.shared.screenAnalysisEnabled = enabled + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/PageHeaderControls.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/PageHeaderControls.swift new file mode 100644 index 00000000000..d605e62d580 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/PageHeaderControls.swift @@ -0,0 +1,313 @@ +import AppKit +import SwiftUI + +// MARK: - Shared page-header controls +// +// The Capture/Listening status pills and the settings menu were born on the +// Home header; they're shared components now so every main page can carry +// the same control cluster (see PageHeaderView). Moved verbatim from +// DashboardPage — visuals unchanged. + +enum HomeStatusState { + case active + case inactive + case blocked + + var indicator: Color { + switch self { + case .active: + return HomePalette.green + case .inactive: + return HomePalette.faint + case .blocked: + return Color(red: 1.0, green: 0.24, blue: 0.30) + } + } + + var text: String { + switch self { + case .active: + return "On" + case .inactive: + return "Off" + case .blocked: + return "Blocked" + } + } + + var isActive: Bool { + if case .active = self { return true } + return false + } + + var isBlocked: Bool { + if case .blocked = self { return true } + return false + } +} + +struct HomeStatusButton: View { + let title: String + let systemImage: String + let status: HomeStatusState + let isToggling: Bool + let action: () -> Void + + @State private var isHovering = false + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + ZStack { + if isToggling { + ProgressView() + .controlSize(.small) + .scaleEffect(0.55) + } else { + Image(systemName: systemImage) + .scaledFont(size: 13, weight: .semibold) + } + } + .frame(width: 18, height: 18) + + Text(title) + .scaledFont(size: 12, weight: .semibold) + .lineLimit(1) + } + .foregroundStyle(status.isActive ? HomePalette.ink : (status.isBlocked ? status.indicator : HomePalette.muted)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(height: 34) + .background( + Capsule(style: .continuous) + .fill(statusFill) + ) + .overlay( + Capsule(style: .continuous) + .stroke(statusStroke, lineWidth: 1) + ) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .disabled(isToggling) + .onHover { isHovering = $0 } + .help("\(title): \(status.text)") + .accessibilityLabel("\(title) \(status.text)") + } + + private var statusFill: Color { + if status.isActive { + return HomePalette.green.opacity(isHovering ? 0.20 : 0.12) + } + if status.isBlocked { + return status.indicator.opacity(isHovering ? 0.16 : 0.10) + } + return isHovering ? HomePalette.tileHover : HomePalette.panel + } + + private var statusStroke: Color { + if status.isActive { + return HomePalette.green.opacity(0.38) + } + if status.isBlocked { + return status.indicator.opacity(isHovering ? 0.54 : 0.38) + } + return HomePalette.hairline.opacity(isHovering ? 0.8 : 0.58) + } +} + +struct HomeListeningStatusButton: View { + let title: String + let systemImage: String + let status: HomeStatusState + let modeTitle: String + let isMeetingsOnly: Bool + let isToggling: Bool + let action: () -> Void + let modeAction: () -> Void + + // Single pill-level hover flag so moving between the title and the mode + // toggle never flickers the revealed controls. + @State private var isHovering = false + + var body: some View { + HStack(spacing: 0) { + Button(action: action) { + HStack(spacing: 8) { + ZStack { + if isToggling { + ProgressView() + .controlSize(.small) + .scaleEffect(0.55) + } else { + Image(systemName: systemImage) + .scaledFont(size: 13, weight: .semibold) + } + } + .frame(width: 18, height: 18) + + VStack(alignment: .leading, spacing: 1) { + Text(title) + .scaledFont(size: 12, weight: .semibold) + .lineLimit(1) + + // Mode ("Always" / "In meeting" / …) is revealed only on + // hover to keep the resting pill clean. + if isHovering { + Text(modeTitle) + .scaledFont(size: 8, weight: .medium) + .foregroundStyle(status.isActive ? HomePalette.secondary : HomePalette.muted) + .lineLimit(1) + .transition(.opacity) + } + } + } + .padding(.leading, 12) + .padding(.trailing, 8) + .frame(height: 34) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isToggling) + .help("Listening: \(status.text), \(modeTitle)") + .accessibilityLabel("Listening \(status.text), \(modeTitle)") + + // Divider + mode toggle are revealed only on hover to keep the + // resting pill compact. + if isHovering { + Rectangle() + .fill(HomePalette.hairline.opacity(0.65)) + .frame(width: 1, height: 18) + .transition(.opacity) + + Button(action: modeAction) { + Image(systemName: isMeetingsOnly ? "person.2.fill" : "person.fill") + .scaledFont(size: 11, weight: .semibold) + .foregroundStyle(modeIconColor) + .frame(width: 30, height: 34) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(isMeetingsOnly ? "Switch to always listening" : "Switch to meetings only") + .accessibilityLabel(isMeetingsOnly ? "Switch Listening to Always" : "Switch Listening to Meetings Only") + .transition(.opacity) + } + } + .foregroundStyle(status.isActive ? HomePalette.ink : (status.isBlocked ? status.indicator : HomePalette.muted)) + .background( + Capsule(style: .continuous) + .fill(statusFill) + ) + .overlay( + Capsule(style: .continuous) + .stroke(statusStroke, lineWidth: 1) + ) + .contentShape(Capsule()) + .frame(height: 34) + .onHover { isHovering = $0 } + .animation(.easeInOut(duration: 0.14), value: isHovering) + } + + private var modeIconColor: Color { + status.isActive ? HomePalette.green : HomePalette.muted + } + + private var statusFill: Color { + if status.isActive { + return HomePalette.green.opacity(isHovering ? 0.20 : 0.12) + } + if status.isBlocked { + return status.indicator.opacity(isHovering ? 0.16 : 0.10) + } + return isHovering ? HomePalette.tileHover : HomePalette.panel + } + + private var statusStroke: Color { + if status.isActive { + return HomePalette.green.opacity(0.38) + } + if status.isBlocked { + return status.indicator.opacity(isHovering ? 0.54 : 0.38) + } + return HomePalette.hairline.opacity(isHovering ? 0.8 : 0.58) + } +} + +struct HomeSettingsMenuButton: View { + let onRefer: () -> Void + let onDiscord: () -> Void + let onSettings: () -> Void + + @State private var isHovering = false + @State private var isPresented = false + + var body: some View { + Button { + isPresented.toggle() + } label: { + ZStack { + Circle() + .fill(isHovering ? HomePalette.tileHover : HomePalette.tile.opacity(0.86)) + .overlay( + Circle() + .stroke(HomePalette.hairline.opacity(isHovering ? 0.9 : 0.68), lineWidth: 1) + ) + + Image(systemName: "gearshape.fill") + .scaledFont(size: 14, weight: .semibold) + .foregroundStyle(isHovering ? HomePalette.ink : HomePalette.secondary) + } + .frame(width: 34, height: 34) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .onHover { isHovering = $0 } + .popover(isPresented: $isPresented, arrowEdge: .top) { + VStack(alignment: .leading, spacing: 4) { + popoverButton(title: "Refer a Friend", systemImage: "gift.fill") { + isPresented = false + onRefer() + } + + popoverButton(title: "Discord", systemImage: "message.fill") { + isPresented = false + onDiscord() + } + + Divider() + .padding(.vertical, 3) + + popoverButton(title: "Settings", systemImage: "gearshape.fill") { + isPresented = false + onSettings() + } + } + .padding(8) + .frame(width: 190) + .background(HomePalette.panel) + } + .help("Settings") + .accessibilityLabel("Settings menu") + } + + private func popoverButton(title: String, systemImage: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 10) { + Image(systemName: systemImage) + .scaledFont(size: 13, weight: .semibold) + .foregroundStyle(HomePalette.secondary) + .frame(width: 18) + + Text(title) + .scaledFont(size: 13, weight: .medium) + .foregroundStyle(HomePalette.ink) + + Spacer(minLength: 0) + } + .padding(.horizontal, 9) + .padding(.vertical, 7) + .contentShape(.rect(cornerRadius: 8)) + } + .buttonStyle(.plain) + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/PageHeaderView.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/PageHeaderView.swift new file mode 100644 index 00000000000..eea83b6da39 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/PageHeaderView.swift @@ -0,0 +1,127 @@ +import AppKit +import SwiftUI + +/// The consistent page header: optional home affordance + serif page title +/// on the left, the Capture/Listening pills and settings menu on the right — +/// the same control cluster the Home header established, available to every +/// main page. +struct PageHeaderView: View { + /// nil hides the title (Home shows only the trailing cluster). + var title: String? = nil + /// Shows the "back to Home" chip on non-Home pages. + var showsHomeButton = false + let appState: AppState + + @ObservedObject private var appStateObserved: AppState + @StateObject private var controls: CaptureListeningController + + init(title: String? = nil, showsHomeButton: Bool = false, appState: AppState) { + self.title = title + self.showsHomeButton = showsHomeButton + self.appState = appState + self.appStateObserved = appState + _controls = StateObject(wrappedValue: CaptureListeningController(appState: appState)) + } + + var body: some View { + HStack(spacing: 12) { + if showsHomeButton { + PageHeaderHomeButton { + Self.navigate(to: .dashboard) + } + } + + if let title { + Text(title) + .font(.system(size: 20, weight: .medium, design: .serif)) + .foregroundStyle(Color.white.opacity(0.92)) + } + + Spacer() + + HStack(spacing: 10) { + HomeStatusButton( + title: "Capture", + systemImage: "viewfinder", + status: controls.captureStatus, + isToggling: controls.isTogglingCapture, + action: controls.toggleCapture + ) + + HomeListeningStatusButton( + title: "Listening", + systemImage: appStateObserved.isTranscribing + ? "waveform.circle.fill" : "mic.circle", + status: appStateObserved.isTranscribing ? .active : .inactive, + modeTitle: controls.listeningModeTitle, + isMeetingsOnly: controls.listeningCaptureMode == .onlyDuringMeetings, + isToggling: controls.isTogglingListening, + action: controls.toggleListening, + modeAction: controls.toggleListeningMode + ) + + HomeSettingsMenuButton( + onRefer: Self.openReferFriend, + onDiscord: Self.openDiscord, + onSettings: { Self.navigate(to: .settings) } + ) + } + } + .frame(height: 36) + .onAppear { controls.syncCaptureState() } + } + + // MARK: - Shared actions + + private static func navigate(to item: SidebarNavItem) { + NotificationCenter.default.post( + name: .navigateToSidebarItem, + object: nil, + userInfo: ["rawValue": item.rawValue] + ) + } + + static func openReferFriend() { + if let url = URL(string: "https://affiliate.omi.me") { + NSWorkspace.shared.open(url) + } + } + + static func openDiscord() { + if let url = URL(string: "https://discord.com/invite/8MP3b9ymvx") { + NSWorkspace.shared.open(url) + } + } +} + +/// Rounded "back to Home" chip, styled to sit beside the header pills. +private struct PageHeaderHomeButton: View { + let action: () -> Void + + @State private var isHovering = false + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + Image(systemName: "house.fill") + .scaledFont(size: 11, weight: .medium) + Text("Home") + .scaledFont(size: 12, weight: .medium) + } + .foregroundStyle(Color.white.opacity(isHovering ? 0.95 : 0.7)) + .padding(.horizontal, 12) + .frame(height: 30) + .background( + Capsule().fill(Color.white.opacity(isHovering ? 0.12 : 0.06)) + ) + .overlay( + Capsule().stroke(Color.white.opacity(0.12), lineWidth: 1) + ) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .onHover { isHovering = $0 } + .help("Back to Home") + .keyboardShortcut("[", modifiers: .command) + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift index 1cf49fed180..6effbc88d6e 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift @@ -231,9 +231,6 @@ struct DashboardPage: View { @State private var appsPopupInitialSection: AppsCatalogInitialSection = .imports @State private var appsPopupPresentationID = UUID() @State private var isLoadingCitation = false - @State private var isCaptureMonitoring = false - @State private var isTogglingCapture = false - @State private var isTogglingListening = false @AppStorage("dashboardWidgetsCollapsed") private var widgetsCollapsed = false @AppStorage("screenAnalysisEnabled") private var screenAnalysisEnabled = true @AppStorage("transcriptionEnabled") private var transcriptionEnabled = true @@ -248,37 +245,6 @@ struct DashboardPage: View { return appProvider.chatApps.first { $0.id == appId } } - private var captureStatus: HomeStatusState { - if appState.isScreenCaptureKitBroken || appState.isScreenRecordingStale || !appState.hasScreenRecordingPermission { - return .blocked - } - - if isCaptureLive { - return .active - } - - return .inactive - } - - private var isCaptureLive: Bool { - isCaptureMonitoring || ProactiveAssistantsPlugin.shared.isMonitoring - } - - private var listeningCaptureMode: AssistantSettings.SystemAudioCaptureMode { - AssistantSettings.SystemAudioCaptureMode(rawValue: systemAudioCaptureModeRaw) ?? .onlyDuringMeetings - } - - private var listeningModeTitle: String { - switch listeningCaptureMode { - case .always: - return "Always" - case .onlyDuringMeetings: - return appState.isAwaitingMeeting ? "Meetings only" : "In meeting" - case .never: - return "Mic only" - } - } - private static let homeStageMaxWidth: CGFloat = 1120 private static let homeStageHorizontalPadding: CGFloat = 34 private static let homeAskBarMaxWidth: CGFloat = 640 @@ -418,25 +384,14 @@ struct DashboardPage: View { if PostOnboardingPromptSuggestions.shouldShowPopup && !postOnboardingSuggestions.isEmpty { NotificationCenter.default.post(name: .showTryAskingPopup, object: nil) } - syncCaptureState() reportHomeAutomationMode() Task { await homeStatus.refresh(force: false) } } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in viewModel.refreshGoals() appState.checkAllPermissions() - syncCaptureState() Task { await homeStatus.refresh(force: false) } } - .onReceive(NotificationCenter.default.publisher(for: .assistantMonitoringStateDidChange)) { _ in - syncCaptureState() - } - .onReceive(NotificationCenter.default.publisher(for: .screenCapturePermissionLost)) { _ in - syncCaptureState() - } - .onReceive(NotificationCenter.default.publisher(for: .screenCaptureKitBroken)) { _ in - syncCaptureState() - } // Clicking into the ask bar reveals the inline chat; the same is true // when focus lands there via keyboard (Tab / Full Keyboard Access). .onChange(of: homeAskFieldFocused) { _, focused in @@ -1122,36 +1077,7 @@ struct DashboardPage: View { } private var homeHeader: some View { - HStack { - Spacer() - HStack(spacing: 10) { - HomeStatusButton( - title: "Capture", - systemImage: "viewfinder", - status: captureStatus, - isToggling: isTogglingCapture, - action: toggleCapture - ) - - HomeListeningStatusButton( - title: "Listening", - systemImage: appState.isTranscribing ? "waveform.circle.fill" : "mic.circle", - status: appState.isTranscribing ? .active : .inactive, - modeTitle: listeningModeTitle, - isMeetingsOnly: listeningCaptureMode == .onlyDuringMeetings, - isToggling: isTogglingListening, - action: toggleListening, - modeAction: toggleListeningMode - ) - - HomeSettingsMenuButton( - onRefer: openReferFriend, - onDiscord: openDiscord, - onSettings: { navigate(to: .settings) } - ) - } - } - .frame(height: 36) + PageHeaderView(appState: appState) } private var sourceColumnHeader: some View { @@ -1317,104 +1243,12 @@ struct DashboardPage: View { selectedExportDestination = nil } - private func openReferFriend() { - if let url = URL(string: "https://affiliate.omi.me") { - NSWorkspace.shared.open(url) - } - } - - private func openDiscord() { - if let url = URL(string: "https://discord.com/invite/8MP3b9ymvx") { - NSWorkspace.shared.open(url) - } - } - private func openOmiDeviceWebsite() { if let url = URL(string: "https://www.omi.me") { NSWorkspace.shared.open(url) } } - private func toggleListening() { - let enabled = !appState.isTranscribing - if enabled && !appState.hasMicrophonePermission { - appState.requestMicrophonePermission() - return - } - - isTogglingListening = true - transcriptionEnabled = enabled - AssistantSettings.shared.transcriptionEnabled = enabled - AnalyticsManager.shared.settingToggled(setting: "transcription", enabled: enabled) - NotificationCenter.default.post( - name: .toggleTranscriptionRequested, - object: nil, - userInfo: ["enabled": enabled] - ) - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - isTogglingListening = false - } - } - - private func toggleListeningMode() { - let nextMode: AssistantSettings.SystemAudioCaptureMode = - listeningCaptureMode == .onlyDuringMeetings ? .always : .onlyDuringMeetings - systemAudioCaptureModeRaw = nextMode.rawValue - AssistantSettings.shared.systemAudioCaptureMode = nextMode - AnalyticsManager.shared.settingToggled( - setting: "meetings_only_listening", - enabled: nextMode == .onlyDuringMeetings - ) - } - - private func toggleCapture() { - syncCaptureState() - let enabled = !isCaptureLive - isTogglingCapture = true - - if enabled { - ProactiveAssistantsPlugin.shared.refreshScreenRecordingPermission() - guard ProactiveAssistantsPlugin.shared.hasScreenRecordingPermission else { - screenAnalysisEnabled = false - isCaptureMonitoring = false - isTogglingCapture = false - ScreenCaptureService.requestScreenRecordingAccessAndOpenSettings() - return - } - } - - screenAnalysisEnabled = enabled - AssistantSettings.shared.screenAnalysisEnabled = enabled - AnalyticsManager.shared.settingToggled(setting: "monitoring", enabled: enabled) - - if enabled { - ProactiveAssistantsPlugin.shared.startMonitoring { success, _ in - DispatchQueue.main.async { - isTogglingCapture = false - isCaptureMonitoring = ProactiveAssistantsPlugin.shared.isMonitoring - if !success { - screenAnalysisEnabled = false - AssistantSettings.shared.screenAnalysisEnabled = false - isCaptureMonitoring = false - } - } - } - } else { - ProactiveAssistantsPlugin.shared.stopMonitoring() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - isTogglingCapture = false - isCaptureMonitoring = false - } - } - } - - private func syncCaptureState() { - ProactiveAssistantsPlugin.shared.refreshScreenRecordingPermission() - screenAnalysisEnabled = AssistantSettings.shared.screenAnalysisEnabled - isCaptureMonitoring = ProactiveAssistantsPlugin.shared.isMonitoring - } - private func formattedCount(_ count: Int) -> String { count.formatted() } @@ -1663,7 +1497,9 @@ struct DashboardPage: View { // MARK: - Home Components -private enum HomePalette { +/// Home design palette — internal so the shared page-header components +/// (PageHeaderControls) keep rendering with the exact Home look. +enum HomePalette { static let paper = Color(red: 0.018, green: 0.019, blue: 0.021) static let panel = Color(red: 0.045, green: 0.046, blue: 0.052) static let tile = Color(red: 0.078, green: 0.078, blue: 0.088) @@ -3411,231 +3247,6 @@ private struct HomeSectionHeader: View { } } -private enum HomeStatusState { - case active - case inactive - case blocked - - var indicator: Color { - switch self { - case .active: - return HomePalette.green - case .inactive: - return HomePalette.faint - case .blocked: - return Color(red: 1.0, green: 0.24, blue: 0.30) - } - } - - var text: String { - switch self { - case .active: - return "On" - case .inactive: - return "Off" - case .blocked: - return "Blocked" - } - } - - var isActive: Bool { - if case .active = self { return true } - return false - } - - var isBlocked: Bool { - if case .blocked = self { return true } - return false - } -} - -private struct HomeStatusButton: View { - let title: String - let systemImage: String - let status: HomeStatusState - let isToggling: Bool - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: 8) { - ZStack { - if isToggling { - ProgressView() - .controlSize(.small) - .scaleEffect(0.55) - } else { - Image(systemName: systemImage) - .scaledFont(size: 13, weight: .semibold) - } - } - .frame(width: 18, height: 18) - - Text(title) - .scaledFont(size: 12, weight: .semibold) - .lineLimit(1) - } - .foregroundStyle(status.isActive ? HomePalette.ink : (status.isBlocked ? status.indicator : HomePalette.muted)) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .frame(height: 34) - .background( - Capsule(style: .continuous) - .fill(statusFill) - ) - .overlay( - Capsule(style: .continuous) - .stroke(statusStroke, lineWidth: 1) - ) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - .disabled(isToggling) - .onHover { isHovering = $0 } - .help("\(title): \(status.text)") - .accessibilityLabel("\(title) \(status.text)") - } - - private var statusFill: Color { - if status.isActive { - return HomePalette.green.opacity(isHovering ? 0.20 : 0.12) - } - if status.isBlocked { - return status.indicator.opacity(isHovering ? 0.16 : 0.10) - } - return isHovering ? HomePalette.tileHover : HomePalette.panel - } - - private var statusStroke: Color { - if status.isActive { - return HomePalette.green.opacity(0.38) - } - if status.isBlocked { - return status.indicator.opacity(isHovering ? 0.54 : 0.38) - } - return HomePalette.hairline.opacity(isHovering ? 0.8 : 0.58) - } -} - -private struct HomeListeningStatusButton: View { - let title: String - let systemImage: String - let status: HomeStatusState - let modeTitle: String - let isMeetingsOnly: Bool - let isToggling: Bool - let action: () -> Void - let modeAction: () -> Void - - // Single pill-level hover flag so moving between the title and the mode - // toggle never flickers the revealed controls. - @State private var isHovering = false - - var body: some View { - HStack(spacing: 0) { - Button(action: action) { - HStack(spacing: 8) { - ZStack { - if isToggling { - ProgressView() - .controlSize(.small) - .scaleEffect(0.55) - } else { - Image(systemName: systemImage) - .scaledFont(size: 13, weight: .semibold) - } - } - .frame(width: 18, height: 18) - - VStack(alignment: .leading, spacing: 1) { - Text(title) - .scaledFont(size: 12, weight: .semibold) - .lineLimit(1) - - // Mode ("Always" / "In meeting" / …) is revealed only on - // hover to keep the resting pill clean. - if isHovering { - Text(modeTitle) - .scaledFont(size: 8, weight: .medium) - .foregroundStyle(status.isActive ? HomePalette.secondary : HomePalette.muted) - .lineLimit(1) - .transition(.opacity) - } - } - } - .padding(.leading, 12) - .padding(.trailing, 8) - .frame(height: 34) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(isToggling) - .help("Listening: \(status.text), \(modeTitle)") - .accessibilityLabel("Listening \(status.text), \(modeTitle)") - - // Divider + mode toggle are revealed only on hover to keep the - // resting pill compact. - if isHovering { - Rectangle() - .fill(HomePalette.hairline.opacity(0.65)) - .frame(width: 1, height: 18) - .transition(.opacity) - - Button(action: modeAction) { - Image(systemName: isMeetingsOnly ? "person.2.fill" : "person.fill") - .scaledFont(size: 11, weight: .semibold) - .foregroundStyle(modeIconColor) - .frame(width: 30, height: 34) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .help(isMeetingsOnly ? "Switch to always listening" : "Switch to meetings only") - .accessibilityLabel(isMeetingsOnly ? "Switch Listening to Always" : "Switch Listening to Meetings Only") - .transition(.opacity) - } - } - .foregroundStyle(status.isActive ? HomePalette.ink : (status.isBlocked ? status.indicator : HomePalette.muted)) - .background( - Capsule(style: .continuous) - .fill(statusFill) - ) - .overlay( - Capsule(style: .continuous) - .stroke(statusStroke, lineWidth: 1) - ) - .contentShape(Capsule()) - .frame(height: 34) - .onHover { isHovering = $0 } - .animation(.easeInOut(duration: 0.14), value: isHovering) - } - - private var modeIconColor: Color { - status.isActive ? HomePalette.green : HomePalette.muted - } - - private var statusFill: Color { - if status.isActive { - return HomePalette.green.opacity(isHovering ? 0.20 : 0.12) - } - if status.isBlocked { - return status.indicator.opacity(isHovering ? 0.16 : 0.10) - } - return isHovering ? HomePalette.tileHover : HomePalette.panel - } - - private var statusStroke: Color { - if status.isActive { - return HomePalette.green.opacity(0.38) - } - if status.isBlocked { - return status.indicator.opacity(isHovering ? 0.54 : 0.38) - } - return HomePalette.hairline.opacity(isHovering ? 0.8 : 0.58) - } -} - private struct HomeIconActionButton: View { let title: String let systemImage: String @@ -3666,85 +3277,6 @@ private struct HomeIconActionButton: View { } } -private struct HomeSettingsMenuButton: View { - let onRefer: () -> Void - let onDiscord: () -> Void - let onSettings: () -> Void - - @State private var isHovering = false - @State private var isPresented = false - - var body: some View { - Button { - isPresented.toggle() - } label: { - ZStack { - Circle() - .fill(isHovering ? HomePalette.tileHover : HomePalette.tile.opacity(0.86)) - .overlay( - Circle() - .stroke(HomePalette.hairline.opacity(isHovering ? 0.9 : 0.68), lineWidth: 1) - ) - - Image(systemName: "gearshape.fill") - .scaledFont(size: 14, weight: .semibold) - .foregroundStyle(isHovering ? HomePalette.ink : HomePalette.secondary) - } - .frame(width: 34, height: 34) - .contentShape(Circle()) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .popover(isPresented: $isPresented, arrowEdge: .top) { - VStack(alignment: .leading, spacing: 4) { - popoverButton(title: "Refer a Friend", systemImage: "gift.fill") { - isPresented = false - onRefer() - } - - popoverButton(title: "Discord", systemImage: "message.fill") { - isPresented = false - onDiscord() - } - - Divider() - .padding(.vertical, 3) - - popoverButton(title: "Settings", systemImage: "gearshape.fill") { - isPresented = false - onSettings() - } - } - .padding(8) - .frame(width: 190) - .background(HomePalette.panel) - } - .help("Settings") - .accessibilityLabel("Settings menu") - } - - private func popoverButton(title: String, systemImage: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - HStack(spacing: 10) { - Image(systemName: systemImage) - .scaledFont(size: 13, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - .frame(width: 18) - - Text(title) - .scaledFont(size: 13, weight: .medium) - .foregroundStyle(HomePalette.ink) - - Spacer(minLength: 0) - } - .padding(.horizontal, 9) - .padding(.vertical, 7) - .contentShape(.rect(cornerRadius: 8)) - } - .buttonStyle(.plain) - } -} - private struct HomeConnectorCard: View { let title: String let subtitle: String From 41ea8f1f18f1c4a6380fd57783e90ca9a991b15a Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:18:42 -0400 Subject: [PATCH 05/31] Adopt the shared page header on Conversations, Tasks and Memories --- .../Sources/MainWindow/DesktopHomeView.swift | 15 ++++++- .../MainWindow/Pages/ConversationsPage.swift | 44 ++++--------------- .../MainWindow/Pages/MemoriesPage.swift | 5 +++ .../Sources/MainWindow/Pages/TasksPage.swift | 11 ++++- .../macos/Desktop/Sources/ViewExporter.swift | 7 ++- .../20260707-consistent-page-header.json | 3 ++ 6 files changed, 44 insertions(+), 41 deletions(-) create mode 100644 desktop/macos/changelog/unreleased/20260707-consistent-page-header.json diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index ba3ff7168b0..f0156a3eb01 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -63,6 +63,15 @@ struct DesktopHomeView: View { selectedIndex == SidebarNavItem.settings.rawValue } + /// Pages that render the shared PageHeaderView (title + Home chip + + /// Capture/Listening cluster) and therefore skip the legacy chrome bar. + private static let pagesWithOwnHeader: Set = [ + SidebarNavItem.dashboard.rawValue, + SidebarNavItem.conversations.rawValue, + SidebarNavItem.tasks.rawValue, + SidebarNavItem.memories.rawValue, + ] + var body: some View { Group { if authState.isRestoringAuth { @@ -899,7 +908,7 @@ struct DesktopHomeView: View { // Extracted into a separate struct so that pages like TasksPage // are not re-rendered when AppState publishes unrelated changes. VStack(spacing: 0) { - if !useLegacyHomeDesign && selectedIndex != SidebarNavItem.dashboard.rawValue { + if !useLegacyHomeDesign && !Self.pagesWithOwnHeader.contains(selectedIndex) { PageChromeBar( onHome: { selectedIndex = SidebarNavItem.dashboard.rawValue @@ -1105,10 +1114,12 @@ private struct PageContentView: View { case 3: MemoriesPage( viewModel: viewModelContainer.memoriesViewModel, - graphViewModel: viewModelContainer.memoryGraphViewModel) + graphViewModel: viewModelContainer.memoryGraphViewModel, + appState: appState) case 4: TasksPage( viewModel: viewModelContainer.tasksViewModel, + appState: appState, chatCoordinator: viewModelContainer.taskChatCoordinator, chatProvider: viewModelContainer.chatProvider) case 5: diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift index 9d7d8b91b62..b5754567a7f 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift @@ -163,23 +163,12 @@ struct ConversationsPage: View { private var mainConversationsView: some View { VStack(spacing: 0) { - // Conversations header - HStack { - Text("Conversations") - .scaledFont(size: 18, weight: .semibold) - .foregroundColor(OmiColors.textPrimary) - - Spacer() - - quickNoteButton - - if !appState.isTranscribing { - startRecordingButton - } - } - .padding(.horizontal, 24) - .padding(.top, 18) - .padding(.bottom, 12) + // Shared page header: title + Home chip + Capture/Listening/settings + // cluster. Recording start/stop lives in the Listening pill. + PageHeaderView(title: "Conversations", showsHomeButton: true, appState: appState) + .padding(.horizontal, 24) + .padding(.top, 18) + .padding(.bottom, 12) // Conversation list conversationListSection @@ -241,6 +230,8 @@ struct ConversationsPage: View { // Filter buttons filterButtonsRow + + quickNoteButton } .padding(.horizontal, 24) .padding(.vertical, 12) @@ -665,25 +656,6 @@ struct ConversationsPage: View { // MARK: - Buttons - private var startRecordingButton: some View { - Button(action: { - appState.startTranscription() - }) { - HStack(spacing: 6) { - Image(systemName: "mic.fill") - .scaledFont(size: 12) - Text("Start Recording") - .scaledFont(size: 13, weight: .medium) - } - .foregroundColor(.black) - .padding(.horizontal, 14) - .padding(.vertical, 9) - .omiControlSurface( - fill: OmiColors.textPrimary, radius: 18, stroke: OmiColors.border.opacity(0.2)) - } - .buttonStyle(.plain) - } - } // MARK: - Transcript Notes Divider diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift index 1df3da87108..97261e737c4 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift @@ -1357,6 +1357,7 @@ class MemoriesViewModel: ObservableObject { struct MemoriesPage: View { @ObservedObject var viewModel: MemoriesViewModel let graphViewModel: MemoryGraphViewModel + let appState: AppState @State private var showCategoryFilter = false @State private var categorySearchText = "" @State private var pendingSelectedTags: Set = [] @@ -1379,6 +1380,10 @@ struct MemoriesPage: View { private var mainMemoriesView: some View { VStack(spacing: 0) { + PageHeaderView(title: "Memories", showsHomeButton: true, appState: appState) + .padding(.horizontal, 24) + .padding(.top, 18) + // Header (includes search, filters, and action buttons) header diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift index 19df5b172be..6b3edd4afcc 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift @@ -2513,6 +2513,7 @@ class TasksViewModel: ObservableObject { struct TasksPage: View { @ObservedObject var viewModel: TasksViewModel + let appState: AppState var chatProvider: ChatProvider? // Chat panel state @@ -2548,8 +2549,12 @@ struct TasksPage: View { @State private var isDraggingDivider = false @State private var dragStartWidth: Double = 0 - init(viewModel: TasksViewModel, chatCoordinator: TaskChatCoordinator, chatProvider: ChatProvider? = nil) { + init( + viewModel: TasksViewModel, appState: AppState, chatCoordinator: TaskChatCoordinator, + chatProvider: ChatProvider? = nil + ) { self.viewModel = viewModel + self.appState = appState self.chatCoordinator = chatCoordinator self.chatProvider = chatProvider } @@ -2746,6 +2751,10 @@ struct TasksPage: View { private var tasksContent: some View { VStack(spacing: 0) { + PageHeaderView(title: "Tasks", showsHomeButton: true, appState: appState) + .padding(.horizontal, 16) + .padding(.top, 18) + // Header with filter toggle and sort headerView diff --git a/desktop/macos/Desktop/Sources/ViewExporter.swift b/desktop/macos/Desktop/Sources/ViewExporter.swift index 4790566102b..cd723eeffe9 100644 --- a/desktop/macos/Desktop/Sources/ViewExporter.swift +++ b/desktop/macos/Desktop/Sources/ViewExporter.swift @@ -292,14 +292,17 @@ enum ViewExporter { "full-ai-chat", 2, { AnyView(ChatPage(appProvider: AppProvider(), chatProvider: previewChatProvider())) } ), - ("full-memories", 3, { AnyView(MemoriesPage(viewModel: previewMemoriesViewModel(), graphViewModel: MemoryGraphViewModel())) }), + ("full-memories", 3, { AnyView(MemoriesPage( + viewModel: previewMemoriesViewModel(), graphViewModel: MemoryGraphViewModel(), + appState: AppState())) }), ( "full-tasks", 4, { let cp = ChatProvider() return AnyView( TasksPage( - viewModel: TasksViewModel(), chatCoordinator: TaskChatCoordinator(chatProvider: cp), + viewModel: TasksViewModel(), appState: AppState(), + chatCoordinator: TaskChatCoordinator(chatProvider: cp), chatProvider: cp)) } ), diff --git a/desktop/macos/changelog/unreleased/20260707-consistent-page-header.json b/desktop/macos/changelog/unreleased/20260707-consistent-page-header.json new file mode 100644 index 00000000000..c13a9f09028 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260707-consistent-page-header.json @@ -0,0 +1,3 @@ +{ + "change": "Added a consistent page header across Home, Conversations, Tasks and Memories — page title, one-click Home, and the Capture/Listening controls always in reach" +} From 1fc73ddc0fa641ffe72e6e9981a49d0cd86cbb90 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:40:35 -0400 Subject: [PATCH 06/31] Open chat as a page instead of an inline panel on Home --- .../MainWindow/Pages/DashboardPage.swift | 86 +------------------ 1 file changed, 3 insertions(+), 83 deletions(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift index 6effbc88d6e..268bcc275fe 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift @@ -392,13 +392,6 @@ struct DashboardPage: View { appState.checkAllPermissions() Task { await homeStatus.refresh(force: false) } } - // Clicking into the ask bar reveals the inline chat; the same is true - // when focus lands there via keyboard (Tab / Full Keyboard Access). - .onChange(of: homeAskFieldFocused) { _, focused in - if focused && !useLegacyHomeDesign && homeMode != .chat { - openHomeChat() - } - } // Automation-bridge entry points (home_open_chat / home_connect_toggle / // home_close_panel / home_ask) — they call the exact functions the // on-screen controls call. @@ -584,9 +577,6 @@ struct DashboardPage: View { homeHubCenterpiece } .transition(.homeHubFade) - case .chat: - homeChatPanel - .transition(.homeDropFromTop) case .connect: homeConnectPanel .transition(.homeDropFromTop) @@ -664,72 +654,6 @@ struct DashboardPage: View { // MARK: Inline chat panel - private var homeChatPanel: some View { - VStack(spacing: 0) { - ChatMessagesView( - messages: ChatTurnOwner.transcriptMessages(chatProvider.messages, floatingSurface: false), - isSending: chatProvider.isSending, - hasMoreMessages: chatProvider.hasMoreMessages, - isLoadingMoreMessages: chatProvider.isLoadingMoreMessages, - isLoadingInitial: (chatProvider.isLoading || chatProvider.isLoadingSessions) - && !chatProvider.isClearing, - app: selectedApp, - onLoadMore: { await chatProvider.loadMoreMessages() }, - onRate: { messageId, rating in - Task { await chatProvider.rateMessage(messageId, rating: rating) } - }, - onCitationTap: { citation in - handleCitationTap(citation) - }, - sessionsLoadError: chatProvider.sessionsLoadError, - onRetry: { Task { await chatProvider.retryLoad() } }, - localSendToken: chatProvider.localSendToken, - onCancelTurn: { chatProvider.stopAgent(owner: .mainChat) }, - welcomeContent: { dashboardChatWelcome } - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .mask( - LinearGradient( - stops: [ - .init(color: .clear, location: 0.0), - .init(color: .black, location: 0.05), - .init(color: .black, location: 0.97), - .init(color: .clear, location: 1.0), - ], - startPoint: .top, - endPoint: .bottom - ) - ) - .padding(.horizontal, 8) - .padding(.vertical, 6) - } - // Barely-there card so the chat reads as a bounded surface while still - // dissolving into the ambient Home canvas. - .background( - RoundedRectangle(cornerRadius: 26, style: .continuous) - .fill( - LinearGradient( - colors: [ - Color.white.opacity(0.018), - HomePalette.stageGlow.opacity(0.014), - Color.white.opacity(0.006), - ], - startPoint: .top, - endPoint: .bottom - ) - ) - ) - .overlay( - RoundedRectangle(cornerRadius: 26, style: .continuous) - .stroke(HomePalette.stageGlow.opacity(0.10), lineWidth: 1) - .blur(radius: 2.5) - .opacity(0.65) - ) - .shadow(color: HomePalette.stageGlow.opacity(0.055), radius: 28, y: 8) - .frame(maxWidth: Self.homeStagePanelMaxWidth) - .padding(.horizontal, Self.homeStageHorizontalPadding) - } - // MARK: Connect tray private var homeConnectPanel: some View { @@ -845,12 +769,10 @@ struct DashboardPage: View { } } + /// Chat lives on its own page — every "open chat" affordance navigates + /// there instead of dropping an inline panel over Home. private func openHomeChat() { - guard homeMode != .chat else { return } - withAnimation(Self.homeStageAnimation) { - homeMode = .chat - } - reportHomeAutomationMode() + navigate(to: .chat) } private func toggleHomeConnectPanel() { @@ -1527,13 +1449,11 @@ private enum HomeDestinationProminence { private enum HomeStageMode: Equatable { case hub - case chat case connect var automationLabel: String { switch self { case .hub: return "hub" - case .chat: return "chat" case .connect: return "connect" } } From d51c7a036544fb47b53eeb7fdf83870a3b9f5655 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:40:35 -0400 Subject: [PATCH 07/31] Match Tasks search field and row hover to the other pages --- .../Desktop/Sources/MainWindow/Pages/TasksPage.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift index 6b3edd4afcc..7423c90d107 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift @@ -2904,10 +2904,12 @@ struct TasksPage: View { .buttonStyle(.plain) } } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(OmiColors.backgroundSecondary) - .cornerRadius(8) + .padding(.horizontal, 10) + .padding(.vertical, 11) + .frame(minHeight: 46) + .omiControlSurface( + fill: OmiColors.backgroundSecondary, radius: 18, + stroke: OmiColors.border.opacity(0.18)) // Saved filter view chips if !viewModel.savedFilterViews.isEmpty && !viewModel.isMultiSelectMode { @@ -4917,7 +4919,7 @@ struct TaskRow: View { .padding(.vertical, 6) .background( RoundedRectangle(cornerRadius: 8) - .fill(isKeyboardSelected ? OmiColors.purplePrimary.opacity(0.10) : (isHovering || isDragging ? OmiColors.backgroundTertiary : (isNewlyCreated ? OmiColors.purplePrimary.opacity(0.15) : Color.clear))) + .fill(isKeyboardSelected ? OmiColors.backgroundQuaternary.opacity(0.55) : (isHovering || isDragging ? OmiColors.backgroundRaised : (isNewlyCreated ? OmiColors.backgroundQuaternary.opacity(0.45) : Color.clear))) ) .overlay( RoundedRectangle(cornerRadius: 8) From 0ca929fcd32423cc82f697931f6c32bdb6637c8b Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:42:32 -0400 Subject: [PATCH 08/31] Add chrome capture and titlebar dump actions to desktop automation bridge --- .../Sources/DesktopAutomationBridge.swift | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift b/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift index 87698b77747..041b6ec7795 100644 --- a/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift +++ b/desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift @@ -1198,12 +1198,13 @@ final class DesktopAutomationActionRegistry { register( name: "capture_main_window_png", - summary: "Write PNG of the frontmost Omi window (in-process capture)", - params: ["path"] + summary: "Write PNG of the frontmost Omi window (in-process capture); chrome=1 includes titlebar/toolbar", + params: ["path", "chrome"] ) { params in guard let path = params["path"], !path.isEmpty else { return ["error": "missing 'path'"] } + let includeChrome = params["chrome"] == "1" return await MainActor.run { () -> [String: String] in guard let window = NSApp.windows.first(where: { @@ -1213,11 +1214,12 @@ final class DesktopAutomationActionRegistry { else { return ["error": "no_visible_window"] } - let bounds = contentView.bounds - guard let rep = contentView.bitmapImageRepForCachingDisplay(in: bounds) else { + let captureView = includeChrome ? (contentView.superview ?? contentView) : contentView + let bounds = captureView.bounds + guard let rep = captureView.bitmapImageRepForCachingDisplay(in: bounds) else { return ["error": "bitmap_rep_failed"] } - contentView.cacheDisplay(in: bounds, to: rep) + captureView.cacheDisplay(in: bounds, to: rep) guard let data = rep.representation(using: .png, properties: [:]) else { return ["error": "png_encode_failed"] } @@ -1230,6 +1232,40 @@ final class DesktopAutomationActionRegistry { } } + register( + name: "dump_window_chrome", + summary: "Dump the main window's titlebar/toolbar view hierarchy (class, frame, text)", + params: [] + ) { _ in + await MainActor.run { () -> [String: String] in + guard + let window = NSApp.windows.first(where: { + $0.isVisible && $0.title.range(of: "omi", options: .caseInsensitive) != nil + }), + let frameView = window.contentView?.superview + else { + return ["error": "no_visible_window"] + } + var lines: [String] = [] + func walk(_ view: NSView, depth: Int) { + // Skip the content subtree — only chrome is interesting here. + if view === window.contentView { return } + let indent = String(repeating: " ", count: depth) + var line = "\(indent)\(type(of: view)) frame=\(NSStringFromRect(view.frame))" + if let text = view as? NSTextField { + line += " text=\"\(text.stringValue)\"" + } + if view.isHidden { line += " hidden" } + lines.append(line) + for sub in view.subviews { + walk(sub, depth: depth + 1) + } + } + walk(frameView, depth: 0) + return ["chrome": lines.joined(separator: "\n")] + } + } + register( name: "capture_floating_bar_png", summary: "Write PNG of the floating control bar window (in-process capture)", From ae8dd6e11ef2c8bfcb67906516a24341e79f20ec Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:42:43 -0400 Subject: [PATCH 09/31] Replace in-content page headers with native unified window toolbar --- .../CaptureListeningController.swift | 45 ++- .../Components/PageHeaderControls.swift | 313 ------------------ .../Components/PageHeaderView.swift | 127 ------- .../Components/ToolbarStatusControls.swift | 172 ++++++++++ .../Sources/MainWindow/DesktopHomeView.swift | 165 +++++---- .../MainWindow/Pages/ConversationsPage.swift | 14 +- .../MainWindow/Pages/DashboardPage.swift | 12 +- .../MainWindow/Pages/MemoriesPage.swift | 4 - .../Sources/MainWindow/Pages/TasksPage.swift | 4 - desktop/macos/Desktop/Sources/OmiApp.swift | 4 + .../Sources/Rewind/UI/RewindPage.swift | 31 +- .../Tests/DashboardCaptureStateTests.swift | 67 ++-- 12 files changed, 359 insertions(+), 599 deletions(-) delete mode 100644 desktop/macos/Desktop/Sources/MainWindow/Components/PageHeaderControls.swift delete mode 100644 desktop/macos/Desktop/Sources/MainWindow/Components/PageHeaderView.swift create mode 100644 desktop/macos/Desktop/Sources/MainWindow/Components/ToolbarStatusControls.swift diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/CaptureListeningController.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/CaptureListeningController.swift index 13dc088e992..a6854b472ee 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/CaptureListeningController.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/CaptureListeningController.swift @@ -1,12 +1,51 @@ import AppKit import SwiftUI -/// Shared state + actions behind the Capture and Listening header pills. -/// One instance per `PageHeaderView`; the underlying truth lives in +/// Status of a capture-style feature as shown in the toolbar controls. +enum HomeStatusState { + case active + case inactive + case blocked + + var indicator: Color { + switch self { + case .active: + return HomePalette.green + case .inactive: + return HomePalette.faint + case .blocked: + return Color(red: 1.0, green: 0.24, blue: 0.30) + } + } + + var text: String { + switch self { + case .active: + return "On" + case .inactive: + return "Off" + case .blocked: + return "Blocked" + } + } + + var isActive: Bool { + if case .active = self { return true } + return false + } + + var isBlocked: Bool { + if case .blocked = self { return true } + return false + } +} + +/// Shared state + actions behind the Capture and Listening toolbar controls. +/// One instance per `ToolbarStatusControls`; the underlying truth lives in /// `ProactiveAssistantsPlugin` / `AssistantSettings` / `AppState`, so /// instances stay in sync via the monitoring notifications. /// -/// Extracted from `DashboardPage` so every main page can host the same +/// Extracted from `DashboardPage` so the window toolbar can host the /// control cluster without duplicating the toggle logic. @MainActor final class CaptureListeningController: ObservableObject { diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/PageHeaderControls.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/PageHeaderControls.swift deleted file mode 100644 index d605e62d580..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/PageHeaderControls.swift +++ /dev/null @@ -1,313 +0,0 @@ -import AppKit -import SwiftUI - -// MARK: - Shared page-header controls -// -// The Capture/Listening status pills and the settings menu were born on the -// Home header; they're shared components now so every main page can carry -// the same control cluster (see PageHeaderView). Moved verbatim from -// DashboardPage — visuals unchanged. - -enum HomeStatusState { - case active - case inactive - case blocked - - var indicator: Color { - switch self { - case .active: - return HomePalette.green - case .inactive: - return HomePalette.faint - case .blocked: - return Color(red: 1.0, green: 0.24, blue: 0.30) - } - } - - var text: String { - switch self { - case .active: - return "On" - case .inactive: - return "Off" - case .blocked: - return "Blocked" - } - } - - var isActive: Bool { - if case .active = self { return true } - return false - } - - var isBlocked: Bool { - if case .blocked = self { return true } - return false - } -} - -struct HomeStatusButton: View { - let title: String - let systemImage: String - let status: HomeStatusState - let isToggling: Bool - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: 8) { - ZStack { - if isToggling { - ProgressView() - .controlSize(.small) - .scaleEffect(0.55) - } else { - Image(systemName: systemImage) - .scaledFont(size: 13, weight: .semibold) - } - } - .frame(width: 18, height: 18) - - Text(title) - .scaledFont(size: 12, weight: .semibold) - .lineLimit(1) - } - .foregroundStyle(status.isActive ? HomePalette.ink : (status.isBlocked ? status.indicator : HomePalette.muted)) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .frame(height: 34) - .background( - Capsule(style: .continuous) - .fill(statusFill) - ) - .overlay( - Capsule(style: .continuous) - .stroke(statusStroke, lineWidth: 1) - ) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - .disabled(isToggling) - .onHover { isHovering = $0 } - .help("\(title): \(status.text)") - .accessibilityLabel("\(title) \(status.text)") - } - - private var statusFill: Color { - if status.isActive { - return HomePalette.green.opacity(isHovering ? 0.20 : 0.12) - } - if status.isBlocked { - return status.indicator.opacity(isHovering ? 0.16 : 0.10) - } - return isHovering ? HomePalette.tileHover : HomePalette.panel - } - - private var statusStroke: Color { - if status.isActive { - return HomePalette.green.opacity(0.38) - } - if status.isBlocked { - return status.indicator.opacity(isHovering ? 0.54 : 0.38) - } - return HomePalette.hairline.opacity(isHovering ? 0.8 : 0.58) - } -} - -struct HomeListeningStatusButton: View { - let title: String - let systemImage: String - let status: HomeStatusState - let modeTitle: String - let isMeetingsOnly: Bool - let isToggling: Bool - let action: () -> Void - let modeAction: () -> Void - - // Single pill-level hover flag so moving between the title and the mode - // toggle never flickers the revealed controls. - @State private var isHovering = false - - var body: some View { - HStack(spacing: 0) { - Button(action: action) { - HStack(spacing: 8) { - ZStack { - if isToggling { - ProgressView() - .controlSize(.small) - .scaleEffect(0.55) - } else { - Image(systemName: systemImage) - .scaledFont(size: 13, weight: .semibold) - } - } - .frame(width: 18, height: 18) - - VStack(alignment: .leading, spacing: 1) { - Text(title) - .scaledFont(size: 12, weight: .semibold) - .lineLimit(1) - - // Mode ("Always" / "In meeting" / …) is revealed only on - // hover to keep the resting pill clean. - if isHovering { - Text(modeTitle) - .scaledFont(size: 8, weight: .medium) - .foregroundStyle(status.isActive ? HomePalette.secondary : HomePalette.muted) - .lineLimit(1) - .transition(.opacity) - } - } - } - .padding(.leading, 12) - .padding(.trailing, 8) - .frame(height: 34) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(isToggling) - .help("Listening: \(status.text), \(modeTitle)") - .accessibilityLabel("Listening \(status.text), \(modeTitle)") - - // Divider + mode toggle are revealed only on hover to keep the - // resting pill compact. - if isHovering { - Rectangle() - .fill(HomePalette.hairline.opacity(0.65)) - .frame(width: 1, height: 18) - .transition(.opacity) - - Button(action: modeAction) { - Image(systemName: isMeetingsOnly ? "person.2.fill" : "person.fill") - .scaledFont(size: 11, weight: .semibold) - .foregroundStyle(modeIconColor) - .frame(width: 30, height: 34) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .help(isMeetingsOnly ? "Switch to always listening" : "Switch to meetings only") - .accessibilityLabel(isMeetingsOnly ? "Switch Listening to Always" : "Switch Listening to Meetings Only") - .transition(.opacity) - } - } - .foregroundStyle(status.isActive ? HomePalette.ink : (status.isBlocked ? status.indicator : HomePalette.muted)) - .background( - Capsule(style: .continuous) - .fill(statusFill) - ) - .overlay( - Capsule(style: .continuous) - .stroke(statusStroke, lineWidth: 1) - ) - .contentShape(Capsule()) - .frame(height: 34) - .onHover { isHovering = $0 } - .animation(.easeInOut(duration: 0.14), value: isHovering) - } - - private var modeIconColor: Color { - status.isActive ? HomePalette.green : HomePalette.muted - } - - private var statusFill: Color { - if status.isActive { - return HomePalette.green.opacity(isHovering ? 0.20 : 0.12) - } - if status.isBlocked { - return status.indicator.opacity(isHovering ? 0.16 : 0.10) - } - return isHovering ? HomePalette.tileHover : HomePalette.panel - } - - private var statusStroke: Color { - if status.isActive { - return HomePalette.green.opacity(0.38) - } - if status.isBlocked { - return status.indicator.opacity(isHovering ? 0.54 : 0.38) - } - return HomePalette.hairline.opacity(isHovering ? 0.8 : 0.58) - } -} - -struct HomeSettingsMenuButton: View { - let onRefer: () -> Void - let onDiscord: () -> Void - let onSettings: () -> Void - - @State private var isHovering = false - @State private var isPresented = false - - var body: some View { - Button { - isPresented.toggle() - } label: { - ZStack { - Circle() - .fill(isHovering ? HomePalette.tileHover : HomePalette.tile.opacity(0.86)) - .overlay( - Circle() - .stroke(HomePalette.hairline.opacity(isHovering ? 0.9 : 0.68), lineWidth: 1) - ) - - Image(systemName: "gearshape.fill") - .scaledFont(size: 14, weight: .semibold) - .foregroundStyle(isHovering ? HomePalette.ink : HomePalette.secondary) - } - .frame(width: 34, height: 34) - .contentShape(Circle()) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .popover(isPresented: $isPresented, arrowEdge: .top) { - VStack(alignment: .leading, spacing: 4) { - popoverButton(title: "Refer a Friend", systemImage: "gift.fill") { - isPresented = false - onRefer() - } - - popoverButton(title: "Discord", systemImage: "message.fill") { - isPresented = false - onDiscord() - } - - Divider() - .padding(.vertical, 3) - - popoverButton(title: "Settings", systemImage: "gearshape.fill") { - isPresented = false - onSettings() - } - } - .padding(8) - .frame(width: 190) - .background(HomePalette.panel) - } - .help("Settings") - .accessibilityLabel("Settings menu") - } - - private func popoverButton(title: String, systemImage: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - HStack(spacing: 10) { - Image(systemName: systemImage) - .scaledFont(size: 13, weight: .semibold) - .foregroundStyle(HomePalette.secondary) - .frame(width: 18) - - Text(title) - .scaledFont(size: 13, weight: .medium) - .foregroundStyle(HomePalette.ink) - - Spacer(minLength: 0) - } - .padding(.horizontal, 9) - .padding(.vertical, 7) - .contentShape(.rect(cornerRadius: 8)) - } - .buttonStyle(.plain) - } -} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/PageHeaderView.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/PageHeaderView.swift deleted file mode 100644 index eea83b6da39..00000000000 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/PageHeaderView.swift +++ /dev/null @@ -1,127 +0,0 @@ -import AppKit -import SwiftUI - -/// The consistent page header: optional home affordance + serif page title -/// on the left, the Capture/Listening pills and settings menu on the right — -/// the same control cluster the Home header established, available to every -/// main page. -struct PageHeaderView: View { - /// nil hides the title (Home shows only the trailing cluster). - var title: String? = nil - /// Shows the "back to Home" chip on non-Home pages. - var showsHomeButton = false - let appState: AppState - - @ObservedObject private var appStateObserved: AppState - @StateObject private var controls: CaptureListeningController - - init(title: String? = nil, showsHomeButton: Bool = false, appState: AppState) { - self.title = title - self.showsHomeButton = showsHomeButton - self.appState = appState - self.appStateObserved = appState - _controls = StateObject(wrappedValue: CaptureListeningController(appState: appState)) - } - - var body: some View { - HStack(spacing: 12) { - if showsHomeButton { - PageHeaderHomeButton { - Self.navigate(to: .dashboard) - } - } - - if let title { - Text(title) - .font(.system(size: 20, weight: .medium, design: .serif)) - .foregroundStyle(Color.white.opacity(0.92)) - } - - Spacer() - - HStack(spacing: 10) { - HomeStatusButton( - title: "Capture", - systemImage: "viewfinder", - status: controls.captureStatus, - isToggling: controls.isTogglingCapture, - action: controls.toggleCapture - ) - - HomeListeningStatusButton( - title: "Listening", - systemImage: appStateObserved.isTranscribing - ? "waveform.circle.fill" : "mic.circle", - status: appStateObserved.isTranscribing ? .active : .inactive, - modeTitle: controls.listeningModeTitle, - isMeetingsOnly: controls.listeningCaptureMode == .onlyDuringMeetings, - isToggling: controls.isTogglingListening, - action: controls.toggleListening, - modeAction: controls.toggleListeningMode - ) - - HomeSettingsMenuButton( - onRefer: Self.openReferFriend, - onDiscord: Self.openDiscord, - onSettings: { Self.navigate(to: .settings) } - ) - } - } - .frame(height: 36) - .onAppear { controls.syncCaptureState() } - } - - // MARK: - Shared actions - - private static func navigate(to item: SidebarNavItem) { - NotificationCenter.default.post( - name: .navigateToSidebarItem, - object: nil, - userInfo: ["rawValue": item.rawValue] - ) - } - - static func openReferFriend() { - if let url = URL(string: "https://affiliate.omi.me") { - NSWorkspace.shared.open(url) - } - } - - static func openDiscord() { - if let url = URL(string: "https://discord.com/invite/8MP3b9ymvx") { - NSWorkspace.shared.open(url) - } - } -} - -/// Rounded "back to Home" chip, styled to sit beside the header pills. -private struct PageHeaderHomeButton: View { - let action: () -> Void - - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: 6) { - Image(systemName: "house.fill") - .scaledFont(size: 11, weight: .medium) - Text("Home") - .scaledFont(size: 12, weight: .medium) - } - .foregroundStyle(Color.white.opacity(isHovering ? 0.95 : 0.7)) - .padding(.horizontal, 12) - .frame(height: 30) - .background( - Capsule().fill(Color.white.opacity(isHovering ? 0.12 : 0.06)) - ) - .overlay( - Capsule().stroke(Color.white.opacity(0.12), lineWidth: 1) - ) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .help("Back to Home") - .keyboardShortcut("[", modifiers: .command) - } -} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ToolbarStatusControls.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ToolbarStatusControls.swift new file mode 100644 index 00000000000..7074f6ef65e --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ToolbarStatusControls.swift @@ -0,0 +1,172 @@ +import SwiftUI + +/// ToolbarItem without the macOS 26 shared glass platter. Bare glyphs fit +/// the app's dark custom surface; floating glass capsules read as foreign +/// against it. Older toolbars have no platters, so the plain item is +/// already correct there. +struct PlainToolbarItem: ToolbarContent { + let placement: ToolbarItemPlacement + @ViewBuilder let content: () -> Content + + init(placement: ToolbarItemPlacement = .automatic, @ViewBuilder content: @escaping () -> Content) { + self.placement = placement + self.content = content + } + + var body: some ToolbarContent { + #if compiler(>=6.2) + if #available(macOS 26.0, *) { + ToolbarItem(placement: placement, content: content) + .sharedBackgroundVisibility(.hidden) + } else { + ToolbarItem(placement: placement, content: content) + } + #else + ToolbarItem(placement: placement, content: content) + #endif + } +} + +/// Capture/Listening status controls and the settings menu for the native +/// window toolbar. Toolbar-scale, icon-first, system-styled — state reads +/// through symbol tint plus a small status dot, details live in tooltips. +struct ToolbarStatusControls: View { + let appState: AppState + + @ObservedObject private var appStateObserved: AppState + @StateObject private var controls: CaptureListeningController + + init(appState: AppState) { + self.appState = appState + self.appStateObserved = appState + _controls = StateObject(wrappedValue: CaptureListeningController(appState: appState)) + } + + var body: some View { + HStack(spacing: 10) { + ToolbarStatusButton( + systemImage: "inset.filled.rectangle.badge.record", + title: "Capture", + state: captureDotState, + action: controls.toggleCapture, + helpText: captureHelp + ) + .disabled(controls.isTogglingCapture) + + ToolbarStatusButton( + systemImage: appStateObserved.isTranscribing ? "waveform" : "mic", + title: "Listening", + state: appStateObserved.isTranscribing ? .active : .off, + action: controls.toggleListening, + helpText: appStateObserved.isTranscribing + ? "Listening is on — click to stop (\(controls.listeningModeTitle))" + : "Listening is off — click to start" + ) + .disabled(controls.isTogglingListening) + .contextMenu { + Button("Audio mode: \(controls.listeningModeTitle) — switch") { + controls.toggleListeningMode() + } + } + + Menu { + Button("Settings…") { navigate(to: .settings) } + Divider() + Button("Refer a Friend") { Self.openReferFriend() } + Button("Join Discord") { Self.openDiscord() } + } label: { + Image(systemName: "gearshape") + } + // The app's root purple tint must not color toolbar glyphs — + // native macOS toolbars use neutral symbols. Menu labels render + // as template images driven by tint, so override tint itself. + .tint(Color.secondary) + .menuIndicator(.hidden) + .help("Settings") + } + .onAppear { controls.syncCaptureState() } + } + + private var captureDotState: ToolbarStatusButton.DotState { + switch controls.captureStatus { + case .active: return .active + case .blocked: return .attention + case .inactive: return .off + } + } + + private var captureHelp: String { + switch controls.captureStatus { + case .active: return "Screen capture is on — click to turn off" + case .inactive: return "Screen capture is off — click to turn on" + case .blocked: return "Screen capture is blocked — click to fix permissions" + } + } + + private func navigate(to item: SidebarNavItem) { + NotificationCenter.default.post( + name: .navigateToSidebarItem, + object: nil, + userInfo: ["rawValue": item.rawValue] + ) + } + + static func openReferFriend() { + if let url = URL(string: "https://affiliate.omi.me") { + NSWorkspace.shared.open(url) + } + } + + static func openDiscord() { + if let url = URL(string: "https://discord.com/invite/8MP3b9ymvx") { + NSWorkspace.shared.open(url) + } + } +} + +/// A labeled toolbar button whose running state shows as a small badge on +/// the glyph: green dot = running, amber triangle = needs attention, +/// nothing = off. The label says what the control is; the badge says how +/// it's doing. +struct ToolbarStatusButton: View { + enum DotState { + case active + case attention + case off + } + + let systemImage: String + let title: String + let state: DotState + let action: () -> Void + let helpText: String + + var body: some View { + Button(action: action) { + HStack(spacing: 5) { + Image(systemName: systemImage) + .foregroundStyle(state == .off ? Color.secondary : Color.primary) + .overlay(alignment: .topTrailing) { + switch state { + case .active: + Circle() + .fill(.green) + .frame(width: 5, height: 5) + .offset(x: 4, y: -3) + case .attention: + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 7)) + .foregroundStyle(.yellow) + .offset(x: 6, y: -4) + case .off: + EmptyView() + } + } + Text(title) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(state == .off ? Color.secondary : Color.primary) + } + } + .help(helpText) + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index f0156a3eb01..3a09c98cb9c 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -63,15 +63,6 @@ struct DesktopHomeView: View { selectedIndex == SidebarNavItem.settings.rawValue } - /// Pages that render the shared PageHeaderView (title + Home chip + - /// Capture/Listening cluster) and therefore skip the legacy chrome bar. - private static let pagesWithOwnHeader: Set = [ - SidebarNavItem.dashboard.rawValue, - SidebarNavItem.conversations.rawValue, - SidebarNavItem.tasks.rawValue, - SidebarNavItem.memories.rawValue, - ] - var body: some View { Group { if authState.isRestoringAuth { @@ -126,6 +117,7 @@ struct DesktopHomeView: View { } } mainContent + .toolbar { mainToolbar } .opacity(viewModelContainer.isInitialLoadComplete ? 1 : 0) .overlay { if appState.showUsageLimitPopup { @@ -833,6 +825,56 @@ struct DesktopHomeView: View { index == SidebarNavItem.memories.rawValue } + /// Native unified-toolbar contents: Home affordance + page title on the + /// left, Capture/Listening status and settings on the right. System bar, + /// system sizes — no in-content chrome. + @ToolbarContentBuilder + private var mainToolbar: some ToolbarContent { + if !useLegacyHomeDesign { + // Home is the root: it gets no leading toolbar items at all. Subpages + // get the house button plus the page title. The `if` must wrap the + // ToolbarItems (not live inside them): an item with empty content + // still renders as an empty slot in the unified bar. PlainToolbarItem + // opts every item out of the macOS 26 glass platter — bare glyphs on + // the app's own dark surface. + if selectedIndex != SidebarNavItem.dashboard.rawValue { + PlainToolbarItem(placement: .navigation) { + Button { + withAnimation(Self.pageNavigationAnimation) { + selectedIndex = SidebarNavItem.dashboard.rawValue + } + } label: { + Image(systemName: "house") + .foregroundStyle(Color.secondary) + } + .help("Back to Home (\u{2318}[)") + .keyboardShortcut("[", modifiers: .command) + } + PlainToolbarItem(placement: .navigation) { + toolbarTitle + } + } + // On macOS `.primaryAction` maps to the *leading* toolbar edge, so a + // flexible spacer is what pushes the status cluster to the trailing side. + ToolbarItem(placement: .automatic) { + Spacer() + } + PlainToolbarItem(placement: .automatic) { + ToolbarStatusControls(appState: appState) + } + } + } + + private var toolbarTitle: some View { + // Native macOS window-title metrics (13pt semibold), not a heading. + Text(currentPageTitle) + .font(.system(size: 13, weight: .semibold)) + } + + private var currentPageTitle: String { + SidebarNavItem(rawValue: selectedIndex)?.title ?? "Omi" + } + private var mainContent: some View { HStack(spacing: 0) { // Sidebar slot: settings sidebar overlays main sidebar @@ -884,41 +926,37 @@ struct DesktopHomeView: View { .clipped() } - // Main content area with rounded container + // Main content area. New design: one continuous near-black sheet — the + // base surface extends under the unified toolbar (top safe area) so the + // glass toolbar controls float directly on the app surface instead of + // on a separate bar. Legacy design keeps its floating rounded card. ZStack { - // Content container background - RoundedRectangle(cornerRadius: OmiChrome.windowRadius, style: .continuous) - .fill( - LinearGradient( - colors: [ - OmiColors.backgroundSecondary.opacity(0.96), - OmiColors.backgroundPrimary.opacity(0.96), - ], - startPoint: .topLeading, - endPoint: .bottomTrailing + if useLegacyHomeDesign { + RoundedRectangle(cornerRadius: OmiChrome.windowRadius, style: .continuous) + .fill( + LinearGradient( + colors: [ + OmiColors.backgroundSecondary.opacity(0.96), + OmiColors.backgroundPrimary.opacity(0.96), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) ) - ) - .overlay( - RoundedRectangle(cornerRadius: OmiChrome.windowRadius, style: .continuous) - .stroke(OmiColors.border.opacity(0.22), lineWidth: 1) - ) - .shadow(color: .black.opacity(0.22), radius: 26, x: 0, y: 14) + .overlay( + RoundedRectangle(cornerRadius: OmiChrome.windowRadius, style: .continuous) + .stroke(OmiColors.border.opacity(0.22), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.22), radius: 26, x: 0, y: 14) + } else { + HomePalette.paper + .ignoresSafeArea(edges: .top) + } // Page content - switch recreates views on tab change // Extracted into a separate struct so that pages like TasksPage // are not re-rendered when AppState publishes unrelated changes. VStack(spacing: 0) { - if !useLegacyHomeDesign && !Self.pagesWithOwnHeader.contains(selectedIndex) { - PageChromeBar( - onHome: { - selectedIndex = SidebarNavItem.dashboard.rawValue - } - ) - .padding(.horizontal, 18) - .padding(.top, 14) - .padding(.bottom, 4) - } - PageContentView( selectedIndex: selectedIndex, appState: appState, @@ -928,9 +966,12 @@ struct DesktopHomeView: View { selectedTabIndex: $selectedIndex ) } - .clipShape(RoundedRectangle(cornerRadius: OmiChrome.windowRadius, style: .continuous)) + .clipShape( + RoundedRectangle( + cornerRadius: useLegacyHomeDesign ? OmiChrome.windowRadius : 0, style: .continuous) + ) } - .padding(14) + .padding(useLegacyHomeDesign ? 14 : 0) } .overlay { // Goal completion celebration overlay @@ -1035,52 +1076,6 @@ struct DesktopHomeView: View { } } -private struct PageChromeBar: View { - let onHome: () -> Void - - var body: some View { - HStack(spacing: 8) { - PageChromeButton(title: "Home", systemImage: "house.fill", action: onHome) - Spacer() - } - .frame(height: 34) - } -} - -private struct PageChromeButton: View { - let title: String - let systemImage: String - let action: () -> Void - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: 7) { - Image(systemName: systemImage) - .scaledFont(size: 12, weight: .semibold) - Text(title) - .scaledFont(size: 12, weight: .semibold) - } - .foregroundStyle(isHovering ? OmiColors.textPrimary : OmiColors.textSecondary) - .padding(.horizontal, 11) - .padding(.vertical, 7) - .background( - Capsule(style: .continuous) - .fill(.ultraThinMaterial) - ) - .overlay( - Capsule(style: .continuous) - .stroke(isHovering ? OmiColors.success.opacity(0.34) : OmiColors.border.opacity(0.4), lineWidth: 1) - ) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .help(title) - .accessibilityLabel(title) - } -} - /// Isolated page content switch — does NOT observe AppState or ViewModelContainer /// as @ObservedObject, so pages like TasksPage won't re-render when unrelated /// AppState properties (conversations, permissions, etc.) change. diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift index b5754567a7f..8b70f4e2cda 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift @@ -162,17 +162,9 @@ struct ConversationsPage: View { // MARK: - Main View with Recording Header + List private var mainConversationsView: some View { - VStack(spacing: 0) { - // Shared page header: title + Home chip + Capture/Listening/settings - // cluster. Recording start/stop lives in the Listening pill. - PageHeaderView(title: "Conversations", showsHomeButton: true, appState: appState) - .padding(.horizontal, 24) - .padding(.top, 18) - .padding(.bottom, 12) - - // Conversation list - conversationListSection - } + // Title and status controls live in the window toolbar. + conversationListSection + .padding(.top, 6) } private var quickNoteButton: some View { diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift index 268bcc275fe..24b3f675723 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift @@ -528,11 +528,6 @@ struct DashboardPage: View { // Full Keyboard Access. .accessibilityHidden(isHomeModalPresented) - homeHeader - .padding(.horizontal, Self.homeStageHorizontalPadding) - .padding(.top, 26) - .accessibilityHidden(isHomeModalPresented) - appsPopupOverlay( contentWidth: proxy.size.width, panelWidth: panelWidth, @@ -998,9 +993,6 @@ struct DashboardPage: View { ) } - private var homeHeader: some View { - PageHeaderView(appState: appState) - } private var sourceColumnHeader: some View { VStack(alignment: .leading, spacing: 4) { @@ -1419,8 +1411,8 @@ struct DashboardPage: View { // MARK: - Home Components -/// Home design palette — internal so the shared page-header components -/// (PageHeaderControls) keep rendering with the exact Home look. +/// Home design palette — internal so shared components (toolbar status +/// controls, capture state) keep rendering with the exact Home look. enum HomePalette { static let paper = Color(red: 0.018, green: 0.019, blue: 0.021) static let panel = Color(red: 0.045, green: 0.046, blue: 0.052) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift index 97261e737c4..77f87cf0b3a 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift @@ -1380,10 +1380,6 @@ struct MemoriesPage: View { private var mainMemoriesView: some View { VStack(spacing: 0) { - PageHeaderView(title: "Memories", showsHomeButton: true, appState: appState) - .padding(.horizontal, 24) - .padding(.top, 18) - // Header (includes search, filters, and action buttons) header diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift index 7423c90d107..e3fc00e80bd 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift @@ -2751,10 +2751,6 @@ struct TasksPage: View { private var tasksContent: some View { VStack(spacing: 0) { - PageHeaderView(title: "Tasks", showsHomeButton: true, appState: appState) - .padding(.horizontal, 16) - .padding(.top, 18) - // Header with filter toggle and sort headerView diff --git a/desktop/macos/Desktop/Sources/OmiApp.swift b/desktop/macos/Desktop/Sources/OmiApp.swift index 21c20249b5f..06d7c947b46 100644 --- a/desktop/macos/Desktop/Sources/OmiApp.swift +++ b/desktop/macos/Desktop/Sources/OmiApp.swift @@ -124,6 +124,10 @@ struct OMIApp: App { } } .windowStyle(.titleBar) + // Native unified toolbar: traffic lights, page title, and controls in one + // system bar. The window title string stays set (isMainOmiWindow matches + // on it) but is hidden — the page name renders as a toolbar item. + .windowToolbarStyle(.unified(showsTitle: false)) .defaultSize(width: defaultWindowSize.width, height: defaultWindowSize.height) .commands { CommandGroup(after: .textFormatting) { diff --git a/desktop/macos/Desktop/Sources/Rewind/UI/RewindPage.swift b/desktop/macos/Desktop/Sources/Rewind/UI/RewindPage.swift index 8d380e2bc26..a37e8dffd5b 100644 --- a/desktop/macos/Desktop/Sources/Rewind/UI/RewindPage.swift +++ b/desktop/macos/Desktop/Sources/Rewind/UI/RewindPage.swift @@ -406,26 +406,19 @@ struct RewindPage: View { .buttonStyle(.plain) .help("Back to results") } else { - // Rewind title - HStack(spacing: 8) { - Text("Rewind") - .scaledFont(size: 16, weight: .semibold) - .foregroundColor(.white) - - // Global hotkey hint - HStack(spacing: 2) { - Text("⌘") - Text("⌥") - Text("R") - } - .scaledFont(size: 10, weight: .medium, design: .rounded) - .foregroundColor(.white.opacity(0.4)) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(Color.white.opacity(0.1)) - .cornerRadius(4) - .help("Press ⌘⌥R from anywhere to open Rewind") + // Global hotkey hint (page title lives in the window toolbar) + HStack(spacing: 2) { + Text("⌘") + Text("⌥") + Text("R") } + .scaledFont(size: 10, weight: .medium, design: .rounded) + .foregroundColor(.white.opacity(0.4)) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Color.white.opacity(0.1)) + .cornerRadius(4) + .help("Press ⌘⌥R from anywhere to open Rewind") } // Search field + date picker - always present diff --git a/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift b/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift index 4fc7f2608a3..bf74a38db1e 100644 --- a/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift +++ b/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift @@ -1,25 +1,25 @@ import XCTest final class DashboardCaptureStateTests: XCTestCase { - func testDashboardCaptureStatusUsesLiveMonitoringState() throws { - let source = try dashboardSource() + func testCaptureStatusUsesLiveMonitoringState() throws { + let source = try captureControllerSource() XCTAssertTrue( - source.contains("private var isCaptureLive: Bool"), - "DashboardPage should centralize live capture state so the header reflects the running monitor" + source.contains("var isCaptureLive: Bool"), + "CaptureListeningController should centralize live capture state so the toolbar reflects the running monitor" ) XCTAssertTrue( - source.contains("if isCaptureLive {\n return .active\n }"), + source.contains("return isCaptureLive ? .active : .inactive"), "Capture status should light up when monitoring is live, even if persisted intent is stale" ) XCTAssertFalse( - source.contains("if screenAnalysisEnabled && isCaptureMonitoring {\n return .active\n }"), + source.contains("screenAnalysisEnabled && isCaptureMonitoring"), "Capture status must not require persisted intent to match the live monitor" ) } - func testDashboardCaptureToggleDerivesFromLiveState() throws { - let source = try dashboardSource() + func testCaptureToggleDerivesFromLiveState() throws { + let source = try captureControllerSource() XCTAssertTrue( source.contains("syncCaptureState()\n let enabled = !isCaptureLive"), @@ -31,21 +31,26 @@ final class DashboardCaptureStateTests: XCTestCase { ) } - func testListeningPillShowsAndTogglesCaptureMode() throws { - let source = try dashboardSource() - - XCTAssertTrue(source.contains("@AppStorage(\"systemAudioCaptureMode\")")) - XCTAssertTrue(source.contains("private var listeningModeTitle: String")) - XCTAssertTrue(source.contains("return appState.isAwaitingMeeting ? \"Meetings only\" : \"In meeting\"")) - XCTAssertTrue(source.contains("HomeListeningStatusButton(")) - XCTAssertTrue(source.contains("modeAction: toggleListeningMode")) - XCTAssertTrue(source.contains("AssistantSettings.shared.systemAudioCaptureMode = nextMode")) - XCTAssertTrue(source.contains("Image(systemName: isMeetingsOnly ? \"person.2.fill\" : \"person.fill\")")) - XCTAssertTrue(source.contains("private var modeIconColor: Color")) - XCTAssertTrue(source.contains(".frame(height: 34)")) - XCTAssertFalse(source.contains("Image(systemName: isMeetingsOnly ? \"person.2.fill\" : \"infinity\")")) - XCTAssertFalse(source.contains("Circle()\n .fill(status.indicator)")) - XCTAssertFalse(source.contains("OmiColors.purplePrimary")) + func testToolbarListeningControlShowsAndTogglesCaptureMode() throws { + let controller = try captureControllerSource() + let toolbar = try toolbarControlsSource() + + // The listening control is labeled — an unlabeled mic glyph doesn't + // tell the user what it controls. + XCTAssertTrue(toolbar.contains("title: \"Listening\"")) + XCTAssertTrue(toolbar.contains("title: \"Capture\"")) + XCTAssertTrue(toolbar.contains("controls.toggleListeningMode()")) + XCTAssertTrue(controller.contains("var listeningModeTitle: String")) + XCTAssertTrue(controller.contains("return appState.isAwaitingMeeting ? \"Meetings only\" : \"In meeting\"")) + XCTAssertTrue(controller.contains("AssistantSettings.shared.systemAudioCaptureMode = nextMode")) + // Toolbar glyphs stay neutral — the app's purple accent is banned in + // the window chrome (and blocked state is amber, never red). + XCTAssertFalse(toolbar.contains("OmiColors.purplePrimary")) + XCTAssertFalse(toolbar.contains("Color.purple")) + XCTAssertTrue( + toolbar.contains(".tint(Color.secondary)"), + "The settings menu glyph must override the app's purple root tint" + ) } func testHomeConnectorButtonsOpenSheetsDirectly() throws { @@ -319,6 +324,22 @@ final class DashboardCaptureStateTests: XCTestCase { return try String(contentsOf: storeURL, encoding: .utf8) } + private func captureControllerSource() throws -> String { + try componentSource(named: "CaptureListeningController.swift") + } + + private func toolbarControlsSource() throws -> String { + try componentSource(named: "ToolbarStatusControls.swift") + } + + private func componentSource(named fileName: String) throws -> String { + let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + let sourceURL = testsURL + .deletingLastPathComponent() + .appendingPathComponent("Sources/MainWindow/Components/\(fileName)") + return try String(contentsOf: sourceURL, encoding: .utf8) + } + private func appsSource() throws -> String { try source(named: "AppsPage.swift") } From 0f4b4d567a2c60c2ee032ba4b2387083449e62bb Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:47:04 -0400 Subject: [PATCH 10/31] Unify header controls across Conversations, Tasks and Memories pages --- .../Components/OmiHeaderControls.swift | 176 ++++++++++++++++++ .../MainWindow/Pages/ConversationsPage.swift | 88 +++------ .../MainWindow/Pages/MemoriesPage.swift | 142 +++----------- .../Sources/MainWindow/Pages/TasksPage.swift | 155 +++------------ ...60707-native-toolbar-consistent-pages.json | 3 + 5 files changed, 258 insertions(+), 306 deletions(-) create mode 100644 desktop/macos/Desktop/Sources/MainWindow/Components/OmiHeaderControls.swift create mode 100644 desktop/macos/changelog/unreleased/20260707-native-toolbar-consistent-pages.json diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/OmiHeaderControls.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/OmiHeaderControls.swift new file mode 100644 index 00000000000..2212e803cc9 --- /dev/null +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/OmiHeaderControls.swift @@ -0,0 +1,176 @@ +import OmiTheme +import SwiftUI + +/// Shared metrics for list-page header rows (Conversations, Tasks, +/// Memories): one control height, one shape family (capsule / circle), one +/// fill, and one active treatment — replacing the per-page mix of radius-8 +/// squares, 42pt white squares, and assorted chip heights. +enum OmiHeader { + static let controlHeight: CGFloat = 36 + static let controlSpacing: CGFloat = 8 + static let rowHorizontalPadding: CGFloat = 24 + static let rowTopPadding: CGFloat = 14 + static let rowBottomPadding: CGFloat = 12 + + static let fill = OmiColors.backgroundSecondary + static let stroke = OmiColors.border.opacity(0.18) + static let activeFill = OmiColors.backgroundRaised + static let activeStroke = OmiColors.border.opacity(0.6) +} + +extension View { + /// Standard capsule surface for a header control. Content supplies its + /// own glyphs/text; this fixes the height, padding, fill, and stroke so + /// every control in a header row reads as one family. + func omiHeaderControl(isActive: Bool = false) -> some View { + padding(.horizontal, 14) + .frame(height: OmiHeader.controlHeight) + .background( + Capsule(style: .continuous) + .fill(isActive ? OmiHeader.activeFill : OmiHeader.fill) + ) + .overlay( + Capsule(style: .continuous) + .stroke(isActive ? OmiHeader.activeStroke : OmiHeader.stroke, lineWidth: 1) + ) + } +} + +/// Capsule search field shared by every list page header. +struct OmiSearchField: View { + let placeholder: String + @Binding var text: String + var isBusy: Bool = false + /// Custom clear behavior (e.g. cancel an in-flight search); defaults to + /// emptying the bound text. + var onClear: (() -> Void)? = nil + + var body: some View { + HStack(spacing: 8) { + if isBusy { + ProgressView() + .scaleEffect(0.6) + .frame(width: 14, height: 14) + } else { + Image(systemName: "magnifyingglass") + .scaledFont(size: 13) + .foregroundColor(OmiColors.textTertiary) + } + + TextField(placeholder, text: $text) + .textFieldStyle(.plain) + .scaledFont(size: 13) + .foregroundColor(OmiColors.textPrimary) + + if !text.isEmpty { + Button { + if let onClear { + onClear() + } else { + text = "" + } + } label: { + Image(systemName: "xmark.circle.fill") + .scaledFont(size: 13) + .foregroundColor(OmiColors.textTertiary) + } + .buttonStyle(.plain) + } + } + .omiHeaderControl() + } +} + +/// Circular icon button for header rows. `prominent` renders filled ink for +/// the row's single primary action (e.g. add). +struct OmiHeaderIconButton: View { + let systemImage: String + var isActive: Bool = false + var isProminent: Bool = false + let action: () -> Void + + @State private var isHovering = false + + var body: some View { + Button(action: action) { + Image(systemName: systemImage) + .scaledFont(size: 13, weight: .medium) + .foregroundColor(glyphColor) + .frame(width: OmiHeader.controlHeight, height: OmiHeader.controlHeight) + .background(Circle().fill(fillColor)) + .overlay( + Circle().stroke( + isProminent ? Color.clear : (isActive ? OmiHeader.activeStroke : OmiHeader.stroke), + lineWidth: 1) + ) + } + .buttonStyle(.plain) + .onHover { isHovering = $0 } + } + + private var glyphColor: Color { + if isProminent { return .black } + if isActive || isHovering { return OmiColors.textPrimary } + return OmiColors.textSecondary + } + + private var fillColor: Color { + if isProminent { return OmiColors.textPrimary } + return isActive ? OmiHeader.activeFill : OmiHeader.fill + } +} + +/// Label content for a capsule chip: optional glyph + title + optional +/// chevron. Usable directly as a `Menu` label; `OmiHeaderChip` wraps it in a +/// button. +struct OmiHeaderChipLabel: View { + var systemImage: String? = nil + let title: String + var isActive: Bool = false + var showsChevron: Bool = false + /// Semantic tint for the glyph in the active state (e.g. amber star); + /// chrome stays neutral. + var activeGlyphTint: Color? = nil + + var body: some View { + HStack(spacing: 6) { + if let systemImage { + Image(systemName: systemImage) + .scaledFont(size: 12) + .foregroundColor(isActive ? (activeGlyphTint ?? OmiColors.textPrimary) : OmiColors.textSecondary) + } + Text(title) + .scaledFont(size: 13, weight: .medium) + .foregroundColor(isActive ? OmiColors.textPrimary : OmiColors.textSecondary) + if showsChevron { + Image(systemName: "chevron.down") + .scaledFont(size: 10) + .foregroundColor(OmiColors.textTertiary) + } + } + .omiHeaderControl(isActive: isActive) + } +} + +/// Capsule chip button for header filter/action rows. +struct OmiHeaderChip: View { + var systemImage: String? = nil + let title: String + var isActive: Bool = false + var showsChevron: Bool = false + var activeGlyphTint: Color? = nil + let action: () -> Void + + var body: some View { + Button(action: action) { + OmiHeaderChipLabel( + systemImage: systemImage, + title: title, + isActive: isActive, + showsChevron: showsChevron, + activeGlyphTint: activeGlyphTint + ) + } + .buttonStyle(.plain) + } +} diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift index 8b70f4e2cda..266e56fa44b 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift @@ -164,26 +164,12 @@ struct ConversationsPage: View { private var mainConversationsView: some View { // Title and status controls live in the window toolbar. conversationListSection - .padding(.top, 6) } private var quickNoteButton: some View { - Button { + OmiHeaderChip(systemImage: "note.text", title: "Quick Note") { NotificationCenter.default.post(name: .navigateToRewindNotes, object: nil) - } label: { - HStack(spacing: 5) { - Image(systemName: "note.text") - .scaledFont(size: 12) - Text("Quick Note") - .scaledFont(size: 13, weight: .medium) - } - .foregroundColor(OmiColors.textSecondary) - .padding(.horizontal, 14) - .padding(.vertical, 9) - .omiControlSurface( - fill: OmiColors.backgroundSecondary, radius: 18, stroke: OmiColors.border.opacity(0.18)) } - .buttonStyle(.plain) } // MARK: - Conversation List Section @@ -191,42 +177,22 @@ struct ConversationsPage: View { private var conversationListSection: some View { VStack(spacing: 0) { // Section header with search bar and filters - HStack(spacing: 8) { - // Search bar - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .scaledFont(size: 13) - .foregroundColor(OmiColors.textTertiary) - - TextField("Search conversations...", text: $searchModel.query) - .textFieldStyle(.plain) - .scaledFont(size: 13) - .foregroundColor(OmiColors.textPrimary) - - if !searchModel.query.isEmpty { - Button(action: { - searchModel.clear() - }) { - Image(systemName: "xmark.circle.fill") - .scaledFont(size: 13) - .foregroundColor(OmiColors.textTertiary) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 11) - .frame(minHeight: 46) - .omiControlSurface( - fill: OmiColors.backgroundSecondary, radius: 18, stroke: OmiColors.border.opacity(0.18)) + HStack(spacing: OmiHeader.controlSpacing) { + OmiSearchField( + placeholder: "Search conversations...", + text: $searchModel.query, + isBusy: searchModel.isSearching, + onClear: { searchModel.clear() } + ) // Filter buttons filterButtonsRow quickNoteButton } - .padding(.horizontal, 24) - .padding(.vertical, 12) + .padding(.horizontal, OmiHeader.rowHorizontalPadding) + .padding(.top, OmiHeader.rowTopPadding) + .padding(.bottom, OmiHeader.rowBottomPadding) // Folder tabs strip FolderTabsStrip( @@ -399,20 +365,14 @@ struct ConversationsPage: View { } else { Image(systemName: appState.showStarredOnly ? "star.fill" : "star") .scaledFont(size: 12) + .foregroundColor(appState.showStarredOnly ? OmiColors.amber : OmiColors.textSecondary) } Text("Starred") - .scaledFont(size: 12, weight: .medium) + .scaledFont(size: 13, weight: .medium) + .foregroundColor( + appState.showStarredOnly ? OmiColors.textPrimary : OmiColors.textSecondary) } - .foregroundColor(appState.showStarredOnly ? OmiColors.amber : OmiColors.textSecondary) - .padding(.horizontal, 14) - .padding(.vertical, 9) - .omiControlSurface( - fill: appState.showStarredOnly - ? OmiColors.amber.opacity(0.16) : OmiColors.backgroundSecondary, - radius: 16, - stroke: appState.showStarredOnly - ? OmiColors.amber.opacity(0.36) : OmiColors.border.opacity(0.14) - ) + .omiHeaderControl(isActive: appState.showStarredOnly) } .buttonStyle(.plain) .disabled(isFilteringStarred) @@ -432,7 +392,7 @@ struct ConversationsPage: View { } if let date = appState.selectedDateFilter { Text(formatFilterDate(date)) - .scaledFont(size: 12, weight: .medium) + .scaledFont(size: 13, weight: .medium) // Clear button Button(action: { Task { @@ -447,19 +407,13 @@ struct ConversationsPage: View { .buttonStyle(.plain) } else { Text("Date") - .scaledFont(size: 12, weight: .medium) + .scaledFont(size: 13, weight: .medium) } } - .foregroundColor(appState.selectedDateFilter != nil ? .black : OmiColors.textSecondary) - .padding(.horizontal, 14) - .padding(.vertical, 9) - .omiControlSurface( - fill: appState.selectedDateFilter != nil - ? OmiColors.textPrimary : OmiColors.backgroundSecondary, - radius: 16, - stroke: appState.selectedDateFilter != nil - ? OmiColors.border.opacity(0.28) : OmiColors.border.opacity(0.14) + .foregroundColor( + appState.selectedDateFilter != nil ? OmiColors.textPrimary : OmiColors.textSecondary ) + .omiHeaderControl(isActive: appState.selectedDateFilter != nil) } .buttonStyle(.plain) .disabled(isFilteringDate) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift index 77f87cf0b3a..13f58adea81 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift @@ -1497,36 +1497,12 @@ struct MemoriesPage: View { // MARK: - Header private var header: some View { - HStack(spacing: 12) { - // Search field - HStack(spacing: 10) { - if viewModel.isSearching || viewModel.isLoadingFiltered { - ProgressView() - .scaleEffect(0.7) - .frame(width: 14, height: 14) - } else { - Image(systemName: "magnifyingglass") - .foregroundColor(OmiColors.textTertiary) - } - - TextField("Search memories...", text: $viewModel.searchText) - .textFieldStyle(.plain) - .foregroundColor(OmiColors.textPrimary) - - if !viewModel.searchText.isEmpty { - Button { - viewModel.searchText = "" - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundColor(OmiColors.textTertiary) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 14) - .padding(.vertical, 12) - .frame(minHeight: 46) - .omiControlSurface(fill: OmiColors.backgroundTertiary, radius: 18) + HStack(spacing: OmiHeader.controlSpacing) { + OmiSearchField( + placeholder: "Search memories...", + text: $viewModel.searchText, + isBusy: viewModel.isSearching || viewModel.isLoadingFiltered + ) if viewModel.canonicalLifecycleExposed { // Layer filter dropdown. Default is product default access: Short-term + Long-term. @@ -1545,25 +1521,12 @@ struct MemoriesPage: View { .help(filter.description) } } label: { - HStack(spacing: 6) { - Image(systemName: viewModel.selectedLayerFilter == .archive ? "archivebox" : "clock.badge.checkmark") - .scaledFont(size: 12) - Text(viewModel.selectedLayerFilter.displayName) - .scaledFont(size: 13, weight: viewModel.selectedLayerFilter == .defaultAccess ? .regular : .medium) - Image(systemName: "chevron.down") - .scaledFont(size: 10) - } - .foregroundColor( - viewModel.selectedLayerFilter == .defaultAccess ? OmiColors.textSecondary : OmiColors.textPrimary - ) - .padding(.horizontal, 14) - .padding(.vertical, 12) - .frame(minHeight: 46) - .omiControlSurface( - fill: viewModel.selectedLayerFilter == .defaultAccess - ? OmiColors.backgroundTertiary : OmiColors.backgroundRaised, - radius: 18, - stroke: viewModel.selectedLayerFilter == .defaultAccess ? nil : OmiColors.border.opacity(0.6) + OmiHeaderChipLabel( + systemImage: viewModel.selectedLayerFilter == .archive + ? "archivebox" : "clock.badge.checkmark", + title: viewModel.selectedLayerFilter.displayName, + isActive: viewModel.selectedLayerFilter != .defaultAccess, + showsChevron: true ) } .menuStyle(.button) @@ -1571,96 +1534,47 @@ struct MemoriesPage: View { .help("Default shows Short-term + Long-term. Archive is explicit.") } - Button { + OmiHeaderChip( + systemImage: "desktopcomputer", + title: "This device", + isActive: viewModel.filterThisDeviceOnly + ) { viewModel.filterThisDeviceOnly.toggle() - } label: { - HStack(spacing: 6) { - Image(systemName: "desktopcomputer") - .scaledFont(size: 12) - Text("This device") - .scaledFont(size: 13, weight: viewModel.filterThisDeviceOnly ? .medium : .regular) - } - .foregroundColor( - viewModel.filterThisDeviceOnly ? OmiColors.textPrimary : OmiColors.textSecondary - ) - .padding(.horizontal, 14) - .padding(.vertical, 12) - .frame(minHeight: 46) - .omiControlSurface( - fill: viewModel.filterThisDeviceOnly - ? OmiColors.backgroundRaised : OmiColors.backgroundTertiary, - radius: 18, - stroke: viewModel.filterThisDeviceOnly ? OmiColors.border.opacity(0.6) : nil - ) } - .buttonStyle(.plain) .help("Show memories captured on this Mac") // Category filter dropdown - Button { + OmiHeaderChip( + systemImage: "line.3.horizontal.decrease", + title: categoryFilterLabel, + isActive: !viewModel.selectedTags.isEmpty, + showsChevron: true + ) { pendingSelectedTags = viewModel.selectedTags categorySearchText = "" showCategoryFilter = true - } label: { - HStack(spacing: 6) { - Image(systemName: "line.3.horizontal.decrease") - .scaledFont(size: 12) - Text(categoryFilterLabel) - .scaledFont(size: 13, weight: viewModel.selectedTags.isEmpty ? .regular : .medium) - Image(systemName: "chevron.down") - .scaledFont(size: 10) - } - .foregroundColor( - viewModel.selectedTags.isEmpty ? OmiColors.textSecondary : OmiColors.textPrimary - ) - .padding(.horizontal, 14) - .padding(.vertical, 12) - .frame(minHeight: 46) - .omiControlSurface( - fill: viewModel.selectedTags.isEmpty - ? OmiColors.backgroundTertiary : OmiColors.backgroundRaised, - radius: 18, - stroke: viewModel.selectedTags.isEmpty ? nil : OmiColors.border.opacity(0.6) - ) } - .buttonStyle(.plain) .popover(isPresented: $showCategoryFilter, arrowEdge: .bottom) { categoryFilterPopover } // Add Memory button (icon only) - Button { + OmiHeaderIconButton(systemImage: "plus", isProminent: true) { viewModel.showingAddMemory = true - } label: { - Image(systemName: "plus") - .scaledFont(size: 14) - .foregroundColor(.black) - .frame(width: 42, height: 42) - .background(OmiColors.textPrimary) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } - .buttonStyle(.plain) .help("Add Memory") // Management menu - Button { + OmiHeaderIconButton(systemImage: "chevron.down") { showManagementMenu = true - } label: { - Image(systemName: "chevron.down") - .scaledFont(size: 12, weight: .medium) - .foregroundColor(.black) - .frame(width: 42, height: 42) - .background(OmiColors.textPrimary) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } - .buttonStyle(.plain) .popover(isPresented: $showManagementMenu, arrowEdge: .bottom) { managementMenuPopover } } - .padding(.horizontal, 28) - .padding(.top, 24) - .padding(.bottom, 20) + .padding(.horizontal, OmiHeader.rowHorizontalPadding) + .padding(.top, OmiHeader.rowTopPadding) + .padding(.bottom, OmiHeader.rowBottomPadding) .alert("Delete Default Memories?", isPresented: $viewModel.showingDeleteAllConfirmation) { Button("Cancel", role: .cancel) {} Button("Delete Default Memories", role: .destructive) { diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift index e3fc00e80bd..2e52cb4b461 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift @@ -2873,60 +2873,22 @@ struct TasksPage: View { // MARK: - Header View private var headerView: some View { - HStack(spacing: 10) { - // Search field - HStack(spacing: 8) { - if viewModel.isSearching || viewModel.isLoadingFiltered { - ProgressView() - .scaleEffect(0.7) - .frame(width: 14, height: 14) - } else { - Image(systemName: "magnifyingglass") - .scaledFont(size: 14) - .foregroundColor(OmiColors.textTertiary) - } - - TextField("Search tasks...", text: $viewModel.searchText) - .textFieldStyle(.plain) - .foregroundColor(OmiColors.textPrimary) - - if !viewModel.searchText.isEmpty { - Button { - viewModel.searchText = "" - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundColor(OmiColors.textTertiary) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 11) - .frame(minHeight: 46) - .omiControlSurface( - fill: OmiColors.backgroundSecondary, radius: 18, - stroke: OmiColors.border.opacity(0.18)) + HStack(spacing: OmiHeader.controlSpacing) { + OmiSearchField( + placeholder: "Search tasks...", + text: $viewModel.searchText, + isBusy: viewModel.isSearching || viewModel.isLoadingFiltered + ) // Saved filter view chips if !viewModel.savedFilterViews.isEmpty && !viewModel.isMultiSelectMode { ForEach(viewModel.savedFilterViews) { savedView in - let isActive = viewModel.isActiveSavedView(savedView) - Button { + OmiHeaderChip( + title: savedView.name, + isActive: viewModel.isActiveSavedView(savedView) + ) { viewModel.applySavedView(savedView) - } label: { - Text(savedView.name) - .scaledFont(size: 11, weight: .medium) - .foregroundColor(isActive ? .white : OmiColors.textSecondary) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(isActive ? OmiColors.backgroundTertiary : OmiColors.backgroundSecondary) - .cornerRadius(6) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(isActive ? OmiColors.border : Color.clear, lineWidth: 1) - ) } - .buttonStyle(.plain) .contextMenu { Button(role: .destructive) { viewModel.deleteSavedView(savedView) @@ -2960,9 +2922,9 @@ struct TasksPage: View { taskSettingsButton } } - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 12) + .padding(.horizontal, OmiHeader.rowHorizontalPadding) + .padding(.top, OmiHeader.rowTopPadding) + .padding(.bottom, OmiHeader.rowBottomPadding) .alert("Save Filter View", isPresented: $showSaveFilterAlert) { TextField("View name", text: $saveFilterName) Button("Save") { @@ -2981,36 +2943,18 @@ struct TasksPage: View { } private var saveFilterButton: some View { - Button { + OmiHeaderIconButton(systemImage: "bookmark") { saveFilterName = "" showSaveFilterAlert = true - } label: { - Image(systemName: "bookmark") - .scaledFont(size: 12) - .foregroundColor(OmiColors.textSecondary) - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background(OmiColors.backgroundSecondary) - .cornerRadius(8) } - .buttonStyle(.plain) .help("Save current filters as a view") } private var addTaskButton: some View { - Button { + OmiHeaderIconButton(systemImage: "plus", isProminent: true) { viewModel.inlineCreateAfterTaskId = nil viewModel.isInlineCreating = true - } label: { - Image(systemName: "plus") - .scaledFont(size: 13) - .foregroundColor(OmiColors.textSecondary) - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background(OmiColors.backgroundSecondary) - .cornerRadius(8) } - .buttonStyle(.plain) .help("Add task (⌘N)") } @@ -3057,25 +3001,16 @@ struct TasksPage: View { } private var filterDropdownButton: some View { - Button { + OmiHeaderIconButton( + systemImage: "line.3.horizontal.decrease", + isActive: viewModel.hasActiveFilters + ) { pendingSelectedTags = viewModel.selectedTags pendingSelectedDynamicTags = viewModel.selectedDynamicTags filterSearchText = "" showFilterPopover = true - } label: { - Image(systemName: "line.3.horizontal.decrease") - .scaledFont(size: 12) - .foregroundColor(viewModel.hasActiveFilters ? OmiColors.textPrimary : OmiColors.textSecondary) - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background(OmiColors.backgroundSecondary) - .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(viewModel.hasActiveFilters ? OmiColors.border : Color.clear, lineWidth: 1) - ) } - .buttonStyle(.plain) + .help("Filter tasks") .popover(isPresented: $showFilterPopover, arrowEdge: .bottom) { filterPopover } @@ -3338,57 +3273,37 @@ struct TasksPage: View { .scaledFont(size: 13, weight: .medium) } .foregroundColor(.white) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(Color.red) - ) + .padding(.horizontal, 14) + .frame(height: OmiHeader.controlHeight) + .background(Capsule(style: .continuous).fill(Color.red)) } .buttonStyle(.plain) } private var cancelMultiSelectButton: some View { - Button { + OmiHeaderChip(title: "Cancel") { viewModel.toggleMultiSelectMode() - } label: { - Text("Cancel") - .scaledFont(size: 13, weight: .medium) - .foregroundColor(OmiColors.textSecondary) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(OmiColors.backgroundSecondary) - ) } - .buttonStyle(.plain) } private var taskSettingsButton: some View { - Button { + OmiHeaderIconButton(systemImage: "gearshape") { NotificationCenter.default.post( name: .navigateToTaskSettings, object: nil ) - } label: { - Image(systemName: "gearshape") - .scaledFont(size: 12) - .foregroundColor(OmiColors.textSecondary) - .padding(8) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(OmiColors.backgroundSecondary) - ) } - .buttonStyle(.plain) .help("Task Settings") } private var chatToggleButton: some View { - Button { + OmiHeaderIconButton( + systemImage: showChatPanel + ? "bubble.left.and.bubble.right.fill" : "bubble.left.and.bubble.right", + isActive: showChatPanel + ) { if showChatPanel { closeChatPanel() } else if let selectedId = viewModel.keyboardSelectedTaskId, @@ -3402,17 +3317,7 @@ struct TasksPage: View { showChatPanel = true } } - } label: { - Image(systemName: showChatPanel ? "bubble.left.and.bubble.right.fill" : "bubble.left.and.bubble.right") - .scaledFont(size: 12) - .foregroundColor(showChatPanel ? OmiColors.purplePrimary : OmiColors.textSecondary) - .padding(8) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(showChatPanel ? OmiColors.purplePrimary.opacity(0.15) : OmiColors.backgroundSecondary) - ) } - .buttonStyle(.plain) .help(showChatPanel ? "Close chat panel" : "Open task chat") } diff --git a/desktop/macos/changelog/unreleased/20260707-native-toolbar-consistent-pages.json b/desktop/macos/changelog/unreleased/20260707-native-toolbar-consistent-pages.json new file mode 100644 index 00000000000..b2d82333bc6 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260707-native-toolbar-consistent-pages.json @@ -0,0 +1,3 @@ +{ + "change": "Redesigned the main window with a native macOS toolbar — labeled Capture and Listening controls with status badges — and unified the search, filter, and button styles across the Conversations, Tasks, and Memories pages" +} From cc714c4874e90a94f872d9a76a9ee80a35677aea Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:56:56 -0400 Subject: [PATCH 11/31] Make toolbar Capture and Listening states readable at a glance --- .../Components/ToolbarStatusControls.swift | 46 +++++++++++++------ .../Tests/DashboardCaptureStateTests.swift | 4 ++ 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ToolbarStatusControls.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ToolbarStatusControls.swift index 7074f6ef65e..75c1636e33d 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ToolbarStatusControls.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ToolbarStatusControls.swift @@ -124,10 +124,11 @@ struct ToolbarStatusControls: View { } } -/// A labeled toolbar button whose running state shows as a small badge on -/// the glyph: green dot = running, amber triangle = needs attention, -/// nothing = off. The label says what the control is; the badge says how -/// it's doing. +/// A labeled toolbar toggle whose state must be readable at a glance: +/// running = green-tinted capsule fill with a green glyph, needs attention = +/// amber-tinted fill with a warning badge, off = plain dimmed glyph. Owns +/// its visuals (`.plain` style) so the toolbar doesn't stack button chrome +/// on top of the state background. struct ToolbarStatusButton: View { enum DotState { case active @@ -141,32 +142,49 @@ struct ToolbarStatusButton: View { let action: () -> Void let helpText: String + @State private var isHovering = false + var body: some View { Button(action: action) { HStack(spacing: 5) { Image(systemName: systemImage) - .foregroundStyle(state == .off ? Color.secondary : Color.primary) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(glyphColor) .overlay(alignment: .topTrailing) { - switch state { - case .active: - Circle() - .fill(.green) - .frame(width: 5, height: 5) - .offset(x: 4, y: -3) - case .attention: + if state == .attention { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 7)) .foregroundStyle(.yellow) .offset(x: 6, y: -4) - case .off: - EmptyView() } } Text(title) .font(.system(size: 12, weight: .medium)) .foregroundStyle(state == .off ? Color.secondary : Color.primary) } + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(Capsule(style: .continuous).fill(backgroundFill)) + .contentShape(Capsule(style: .continuous)) } + .buttonStyle(.plain) + .onHover { isHovering = $0 } .help(helpText) } + + private var glyphColor: Color { + switch state { + case .active: return HomePalette.green + case .attention: return Color.primary + case .off: return Color.secondary + } + } + + private var backgroundFill: Color { + switch state { + case .active: return HomePalette.green.opacity(0.16) + case .attention: return Color.yellow.opacity(0.12) + case .off: return isHovering ? Color.white.opacity(0.07) : Color.clear + } + } } diff --git a/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift b/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift index bf74a38db1e..7d2ff14fd7b 100644 --- a/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift +++ b/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift @@ -51,6 +51,10 @@ final class DashboardCaptureStateTests: XCTestCase { toolbar.contains(".tint(Color.secondary)"), "The settings menu glyph must override the app's purple root tint" ) + // On/off must be readable at a glance: the running state fills the + // control, it doesn't hide in a tiny badge. + XCTAssertTrue(toolbar.contains("return HomePalette.green.opacity(0.16)")) + XCTAssertTrue(toolbar.contains("return Color.yellow.opacity(0.12)")) } func testHomeConnectorButtonsOpenSheetsDirectly() throws { From 0d056fae5bd822f3ff965b1bb741e2e5953cacb7 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:08:03 -0400 Subject: [PATCH 12/31] Show the Today section task count next to its clean action --- .../Sources/MainWindow/Pages/TasksPage.swift | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift index 2e52cb4b461..f5ff8741e4e 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift @@ -3767,6 +3767,18 @@ struct TaskCategorySection: View { Spacer() + // Every section shows its task count; Today additionally + // offers the clean-up action next to it. + Text("\(orderedTasks.count)") + .scaledFont(size: 12, weight: .medium) + .foregroundColor(OmiColors.textTertiary) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background( + Capsule() + .fill(OmiColors.textTertiary.opacity(0.1)) + ) + if category == .today { Button { confirmClearTodayDeadlines() @@ -3779,16 +3791,6 @@ struct TaskCategorySection: View { .buttonStyle(.plain) .contentShape(Rectangle()) .help("Clean today's tasks") - } else { - Text("\(orderedTasks.count)") - .scaledFont(size: 12, weight: .medium) - .foregroundColor(OmiColors.textTertiary) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background( - Capsule() - .fill(OmiColors.textTertiary.opacity(0.1)) - ) } } From 14de2ff89579fc0636203ea223f4b60a9a7d7b18 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:08:22 -0400 Subject: [PATCH 13/31] Remove the prominent fill from header add buttons --- .../Components/OmiHeaderControls.swift | 26 ++++++------------- .../MainWindow/Pages/MemoriesPage.swift | 2 +- .../Sources/MainWindow/Pages/TasksPage.swift | 2 +- 3 files changed, 10 insertions(+), 20 deletions(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/OmiHeaderControls.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/OmiHeaderControls.swift index 2212e803cc9..6fa10011a85 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/OmiHeaderControls.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/OmiHeaderControls.swift @@ -81,12 +81,12 @@ struct OmiSearchField: View { } } -/// Circular icon button for header rows. `prominent` renders filled ink for -/// the row's single primary action (e.g. add). +/// Circular icon button for header rows. All header actions render at the +/// same quiet weight — native macOS header rows don't shout any single +/// action with an accent fill. struct OmiHeaderIconButton: View { let systemImage: String var isActive: Bool = false - var isProminent: Bool = false let action: () -> Void @State private var isHovering = false @@ -95,29 +95,19 @@ struct OmiHeaderIconButton: View { Button(action: action) { Image(systemName: systemImage) .scaledFont(size: 13, weight: .medium) - .foregroundColor(glyphColor) + .foregroundColor( + isActive || isHovering ? OmiColors.textPrimary : OmiColors.textSecondary + ) .frame(width: OmiHeader.controlHeight, height: OmiHeader.controlHeight) - .background(Circle().fill(fillColor)) + .background(Circle().fill(isActive ? OmiHeader.activeFill : OmiHeader.fill)) .overlay( Circle().stroke( - isProminent ? Color.clear : (isActive ? OmiHeader.activeStroke : OmiHeader.stroke), - lineWidth: 1) + isActive ? OmiHeader.activeStroke : OmiHeader.stroke, lineWidth: 1) ) } .buttonStyle(.plain) .onHover { isHovering = $0 } } - - private var glyphColor: Color { - if isProminent { return .black } - if isActive || isHovering { return OmiColors.textPrimary } - return OmiColors.textSecondary - } - - private var fillColor: Color { - if isProminent { return OmiColors.textPrimary } - return isActive ? OmiHeader.activeFill : OmiHeader.fill - } } /// Label content for a capsule chip: optional glyph + title + optional diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift index 13f58adea81..32bac408809 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift @@ -1559,7 +1559,7 @@ struct MemoriesPage: View { } // Add Memory button (icon only) - OmiHeaderIconButton(systemImage: "plus", isProminent: true) { + OmiHeaderIconButton(systemImage: "plus") { viewModel.showingAddMemory = true } .help("Add Memory") diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift index f5ff8741e4e..c5ca6d77d35 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift @@ -2951,7 +2951,7 @@ struct TasksPage: View { } private var addTaskButton: some View { - OmiHeaderIconButton(systemImage: "plus", isProminent: true) { + OmiHeaderIconButton(systemImage: "plus") { viewModel.inlineCreateAfterTaskId = nil viewModel.isInlineCreating = true } From d80a84baf613fb030a8198af39181a5b2b5fd89d Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:24:04 -0400 Subject: [PATCH 14/31] Clean up task rows: hover-cluster detail button, first-line checkbox, neutral chat accents --- .../MainWindow/Pages/TaskDetailViews.swift | 2 +- .../Sources/MainWindow/Pages/TasksPage.swift | 22 ++++++++++++------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/TaskDetailViews.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/TaskDetailViews.swift index c406ca528da..d5228b7af6c 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/TaskDetailViews.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/TaskDetailViews.swift @@ -20,7 +20,7 @@ struct TaskDetailButton: View { showDetail = true } label: { Image(systemName: "info.circle") - .scaledFont(size: 10) + .scaledFont(size: 12) .foregroundColor(showTooltip ? OmiColors.textSecondary : OmiColors.textTertiary) } .buttonStyle(.plain) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift index c5ca6d77d35..832dafca31f 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift @@ -4217,7 +4217,7 @@ struct TaskRow: View { } var body: some View { - HStack(alignment: .center, spacing: 0) { + HStack(alignment: .top, spacing: 0) { // Drag handle OUTSIDE swipeableContent so DragGesture doesn't intercept it if category != nil && !isMultiSelectMode && !isDeletedTask { Image(systemName: "line.3.horizontal") @@ -4252,11 +4252,11 @@ struct TaskRow: View { } .background( RoundedRectangle(cornerRadius: 8) - .fill(isActiveChatTask ? OmiColors.purplePrimary.opacity(0.08) : Color.clear) + .fill(isActiveChatTask ? OmiColors.backgroundRaised : Color.clear) ) .overlay( RoundedRectangle(cornerRadius: 8) - .stroke(isActiveChatTask ? OmiColors.purplePrimary.opacity(0.3) : Color.clear, lineWidth: 1) + .stroke(isActiveChatTask ? OmiColors.border.opacity(0.6) : Color.clear, lineWidth: 1) ) .overlay(alignment: .topTrailing) { if showShareCopiedToast { @@ -4441,7 +4441,9 @@ struct TaskRow: View { } private var taskRowContent: some View { - HStack(alignment: .center, spacing: 12) { + // Top-aligned so the checkbox sits on the first text line of wrapped + // rows instead of floating between lines. + HStack(alignment: .top, spacing: 12) { // Indent visual (vertical line for indented tasks) if indentLevel > 0 { HStack(spacing: 0) { @@ -4642,7 +4644,7 @@ struct TaskRow: View { Text("View chat") .scaledFont(size: 10, weight: .medium) } - .foregroundColor(OmiColors.purplePrimary) + .foregroundColor(OmiColors.textSecondary) } .buttonStyle(.plain) .help("View previous AI investigation") @@ -4668,9 +4670,6 @@ struct TaskRow: View { if let coordinator = chatCoordinator, TaskAgentSettings.shared.isChatEnabled { ChatSessionStatusIndicator(task: task, coordinator: coordinator, onOpenChat: onOpenChat) } - - // Task detail button (hover for preview, click for full detail) - TaskDetailButton(task: task, showDetail: $showTaskDetail) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -4773,6 +4772,13 @@ struct TaskRow: View { .help("Increase indent") } + // Task detail (hover for preview, click for full detail). + // Lives with the other row actions instead of costing every + // row a permanent badge line under the title. + TaskDetailButton(task: task, showDetail: $showTaskDetail) + .frame(width: 24, height: 24) + .help("Task details") + // Share link button Button { Task { await copyShareLink() } From d2cb669e34944a5c5ba47ee5ba79a257466f4673 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:32:49 -0400 Subject: [PATCH 15/31] Match Rewind header controls to the shared page header style --- .../Sources/Rewind/UI/RewindPage.swift | 47 +++++++------------ 1 file changed, 17 insertions(+), 30 deletions(-) diff --git a/desktop/macos/Desktop/Sources/Rewind/UI/RewindPage.swift b/desktop/macos/Desktop/Sources/Rewind/UI/RewindPage.swift index a37e8dffd5b..f27b02e5aaf 100644 --- a/desktop/macos/Desktop/Sources/Rewind/UI/RewindPage.swift +++ b/desktop/macos/Desktop/Sources/Rewind/UI/RewindPage.swift @@ -321,8 +321,10 @@ struct RewindPage: View { private var rewindToggle: some View { ZStack { + // Green = capturing (matches the toolbar's capture-state language); + // neutral = off. Never red (reads as recording) or purple (banned). Capsule() - .fill(isMonitoring ? OmiColors.purplePrimary : Color.red) + .fill(isMonitoring ? HomePalette.green : Color.white.opacity(0.2)) .frame(width: 36, height: 20) Circle() @@ -390,7 +392,7 @@ struct RewindPage: View { // MARK: - Unified Top Bar (persistent search field) private var unifiedTopBar: some View { - HStack(spacing: 12) { + HStack(spacing: OmiHeader.controlSpacing) { // Left side: Back button (search timeline mode) or Rewind logo (other modes) if isInSearchMode && searchViewMode == .timeline { Button { @@ -471,25 +473,20 @@ struct RewindPage: View { Spacer() // Settings - Button { + OmiHeaderIconButton(systemImage: "gearshape") { NotificationCenter.default.post( name: .navigateToRewindSettings, object: nil ) - } label: { - Image(systemName: "gearshape") - .scaledFont(size: 12) - .foregroundColor(.white.opacity(0.6)) } - .buttonStyle(.plain) .help("Rewind Settings") // Rewind on/off toggle (screen capture only) rewindToggle } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(OmiColors.backgroundTertiary.opacity(0.8)) + .padding(.horizontal, OmiHeader.rowHorizontalPadding) + .padding(.top, OmiHeader.rowTopPadding) + .padding(.bottom, OmiHeader.rowBottomPadding) } // MARK: - Timeline Content Body (without top bar) @@ -600,8 +597,8 @@ struct RewindPage: View { private func searchField(showResultsCount: Bool = false) -> some View { HStack(spacing: 8) { Image(systemName: "magnifyingglass") - .scaledFont(size: 12) - .foregroundColor(isSearchFocused ? OmiColors.purplePrimary : .white.opacity(0.5)) + .scaledFont(size: 13) + .foregroundColor(OmiColors.textTertiary) TextField("Search your screen history...", text: $viewModel.searchQuery) .textFieldStyle(.plain) @@ -640,17 +637,10 @@ struct RewindPage: View { .buttonStyle(.plain) } } - .padding(.horizontal, 12) - .padding(.vertical, 8) + // Shared header-control surface; focus reads through the active + // (raised + stronger border) treatment, not a purple accent. + .omiHeaderControl(isActive: isSearchFocused) .frame(maxWidth: 400) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(Color.white.opacity(0.1)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(isSearchFocused ? OmiColors.purplePrimary.opacity(0.5) : Color.clear, lineWidth: 1) - ) - ) } // MARK: - Date Picker Controls @@ -661,16 +651,13 @@ struct RewindPage: View { } label: { HStack(spacing: 6) { Text(viewModel.selectedDate.formatted(.dateTime.month().day().year())) - .scaledFont(size: 12) - .foregroundColor(.white) + .scaledFont(size: 13, weight: .medium) + .foregroundColor(OmiColors.textSecondary) Image(systemName: "chevron.up.chevron.down") .scaledFont(size: 8, weight: .semibold) - .foregroundColor(.white.opacity(0.5)) + .foregroundColor(OmiColors.textTertiary) } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Color.white.opacity(0.15)) - .cornerRadius(6) + .omiHeaderControl() } .buttonStyle(.plain) .popover(isPresented: $showDatePicker) { From e55995625948405dc3281aec072b49aed6df7b92 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:32:50 -0400 Subject: [PATCH 16/31] Collapse chat input to a single surface with inline attach and send --- .../MainWindow/Components/ChatInputView.swift | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatInputView.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatInputView.swift index e7b39084ee1..d479dd4d585 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ChatInputView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ChatInputView.swift @@ -57,15 +57,19 @@ struct ChatInputView: View { attachments: currentAttachments, onRemove: { id in onAttachmentRemoved?(id) } ) + .padding(.top, 8) + .padding(.horizontal, 4) } - HStack(alignment: .center, spacing: 8) { + // One surface: paperclip, editor, and send all live inside the + // container — no field-inside-a-panel double chrome. + HStack(alignment: .bottom, spacing: 4) { if attachmentsEnabled { Button(action: pickFiles) { Image(systemName: "paperclip") - .scaledFont(size: 18, weight: .medium) + .scaledFont(size: 16, weight: .medium) .foregroundColor(OmiColors.textTertiary) - .frame(width: 32, height: 32) + .frame(width: 32, height: 40) } .buttonStyle(.plain) .help("Attach files") @@ -110,8 +114,6 @@ struct ChatInputView: View { } .frame(maxHeight: 200) .clipped() - .background(OmiColors.backgroundTertiary) - .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) // Floating Ask/Act toggle (top-right, inside the input area) if askModeEnabled { @@ -121,17 +123,20 @@ struct ChatInputView: View { } } - // Send/Stop button — inline to the right of the input + // Send/Stop button — inside the container, trailing edge. + // Upstream dropped follow-up-while-streaming, so Stop shows + // for the whole sending state. if isSending { if isStopping { ProgressView() .controlSize(.small) - .frame(width: 24, height: 24) + .frame(width: 28, height: 40) } else { Button(action: { onStop?() }) { Image(systemName: "stop.circle.fill") .scaledFont(size: 24) .foregroundColor(.red.opacity(0.8)) + .frame(width: 28, height: 40) } .buttonStyle(.plain) } @@ -139,15 +144,24 @@ struct ChatInputView: View { Button(action: handleSubmit) { Image(systemName: "arrow.up.circle.fill") .scaledFont(size: 24) - .foregroundColor(canSend ? OmiColors.purplePrimary : OmiColors.textQuaternary) + .foregroundColor(canSend ? OmiColors.textPrimary : OmiColors.textQuaternary) + .frame(width: 28, height: 40) } .buttonStyle(.plain) .disabled(!canSend) } } } - .padding(12) - .omiPanel(fill: OmiColors.backgroundSecondary, radius: 22, stroke: dropStrokeColor, shadowOpacity: 0.1, shadowRadius: 12, shadowY: 6) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(OmiColors.backgroundSecondary) + ) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(dropStrokeColor, lineWidth: 1) + ) .fixedSize(horizontal: false, vertical: true) .if(attachmentsEnabled) { view in view.onDrop(of: [UTType.fileURL], isTargeted: $isDropTargeted, perform: handleDrop) @@ -185,7 +199,7 @@ struct ChatInputView: View { } private var dropStrokeColor: Color { - isDropTargeted ? OmiColors.purplePrimary.opacity(0.6) : OmiColors.border.opacity(0.2) + isDropTargeted ? OmiColors.border : OmiColors.border.opacity(0.25) } private func handleSubmit() { @@ -421,10 +435,10 @@ struct ChatModeToggle: View { Button(action: { mode = targetMode }) { Text(label) .scaledFont(size: 12, weight: .medium) - .foregroundColor(mode == targetMode ? .white : OmiColors.textTertiary) + .foregroundColor(mode == targetMode ? .black : OmiColors.textTertiary) .padding(.horizontal, 10) .padding(.vertical, 5) - .background(mode == targetMode ? OmiColors.userBubble : Color.clear) + .background(mode == targetMode ? OmiColors.textPrimary : Color.clear) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } .buttonStyle(.plain) From 659c25013908f17b340e22d4eef623318efe5688 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:32:50 -0400 Subject: [PATCH 17/31] Align task drag handle with the checkbox on hover --- desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift index 832dafca31f..f17be045221 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift @@ -4224,6 +4224,9 @@ struct TaskRow: View { .scaledFont(size: 10) .foregroundColor(isHovering ? OmiColors.textTertiary : .clear) .frame(width: 16, height: 24) + // Match taskRowContent's vertical inset so the handle lines + // up with the checkbox in the top-aligned outer HStack. + .padding(.top, 6) .contentShape(Rectangle()) .onDrag { log("DRAG: onDrag started for task \(task.id) — \(task.description.prefix(40))") From 8ebe2a6b20818949126eea62ff8a737738cb7f99 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:43:06 -0400 Subject: [PATCH 18/31] Remove the standalone hotkey chip from the Rewind header --- .../Desktop/Sources/Rewind/UI/RewindPage.swift | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/desktop/macos/Desktop/Sources/Rewind/UI/RewindPage.swift b/desktop/macos/Desktop/Sources/Rewind/UI/RewindPage.swift index f27b02e5aaf..d05c26b685b 100644 --- a/desktop/macos/Desktop/Sources/Rewind/UI/RewindPage.swift +++ b/desktop/macos/Desktop/Sources/Rewind/UI/RewindPage.swift @@ -407,21 +407,11 @@ struct RewindPage: View { } .buttonStyle(.plain) .help("Back to results") - } else { - // Global hotkey hint (page title lives in the window toolbar) - HStack(spacing: 2) { - Text("⌘") - Text("⌥") - Text("R") - } - .scaledFont(size: 10, weight: .medium, design: .rounded) - .foregroundColor(.white.opacity(0.4)) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(Color.white.opacity(0.1)) - .cornerRadius(4) - .help("Press ⌘⌥R from anywhere to open Rewind") } + // No standalone hotkey chip here: it read as a search shortcut, + // floated unanchored, and displayed ⌘⌥R while the registered + // hotkey is actually ⌃⌥R. The shortcut itself still works; it + // belongs in tooltips/settings, not as loose header chrome. // Search field + date picker - always present searchField(showResultsCount: isInSearchMode) From 57cf0e132f0ed987904e6ca86bf25e4a4cfaff72 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:26:32 -0400 Subject: [PATCH 19/31] Revert "Open chat as a page instead of an inline panel on Home" This reverts commit 095d56e982885e22eb80acd5a6ce401dc3833599. --- .../MainWindow/Pages/DashboardPage.swift | 84 ++++++++++++++++++- 1 file changed, 81 insertions(+), 3 deletions(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift index 24b3f675723..4b3c33738bc 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift @@ -392,6 +392,13 @@ struct DashboardPage: View { appState.checkAllPermissions() Task { await homeStatus.refresh(force: false) } } + // Clicking into the ask bar reveals the inline chat; the same is true + // when focus lands there via keyboard (Tab / Full Keyboard Access). + .onChange(of: homeAskFieldFocused) { _, focused in + if focused && !useLegacyHomeDesign && homeMode != .chat { + openHomeChat() + } + } // Automation-bridge entry points (home_open_chat / home_connect_toggle / // home_close_panel / home_ask) — they call the exact functions the // on-screen controls call. @@ -572,6 +579,9 @@ struct DashboardPage: View { homeHubCenterpiece } .transition(.homeHubFade) + case .chat: + homeChatPanel + .transition(.homeDropFromTop) case .connect: homeConnectPanel .transition(.homeDropFromTop) @@ -649,6 +659,70 @@ struct DashboardPage: View { // MARK: Inline chat panel + private var homeChatPanel: 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: selectedApp, + onLoadMore: { await chatProvider.loadMoreMessages() }, + onRate: { messageId, rating in + Task { await chatProvider.rateMessage(messageId, rating: rating) } + }, + onCitationTap: { citation in + handleCitationTap(citation) + }, + sessionsLoadError: chatProvider.sessionsLoadError, + onRetry: { Task { await chatProvider.retryLoad() } }, + localSendToken: chatProvider.localSendToken, + onCancelTurn: { chatProvider.stopAgent(owner: .mainChat) }, + welcomeContent: { dashboardChatWelcome } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .mask( + LinearGradient( + stops: [ + .init(color: .clear, location: 0.0), + .init(color: .black, location: 0.05), + .init(color: .black, location: 0.97), + .init(color: .clear, location: 1.0), + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + // Barely-there card so the chat reads as a bounded surface — making it + // obvious the canvas around it is clickable (and closes the chat). + // The stroke is a soft gradient that dissolves toward the bottom. + .background( + RoundedRectangle(cornerRadius: 26, style: .continuous) + .fill(Color.white.opacity(0.012)) + ) + .overlay( + RoundedRectangle(cornerRadius: 26, style: .continuous) + .stroke( + LinearGradient( + colors: [ + HomePalette.hairline.opacity(0.45), + HomePalette.hairline.opacity(0.10), + ], + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 1 + ) + ) + .frame(maxWidth: Self.homeStagePanelMaxWidth) + .padding(.horizontal, Self.homeStageHorizontalPadding) + } + // MARK: Connect tray private var homeConnectPanel: some View { @@ -764,10 +838,12 @@ struct DashboardPage: View { } } - /// Chat lives on its own page — every "open chat" affordance navigates - /// there instead of dropping an inline panel over Home. private func openHomeChat() { - navigate(to: .chat) + guard homeMode != .chat else { return } + withAnimation(Self.homeStageAnimation) { + homeMode = .chat + } + reportHomeAutomationMode() } private func toggleHomeConnectPanel() { @@ -1441,11 +1517,13 @@ private enum HomeDestinationProminence { private enum HomeStageMode: Equatable { case hub + case chat case connect var automationLabel: String { switch self { case .hub: return "hub" + case .chat: return "chat" case .connect: return "connect" } } From 427d842d696b23f363133ec652fd6eb9222343d2 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:34:23 -0400 Subject: [PATCH 20/31] Drop the superseded page-header changelog fragment --- .../changelog/unreleased/20260707-consistent-page-header.json | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 desktop/macos/changelog/unreleased/20260707-consistent-page-header.json diff --git a/desktop/macos/changelog/unreleased/20260707-consistent-page-header.json b/desktop/macos/changelog/unreleased/20260707-consistent-page-header.json deleted file mode 100644 index c13a9f09028..00000000000 --- a/desktop/macos/changelog/unreleased/20260707-consistent-page-header.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "change": "Added a consistent page header across Home, Conversations, Tasks and Memories — page title, one-click Home, and the Capture/Listening controls always in reach" -} From fed99ca7dd301f545b23feab513ffe596df30ac3 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:22:26 -0400 Subject: [PATCH 21/31] Guard home connector status across session changes --- .../Sources/MainWindow/Pages/AppsPage.swift | 178 ++++++++++++++---- .../OnboardingPagedIntroCoordinator.swift | 23 ++- .../Sources/Stores/HomeStatusStore.swift | 93 ++++++--- .../Tests/DashboardCaptureStateTests.swift | 16 +- .../ImportConnectorStatusStoreTests.swift | 51 ++++- 5 files changed, 278 insertions(+), 83 deletions(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/AppsPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/AppsPage.swift index 63ba838274d..b3a94965506 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/AppsPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/AppsPage.swift @@ -750,10 +750,13 @@ final class ImportConnectorStatusStore: ObservableObject { private let manualConnectorIDs: Set = ["chatgpt", "claude"] private let onboardingChatGPTImportedMemoriesKey = "onboardingChatGPTImportedMemoriesCount" private let onboardingClaudeImportedMemoriesKey = "onboardingClaudeImportedMemoriesCount" + private var loadedUserID: String? + private var refreshGeneration = 0 init(defaults: UserDefaults = .standard) { self.defaults = defaults - load() + metricsByID = Self.emptyMetrics() + reloadForCurrentUserIfNeeded() } func snapshot(for connector: ImportConnector) -> Snapshot { @@ -782,78 +785,132 @@ final class ImportConnectorStatusStore: ObservableObject { availabilityText: String? = nil, syncedAt: Date = Date() ) { + reloadForCurrentUserIfNeeded() + let userID = loadedUserID var metrics = metricsByID[connectorID] ?? ConnectorMetrics() if let sourceCount { metrics.sourceCount = max(sourceCount, 0) - defaults.set(metrics.sourceCount, forKey: sourceCountKeyPrefix + connectorID) + if let key = scopedKey(sourceCountKeyPrefix, connectorID: connectorID, userID: userID) { + defaults.set(metrics.sourceCount, forKey: key) + } } if let memoryCount { metrics.memoryCount = max(memoryCount, 0) - defaults.set(metrics.memoryCount, forKey: memoryCountKeyPrefix + connectorID) + if let key = scopedKey(memoryCountKeyPrefix, connectorID: connectorID, userID: userID) { + defaults.set(metrics.memoryCount, forKey: key) + } } metrics.lastSyncedAt = syncedAt - defaults.set(syncedAt.timeIntervalSince1970, forKey: lastSyncedAtKeyPrefix + connectorID) + if let key = scopedKey(lastSyncedAtKeyPrefix, connectorID: connectorID, userID: userID) { + defaults.set(syncedAt.timeIntervalSince1970, forKey: key) + } metrics.lastDeltaCount = lastDeltaCount - defaults.set(lastDeltaCount != nil, forKey: hasLastDeltaKeyPrefix + connectorID) - if let lastDeltaCount { - defaults.set(lastDeltaCount, forKey: lastDeltaCountKeyPrefix + connectorID) - } else { - defaults.removeObject(forKey: lastDeltaCountKeyPrefix + connectorID) + if let key = scopedKey(hasLastDeltaKeyPrefix, connectorID: connectorID, userID: userID) { + defaults.set(lastDeltaCount != nil, forKey: key) + } + if let key = scopedKey(lastDeltaCountKeyPrefix, connectorID: connectorID, userID: userID) { + if let lastDeltaCount { + defaults.set(lastDeltaCount, forKey: key) + } else { + defaults.removeObject(forKey: key) + } } if let availabilityText { metrics.availabilityText = availabilityText - defaults.set(availabilityText, forKey: availabilityTextKeyPrefix + connectorID) + if let key = scopedKey(availabilityTextKeyPrefix, connectorID: connectorID, userID: userID) { + defaults.set(availabilityText, forKey: key) + } } metricsByID[connectorID] = metrics } private func clearStoredMetrics(for connectorID: String) { - defaults.removeObject(forKey: sourceCountKeyPrefix + connectorID) - defaults.removeObject(forKey: memoryCountKeyPrefix + connectorID) - defaults.removeObject(forKey: lastSyncedAtKeyPrefix + connectorID) - defaults.removeObject(forKey: lastDeltaCountKeyPrefix + connectorID) - defaults.removeObject(forKey: hasLastDeltaKeyPrefix + connectorID) - defaults.removeObject(forKey: availabilityTextKeyPrefix + connectorID) + guard let userID = loadedUserID else { + metricsByID[connectorID] = ConnectorMetrics() + return + } + for prefix in [ + sourceCountKeyPrefix, + memoryCountKeyPrefix, + lastSyncedAtKeyPrefix, + lastDeltaCountKeyPrefix, + hasLastDeltaKeyPrefix, + availabilityTextKeyPrefix, + ] { + if let key = scopedKey(prefix, connectorID: connectorID, userID: userID) { + defaults.removeObject(forKey: key) + } + } metricsByID[connectorID] = ConnectorMetrics() } func refresh() async { - await refreshLocalFilesMetrics() - await refreshAppleNotesMetrics() + reloadForCurrentUserIfNeeded() + let generation = refreshGeneration + let userID = loadedUserID + await refreshLocalFilesMetrics(generation: generation, userID: userID) + await refreshAppleNotesMetrics(generation: generation, userID: userID) + } + + func resetSessionState() { + refreshGeneration += 1 + loadedUserID = nil + metricsByID = Self.emptyMetrics() + } + + private func reloadForCurrentUserIfNeeded() { + let userID = currentUserID() + guard loadedUserID != userID else { return } + loadedUserID = userID + metricsByID = Self.emptyMetrics() + guard let userID else { return } + load(userID: userID) } - private func load() { + private func load(userID: String) { for connector in ImportConnector.all { var metrics = ConnectorMetrics() - if defaults.object(forKey: sourceCountKeyPrefix + connector.id) != nil { - metrics.sourceCount = defaults.integer(forKey: sourceCountKeyPrefix + connector.id) + if let key = scopedKey(sourceCountKeyPrefix, connectorID: connector.id, userID: userID), + defaults.object(forKey: key) != nil + { + metrics.sourceCount = defaults.integer(forKey: key) } - if defaults.object(forKey: memoryCountKeyPrefix + connector.id) != nil { - metrics.memoryCount = defaults.integer(forKey: memoryCountKeyPrefix + connector.id) + if let key = scopedKey(memoryCountKeyPrefix, connectorID: connector.id, userID: userID), + defaults.object(forKey: key) != nil + { + metrics.memoryCount = defaults.integer(forKey: key) } - if defaults.object(forKey: lastSyncedAtKeyPrefix + connector.id) != nil { - let timestamp = defaults.double(forKey: lastSyncedAtKeyPrefix + connector.id) + if let key = scopedKey(lastSyncedAtKeyPrefix, connectorID: connector.id, userID: userID), + defaults.object(forKey: key) != nil + { + let timestamp = defaults.double(forKey: key) if timestamp > 0 { metrics.lastSyncedAt = Date(timeIntervalSince1970: timestamp) } } - if defaults.bool(forKey: hasLastDeltaKeyPrefix + connector.id) { - metrics.lastDeltaCount = defaults.integer(forKey: lastDeltaCountKeyPrefix + connector.id) + if let key = scopedKey(hasLastDeltaKeyPrefix, connectorID: connector.id, userID: userID), + defaults.bool(forKey: key), + let deltaKey = scopedKey(lastDeltaCountKeyPrefix, connectorID: connector.id, userID: userID) + { + metrics.lastDeltaCount = defaults.integer(forKey: deltaKey) + } + if let key = scopedKey(availabilityTextKeyPrefix, connectorID: connector.id, userID: userID) { + metrics.availabilityText = defaults.string(forKey: key) } - metrics.availabilityText = defaults.string(forKey: availabilityTextKeyPrefix + connector.id) metricsByID[connector.id] = metrics } - hydrateLegacyManualImports() + hydrateLegacyManualImports(userID: userID) // A remembered path is not enough to call Apple Notes connected. The // status becomes connected only after the reader proves the store is readable. } - private func hydrateLegacyManualImports() { - let legacyChatGPTCount = defaults.integer(forKey: onboardingChatGPTImportedMemoriesKey) + private func hydrateLegacyManualImports(userID: String) { + let legacyChatGPTCount = defaults.integer( + forKey: scopedManualImportKey(onboardingChatGPTImportedMemoriesKey, userID: userID)) if legacyChatGPTCount > 0, metricsByID["chatgpt"]?.memoryCount == nil { var metrics = metricsByID["chatgpt"] ?? ConnectorMetrics() metrics.memoryCount = legacyChatGPTCount @@ -861,7 +918,8 @@ final class ImportConnectorStatusStore: ObservableObject { metricsByID["chatgpt"] = metrics } - let legacyClaudeCount = defaults.integer(forKey: onboardingClaudeImportedMemoriesKey) + let legacyClaudeCount = defaults.integer( + forKey: scopedManualImportKey(onboardingClaudeImportedMemoriesKey, userID: userID)) if legacyClaudeCount > 0, metricsByID["claude"]?.memoryCount == nil { var metrics = metricsByID["claude"] ?? ConnectorMetrics() metrics.memoryCount = legacyClaudeCount @@ -870,7 +928,7 @@ final class ImportConnectorStatusStore: ObservableObject { } } - private func refreshLocalFilesMetrics() async { + private func refreshLocalFilesMetrics(generation: Int, userID: String?) async { guard let dbQueue = await RewindDatabase.shared.getDatabaseQueue() else { return } do { @@ -889,19 +947,28 @@ final class ImportConnectorStatusStore: ObservableObject { return (count, lastIndexedAt) } + guard canApplyRefresh(generation: generation, userID: userID) else { return } var metrics = metricsByID["local-files"] ?? ConnectorMetrics() metrics.sourceCount = result.count - defaults.set(result.count, forKey: sourceCountKeyPrefix + "local-files") + if let key = scopedKey(sourceCountKeyPrefix, connectorID: "local-files", userID: userID) { + defaults.set(result.count, forKey: key) + } if metrics.lastSyncedAt == nil, let lastIndexedAt = result.lastIndexedAt { metrics.lastSyncedAt = lastIndexedAt - defaults.set(lastIndexedAt.timeIntervalSince1970, forKey: lastSyncedAtKeyPrefix + "local-files") + if let key = scopedKey(lastSyncedAtKeyPrefix, connectorID: "local-files", userID: userID) { + defaults.set(lastIndexedAt.timeIntervalSince1970, forKey: key) + } } if metrics.lastSyncedAt != nil || result.count > 0 { metrics.availabilityText = "On-device index" - defaults.set("On-device index", forKey: availabilityTextKeyPrefix + "local-files") + if let key = scopedKey(availabilityTextKeyPrefix, connectorID: "local-files", userID: userID) { + defaults.set("On-device index", forKey: key) + } } else { metrics.availabilityText = nil - defaults.removeObject(forKey: availabilityTextKeyPrefix + "local-files") + if let key = scopedKey(availabilityTextKeyPrefix, connectorID: "local-files", userID: userID) { + defaults.removeObject(forKey: key) + } } metricsByID["local-files"] = metrics } catch { @@ -909,20 +976,27 @@ final class ImportConnectorStatusStore: ObservableObject { } } - private func refreshAppleNotesMetrics() async { + private func refreshAppleNotesMetrics(generation: Int, userID: String?) async { let status = await AppleNotesReaderService.shared.connectionStatus(maxResults: 250) + guard canApplyRefresh(generation: generation, userID: userID) else { return } switch status { case .connected(let noteCount, _): var metrics = metricsByID["apple-notes"] ?? ConnectorMetrics() metrics.sourceCount = noteCount - defaults.set(noteCount, forKey: sourceCountKeyPrefix + "apple-notes") + if let key = scopedKey(sourceCountKeyPrefix, connectorID: "apple-notes", userID: userID) { + defaults.set(noteCount, forKey: key) + } if metrics.lastSyncedAt == nil { let syncedAt = Date() metrics.lastSyncedAt = syncedAt - defaults.set(syncedAt.timeIntervalSince1970, forKey: lastSyncedAtKeyPrefix + "apple-notes") + if let key = scopedKey(lastSyncedAtKeyPrefix, connectorID: "apple-notes", userID: userID) { + defaults.set(syncedAt.timeIntervalSince1970, forKey: key) + } } metrics.availabilityText = "Private notes accessible" - defaults.set("Private notes accessible", forKey: availabilityTextKeyPrefix + "apple-notes") + if let key = scopedKey(availabilityTextKeyPrefix, connectorID: "apple-notes", userID: userID) { + defaults.set("Private notes accessible", forKey: key) + } metricsByID["apple-notes"] = metrics case .needsAccess(_, let reasonCode), .error(_, let reasonCode): log("ImportConnectorStatusStore: Apple Notes refresh unavailable code=\(reasonCode)") @@ -930,6 +1004,28 @@ final class ImportConnectorStatusStore: ObservableObject { } } + private func canApplyRefresh(generation: Int, userID: String?) -> Bool { + generation == refreshGeneration && userID == loadedUserID + } + + private func scopedKey(_ prefix: String, connectorID: String, userID: String?) -> String? { + guard let userID, !userID.isEmpty else { return nil } + return prefix + userID + "." + connectorID + } + + private func scopedManualImportKey(_ key: String, userID: String) -> String { + key + "." + userID + } + + private func currentUserID() -> String? { + guard let userID = defaults.string(forKey: .authUserId), !userID.isEmpty else { return nil } + return userID + } + + private static func emptyMetrics() -> [String: ConnectorMetrics] { + Dictionary(uniqueKeysWithValues: ImportConnector.all.map { ($0.id, ConnectorMetrics()) }) + } + private func isConnected(connector: ImportConnector, metrics: ConnectorMetrics) -> Bool { if metrics.lastSyncedAt != nil { return true diff --git a/desktop/macos/Desktop/Sources/Onboarding/OnboardingPagedIntroCoordinator.swift b/desktop/macos/Desktop/Sources/Onboarding/OnboardingPagedIntroCoordinator.swift index a8a62b67930..5b42eec265a 100644 --- a/desktop/macos/Desktop/Sources/Onboarding/OnboardingPagedIntroCoordinator.swift +++ b/desktop/macos/Desktop/Sources/Onboarding/OnboardingPagedIntroCoordinator.swift @@ -125,10 +125,10 @@ final class OnboardingPagedIntroCoordinator: ObservableObject { ? AssistantSettings.shared.voiceLanguages : [] let defaults = UserDefaults.standard - chatGPTImportedMemoriesCount = defaults.integer(forKey: chatGPTImportedMemoriesKey) - claudeImportedMemoriesCount = defaults.integer(forKey: claudeImportedMemoriesKey) - chatGPTImportSummary = defaults.string(forKey: chatGPTImportSummaryKey) ?? "" - claudeImportSummary = defaults.string(forKey: claudeImportSummaryKey) ?? "" + chatGPTImportedMemoriesCount = defaults.integer(forKey: Self.scopedDefaultsKey(chatGPTImportedMemoriesKey)) + claudeImportedMemoriesCount = defaults.integer(forKey: Self.scopedDefaultsKey(claudeImportedMemoriesKey)) + chatGPTImportSummary = defaults.string(forKey: Self.scopedDefaultsKey(chatGPTImportSummaryKey)) ?? "" + claudeImportSummary = defaults.string(forKey: Self.scopedDefaultsKey(claudeImportSummaryKey)) ?? "" } deinit { @@ -236,13 +236,13 @@ final class OnboardingPagedIntroCoordinator: ObservableObject { case .chatgpt: chatGPTImportedMemoriesCount = result.memories chatGPTImportSummary = result.profileSummary - defaults.set(result.memories, forKey: chatGPTImportedMemoriesKey) - defaults.set(result.profileSummary, forKey: chatGPTImportSummaryKey) + defaults.set(result.memories, forKey: Self.scopedDefaultsKey(chatGPTImportedMemoriesKey)) + defaults.set(result.profileSummary, forKey: Self.scopedDefaultsKey(chatGPTImportSummaryKey)) case .claude: claudeImportedMemoriesCount = result.memories claudeImportSummary = result.profileSummary - defaults.set(result.memories, forKey: claudeImportedMemoriesKey) - defaults.set(result.profileSummary, forKey: claudeImportSummaryKey) + defaults.set(result.memories, forKey: Self.scopedDefaultsKey(claudeImportedMemoriesKey)) + defaults.set(result.profileSummary, forKey: Self.scopedDefaultsKey(claudeImportSummaryKey)) } await saveGraph( @@ -261,6 +261,13 @@ final class OnboardingPagedIntroCoordinator: ObservableObject { ) } + private static func scopedDefaultsKey(_ key: String) -> String { + guard let userID = UserDefaults.standard.string(forKey: .authUserId), !userID.isEmpty else { + return key + } + return key + "." + userID + } + func selectAppleNotesFolderAndSync() async { lastActionError = nil diff --git a/desktop/macos/Desktop/Sources/Stores/HomeStatusStore.swift b/desktop/macos/Desktop/Sources/Stores/HomeStatusStore.swift index 8d8a7481568..0b860b0eb06 100644 --- a/desktop/macos/Desktop/Sources/Stores/HomeStatusStore.swift +++ b/desktop/macos/Desktop/Sources/Stores/HomeStatusStore.swift @@ -13,7 +13,7 @@ import SwiftUI /// MCP status scans). @MainActor final class HomeStatusStore: ObservableObject { - static let omiDeviceHistoryDefaultsKey = "home-omi-device-account-history" + static let omiDeviceHistoryDefaultsKeyPrefix = "home-omi-device-account-history." /// Import-connector (Gmail, Notes, …) status cache. Owned here so the /// connected badges don't reset to unknown on every Home visit. @@ -29,11 +29,11 @@ final class HomeStatusStore: ObservableObject { @Published var memoryExportStatuses: [MemoryExportDestination: MemoryExportStatus] = [:] /// Wearable used on this account (any friend/omi-sourced conversation). /// Seeded from UserDefaults so the badge is instant on later launches. - @Published var accountHasOmiDeviceConversations = UserDefaults.standard.bool( - forKey: HomeStatusStore.omiDeviceHistoryDefaultsKey) + @Published var accountHasOmiDeviceConversations = HomeStatusStore.cachedOmiDeviceHistory() private var lastRefreshAt = Date.distantPast private var isRefreshing = false + private var refreshGeneration = 0 private var cancellables: Set = [] init() { @@ -57,43 +57,74 @@ final class HomeStatusStore: ObservableObject { guard !isRefreshing else { return } isRefreshing = true lastRefreshAt = now - defer { isRefreshing = false } + let generation = refreshGeneration + defer { + if generation == refreshGeneration { + isRefreshing = false + } + } async let importConnectorStatuses: Void = importConnectorStatusStore.refresh() - async let screenshots: Void = loadScreenshotCount() - async let knowledgeCounts: Void = loadKnowledgeCounts() - async let exportStatuses: Void = loadMemoryExportStatuses() - _ = await (importConnectorStatuses, screenshots, knowledgeCounts, exportStatuses) + async let screenshots = loadScreenshotCount() + async let knowledgeCounts = loadKnowledgeCounts() + async let exportStatuses = loadMemoryExportStatuses() + let (_, screenshotCount, counts, statuses) = await ( + importConnectorStatuses, screenshots, knowledgeCounts, exportStatuses) + + guard generation == refreshGeneration else { return } + self.screenshotCount = screenshotCount + if let conversationCount = counts.conversationCount { + self.conversationCount = conversationCount + } + if let memoryCount = counts.memoryCount { + self.memoryCount = memoryCount + } + if let taskCount = counts.taskCount { + self.taskCount = taskCount + } + if counts.hasOmiDeviceHistory == true { + accountHasOmiDeviceConversations = true + Self.setCachedOmiDeviceHistory() + } + memoryExportStatuses = statuses } /// Clear per-account data on sign-out/account switch. func resetSessionState() { + refreshGeneration += 1 + isRefreshing = false screenshotCount = nil conversationCount = nil memoryCount = nil taskCount = nil memoryExportStatuses = [:] - accountHasOmiDeviceConversations = UserDefaults.standard.bool( - forKey: HomeStatusStore.omiDeviceHistoryDefaultsKey) + accountHasOmiDeviceConversations = Self.cachedOmiDeviceHistory() + importConnectorStatusStore.resetSessionState() lastRefreshAt = .distantPast } // MARK: - Loaders - private func loadScreenshotCount() async { + private struct KnowledgeCounts { + var conversationCount: Int? + var memoryCount: Int? + var taskCount: Int? + var hasOmiDeviceHistory: Bool? + } + + private func loadScreenshotCount() async -> Int? { let stats = await RewindIndexer.shared.getStats() - screenshotCount = stats?.total + return stats?.total } - private func loadMemoryExportStatuses() async { - let statuses = await MemoryExportService.shared.allStatuses() - memoryExportStatuses = statuses + private func loadMemoryExportStatuses() async -> [MemoryExportDestination: MemoryExportStatus] { + await MemoryExportService.shared.allStatuses() } /// Load the true totals behind the "What omi knows" tiles. Conversations come /// from the server count endpoint (not stored locally); memories and tasks are /// counted from the synced local DB — the same totals the detail pages show. - private func loadKnowledgeCounts() async { + private func loadKnowledgeCounts() async -> KnowledgeCounts { async let convos = try? APIClient.shared.getConversationsCount(includeDiscarded: false) async let mems = try? MemoryStorage.shared.getLocalMemoriesCount() // Open tasks only (matches the "Tasks" label and the old tile's intent — @@ -102,18 +133,30 @@ final class HomeStatusStore: ObservableObject { let shouldLoadDeviceHistory = !accountHasOmiDeviceConversations async let deviceHistory = shouldLoadDeviceHistory ? loadOmiDeviceHistory() : nil let (c, m, t, d) = await (convos, mems, tasks, deviceHistory) - if let c { conversationCount = c } - if let m { memoryCount = m } - if let t { taskCount = t } - // Sticky: device history never un-happens; keep the badge across - // launches and network failures once observed. - if d == true { - accountHasOmiDeviceConversations = true - UserDefaults.standard.set(true, forKey: Self.omiDeviceHistoryDefaultsKey) - } + return KnowledgeCounts( + conversationCount: c, + memoryCount: m, + taskCount: t, + hasOmiDeviceHistory: d + ) } private func loadOmiDeviceHistory() async -> Bool? { try? await APIClient.shared.hasOmiDeviceConversations() } + + private static func cachedOmiDeviceHistory(defaults: UserDefaults = .standard) -> Bool { + guard let key = omiDeviceHistoryDefaultsKey(defaults: defaults) else { return false } + return defaults.bool(forKey: key) + } + + private static func setCachedOmiDeviceHistory(defaults: UserDefaults = .standard) { + guard let key = omiDeviceHistoryDefaultsKey(defaults: defaults) else { return } + defaults.set(true, forKey: key) + } + + private static func omiDeviceHistoryDefaultsKey(defaults: UserDefaults = .standard) -> String? { + guard let userId = defaults.string(forKey: .authUserId), !userId.isEmpty else { return nil } + return omiDeviceHistoryDefaultsKeyPrefix + userId + } } diff --git a/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift b/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift index 7d2ff14fd7b..116a154500d 100644 --- a/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift +++ b/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift @@ -174,10 +174,13 @@ final class DashboardCaptureStateTests: XCTestCase { XCTAssertTrue(refresh.contains("PollingConfig.shouldAllowActivationRefresh")) XCTAssertTrue(refresh.contains("lastRefreshAt = now")) XCTAssertTrue(refresh.contains("guard !isRefreshing else { return }")) + XCTAssertTrue(store.contains("private var refreshGeneration = 0")) + XCTAssertTrue(refresh.contains("guard generation == refreshGeneration else { return }")) + XCTAssertTrue(store.contains("importConnectorStatusStore.resetSessionState()")) XCTAssertTrue(refresh.contains("async let importConnectorStatuses: Void = importConnectorStatusStore.refresh()")) - XCTAssertTrue(refresh.contains("async let screenshots: Void = loadScreenshotCount()")) - XCTAssertTrue(refresh.contains("async let knowledgeCounts: Void = loadKnowledgeCounts()")) - XCTAssertTrue(refresh.contains("async let exportStatuses: Void = loadMemoryExportStatuses()")) + XCTAssertTrue(refresh.contains("async let screenshots = loadScreenshotCount()")) + XCTAssertTrue(refresh.contains("async let knowledgeCounts = loadKnowledgeCounts()")) + XCTAssertTrue(refresh.contains("async let exportStatuses = loadMemoryExportStatuses()")) XCTAssertFalse(store.contains("memoryExportStatusActiveRefreshThrottle")) XCTAssertFalse(store.contains("loadMemoryExportStatuses(force:")) } @@ -186,8 +189,8 @@ final class DashboardCaptureStateTests: XCTestCase { let store = try homeStatusStoreSource() let method = try methodBody(named: "loadMemoryExportStatuses", in: store) - XCTAssertTrue(method.contains("let statuses = await MemoryExportService.shared.allStatuses()")) - XCTAssertTrue(method.contains("memoryExportStatuses = statuses")) + XCTAssertTrue(method.contains("await MemoryExportService.shared.allStatuses()")) + XCTAssertTrue(store.contains("memoryExportStatuses = statuses")) XCTAssertFalse(method.contains("PollingConfig.shouldAllowActivationRefresh")) XCTAssertFalse(method.contains("lastRefreshAt")) XCTAssertFalse(method.contains("memoryExportStatusActiveRefreshThrottle")) @@ -201,7 +204,8 @@ final class DashboardCaptureStateTests: XCTestCase { XCTAssertTrue(method.contains("let shouldLoadDeviceHistory = !accountHasOmiDeviceConversations")) XCTAssertTrue(method.contains("async let deviceHistory = shouldLoadDeviceHistory ? loadOmiDeviceHistory() : nil")) XCTAssertTrue(helper.contains("APIClient.shared.hasOmiDeviceConversations()")) - XCTAssertTrue(method.contains("UserDefaults.standard.set(true, forKey: Self.omiDeviceHistoryDefaultsKey)")) + XCTAssertTrue(store.contains("Self.setCachedOmiDeviceHistory()")) + XCTAssertTrue(store.contains("omiDeviceHistoryDefaultsKeyPrefix + userId")) } func testConnectorRowsUseStatusConnectionForConnectedState() throws { diff --git a/desktop/macos/Desktop/Tests/ImportConnectorStatusStoreTests.swift b/desktop/macos/Desktop/Tests/ImportConnectorStatusStoreTests.swift index 11e1939b5ba..354d74e6082 100644 --- a/desktop/macos/Desktop/Tests/ImportConnectorStatusStoreTests.swift +++ b/desktop/macos/Desktop/Tests/ImportConnectorStatusStoreTests.swift @@ -7,10 +7,12 @@ final class ImportConnectorStatusStoreTests: XCTestCase { override func setUp() { super.setUp() resetImportConnectorDefaults() + UserDefaults.standard.set("test-user", forKey: .authUserId) } override func tearDown() { resetImportConnectorDefaults() + UserDefaults.standard.removeObject(forKey: .authUserId) super.tearDown() } @@ -70,7 +72,9 @@ final class ImportConnectorStatusStoreTests: XCTestCase { func testAvailabilityTextAloneDoesNotMarkConnectorConnected() { let connector = ImportConnector.all.first { $0.id == "apple-notes" }! - UserDefaults.standard.set("Private notes accessible", forKey: "appsImportConnectorAvailabilityText.apple-notes") + UserDefaults.standard.set( + "Private notes accessible", + forKey: "appsImportConnectorAvailabilityText.test-user.apple-notes") let store = ImportConnectorStatusStore() let snapshot = store.snapshot(for: connector) @@ -83,7 +87,7 @@ final class ImportConnectorStatusStoreTests: XCTestCase { func testPositiveCountWithoutSuccessfulSyncDoesNotMarkConnectorConnected() { let connector = ImportConnector.all.first { $0.id == "calendar" }! - UserDefaults.standard.set(3, forKey: "appsImportConnectorSourceCount.calendar") + UserDefaults.standard.set(3, forKey: "appsImportConnectorSourceCount.test-user.calendar") let snapshot = ImportConnectorStatusStore().snapshot(for: connector) XCTAssertFalse(snapshot.isConnected) @@ -93,13 +97,48 @@ final class ImportConnectorStatusStoreTests: XCTestCase { func testLegacyManualImportCountStillMarksConnectorConnected() { let connector = ImportConnector.all.first { $0.id == "chatgpt" }! - UserDefaults.standard.set(4, forKey: "onboardingChatGPTImportedMemoriesCount") + UserDefaults.standard.set(4, forKey: "onboardingChatGPTImportedMemoriesCount.test-user") let snapshot = ImportConnectorStatusStore().snapshot(for: connector) XCTAssertTrue(snapshot.isConnected) XCTAssertEqual(snapshot.actionTitle, "Update") } + func testConnectorMetricsAreScopedBySignedInUser() { + let connector = ImportConnector.all.first { $0.id == "email" }! + let syncedAt = Date(timeIntervalSince1970: 1_700_000_000) + let store = ImportConnectorStatusStore() + + store.markSynced(connectorID: "email", sourceCount: 12, memoryCount: 2, syncedAt: syncedAt) + + UserDefaults.standard.set("other-user", forKey: .authUserId) + let otherUserSnapshot = ImportConnectorStatusStore().snapshot(for: connector) + + UserDefaults.standard.set("test-user", forKey: .authUserId) + let originalUserSnapshot = ImportConnectorStatusStore().snapshot(for: connector) + + XCTAssertFalse(otherUserSnapshot.isConnected) + XCTAssertEqual(otherUserSnapshot.actionTitle, "Connect") + XCTAssertTrue(originalUserSnapshot.isConnected) + XCTAssertEqual(originalUserSnapshot.primaryText, "12 emails • 2 memories") + } + + func testResetSessionStateClearsConnectorMetricsUntilCurrentUserReloads() { + let connector = ImportConnector.all.first { $0.id == "email" }! + let syncedAt = Date(timeIntervalSince1970: 1_700_000_000) + let store = ImportConnectorStatusStore() + + store.markSynced(connectorID: "email", sourceCount: 12, memoryCount: 2, syncedAt: syncedAt) + XCTAssertTrue(store.snapshot(for: connector).isConnected) + + UserDefaults.standard.removeObject(forKey: .authUserId) + store.resetSessionState() + XCTAssertFalse(store.snapshot(for: connector).isConnected) + + UserDefaults.standard.set("test-user", forKey: .authUserId) + XCTAssertTrue(ImportConnectorStatusStore().snapshot(for: connector).isConnected) + } + private func resetImportConnectorDefaults() { let prefixes = [ "appsImportConnectorSourceCount.", @@ -112,9 +151,15 @@ final class ImportConnectorStatusStoreTests: XCTestCase { for connector in ImportConnector.all { for prefix in prefixes { UserDefaults.standard.removeObject(forKey: prefix + connector.id) + UserDefaults.standard.removeObject(forKey: prefix + "test-user." + connector.id) + UserDefaults.standard.removeObject(forKey: prefix + "other-user." + connector.id) } } UserDefaults.standard.removeObject(forKey: "onboardingChatGPTImportedMemoriesCount") UserDefaults.standard.removeObject(forKey: "onboardingClaudeImportedMemoriesCount") + UserDefaults.standard.removeObject(forKey: "onboardingChatGPTImportedMemoriesCount.test-user") + UserDefaults.standard.removeObject(forKey: "onboardingClaudeImportedMemoriesCount.test-user") + UserDefaults.standard.removeObject(forKey: "onboardingChatGPTImportedMemoriesCount.other-user") + UserDefaults.standard.removeObject(forKey: "onboardingClaudeImportedMemoriesCount.other-user") } } From 1e6bdfa6ca8047af29ba1bb78518d792ed6856c0 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:22:34 -0400 Subject: [PATCH 22/31] Preserve graph bootstrap retry on rebuild failure --- .../MainWindow/Pages/MemoryGraph/MemoryGraphPage.swift | 7 +++++-- desktop/macos/Desktop/Tests/MemoryGraphRevisitTests.swift | 4 ++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoryGraph/MemoryGraphPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoryGraph/MemoryGraphPage.swift index d4d89559f04..15df9adec5b 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoryGraph/MemoryGraphPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoryGraph/MemoryGraphPage.swift @@ -267,8 +267,8 @@ class MemoryGraphViewModel: ObservableObject { if isEmpty && !hasRunEmptyBootstrap { // First-session bootstrap for sparse accounts: ask the backend to build // the graph, then poll for it. Run once per session — not per visit. + guard await rebuildGraph() else { return } hasRunEmptyBootstrap = true - await rebuildGraph() for _ in 1...10 { try? await Task.sleep(nanoseconds: 3_000_000_000) await loadGraph() @@ -446,7 +446,8 @@ class MemoryGraphViewModel: ObservableObject { // MARK: - Rebuild Graph - func rebuildGraph() async { + @discardableResult + func rebuildGraph() async -> Bool { isRebuilding = true defer { isRebuilding = false } @@ -458,8 +459,10 @@ class MemoryGraphViewModel: ObservableObject { // Reload the graph await loadGraph() + return true } catch { log("Failed to rebuild knowledge graph: \(error.localizedDescription)") + return false } } diff --git a/desktop/macos/Desktop/Tests/MemoryGraphRevisitTests.swift b/desktop/macos/Desktop/Tests/MemoryGraphRevisitTests.swift index a0ca7915901..34f3bb0d0ee 100644 --- a/desktop/macos/Desktop/Tests/MemoryGraphRevisitTests.swift +++ b/desktop/macos/Desktop/Tests/MemoryGraphRevisitTests.swift @@ -32,6 +32,10 @@ final class MemoryGraphRevisitTests: XCTestCase { method.contains("if isEmpty && !hasRunEmptyBootstrap {"), "The empty-graph rebuild+poll bootstrap must run once per session, not on every visit" ) + XCTAssertTrue( + method.contains("guard await rebuildGraph() else { return }"), + "A failed rebuild request must not spend the one-shot empty-graph bootstrap latch" + ) } func testLoadGraphSkipsResimulationForUnchangedGraph() throws { From cb88cab186a081caf94d7793b014c68dbca1b6e8 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:22:39 -0400 Subject: [PATCH 23/31] Keep toolbar controls available in legacy home --- .../CaptureListeningController.swift | 3 -- .../Sources/MainWindow/DesktopHomeView.swift | 54 +++++++++---------- 2 files changed, 26 insertions(+), 31 deletions(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/CaptureListeningController.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/CaptureListeningController.swift index a6854b472ee..32a1df28455 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/CaptureListeningController.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/CaptureListeningController.swift @@ -120,7 +120,6 @@ final class CaptureListeningController: ObservableObject { } isTogglingListening = true - UserDefaults.standard.set(enabled, forKey: "transcriptionEnabled") AssistantSettings.shared.transcriptionEnabled = enabled AnalyticsManager.shared.settingToggled(setting: "transcription", enabled: enabled) NotificationCenter.default.post( @@ -137,7 +136,6 @@ final class CaptureListeningController: ObservableObject { func toggleListeningMode() { let nextMode: AssistantSettings.SystemAudioCaptureMode = listeningCaptureMode == .onlyDuringMeetings ? .always : .onlyDuringMeetings - UserDefaults.standard.set(nextMode.rawValue, forKey: "systemAudioCaptureMode") AssistantSettings.shared.systemAudioCaptureMode = nextMode AnalyticsManager.shared.settingToggled( setting: "meetings_only_listening", @@ -193,7 +191,6 @@ final class CaptureListeningController: ObservableObject { } private func setScreenAnalysisEnabled(_ enabled: Bool) { - UserDefaults.standard.set(enabled, forKey: "screenAnalysisEnabled") AssistantSettings.shared.screenAnalysisEnabled = enabled } } diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index 3a09c98cb9c..64ddaeb461e 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -830,39 +830,37 @@ struct DesktopHomeView: View { /// system sizes — no in-content chrome. @ToolbarContentBuilder private var mainToolbar: some ToolbarContent { - if !useLegacyHomeDesign { - // Home is the root: it gets no leading toolbar items at all. Subpages - // get the house button plus the page title. The `if` must wrap the - // ToolbarItems (not live inside them): an item with empty content - // still renders as an empty slot in the unified bar. PlainToolbarItem - // opts every item out of the macOS 26 glass platter — bare glyphs on - // the app's own dark surface. - if selectedIndex != SidebarNavItem.dashboard.rawValue { - PlainToolbarItem(placement: .navigation) { - Button { - withAnimation(Self.pageNavigationAnimation) { - selectedIndex = SidebarNavItem.dashboard.rawValue - } - } label: { - Image(systemName: "house") - .foregroundStyle(Color.secondary) + // Home is the root: it gets no leading toolbar items at all. Subpages + // get the house button plus the page title. The `if` must wrap the + // ToolbarItems (not live inside them): an item with empty content + // still renders as an empty slot in the unified bar. PlainToolbarItem + // opts every item out of the macOS 26 glass platter — bare glyphs on + // the app's own dark surface. + if !useLegacyHomeDesign, selectedIndex != SidebarNavItem.dashboard.rawValue { + PlainToolbarItem(placement: .navigation) { + Button { + withAnimation(Self.pageNavigationAnimation) { + selectedIndex = SidebarNavItem.dashboard.rawValue } - .help("Back to Home (\u{2318}[)") - .keyboardShortcut("[", modifiers: .command) - } - PlainToolbarItem(placement: .navigation) { - toolbarTitle + } label: { + Image(systemName: "house") + .foregroundStyle(Color.secondary) } + .help("Back to Home (\u{2318}[)") + .keyboardShortcut("[", modifiers: .command) } - // On macOS `.primaryAction` maps to the *leading* toolbar edge, so a - // flexible spacer is what pushes the status cluster to the trailing side. - ToolbarItem(placement: .automatic) { - Spacer() - } - PlainToolbarItem(placement: .automatic) { - ToolbarStatusControls(appState: appState) + PlainToolbarItem(placement: .navigation) { + toolbarTitle } } + // On macOS `.primaryAction` maps to the *leading* toolbar edge, so a + // flexible spacer is what pushes the status cluster to the trailing side. + ToolbarItem(placement: .automatic) { + Spacer() + } + PlainToolbarItem(placement: .automatic) { + ToolbarStatusControls(appState: appState) + } } private var toolbarTitle: some View { From ff6dd78a09fbf8b3dc80a5e9bee203244f2589c0 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:22:44 -0400 Subject: [PATCH 24/31] Cover desktop UI refactor files in e2e flows --- desktop/macos/e2e/flows/apps.yaml | 1 + desktop/macos/e2e/flows/audio-recording.yaml | 1 + desktop/macos/e2e/flows/home-stage.yaml | 1 + desktop/macos/e2e/flows/language.yaml | 1 + desktop/macos/e2e/flows/memories.yaml | 3 +++ desktop/macos/e2e/flows/navigation.yaml | 1 + desktop/macos/e2e/flows/screen-recording-permission.yaml | 2 ++ desktop/macos/e2e/flows/tasks.yaml | 1 + 8 files changed, 11 insertions(+) diff --git a/desktop/macos/e2e/flows/apps.yaml b/desktop/macos/e2e/flows/apps.yaml index 56a09485fb0..b8393d2df15 100644 --- a/desktop/macos/e2e/flows/apps.yaml +++ b/desktop/macos/e2e/flows/apps.yaml @@ -5,6 +5,7 @@ description: Apps/Integrations marketplace flow — browse apps, filter by categ app: com.omi.computer-macos covers: - desktop/Desktop/Sources/MainWindow/Pages/IntegrationsPage.swift + - desktop/macos/Desktop/Sources/MainWindow/Pages/AppsPage.swift preconditions: - auth_ready diff --git a/desktop/macos/e2e/flows/audio-recording.yaml b/desktop/macos/e2e/flows/audio-recording.yaml index 7df599e1c37..6ca03233818 100644 --- a/desktop/macos/e2e/flows/audio-recording.yaml +++ b/desktop/macos/e2e/flows/audio-recording.yaml @@ -5,6 +5,7 @@ description: Audio recording flow — verify microphone permission, Start Record app: com.omi.computer-macos covers: - desktop/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift + - desktop/macos/Desktop/Sources/Stores/ConversationsSearchModel.swift - desktop/Desktop/Sources/AudioCaptureService.swift - desktop/Desktop/Sources/Audio/AudioSourceManager.swift - desktop/Desktop/Sources/AppState.swift diff --git a/desktop/macos/e2e/flows/home-stage.yaml b/desktop/macos/e2e/flows/home-stage.yaml index 1c5d5157a7e..70757307160 100644 --- a/desktop/macos/e2e/flows/home-stage.yaml +++ b/desktop/macos/e2e/flows/home-stage.yaml @@ -5,6 +5,7 @@ description: Redesigned Home stage transitions (hub/chat/connect) via automation app: non-prod covers: - desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift + - desktop/macos/Desktop/Sources/Stores/HomeStatusStore.swift - desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift preconditions: - automation_bridge_ready diff --git a/desktop/macos/e2e/flows/language.yaml b/desktop/macos/e2e/flows/language.yaml index ae4a32d7718..afa36cd96db 100644 --- a/desktop/macos/e2e/flows/language.yaml +++ b/desktop/macos/e2e/flows/language.yaml @@ -5,6 +5,7 @@ description: Transcription language settings navigation via automation bridge app: non-prod covers: - desktop/macos/Desktop/Sources/MainWindow/Pages/SettingsPage.swift + - desktop/macos/Desktop/Sources/Onboarding/OnboardingPagedIntroCoordinator.swift preconditions: - automation_bridge_ready diff --git a/desktop/macos/e2e/flows/memories.yaml b/desktop/macos/e2e/flows/memories.yaml index 1c274171777..0f7f6d141dc 100644 --- a/desktop/macos/e2e/flows/memories.yaml +++ b/desktop/macos/e2e/flows/memories.yaml @@ -5,6 +5,9 @@ description: Memories page navigation and refresh via automation bridge app: non-prod covers: - desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift + - desktop/macos/Desktop/Sources/MainWindow/Components/OmiHeaderControls.swift + - desktop/macos/Desktop/Sources/MainWindow/Pages/MemoryGraph/ForceDirectedSimulation.swift + - desktop/macos/Desktop/Sources/MainWindow/Pages/MemoryGraph/MemoryGraphPage.swift preconditions: - automation_bridge_ready diff --git a/desktop/macos/e2e/flows/navigation.yaml b/desktop/macos/e2e/flows/navigation.yaml index d75fed1cc80..668fe42b5ee 100644 --- a/desktop/macos/e2e/flows/navigation.yaml +++ b/desktop/macos/e2e/flows/navigation.yaml @@ -7,6 +7,7 @@ covers: - desktop/macos/Desktop/Sources/OmiApp.swift - desktop/macos/Desktop/Sources/MainWindow/SidebarView.swift - desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift + - desktop/macos/Desktop/Sources/ViewModelContainer.swift preconditions: - automation_bridge_ready diff --git a/desktop/macos/e2e/flows/screen-recording-permission.yaml b/desktop/macos/e2e/flows/screen-recording-permission.yaml index 9fed6185a10..9b0e5e2b301 100644 --- a/desktop/macos/e2e/flows/screen-recording-permission.yaml +++ b/desktop/macos/e2e/flows/screen-recording-permission.yaml @@ -5,6 +5,8 @@ description: Screen Recording permission flow — verify Rewind permission state app: com.omi.computer-macos covers: - desktop/Desktop/Sources/Rewind/UI/RewindPage.swift + - desktop/macos/Desktop/Sources/MainWindow/Components/CaptureListeningController.swift + - desktop/macos/Desktop/Sources/MainWindow/Components/ToolbarStatusControls.swift - desktop/Desktop/Sources/ScreenCaptureService.swift - desktop/Desktop/Sources/MainWindow/Pages/PermissionsPage.swift - desktop/Desktop/Sources/AppState/AppState+SystemActions.swift diff --git a/desktop/macos/e2e/flows/tasks.yaml b/desktop/macos/e2e/flows/tasks.yaml index f96d2524f44..7cce30abc00 100644 --- a/desktop/macos/e2e/flows/tasks.yaml +++ b/desktop/macos/e2e/flows/tasks.yaml @@ -5,6 +5,7 @@ description: Tasks page navigation and tasks snapshot via automation bridge app: non-prod covers: - desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift + - desktop/macos/Desktop/Sources/MainWindow/Pages/TaskDetailViews.swift - desktop/macos/Desktop/Sources/MainWindow/Components/TaskChatPanel.swift - desktop/macos/Desktop/Sources/Stores/TasksStore.swift - desktop/macos/Desktop/Sources/ProactiveAssistants/Assistants/TaskAgent/TaskChatState.swift From 2aba8ccbaf555e1df9f4a11803b19343cea26ae8 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:54:52 -0400 Subject: [PATCH 25/31] Migrate legacy Omi device home status --- .../Desktop/Sources/Stores/HomeStatusStore.swift | 6 ++++++ .../Tests/ImportConnectorStatusStoreTests.swift | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/desktop/macos/Desktop/Sources/Stores/HomeStatusStore.swift b/desktop/macos/Desktop/Sources/Stores/HomeStatusStore.swift index 0b860b0eb06..682a3fd1937 100644 --- a/desktop/macos/Desktop/Sources/Stores/HomeStatusStore.swift +++ b/desktop/macos/Desktop/Sources/Stores/HomeStatusStore.swift @@ -13,6 +13,7 @@ import SwiftUI /// MCP status scans). @MainActor final class HomeStatusStore: ObservableObject { + static let legacyOmiDeviceHistoryDefaultsKey = "home-omi-device-account-history" static let omiDeviceHistoryDefaultsKeyPrefix = "home-omi-device-account-history." /// Import-connector (Gmail, Notes, …) status cache. Owned here so the @@ -147,6 +148,11 @@ final class HomeStatusStore: ObservableObject { private static func cachedOmiDeviceHistory(defaults: UserDefaults = .standard) -> Bool { guard let key = omiDeviceHistoryDefaultsKey(defaults: defaults) else { return false } + if defaults.object(forKey: key) == nil, + defaults.bool(forKey: legacyOmiDeviceHistoryDefaultsKey) + { + defaults.set(true, forKey: key) + } return defaults.bool(forKey: key) } diff --git a/desktop/macos/Desktop/Tests/ImportConnectorStatusStoreTests.swift b/desktop/macos/Desktop/Tests/ImportConnectorStatusStoreTests.swift index 354d74e6082..37f64928511 100644 --- a/desktop/macos/Desktop/Tests/ImportConnectorStatusStoreTests.swift +++ b/desktop/macos/Desktop/Tests/ImportConnectorStatusStoreTests.swift @@ -139,6 +139,16 @@ final class ImportConnectorStatusStoreTests: XCTestCase { XCTAssertTrue(ImportConnectorStatusStore().snapshot(for: connector).isConnected) } + func testOmiDeviceHistoryMigratesLegacyGlobalKeyToCurrentUser() { + UserDefaults.standard.set(true, forKey: "home-omi-device-account-history") + UserDefaults.standard.set("test-user", forKey: .authUserId) + + let store = HomeStatusStore() + + XCTAssertTrue(store.accountHasOmiDeviceConversations) + XCTAssertTrue(UserDefaults.standard.bool(forKey: "home-omi-device-account-history.test-user")) + } + private func resetImportConnectorDefaults() { let prefixes = [ "appsImportConnectorSourceCount.", @@ -161,5 +171,8 @@ final class ImportConnectorStatusStoreTests: XCTestCase { UserDefaults.standard.removeObject(forKey: "onboardingClaudeImportedMemoriesCount.test-user") UserDefaults.standard.removeObject(forKey: "onboardingChatGPTImportedMemoriesCount.other-user") UserDefaults.standard.removeObject(forKey: "onboardingClaudeImportedMemoriesCount.other-user") + UserDefaults.standard.removeObject(forKey: "home-omi-device-account-history") + UserDefaults.standard.removeObject(forKey: "home-omi-device-account-history.test-user") + UserDefaults.standard.removeObject(forKey: "home-omi-device-account-history.other-user") } } From 8a73087e05308363a94e9e8fa5e3f5ac4d1cd4e2 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:55:06 -0400 Subject: [PATCH 26/31] Keep onboarding import scoped to active user --- .../OnboardingPagedIntroCoordinator.swift | 33 ++++++++++++------- .../Tests/DashboardCaptureStateTests.swift | 17 ++++++++++ 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/desktop/macos/Desktop/Sources/Onboarding/OnboardingPagedIntroCoordinator.swift b/desktop/macos/Desktop/Sources/Onboarding/OnboardingPagedIntroCoordinator.swift index 5b42eec265a..9702918c7d0 100644 --- a/desktop/macos/Desktop/Sources/Onboarding/OnboardingPagedIntroCoordinator.swift +++ b/desktop/macos/Desktop/Sources/Onboarding/OnboardingPagedIntroCoordinator.swift @@ -125,10 +125,15 @@ final class OnboardingPagedIntroCoordinator: ObservableObject { ? AssistantSettings.shared.voiceLanguages : [] let defaults = UserDefaults.standard - chatGPTImportedMemoriesCount = defaults.integer(forKey: Self.scopedDefaultsKey(chatGPTImportedMemoriesKey)) - claudeImportedMemoriesCount = defaults.integer(forKey: Self.scopedDefaultsKey(claudeImportedMemoriesKey)) - chatGPTImportSummary = defaults.string(forKey: Self.scopedDefaultsKey(chatGPTImportSummaryKey)) ?? "" - claudeImportSummary = defaults.string(forKey: Self.scopedDefaultsKey(claudeImportSummaryKey)) ?? "" + let userID = Self.currentUserID() + chatGPTImportedMemoriesCount = defaults.integer( + forKey: Self.scopedDefaultsKey(chatGPTImportedMemoriesKey, userID: userID)) + claudeImportedMemoriesCount = defaults.integer( + forKey: Self.scopedDefaultsKey(claudeImportedMemoriesKey, userID: userID)) + chatGPTImportSummary = + defaults.string(forKey: Self.scopedDefaultsKey(chatGPTImportSummaryKey, userID: userID)) ?? "" + claudeImportSummary = + defaults.string(forKey: Self.scopedDefaultsKey(claudeImportSummaryKey, userID: userID)) ?? "" } deinit { @@ -220,11 +225,13 @@ final class OnboardingPagedIntroCoordinator: ObservableObject { func importMemoryLog(_ rawText: String, source: OnboardingMemoryLogSource) async { lastActionError = nil + let importUserID = Self.currentUserID() importingMemoryLogSource = source defer { importingMemoryLogSource = nil } let result = await OnboardingMemoryLogImportService.shared.importMemoryLog( rawText, source: source) + guard Self.currentUserID() == importUserID else { return } guard result.memories > 0 else { lastActionError = "Couldn’t extract durable memories from the pasted \(source.displayName) log." @@ -236,13 +243,13 @@ final class OnboardingPagedIntroCoordinator: ObservableObject { case .chatgpt: chatGPTImportedMemoriesCount = result.memories chatGPTImportSummary = result.profileSummary - defaults.set(result.memories, forKey: Self.scopedDefaultsKey(chatGPTImportedMemoriesKey)) - defaults.set(result.profileSummary, forKey: Self.scopedDefaultsKey(chatGPTImportSummaryKey)) + defaults.set(result.memories, forKey: Self.scopedDefaultsKey(chatGPTImportedMemoriesKey, userID: importUserID)) + defaults.set(result.profileSummary, forKey: Self.scopedDefaultsKey(chatGPTImportSummaryKey, userID: importUserID)) case .claude: claudeImportedMemoriesCount = result.memories claudeImportSummary = result.profileSummary - defaults.set(result.memories, forKey: Self.scopedDefaultsKey(claudeImportedMemoriesKey)) - defaults.set(result.profileSummary, forKey: Self.scopedDefaultsKey(claudeImportSummaryKey)) + defaults.set(result.memories, forKey: Self.scopedDefaultsKey(claudeImportedMemoriesKey, userID: importUserID)) + defaults.set(result.profileSummary, forKey: Self.scopedDefaultsKey(claudeImportSummaryKey, userID: importUserID)) } await saveGraph( @@ -261,13 +268,15 @@ final class OnboardingPagedIntroCoordinator: ObservableObject { ) } - private static func scopedDefaultsKey(_ key: String) -> String { - guard let userID = UserDefaults.standard.string(forKey: .authUserId), !userID.isEmpty else { - return key - } + private static func scopedDefaultsKey(_ key: String, userID: String?) -> String { + guard let userID, !userID.isEmpty else { return key } return key + "." + userID } + private static func currentUserID() -> String? { + UserDefaults.standard.string(forKey: .authUserId) + } + func selectAppleNotesFolderAndSync() async { lastActionError = nil diff --git a/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift b/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift index 116a154500d..dcc8c165d6d 100644 --- a/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift +++ b/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift @@ -230,6 +230,15 @@ final class DashboardCaptureStateTests: XCTestCase { XCTAssertFalse(source.contains("resultMessage = .failure(error.localizedDescription)")) } + func testOnboardingMemoryLogImportKeepsCapturedUserScope() throws { + let source = try onboardingCoordinatorSource() + + XCTAssertTrue(source.contains("let importUserID = Self.currentUserID()")) + XCTAssertTrue(source.contains("guard Self.currentUserID() == importUserID else { return }")) + XCTAssertTrue(source.contains("Self.scopedDefaultsKey(chatGPTImportedMemoriesKey, userID: importUserID)")) + XCTAssertTrue(source.contains("Self.scopedDefaultsKey(claudeImportedMemoriesKey, userID: importUserID)")) + } + func testAppsPageSupportsPopupDismissalAndFocusedSections() throws { let source = try appsSource() @@ -352,6 +361,14 @@ final class DashboardCaptureStateTests: XCTestCase { try source(named: "AppsPage.swift") } + private func onboardingCoordinatorSource() throws -> String { + let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + let sourceURL = testsURL + .deletingLastPathComponent() + .appendingPathComponent("Sources/Onboarding/OnboardingPagedIntroCoordinator.swift") + return try String(contentsOf: sourceURL, encoding: .utf8) + } + private func source(named fileName: String) throws -> String { let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() let sourceURL = testsURL From fc8c011c13a50f09b374c49b6ffcd2df258a2338 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:06:11 -0400 Subject: [PATCH 27/31] Remove legacy Omi device flag after migration --- desktop/macos/Desktop/Sources/Stores/HomeStatusStore.swift | 1 + .../Desktop/Tests/ImportConnectorStatusStoreTests.swift | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/desktop/macos/Desktop/Sources/Stores/HomeStatusStore.swift b/desktop/macos/Desktop/Sources/Stores/HomeStatusStore.swift index 682a3fd1937..537c6d2765d 100644 --- a/desktop/macos/Desktop/Sources/Stores/HomeStatusStore.swift +++ b/desktop/macos/Desktop/Sources/Stores/HomeStatusStore.swift @@ -152,6 +152,7 @@ final class HomeStatusStore: ObservableObject { defaults.bool(forKey: legacyOmiDeviceHistoryDefaultsKey) { defaults.set(true, forKey: key) + defaults.removeObject(forKey: legacyOmiDeviceHistoryDefaultsKey) } return defaults.bool(forKey: key) } diff --git a/desktop/macos/Desktop/Tests/ImportConnectorStatusStoreTests.swift b/desktop/macos/Desktop/Tests/ImportConnectorStatusStoreTests.swift index 37f64928511..d8e05455c08 100644 --- a/desktop/macos/Desktop/Tests/ImportConnectorStatusStoreTests.swift +++ b/desktop/macos/Desktop/Tests/ImportConnectorStatusStoreTests.swift @@ -147,6 +147,11 @@ final class ImportConnectorStatusStoreTests: XCTestCase { XCTAssertTrue(store.accountHasOmiDeviceConversations) XCTAssertTrue(UserDefaults.standard.bool(forKey: "home-omi-device-account-history.test-user")) + XCTAssertNil(UserDefaults.standard.object(forKey: "home-omi-device-account-history")) + + UserDefaults.standard.set("other-user", forKey: .authUserId) + let otherStore = HomeStatusStore() + XCTAssertFalse(otherStore.accountHasOmiDeviceConversations) } private func resetImportConnectorDefaults() { From e73e1aeffd0ac4c0013d220c79858b879e3549ab Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:06:15 -0400 Subject: [PATCH 28/31] Tighten onboarding import scope regression --- .../Tests/DashboardCaptureStateTests.swift | 47 ++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift b/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift index dcc8c165d6d..28b1e9e390a 100644 --- a/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift +++ b/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift @@ -232,11 +232,24 @@ final class DashboardCaptureStateTests: XCTestCase { func testOnboardingMemoryLogImportKeepsCapturedUserScope() throws { let source = try onboardingCoordinatorSource() + let method = try methodBody(named: "importMemoryLog", in: source) + let guardRange = try XCTUnwrap( + method.range(of: "guard Self.currentUserID() == importUserID else { return }") + ) + let guardOffset = method.distance(from: method.startIndex, to: guardRange.lowerBound) + let scopedDefaultsWrites = [ + "Self.scopedDefaultsKey(chatGPTImportedMemoriesKey, userID: importUserID)", + "Self.scopedDefaultsKey(chatGPTImportSummaryKey, userID: importUserID)", + "Self.scopedDefaultsKey(claudeImportedMemoriesKey, userID: importUserID)", + "Self.scopedDefaultsKey(claudeImportSummaryKey, userID: importUserID)", + ] - XCTAssertTrue(source.contains("let importUserID = Self.currentUserID()")) - XCTAssertTrue(source.contains("guard Self.currentUserID() == importUserID else { return }")) - XCTAssertTrue(source.contains("Self.scopedDefaultsKey(chatGPTImportedMemoriesKey, userID: importUserID)")) - XCTAssertTrue(source.contains("Self.scopedDefaultsKey(claudeImportedMemoriesKey, userID: importUserID)")) + XCTAssertTrue(method.contains("let importUserID = Self.currentUserID()")) + for scopedDefaultsWrite in scopedDefaultsWrites { + let writeRange = try XCTUnwrap(method.range(of: scopedDefaultsWrite)) + let writeOffset = method.distance(from: method.startIndex, to: writeRange.lowerBound) + XCTAssertGreaterThan(writeOffset, guardOffset) + } } func testAppsPageSupportsPopupDismissalAndFocusedSections() throws { @@ -378,12 +391,32 @@ final class DashboardCaptureStateTests: XCTestCase { } private func methodBody(named name: String, in source: String) throws -> String { - let pattern = #"(?:private )?func \#(name)\([^\)]*\)[^{]*\{([\s\S]*?)\n \}"# + let pattern = #"(?:private )?func \#(name)\b"# let regex = try NSRegularExpression(pattern: pattern) let range = NSRange(source.startIndex.. String { From 61f7e14e844398cb828f0d5de7820691275dd955 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Wed, 8 Jul 2026 01:05:00 -0400 Subject: [PATCH 29/31] Resolve screen recording helper after toolbar extraction --- .../macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift | 2 +- .../Desktop/Tests/ScreenRecordingPermissionPolicyTests.swift | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift index 4b3c33738bc..42711b52e2a 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift @@ -832,7 +832,7 @@ struct DashboardPage: View { private func reportHomeAutomationMode() { guard DesktopAutomationLaunchOptions.isEnabled else { return } let modeLabel = useLegacyHomeDesign ? nil : homeMode.automationLabel - DesktopAutomationStateStore.shared.updateLiveFields { snapshot in + _ = DesktopAutomationStateStore.shared.updateLiveFields { snapshot in snapshot.homeMode = modeLabel snapshot.updatedAt = ISO8601DateFormatter().string(from: Date()) } diff --git a/desktop/macos/Desktop/Tests/ScreenRecordingPermissionPolicyTests.swift b/desktop/macos/Desktop/Tests/ScreenRecordingPermissionPolicyTests.swift index 7915d534054..9da76017921 100644 --- a/desktop/macos/Desktop/Tests/ScreenRecordingPermissionPolicyTests.swift +++ b/desktop/macos/Desktop/Tests/ScreenRecordingPermissionPolicyTests.swift @@ -46,7 +46,7 @@ final class ScreenRecordingPermissionPolicyTests: XCTestCase { "Sources/MainWindow/Pages/PermissionsPage.swift", "Sources/MainWindow/SidebarView.swift", "Sources/Rewind/UI/RewindPage.swift", - "Sources/MainWindow/Pages/DashboardPage.swift", + "Sources/MainWindow/Components/CaptureListeningController.swift", "Sources/OmiApp.swift", "Sources/MainWindow/Pages/Settings/Components/SettingsContentView+BillingHelpers.swift", "Sources/MainWindow/RewindOnlyView.swift", @@ -60,6 +60,7 @@ final class ScreenRecordingPermissionPolicyTests: XCTestCase { "Sources/MainWindow/Pages/PermissionsPage.swift", "Sources/MainWindow/SidebarView.swift", "Sources/Rewind/UI/RewindPage.swift", + "Sources/MainWindow/Components/CaptureListeningController.swift", "Sources/MainWindow/Pages/DashboardPage.swift", ] { let src = try sourceFile(path) From 0bdbbef5f80f814a98b8870093ec8e0f240bfe42 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:14:27 -0400 Subject: [PATCH 30/31] Filter redesigned home chat transcript --- .../macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift index 42711b52e2a..9e963f90f78 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift @@ -662,7 +662,7 @@ struct DashboardPage: View { private var homeChatPanel: some View { VStack(spacing: 0) { ChatMessagesView( - messages: chatProvider.messages, + messages: ChatTurnOwner.transcriptMessages(chatProvider.messages, floatingSurface: false), isSending: chatProvider.isSending, hasMoreMessages: chatProvider.hasMoreMessages, isLoadingMoreMessages: chatProvider.isLoadingMoreMessages, From eb2d7b38b881957bb537f069cdff9a52bbb7f864 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 8 Jul 2026 20:18:33 -0400 Subject: [PATCH 31/31] desktop: keep navigation chrome in app Preserve the PR's navigation/state improvements while moving the Home/status affordances out of the native macOS toolbar and back into the app surface. Verification: - cd desktop/macos && xcrun swift build -c debug --package-path Desktop - cd desktop/macos && xcrun swift test --package-path Desktop --filter DashboardCaptureStateTests - cd desktop/macos && TMPDIR=/tmp/omi-pr-9263-inappnav-run OMI_APP_NAME="omi-pr-9263-inappnav" ./run.sh --yolo - cd desktop/macos && OMI_AUTOMATION_PORT=47919 ./scripts/omi-ctl state --- .../CaptureListeningController.swift | 8 +- .../Components/ToolbarStatusControls.swift | 80 +++--------- .../Sources/MainWindow/DesktopHomeView.swift | 115 +++++++++--------- .../MainWindow/Pages/ConversationsPage.swift | 2 +- .../MainWindow/Pages/DashboardPage.swift | 5 + desktop/macos/Desktop/Sources/OmiApp.swift | 4 - .../Tests/DashboardCaptureStateTests.swift | 30 ++--- 7 files changed, 99 insertions(+), 145 deletions(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/CaptureListeningController.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/CaptureListeningController.swift index 32a1df28455..7cbff43c031 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/CaptureListeningController.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/CaptureListeningController.swift @@ -40,13 +40,13 @@ enum HomeStatusState { } } -/// Shared state + actions behind the Capture and Listening toolbar controls. -/// One instance per `ToolbarStatusControls`; the underlying truth lives in +/// Shared state + actions behind the Capture and Listening Home controls. +/// One instance per `InAppStatusControls`; the underlying truth lives in /// `ProactiveAssistantsPlugin` / `AssistantSettings` / `AppState`, so /// instances stay in sync via the monitoring notifications. /// -/// Extracted from `DashboardPage` so the window toolbar can host the -/// control cluster without duplicating the toggle logic. +/// Extracted from `DashboardPage` so control surfaces can share the toggle +/// logic without duplicating permission and monitoring state handling. @MainActor final class CaptureListeningController: ObservableObject { @Published private(set) var isCaptureMonitoring = false diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ToolbarStatusControls.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ToolbarStatusControls.swift index 75c1636e33d..6050676397c 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ToolbarStatusControls.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ToolbarStatusControls.swift @@ -1,36 +1,8 @@ import SwiftUI -/// ToolbarItem without the macOS 26 shared glass platter. Bare glyphs fit -/// the app's dark custom surface; floating glass capsules read as foreign -/// against it. Older toolbars have no platters, so the plain item is -/// already correct there. -struct PlainToolbarItem: ToolbarContent { - let placement: ToolbarItemPlacement - @ViewBuilder let content: () -> Content - - init(placement: ToolbarItemPlacement = .automatic, @ViewBuilder content: @escaping () -> Content) { - self.placement = placement - self.content = content - } - - var body: some ToolbarContent { - #if compiler(>=6.2) - if #available(macOS 26.0, *) { - ToolbarItem(placement: placement, content: content) - .sharedBackgroundVisibility(.hidden) - } else { - ToolbarItem(placement: placement, content: content) - } - #else - ToolbarItem(placement: placement, content: content) - #endif - } -} - -/// Capture/Listening status controls and the settings menu for the native -/// window toolbar. Toolbar-scale, icon-first, system-styled — state reads -/// through symbol tint plus a small status dot, details live in tooltips. -struct ToolbarStatusControls: View { +/// Capture/Listening status controls shown inside the Home surface. +/// State reads through symbol tint and subtle capsule fill; details live in tooltips. +struct InAppStatusControls: View { let appState: AppState @ObservedObject private var appStateObserved: AppState @@ -44,7 +16,7 @@ struct ToolbarStatusControls: View { var body: some View { HStack(spacing: 10) { - ToolbarStatusButton( + InAppStatusButton( systemImage: "inset.filled.rectangle.badge.record", title: "Capture", state: captureDotState, @@ -53,7 +25,7 @@ struct ToolbarStatusControls: View { ) .disabled(controls.isTogglingCapture) - ToolbarStatusButton( + InAppStatusButton( systemImage: appStateObserved.isTranscribing ? "waveform" : "mic", title: "Listening", state: appStateObserved.isTranscribing ? .active : .off, @@ -69,25 +41,18 @@ struct ToolbarStatusControls: View { } } - Menu { - Button("Settings…") { navigate(to: .settings) } - Divider() - Button("Refer a Friend") { Self.openReferFriend() } - Button("Join Discord") { Self.openDiscord() } - } label: { - Image(systemName: "gearshape") - } - // The app's root purple tint must not color toolbar glyphs — - // native macOS toolbars use neutral symbols. Menu labels render - // as template images driven by tint, so override tint itself. - .tint(Color.secondary) - .menuIndicator(.hidden) - .help("Settings") + InAppStatusButton( + systemImage: "gearshape", + title: "Settings", + state: .off, + action: { navigate(to: .settings) }, + helpText: "Settings" + ) } .onAppear { controls.syncCaptureState() } } - private var captureDotState: ToolbarStatusButton.DotState { + private var captureDotState: InAppStatusButton.DotState { switch controls.captureStatus { case .active: return .active case .blocked: return .attention @@ -111,25 +76,12 @@ struct ToolbarStatusControls: View { ) } - static func openReferFriend() { - if let url = URL(string: "https://affiliate.omi.me") { - NSWorkspace.shared.open(url) - } - } - - static func openDiscord() { - if let url = URL(string: "https://discord.com/invite/8MP3b9ymvx") { - NSWorkspace.shared.open(url) - } - } } -/// A labeled toolbar toggle whose state must be readable at a glance: +/// A labeled Home toggle whose state must be readable at a glance: /// running = green-tinted capsule fill with a green glyph, needs attention = -/// amber-tinted fill with a warning badge, off = plain dimmed glyph. Owns -/// its visuals (`.plain` style) so the toolbar doesn't stack button chrome -/// on top of the state background. -struct ToolbarStatusButton: View { +/// amber-tinted fill with a warning badge, off = plain dimmed glyph. +struct InAppStatusButton: View { enum DotState { case active case attention diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index 64ddaeb461e..f215e4fdcb5 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -115,9 +115,8 @@ struct DesktopHomeView: View { log("DesktopHomeView: Onboarding just completed — navigating to Dashboard") selectedIndex = SidebarNavItem.dashboard.rawValue } - } + } mainContent - .toolbar { mainToolbar } .opacity(viewModelContainer.isInitialLoadComplete ? 1 : 0) .overlay { if appState.showUsageLimitPopup { @@ -825,54 +824,6 @@ struct DesktopHomeView: View { index == SidebarNavItem.memories.rawValue } - /// Native unified-toolbar contents: Home affordance + page title on the - /// left, Capture/Listening status and settings on the right. System bar, - /// system sizes — no in-content chrome. - @ToolbarContentBuilder - private var mainToolbar: some ToolbarContent { - // Home is the root: it gets no leading toolbar items at all. Subpages - // get the house button plus the page title. The `if` must wrap the - // ToolbarItems (not live inside them): an item with empty content - // still renders as an empty slot in the unified bar. PlainToolbarItem - // opts every item out of the macOS 26 glass platter — bare glyphs on - // the app's own dark surface. - if !useLegacyHomeDesign, selectedIndex != SidebarNavItem.dashboard.rawValue { - PlainToolbarItem(placement: .navigation) { - Button { - withAnimation(Self.pageNavigationAnimation) { - selectedIndex = SidebarNavItem.dashboard.rawValue - } - } label: { - Image(systemName: "house") - .foregroundStyle(Color.secondary) - } - .help("Back to Home (\u{2318}[)") - .keyboardShortcut("[", modifiers: .command) - } - PlainToolbarItem(placement: .navigation) { - toolbarTitle - } - } - // On macOS `.primaryAction` maps to the *leading* toolbar edge, so a - // flexible spacer is what pushes the status cluster to the trailing side. - ToolbarItem(placement: .automatic) { - Spacer() - } - PlainToolbarItem(placement: .automatic) { - ToolbarStatusControls(appState: appState) - } - } - - private var toolbarTitle: some View { - // Native macOS window-title metrics (13pt semibold), not a heading. - Text(currentPageTitle) - .font(.system(size: 13, weight: .semibold)) - } - - private var currentPageTitle: String { - SidebarNavItem(rawValue: selectedIndex)?.title ?? "Omi" - } - private var mainContent: some View { HStack(spacing: 0) { // Sidebar slot: settings sidebar overlays main sidebar @@ -924,10 +875,8 @@ struct DesktopHomeView: View { .clipped() } - // Main content area. New design: one continuous near-black sheet — the - // base surface extends under the unified toolbar (top safe area) so the - // glass toolbar controls float directly on the app surface instead of - // on a separate bar. Legacy design keeps its floating rounded card. + // Main content area. New design: one continuous near-black sheet. + // Legacy design keeps its floating rounded card. ZStack { if useLegacyHomeDesign { RoundedRectangle(cornerRadius: OmiChrome.windowRadius, style: .continuous) @@ -948,13 +897,23 @@ struct DesktopHomeView: View { .shadow(color: .black.opacity(0.22), radius: 26, x: 0, y: 14) } else { HomePalette.paper - .ignoresSafeArea(edges: .top) } // Page content - switch recreates views on tab change // Extracted into a separate struct so that pages like TasksPage // are not re-rendered when AppState publishes unrelated changes. VStack(spacing: 0) { + if !useLegacyHomeDesign && selectedIndex != SidebarNavItem.dashboard.rawValue { + PageChromeBar( + onHome: { + selectedIndex = SidebarNavItem.dashboard.rawValue + } + ) + .padding(.horizontal, 18) + .padding(.top, 14) + .padding(.bottom, 4) + } + PageContentView( selectedIndex: selectedIndex, appState: appState, @@ -1074,6 +1033,52 @@ struct DesktopHomeView: View { } } +private struct PageChromeBar: View { + let onHome: () -> Void + + var body: some View { + HStack(spacing: 8) { + PageChromeButton(title: "Home", systemImage: "house.fill", action: onHome) + Spacer() + } + .frame(height: 34) + } +} + +private struct PageChromeButton: View { + let title: String + let systemImage: String + let action: () -> Void + @State private var isHovering = false + + var body: some View { + Button(action: action) { + HStack(spacing: 7) { + Image(systemName: systemImage) + .scaledFont(size: 12, weight: .semibold) + Text(title) + .scaledFont(size: 12, weight: .semibold) + } + .foregroundStyle(isHovering ? OmiColors.textPrimary : OmiColors.textSecondary) + .padding(.horizontal, 11) + .padding(.vertical, 7) + .background( + Capsule(style: .continuous) + .fill(.ultraThinMaterial) + ) + .overlay( + Capsule(style: .continuous) + .stroke(isHovering ? OmiColors.success.opacity(0.34) : OmiColors.border.opacity(0.4), lineWidth: 1) + ) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .onHover { isHovering = $0 } + .help(title) + .accessibilityLabel(title) + } +} + /// Isolated page content switch — does NOT observe AppState or ViewModelContainer /// as @ObservedObject, so pages like TasksPage won't re-render when unrelated /// AppState properties (conversations, permissions, etc.) change. diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift index 266e56fa44b..d47f9c2d9ea 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/ConversationsPage.swift @@ -162,7 +162,7 @@ struct ConversationsPage: View { // MARK: - Main View with Recording Header + List private var mainConversationsView: some View { - // Title and status controls live in the window toolbar. + // Title lives in the page chrome; status controls live on Home. conversationListSection } diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift index 9e963f90f78..1b2c1bb4e81 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift @@ -516,6 +516,11 @@ struct DashboardPage: View { ZStack(alignment: .topTrailing) { HomeCanvasBackground() + InAppStatusControls(appState: appState) + .padding(.top, 16) + .padding(.trailing, 20) + .zIndex(2) + // Clicking anywhere outside the chat / connect panel collapses // back to the hub (panels and the ask bar consume their own // clicks above this catcher). diff --git a/desktop/macos/Desktop/Sources/OmiApp.swift b/desktop/macos/Desktop/Sources/OmiApp.swift index 06d7c947b46..21c20249b5f 100644 --- a/desktop/macos/Desktop/Sources/OmiApp.swift +++ b/desktop/macos/Desktop/Sources/OmiApp.swift @@ -124,10 +124,6 @@ struct OMIApp: App { } } .windowStyle(.titleBar) - // Native unified toolbar: traffic lights, page title, and controls in one - // system bar. The window title string stays set (isMainOmiWindow matches - // on it) but is hidden — the page name renders as a toolbar item. - .windowToolbarStyle(.unified(showsTitle: false)) .defaultSize(width: defaultWindowSize.width, height: defaultWindowSize.height) .commands { CommandGroup(after: .textFormatting) { diff --git a/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift b/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift index 28b1e9e390a..d473e4e56bf 100644 --- a/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift +++ b/desktop/macos/Desktop/Tests/DashboardCaptureStateTests.swift @@ -6,7 +6,7 @@ final class DashboardCaptureStateTests: XCTestCase { XCTAssertTrue( source.contains("var isCaptureLive: Bool"), - "CaptureListeningController should centralize live capture state so the toolbar reflects the running monitor" + "CaptureListeningController should centralize live capture state so Home controls reflect the running monitor" ) XCTAssertTrue( source.contains("return isCaptureLive ? .active : .inactive"), @@ -31,30 +31,26 @@ final class DashboardCaptureStateTests: XCTestCase { ) } - func testToolbarListeningControlShowsAndTogglesCaptureMode() throws { + func testHomeListeningControlShowsAndTogglesCaptureMode() throws { let controller = try captureControllerSource() - let toolbar = try toolbarControlsSource() + let controls = try homeControlsSource() // The listening control is labeled — an unlabeled mic glyph doesn't // tell the user what it controls. - XCTAssertTrue(toolbar.contains("title: \"Listening\"")) - XCTAssertTrue(toolbar.contains("title: \"Capture\"")) - XCTAssertTrue(toolbar.contains("controls.toggleListeningMode()")) + XCTAssertTrue(controls.contains("title: \"Listening\"")) + XCTAssertTrue(controls.contains("title: \"Capture\"")) + XCTAssertTrue(controls.contains("controls.toggleListeningMode()")) XCTAssertTrue(controller.contains("var listeningModeTitle: String")) XCTAssertTrue(controller.contains("return appState.isAwaitingMeeting ? \"Meetings only\" : \"In meeting\"")) XCTAssertTrue(controller.contains("AssistantSettings.shared.systemAudioCaptureMode = nextMode")) - // Toolbar glyphs stay neutral — the app's purple accent is banned in - // the window chrome (and blocked state is amber, never red). - XCTAssertFalse(toolbar.contains("OmiColors.purplePrimary")) - XCTAssertFalse(toolbar.contains("Color.purple")) - XCTAssertTrue( - toolbar.contains(".tint(Color.secondary)"), - "The settings menu glyph must override the app's purple root tint" - ) + // Home controls stay neutral — the app's purple accent is banned, and + // blocked state is amber rather than a brand-breaking red treatment. + XCTAssertFalse(controls.contains("OmiColors.purplePrimary")) + XCTAssertFalse(controls.contains("Color.purple")) // On/off must be readable at a glance: the running state fills the // control, it doesn't hide in a tiny badge. - XCTAssertTrue(toolbar.contains("return HomePalette.green.opacity(0.16)")) - XCTAssertTrue(toolbar.contains("return Color.yellow.opacity(0.12)")) + XCTAssertTrue(controls.contains("return HomePalette.green.opacity(0.16)")) + XCTAssertTrue(controls.contains("return Color.yellow.opacity(0.12)")) } func testHomeConnectorButtonsOpenSheetsDirectly() throws { @@ -358,7 +354,7 @@ final class DashboardCaptureStateTests: XCTestCase { try componentSource(named: "CaptureListeningController.swift") } - private func toolbarControlsSource() throws -> String { + private func homeControlsSource() throws -> String { try componentSource(named: "ToolbarStatusControls.swift") }