From ccad92afa1a03255ddec883bb90232ccacaa7f93 Mon Sep 17 00:00:00 2001 From: Drake Thomsen Date: Thu, 18 Jun 2026 19:23:30 -0400 Subject: [PATCH 01/18] Improve desktop startup warmup performance --- desktop/macos/Desktop/Sources/APIClient.swift | 5 + .../Sources/MainWindow/CrispManager.swift | 29 ++- .../Sources/MainWindow/DesktopHomeView.swift | 142 ++++++++++---- .../MainWindow/Pages/DashboardPage.swift | 20 +- .../MainWindow/Pages/MemoriesPage.swift | 8 + .../Sources/MainWindow/Pages/TasksPage.swift | 13 ++ desktop/macos/Desktop/Sources/OmiApp.swift | 106 +++++++++-- .../Sources/Providers/AppProvider.swift | 13 ++ .../Sources/Providers/ChatProvider.swift | 16 +- .../Sources/ScreenActivitySyncService.swift | 11 +- .../Sources/StartupWarmupCoordinator.swift | 175 ++++++++++++++++++ .../Desktop/Sources/StartupWarmupPolicy.swift | 75 ++++++++ .../Stores/DashboardTaskRefreshPolicy.swift | 129 +++++++++++++ .../Stores/DashboardTaskRefreshService.swift | 129 +++++++++++++ .../Desktop/Sources/Stores/TasksStore.swift | 4 + .../Desktop/Sources/ViewModelContainer.swift | 89 +++------ .../DashboardTaskRefreshPolicyTests.swift | 112 +++++++++++ .../Tests/StartupWarmupPolicyTests.swift | 156 ++++++++++++++++ 18 files changed, 1101 insertions(+), 131 deletions(-) create mode 100644 desktop/macos/Desktop/Sources/StartupWarmupCoordinator.swift create mode 100644 desktop/macos/Desktop/Sources/StartupWarmupPolicy.swift create mode 100644 desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshPolicy.swift create mode 100644 desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshService.swift create mode 100644 desktop/macos/Desktop/Tests/DashboardTaskRefreshPolicyTests.swift create mode 100644 desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift diff --git a/desktop/macos/Desktop/Sources/APIClient.swift b/desktop/macos/Desktop/Sources/APIClient.swift index 939b3b3ea36..22e839ec0f8 100644 --- a/desktop/macos/Desktop/Sources/APIClient.swift +++ b/desktop/macos/Desktop/Sources/APIClient.swift @@ -1709,6 +1709,11 @@ extension APIClient { return try await get(endpoint) } + /// Fetches one action item by backend ID. + func getActionItem(id: String) async throws -> TaskActionItem { + try await get("v1/action-items/\(id)") + } + /// Updates an action item func updateActionItem( id: String, diff --git a/desktop/macos/Desktop/Sources/MainWindow/CrispManager.swift b/desktop/macos/Desktop/Sources/MainWindow/CrispManager.swift index a0e9928f8a5..58781609f98 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/CrispManager.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/CrispManager.swift @@ -62,11 +62,14 @@ class CrispManager: ObservableObject { /// Call once after sign-in to fetch Crisp messages and listen for activation/Cmd+R. /// - /// - Parameter performInitialPoll: If `true` (default), kicks off an immediate - /// `pollForMessages()` call that hits `APIClient.shared`. Pass `false` only - /// from lifecycle unit tests that want to exercise observer registration - /// without touching the network, auth state, or firing real notifications. - func start(performInitialPoll: Bool = true) { + /// - Parameters: + /// - performInitialPoll: If `true` (default), schedules an initial + /// `pollForMessages()` call that hits `APIClient.shared`. Pass `false` only + /// from lifecycle unit tests that want to exercise observer registration + /// without touching the network, auth state, or firing real notifications. + /// - initialPollDelay: Optional delay before the initial poll. Activation and + /// Cmd+R events still poll immediately. + func start(performInitialPoll: Bool = true, initialPollDelay: TimeInterval = 0) { guard !isStarted else { return } isStarted = true @@ -78,7 +81,15 @@ class CrispManager: ObservableObject { } if performInitialPoll { - pollForMessages() + if initialPollDelay > 0 { + Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(initialPollDelay * 1_000_000_000)) + guard let self, self.isStarted else { return } + self.pollForMessages() + } + } else { + pollForMessages() + } } // Refresh on app activation and Cmd+R (no periodic timer) @@ -117,6 +128,7 @@ class CrispManager: ObservableObject { private func pollForMessages() { pollInvocations += 1 + guard !isRunningUnderXCTest else { return } Task { // Skip if in auth backoff period (recent 401 errors) guard !AuthBackoffTracker.shared.shouldSkipRequest() else { return } @@ -165,6 +177,11 @@ class CrispManager: ObservableObject { } } + private var isRunningUnderXCTest: Bool { + ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + || NSClassFromString("XCTestCase") != nil + } + private struct CrispUnreadResponse: Codable { let unread_count: Int let messages: [CrispOperatorMessage] diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index 03f3182161a..f1790c1ff60 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -34,6 +34,10 @@ struct DesktopHomeView: View { @State private var previousIndexBeforeSettings: Int = 0 @State private var logoPulse = false @State private var lastActivationRefresh = Date.distantPast + @State private var didScheduleAgentVMProvisioning = false + @State private var proactiveMonitoringStartGate = RetryableDelayedStartGate() + @State private var didScheduleConversationWarmup = false + @State private var initialFileIndexingBackfill = DelayedFileIndexingBackfillState() // Dismiss state for the Neo "no desktop access" banner (resets each launch). @State private var neoDesktopBannerDismissed = false @@ -140,11 +144,7 @@ struct DesktopHomeView: View { // For existing users who haven't indexed files yet, run a background scan if !UserDefaults.standard.bool(forKey: "hasCompletedFileIndexing") { - UserDefaults.standard.set(true, forKey: "hasCompletedFileIndexing") - Task { - log("DesktopHomeView: Running background file scan for existing user") - await FileIndexerService.shared.backgroundRescan() - } + scheduleInitialFileIndexing() } let settings = AssistantSettings.shared @@ -183,15 +183,7 @@ struct DesktopHomeView: View { // If API keys aren't loaded yet, this may fail — onChange below retries. if settings.screenAnalysisEnabled { if APIKeyService.keysAvailable { - ProactiveAssistantsPlugin.shared.startMonitoring { success, error in - if success { - log("DesktopHomeView: Screen analysis started") - } else { - log( - "DesktopHomeView: Screen analysis failed to start: \(error ?? "unknown") — setting remains enabled for next launch" - ) - } - } + scheduleProactiveMonitoringStart(reason: "launch") } else { log( "DesktopHomeView: Deferring screen analysis — API keys not yet loaded" @@ -202,7 +194,7 @@ struct DesktopHomeView: View { } // Start Crisp chat in background for notifications - CrispManager.shared.start() + CrispManager.shared.start(initialPollDelay: StartupWarmupPolicy.crispInitialPollDelay) // Set up floating control bar (only show if user hasn't disabled it) FloatingControlBarManager.shared.setup( @@ -218,14 +210,9 @@ struct DesktopHomeView: View { } .task { // Trigger eager data loading when main content appears - // Load conversations/folders in parallel with other data - async let vmLoad: Void = viewModelContainer.loadAllData() - async let conversations: Void = appState.loadConversations() - async let folders: Void = appState.loadFolders() - _ = await (vmLoad, conversations, folders) - - // Backend-based check: ensure user has a cloud agent VM - await AgentVMService.shared.ensureProvisioned() + await viewModelContainer.loadAllData() + scheduleConversationWarmup() + scheduleAgentVMProvisioning() } // Refresh conversations when app becomes active (e.g. switching back from another app) .onReceive( @@ -244,8 +231,8 @@ struct DesktopHomeView: View { if AssistantSettings.shared.screenAnalysisEnabled && !plugin.isMonitoring { plugin.refreshScreenRecordingPermission() if plugin.hasScreenRecordingPermission { - log("DesktopHomeView: Permission available on app active — starting monitoring") - plugin.startMonitoring { _, _ in } + log("DesktopHomeView: Permission available on app active — scheduling monitoring") + scheduleProactiveMonitoringStart(reason: "app active") } } } @@ -260,15 +247,7 @@ struct DesktopHomeView: View { // Retry screen analysis let plugin = ProactiveAssistantsPlugin.shared if AssistantSettings.shared.screenAnalysisEnabled && !plugin.isMonitoring { - plugin.startMonitoring { success, error in - if success { - log("DesktopHomeView: Screen analysis started (after key load)") - } else { - log( - "DesktopHomeView: Screen analysis retry failed: \(error ?? "unknown")" - ) - } - } + scheduleProactiveMonitoringStart(reason: "key load") } } // Cmd+R: refresh all data (conversations, chat, tasks, memories) @@ -503,7 +482,7 @@ struct DesktopHomeView: View { updatedAt: ISO8601DateFormatter().string(from: Date()) ) - Task { + Task { @MainActor in await DesktopAutomationStateStore.shared.update(snapshot) } } @@ -593,6 +572,99 @@ struct DesktopHomeView: View { } } + private func scheduleAgentVMProvisioning() { + guard !didScheduleAgentVMProvisioning else { return } + didScheduleAgentVMProvisioning = true + + Task { + try? await Task.sleep( + nanoseconds: UInt64(StartupWarmupPolicy.agentVMProvisioningDelay * 1_000_000_000) + ) + await AgentVMService.shared.ensureProvisioned() + } + } + + private func scheduleConversationWarmup() { + guard !didScheduleConversationWarmup else { return } + didScheduleConversationWarmup = true + + Task { @MainActor in + try? await Task.sleep( + nanoseconds: UInt64(StartupWarmupPolicy.conversationWarmupDelay * 1_000_000_000) + ) + + async let conversations: Void = loadConversationsIfNeeded() + async let folders: Void = loadFoldersIfNeeded() + _ = await (conversations, folders) + } + } + + private func loadConversationsIfNeeded() async { + guard appState.conversations.isEmpty else { return } + await appState.loadConversations() + } + + private func loadFoldersIfNeeded() async { + guard appState.folders.isEmpty else { return } + await appState.loadFolders() + } + + private func scheduleInitialFileIndexing() { + guard + initialFileIndexingBackfill.reserveIfNeeded( + hasCompletedBackfill: UserDefaults.standard.bool(forKey: "hasCompletedFileIndexing")) + else { return } + + Task { + try? await Task.sleep( + nanoseconds: UInt64(StartupWarmupPolicy.initialFileIndexingDelay * 1_000_000_000) + ) + log("DesktopHomeView: Running delayed background file scan for existing user") + await FileIndexerService.shared.backgroundRescan() + initialFileIndexingBackfill.markScanCompleted() + if initialFileIndexingBackfill.shouldMarkComplete { + UserDefaults.standard.set(true, forKey: "hasCompletedFileIndexing") + log( + "DesktopHomeView: Marked existing-user file indexing backfill complete after background scan returned" + ) + } + } + } + + private func scheduleProactiveMonitoringStart(reason: String) { + guard proactiveMonitoringStartGate.reserve() else { return } + + Task { @MainActor in + try? await Task.sleep( + nanoseconds: UInt64(StartupWarmupPolicy.proactiveAssistantsStartDelay * 1_000_000_000) + ) + + let plugin = ProactiveAssistantsPlugin.shared + guard AssistantSettings.shared.screenAnalysisEnabled, !plugin.isMonitoring else { + proactiveMonitoringStartGate.finishAttempt() + return + } + guard APIKeyService.keysAvailable else { + proactiveMonitoringStartGate.finishAttempt() + log("DesktopHomeView: Screen analysis still deferred after \(reason) — API keys not yet loaded") + return + } + + plugin.startMonitoring { success, error in + Task { @MainActor in + proactiveMonitoringStartGate.finishAttempt() + if success { + log("DesktopHomeView: Screen analysis started (\(reason), delayed)") + } else { + log( + "DesktopHomeView: Screen analysis failed to start (\(reason)): \(error ?? "unknown") — setting remains enabled for next launch" + ) + } + } + } + } + } + private func updateStoreActivity(for index: Int) { viewModelContainer.tasksStore.isActive = index == SidebarNavItem.dashboard.rawValue || index == SidebarNavItem.tasks.rawValue diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift index 3832649525e..0b1a798195b 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift @@ -50,7 +50,7 @@ class DashboardViewModel: ObservableObject { // Load all data in parallel async let scoreTask: Void = loadScores() - async let tasksTask: Void = tasksStore.loadTasksIfNeeded() // Don't re-fetch if ViewModelContainer already loaded + async let tasksTask: Void = tasksStore.refreshDashboardTasksFromServer() async let goalsTask: Void = loadGoals() let _ = await (scoreTask, tasksTask, goalsTask) @@ -58,6 +58,10 @@ class DashboardViewModel: ObservableObject { isLoading = false } + func loadCachedDashboardData() async { + await loadGoalsFromLocalSnapshot() + } + private func loadScores() async { do { scoreResponse = try await APIClient.shared.getScores() @@ -94,11 +98,15 @@ class DashboardViewModel: ObservableObject { private func loadGoalsFromLocal() { Task { - do { - goals = try await GoalStorage.shared.getLocalGoals() - } catch { - logError("Failed to load goals from local storage", error: error) - } + await loadGoalsFromLocalSnapshot() + } + } + + private func loadGoalsFromLocalSnapshot() async { + do { + goals = try await GoalStorage.shared.getLocalGoals() + } catch { + logError("Failed to load goals from local storage", error: error) } } diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift index e70018ecdf9..82da9b7279f 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift @@ -470,6 +470,11 @@ class MemoriesViewModel: ObservableObject { } } + func loadMemoriesIfNeeded() async { + guard !hasLoadedInitially && memories.isEmpty else { return } + await loadMemories() + } + /// One-time cache reconcile. The local SQLite cache can diverge from the /// backend (the source of truth): stale categories after the server-side /// category cleanup, plus "orphan" rows whose backendId no longer exists on @@ -954,6 +959,9 @@ struct MemoriesPage: View { } } } + .task { + await viewModel.loadMemoriesIfNeeded() + } } // MARK: - Undo Delete Toast diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift index 0c2f17c7cbc..824347e1a7d 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift @@ -1808,6 +1808,16 @@ class TasksViewModel: ObservableObject { // MARK: - Actions (delegate to shared store) + func loadTasksForFirstUse() async { + guard TasksPageFirstUseLoadPolicy.shouldLoadTasks( + hasRenderedTasks: !store.tasks.isEmpty, + isLoading: store.isLoading + ) else { return } + + log("TasksPage: First-use loading task list") + await store.loadTasksIfNeeded() + } + func loadTasks() async { await store.loadTasks() } @@ -2261,6 +2271,9 @@ struct TasksPage: View { .background(Color.clear) // Modal creation sheet removed — Cmd+N now creates inline at top .onAppear { + Task { + await viewModel.loadTasksForFirstUse() + } // Restore panel UI if coordinator was open when we navigated away if chatCoordinator.isPanelOpen, chatCoordinator.activeTaskId != nil { showChatPanel = true diff --git a/desktop/macos/Desktop/Sources/OmiApp.swift b/desktop/macos/Desktop/Sources/OmiApp.swift index 7b59267448c..c0c8035a1f8 100644 --- a/desktop/macos/Desktop/Sources/OmiApp.swift +++ b/desktop/macos/Desktop/Sources/OmiApp.swift @@ -220,6 +220,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private var screenCaptureSwitch: NSSwitch? private var audioRecordingSwitch: NSSwitch? private var relaunchOnLoginSuppressedForOnboarding = false + private var apiKeyFetchTask: Task? + private var appLifecycleMaintenanceTask: Task? + private var didScheduleInitialSettingsSync = false + private var initialSettingsSyncTask: Task? func applicationDidFinishLaunching(_ notification: Notification) { if ViewExporter.shouldExport() { @@ -408,21 +412,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { AnalyticsManager.shared.trackFirstLaunchIfNeeded() // Set per-user database path before any async tasks can trigger DB initialization. - // This is synchronous and must happen before TierManager / TranscriptionRetryService. + // This is synchronous and must happen before ViewModelContainer initializes SQLite. let userId = UserDefaults.standard.string(forKey: "auth_userId") RewindDatabase.currentUserId = (userId?.isEmpty == false) ? userId : "anonymous" // Start resource monitoring (memory, CPU, disk) ResourceMonitor.shared.start() - // Recover any pending/failed transcription sessions from previous runs - Task { - await TranscriptionRetryService.shared.recoverPendingTranscriptions() - TranscriptionRetryService.shared.start() - } - - // Start recurring task scheduler (checks every 60s for due tasks) - RecurringTaskScheduler.shared.start() + scheduleAppLifecycleMaintenance() // Identify user if already signed in if AuthState.shared.isSignedIn { @@ -435,14 +432,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { AuthService.shared.displayName.isEmpty ? nil : AuthService.shared.displayName SentrySDK.setUser(sentryUser) } - // Fetch conversations on startup - AuthService.shared.fetchConversations() + // Fetch API keys after first-window warmup settles. First-use paths call waitForKeys(). + scheduleAPIKeyFetch() - // Fetch API keys from backend (keys are not bundled in the app) - APIKeyService.shared.startFetchingKeys() - - // Fetch subscription plan for floating bar usage limits - Task { await FloatingBarUsageLimiter.shared.fetchPlan() } + // Fetch subscription plan for floating bar usage limits after the startup warmup settles. + scheduleFloatingBarPlanFetch() // Start trial metadata polling (countdown UI + pre-expiry nudges) if let state = AppState.current { @@ -1173,6 +1167,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { sentryHeartbeatTimer?.invalidate() sentryHeartbeatTimer = nil + apiKeyFetchTask?.cancel() + apiKeyFetchTask = nil + appLifecycleMaintenanceTask?.cancel() + appLifecycleMaintenanceTask = nil + // Stop transcription retry service TranscriptionRetryService.shared.stop() @@ -1337,8 +1336,83 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } } + private func scheduleFloatingBarPlanFetch() { + Task { + let delay = StartupWarmupPolicy.floatingBarPlanFetchDelay + if delay > 0 { + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } + guard await AuthState.shared.isSignedIn else { return } + await FloatingBarUsageLimiter.shared.fetchPlan() + } + } + + private func scheduleAPIKeyFetch() { + apiKeyFetchTask?.cancel() + apiKeyFetchTask = Task { + let delay = StartupWarmupPolicy.apiKeyFetchDelay + if delay > 0 { + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } + guard !Task.isCancelled else { return } + guard await AuthState.shared.isSignedIn else { return } + log("AppDelegate: Starting delayed API key fetch") + await APIKeyService.shared.waitForKeys() + } + } + + private func scheduleAppLifecycleMaintenance() { + appLifecycleMaintenanceTask?.cancel() + appLifecycleMaintenanceTask = Task { + let recoveryDelay = StartupWarmupPolicy.transcriptionRetryRecoveryDelay + if recoveryDelay > 0 { + try? await Task.sleep(nanoseconds: UInt64(recoveryDelay * 1_000_000_000)) + } + guard !Task.isCancelled else { return } + + await measurePerfAsync("AppDelegate: Transcription retry recovery") { + await TranscriptionRetryService.shared.recoverPendingTranscriptions() + await MainActor.run { + TranscriptionRetryService.shared.start() + } + } + + let schedulerDelay = max( + 0, + StartupWarmupPolicy.recurringTaskSchedulerInitialDelay + - StartupWarmupPolicy.transcriptionRetryRecoveryDelay + ) + if schedulerDelay > 0 { + try? await Task.sleep(nanoseconds: UInt64(schedulerDelay * 1_000_000_000)) + } + guard !Task.isCancelled else { return } + + await MainActor.run { + RecurringTaskScheduler.shared.start() + } + } + } + func applicationDidBecomeActive(_ notification: Notification) { + guard didScheduleInitialSettingsSync else { + scheduleInitialSettingsSync() + return + } + // Sync remote assistant settings so server-side changes take effect promptly Task { await SettingsSyncManager.shared.syncFromServer() } } + + private func scheduleInitialSettingsSync() { + didScheduleInitialSettingsSync = true + initialSettingsSyncTask?.cancel() + initialSettingsSyncTask = Task { + let delay = StartupWarmupPolicy.initialSettingsSyncDelay + if delay > 0 { + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } + guard !Task.isCancelled else { return } + await SettingsSyncManager.shared.syncFromServer() + } + } } diff --git a/desktop/macos/Desktop/Sources/Providers/AppProvider.swift b/desktop/macos/Desktop/Sources/Providers/AppProvider.swift index c6b1efaabe4..3d888b39974 100644 --- a/desktop/macos/Desktop/Sources/Providers/AppProvider.swift +++ b/desktop/macos/Desktop/Sources/Providers/AppProvider.swift @@ -34,6 +34,19 @@ class AppProvider: ObservableObject { // MARK: - Fetch Methods + /// Fetch only chat-capable apps for startup chat picker warmup. + /// The full Apps page still loads categories, capabilities, ratings, and all groups on first use. + func fetchChatAppsForStartup() async { + do { + let v2Response = try await apiClient.getAppsV2() + let chat = v2Response.groups.first { $0.capability.id == "chat" }?.data ?? [] + chatApps = chat + log("Fetched \(chatApps.count) chat apps for startup") + } catch { + logError("Failed to fetch startup chat apps", error: error) + } + } + /// Fetch all apps data using v2/apps endpoint (grouped by capability, matching Flutter) func fetchApps() async { isLoading = true diff --git a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift index 63f7f4f970e..c572bfa95ae 100644 --- a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift +++ b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift @@ -976,11 +976,7 @@ BROWSER TABS: when you use the browser (Playwright), on your FIRST browser actio /// Ensures all prompt-backed local context is loaded before we build and cache the ACP session prompt. private func preparePromptContextIfNeeded() async { - await loadMemoriesIfNeeded() - await loadGoalsIfNeeded() - await loadTasksIfNeeded() - await loadAIProfileIfNeeded() - await loadSchemaIfNeeded() + await warmupPromptContext() } /// Switch between bridge modes (Omi AI via piMono, or user's Claude OAuth) @@ -1925,6 +1921,12 @@ BROWSER TABS: when you use the browser (Playwright), on your FIRST browser actio /// Initialize chat: fetch sessions and load messages func initialize() async { + await initializeVisibleMessages() + await warmupPromptContext() + } + + /// Load the chat state that is directly visible in Dashboard/Chat without warming prompt-only context. + func initializeVisibleMessages() async { // Seed cumulative Omi AI cost from backend now that auth is ready (background, no latency) Task.detached(priority: .background) { [weak self] in guard let serverCost = await APIClient.shared.fetchTotalOmiAICost() else { return } @@ -1951,6 +1953,10 @@ BROWSER TABS: when you use the browser (Playwright), on your FIRST browser actio isLoadingSessions = false await loadDefaultChatMessages() } + } + + /// Warm local prompt context used by first send / bridge startup. + func warmupPromptContext() async { await loadMemoriesIfNeeded() await loadGoalsIfNeeded() await loadTasksIfNeeded() diff --git a/desktop/macos/Desktop/Sources/ScreenActivitySyncService.swift b/desktop/macos/Desktop/Sources/ScreenActivitySyncService.swift index 0e7a428fb77..0cf71b54e10 100644 --- a/desktop/macos/Desktop/Sources/ScreenActivitySyncService.swift +++ b/desktop/macos/Desktop/Sources/ScreenActivitySyncService.swift @@ -27,15 +27,15 @@ actor ScreenActivitySyncService { // MARK: - Public API /// Start the sync loop. Call after auth is established and database is ready. - func start() { + func start(initialDelay: TimeInterval = 0) { guard !isRunning else { log("ScreenActivitySync: already running") return } isRunning = true loadCursor() - log("ScreenActivitySync: starting (lastSyncedId=\(lastSyncedId))") - syncLoop() + log("ScreenActivitySync: starting (lastSyncedId=\(lastSyncedId), initialDelay=\(initialDelay)s)") + syncLoop(initialDelay: initialDelay) } /// Stop the sync loop. @@ -49,8 +49,11 @@ actor ScreenActivitySyncService { // MARK: - Sync loop - private func syncLoop() { + private func syncLoop(initialDelay: TimeInterval) { syncTask = Task { + if initialDelay > 0 { + try? await Task.sleep(nanoseconds: UInt64(initialDelay * 1_000_000_000)) + } while !Task.isCancelled && isRunning { // Skip if user is signed out guard await AuthState.shared.isSignedIn else { diff --git a/desktop/macos/Desktop/Sources/StartupWarmupCoordinator.swift b/desktop/macos/Desktop/Sources/StartupWarmupCoordinator.swift new file mode 100644 index 00000000000..d40641f5330 --- /dev/null +++ b/desktop/macos/Desktop/Sources/StartupWarmupCoordinator.swift @@ -0,0 +1,175 @@ +import Foundation + +@MainActor +final class StartupWarmupCoordinator { + private let tasksStore: TasksStore + private let dashboardViewModel: DashboardViewModel + private let appProvider: AppProvider + private let chatProvider: ChatProvider + private let retryDatabaseInit: () async -> Bool + + private var scheduleState = StartupWarmupScheduleState() + private var serviceWarmupTask: Task? + private var databaseWarmupTask: Task? + private var dashboardNetworkRefreshTask: Task? + private var chatPromptContextWarmupTask: Task? + private var databaseRetryTask: Task? + + init( + tasksStore: TasksStore, + dashboardViewModel: DashboardViewModel, + appProvider: AppProvider, + chatProvider: ChatProvider, + retryDatabaseInit: @escaping () async -> Bool + ) { + self.tasksStore = tasksStore + self.dashboardViewModel = dashboardViewModel + self.appProvider = appProvider + self.chatProvider = chatProvider + self.retryDatabaseInit = retryDatabaseInit + } + + func cancel() { + serviceWarmupTask?.cancel() + databaseWarmupTask?.cancel() + dashboardNetworkRefreshTask?.cancel() + chatPromptContextWarmupTask?.cancel() + databaseRetryTask?.cancel() + } + + func schedulePostInteractiveWarmup(dbAvailable: Bool) { + if scheduleState.reserveServiceWarmup() { + serviceWarmupTask = Task { [weak self] in + await self?.runServiceWarmup() + } + scheduleChatPromptContextWarmup() + } + + scheduleDatabaseWarmup(dbAvailable: dbAvailable) + scheduleDatabaseRetryIfNeeded(dbAvailable: dbAvailable) + } + + private func scheduleDatabaseWarmup(dbAvailable: Bool) { + guard scheduleState.reserveDatabaseWarmup(dbAvailable: dbAvailable) else { + if !dbAvailable { + log("DATA LOAD: Waiting to schedule DB warmup until database retry succeeds") + } + return + } + + databaseWarmupTask = Task { [weak self] in + await self?.runDatabaseWarmup() + } + scheduleDashboardNetworkRefresh(dbAvailable: true) + } + + private func runDatabaseWarmup() async { + guard await sleepForStartupDelay(StartupWarmupPolicy.immediateWarmupDelay) else { return } + + await measurePerfAsync("DATA LOAD: Immediate warmup") { [self] in + async let tasks: Void = measurePerfAsync("DATA LOAD: TasksStore dashboard snapshot") { + await tasksStore.loadDashboardTasks() + } + async let dashboard: Void = measurePerfAsync("DATA LOAD: Dashboard cached snapshot") { + await dashboardViewModel.loadCachedDashboardData() + } + _ = await (tasks, dashboard) + } + + guard await sleepForStartupDelay(StartupWarmupPolicy.deferredWarmupDelay) else { return } + + await measurePerfAsync("DATA LOAD: DB lifecycle warmup") { + await measurePerfAsync("DATA LOAD: Task agent restore") { + await TaskAgentManager.shared.restoreSessionsFromDatabase() + } + await measurePerfAsync("DATA LOAD: Screen activity sync") { + await ScreenActivitySyncService.shared.start( + initialDelay: StartupWarmupPolicy.screenActivitySyncInitialDelay + ) + } + } + + logPerf("DATA LOAD: DB warmup complete", cpu: true) + } + + private func runServiceWarmup() async { + guard await sleepForStartupDelay( + StartupWarmupPolicy.immediateWarmupDelay + StartupWarmupPolicy.deferredWarmupDelay + ) else { return } + + await measurePerfAsync("DATA LOAD: Deferred service warmup") { [self] in + async let apps: Void = measurePerfAsync("DATA LOAD: Chat apps") { + await appProvider.fetchChatAppsForStartup() + } + async let chatMessages: Void = measurePerfAsync("DATA LOAD: Chat messages") { + await chatProvider.initializeVisibleMessages() + } + + _ = await (apps, chatMessages) + } + + logPerf("DATA LOAD: Service warmup complete", cpu: true) + } + + private func scheduleDashboardNetworkRefresh(dbAvailable: Bool) { + guard dbAvailable else { + log("DATA LOAD: Skipping dashboard network refresh (database unavailable)") + return + } + + dashboardNetworkRefreshTask = Task { [weak self] in + guard let self else { return } + guard await sleepForStartupDelay(StartupWarmupPolicy.dashboardNetworkRefreshDelay) else { return } + + await measurePerfAsync("DATA LOAD: Dashboard network refresh") { + await self.dashboardViewModel.loadDashboardData() + } + } + } + + private func scheduleChatPromptContextWarmup() { + chatPromptContextWarmupTask = Task { [weak self] in + guard let self else { return } + guard await sleepForStartupDelay(StartupWarmupPolicy.chatPromptContextWarmupDelay) else { return } + + await measurePerfAsync("DATA LOAD: Chat prompt context") { + await self.chatProvider.warmupPromptContext() + } + } + } + + private func scheduleDatabaseRetryIfNeeded(dbAvailable: Bool) { + guard !dbAvailable else { return } + guard databaseRetryTask == nil else { return } + + databaseRetryTask = Task { [weak self] in + guard let self else { return } + var delay = StartupWarmupPolicy.databaseRetryInitialDelay + + while !Task.isCancelled { + guard await self.sleepForStartupDelay(delay) else { return } + let didRecover = await self.retryDatabaseInit() + if didRecover { + self.databaseRetryTask = nil + return + } + + delay = min(delay * 2, StartupWarmupPolicy.databaseRetryMaxDelay) + } + } + } + + func markDatabaseRetryComplete() { + databaseRetryTask = nil + } + + private func sleepForStartupDelay(_ seconds: TimeInterval) async -> Bool { + guard seconds > 0 else { return !Task.isCancelled } + do { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + return !Task.isCancelled + } catch { + return false + } + } +} diff --git a/desktop/macos/Desktop/Sources/StartupWarmupPolicy.swift b/desktop/macos/Desktop/Sources/StartupWarmupPolicy.swift new file mode 100644 index 00000000000..d86c63c0ba0 --- /dev/null +++ b/desktop/macos/Desktop/Sources/StartupWarmupPolicy.swift @@ -0,0 +1,75 @@ +import Foundation + +struct StartupWarmupScheduleState { + private var didScheduleServiceWarmup = false + private var didScheduleDatabaseWarmup = false + + mutating func reserveServiceWarmup() -> Bool { + guard !didScheduleServiceWarmup else { return false } + didScheduleServiceWarmup = true + return true + } + + mutating func reserveDatabaseWarmup(dbAvailable: Bool) -> Bool { + guard dbAvailable, !didScheduleDatabaseWarmup else { return false } + didScheduleDatabaseWarmup = true + return true + } +} + +struct RetryableDelayedStartGate { + private var isAttemptReserved = false + + mutating func reserve() -> Bool { + guard !isAttemptReserved else { return false } + isAttemptReserved = true + return true + } + + mutating func finishAttempt() { + isAttemptReserved = false + } +} + +struct DelayedFileIndexingBackfillState { + private var isScheduled = false + private(set) var shouldMarkComplete = false + + mutating func reserveIfNeeded(hasCompletedBackfill: Bool) -> Bool { + guard !hasCompletedBackfill, !isScheduled else { return false } + isScheduled = true + shouldMarkComplete = false + return true + } + + mutating func markScanCompleted() { + isScheduled = false + shouldMarkComplete = true + } +} + +enum TasksPageFirstUseLoadPolicy { + static func shouldLoadTasks(hasRenderedTasks: Bool, isLoading: Bool) -> Bool { + !hasRenderedTasks && !isLoading + } +} + +enum StartupWarmupPolicy { + static let immediateWarmupDelay: TimeInterval = 0.25 + static let deferredWarmupDelay: TimeInterval = 2.0 + static let databaseRetryInitialDelay: TimeInterval = 1.0 + static let databaseRetryMaxDelay: TimeInterval = 30.0 + static let dashboardNetworkRefreshDelay: TimeInterval = 4.0 + static let initialSettingsSyncDelay: TimeInterval = 5.0 + static let apiKeyFetchDelay: TimeInterval = 9.0 + static let chatPromptContextWarmupDelay: TimeInterval = 10.0 + static let screenActivitySyncInitialDelay: TimeInterval = 10.0 + static let floatingBarPlanFetchDelay: TimeInterval = 12.0 + static let crispInitialPollDelay: TimeInterval = 15.0 + static let agentVMProvisioningDelay: TimeInterval = 20.0 + static let proactiveAssistantsStartDelay: TimeInterval = 30.0 + static let conversationWarmupDelay: TimeInterval = 6.0 + static let transcriptionRetryRecoveryDelay: TimeInterval = 8.0 + static let recurringTaskSchedulerInitialDelay: TimeInterval = 12.0 + static let initialFileIndexingDelay: TimeInterval = 45.0 +} diff --git a/desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshPolicy.swift b/desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshPolicy.swift new file mode 100644 index 00000000000..8f4368ae1f7 --- /dev/null +++ b/desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshPolicy.swift @@ -0,0 +1,129 @@ +import Foundation + +enum DashboardTaskRefreshPolicy { + static let shouldSyncFromServer = true + static let shouldMarkIncompleteTasksLoaded = false + static let shouldAssignTasksPageList = false + static let serverFetchLimit = 100 +} + +enum DashboardExactTaskFetchPolicy { + static let maxConcurrentRequests = 6 + + static func chunks(ids: [String]) -> [[String]] { + guard !ids.isEmpty else { return [] } + + return stride(from: 0, to: ids.count, by: maxConcurrentRequests).map { start in + let end = min(start + maxConcurrentRequests, ids.count) + return Array(ids[start..( + ids: [String], + operation: @escaping @Sendable (String) async -> Result + ) async -> [Result] { + var results: [Result] = [] + + for chunk in DashboardExactTaskFetchPolicy.chunks(ids: ids) { + let chunkResults = await withTaskGroup(of: Result.self, returning: [Result].self) { group in + for id in chunk { + group.addTask { + await operation(id) + } + } + + var chunkResults: [Result] = [] + for await result in group { + chunkResults.append(result) + } + return chunkResults + } + results.append(contentsOf: chunkResults) + } + + return results + } +} + +struct DashboardTaskReconciliationPlan { + let itemsToSync: [TaskActionItem] + let backendIdsToHardDelete: Set + let dashboardVisibleServerIds: Set + let completedServerIds: Set + let movedOutServerIds: Set + + var shouldMarkIncompleteTasksLoaded: Bool { + DashboardTaskRefreshPolicy.shouldMarkIncompleteTasksLoaded + } + + var shouldAssignTasksPageList: Bool { + DashboardTaskRefreshPolicy.shouldAssignTasksPageList + } +} + +enum DashboardTaskReconciliationPlanner { + static func plan( + localDashboardIds: Set, + dashboardWindowServerItems: [TaskActionItem], + exactServerItemsById: [String: TaskActionItem], + missingServerIds: Set, + now: Date = Date(), + calendar: Calendar = .current + ) -> DashboardTaskReconciliationPlan { + var itemsById = dashboardWindowServerItems.reduce(into: [String: TaskActionItem]()) { result, item in + result[item.id] = item + } + exactServerItemsById.values.forEach { item in + itemsById[item.id] = item + } + + var dashboardVisibleServerIds = Set() + var completedServerIds = Set() + var movedOutServerIds = Set() + + for item in exactServerItemsById.values { + if item.completed || item.deleted == true { + completedServerIds.insert(item.id) + } else if isDashboardVisible(item, now: now, calendar: calendar) { + dashboardVisibleServerIds.insert(item.id) + } else { + movedOutServerIds.insert(item.id) + } + } + + for item in dashboardWindowServerItems where isDashboardVisible(item, now: now, calendar: calendar) { + dashboardVisibleServerIds.insert(item.id) + } + + return DashboardTaskReconciliationPlan( + itemsToSync: Array(itemsById.values), + backendIdsToHardDelete: missingServerIds.intersection(localDashboardIds), + dashboardVisibleServerIds: dashboardVisibleServerIds, + completedServerIds: completedServerIds, + movedOutServerIds: movedOutServerIds + ) + } + + static func isDashboardVisible( + _ item: TaskActionItem, + now: Date = Date(), + calendar: Calendar = .current + ) -> Bool { + guard !item.completed, item.deleted != true else { return false } + + let startOfToday = calendar.startOfDay(for: now) + guard + let endOfToday = calendar.date(byAdding: .day, value: 1, to: startOfToday), + let sevenDaysAgo = calendar.date(byAdding: .day, value: -7, to: now) + else { return false } + + if let dueAt = item.dueAt { + return dueAt >= sevenDaysAgo && dueAt < endOfToday + } + + return item.createdAt >= sevenDaysAgo + } +} diff --git a/desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshService.swift b/desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshService.swift new file mode 100644 index 00000000000..eeaf9afe00c --- /dev/null +++ b/desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshService.swift @@ -0,0 +1,129 @@ +import Foundation + +@MainActor +enum DashboardTaskRefreshService { + private static var isRefreshing = false + + static func refresh(store: TasksStore) async { + guard DashboardTaskRefreshPolicy.shouldSyncFromServer else { + await store.loadDashboardTasks() + return + } + guard AuthService.shared.isSignedIn else { + await store.loadDashboardTasks() + return + } + guard !AuthBackoffTracker.shared.shouldSkipRequest() else { + await store.loadDashboardTasks() + return + } + guard !isRefreshing else { return } + + isRefreshing = true + defer { isRefreshing = false } + + await store.loadDashboardTasks() + + let dashboardIds = Set((store.overdueTasks + store.todaysTasks + store.tasksWithoutDueDate) + .map(\.id) + .filter { !$0.hasPrefix("local_") && !$0.hasPrefix("staged_") }) + + do { + let calendar = Calendar.current + let windowItems = try await fetchDashboardWindowItems(calendar: calendar) + let serverTruth = await fetchExactServerTruth(forDashboardIds: dashboardIds) + let plan = DashboardTaskReconciliationPlanner.plan( + localDashboardIds: dashboardIds, + dashboardWindowServerItems: windowItems, + exactServerItemsById: serverTruth.itemsById, + missingServerIds: serverTruth.missingIds, + calendar: calendar + ) + + if !plan.itemsToSync.isEmpty { + try await ActionItemStorage.shared.syncTaskActionItems(plan.itemsToSync) + } + for backendId in plan.backendIdsToHardDelete { + try await ActionItemStorage.shared.hardDeleteByBackendId(backendId) + } + log( + "DashboardTaskRefreshService: Dashboard freshness reconciled sync=\(plan.itemsToSync.count), hardDeleted=\(plan.backendIdsToHardDelete.count), visible=\(plan.dashboardVisibleServerIds.count), completed=\(plan.completedServerIds.count), movedOut=\(plan.movedOutServerIds.count) without loading Tasks page list" + ) + AuthBackoffTracker.shared.reportSuccess() + } catch { + if case APIError.unauthorized = error { + AuthBackoffTracker.shared.reportAuthFailure() + } + logError("DashboardTaskRefreshService: Dashboard freshness sync failed", error: error) + } + + await store.loadDashboardTasks() + } + + private static func fetchDashboardWindowItems(calendar: Calendar) async throws -> [TaskActionItem] { + let startOfToday = calendar.startOfDay(for: Date()) + let endOfToday = calendar.date(byAdding: .day, value: 1, to: startOfToday)! + let sevenDaysAgo = calendar.date(byAdding: .day, value: -7, to: Date()) ?? Date() + let limit = DashboardTaskRefreshPolicy.serverFetchLimit + + async let dueWindowResponse = APIClient.shared.getActionItems( + limit: limit, + offset: 0, + completed: false, + dueStartDate: sevenDaysAgo, + dueEndDate: endOfToday + ) + async let recentResponse = APIClient.shared.getActionItems( + limit: limit, + offset: 0, + completed: false, + startDate: sevenDaysAgo + ) + + let (dueWindow, recent) = try await (dueWindowResponse, recentResponse) + return [dueWindow, recent].flatMap(\.items) + } + + private static func fetchExactServerTruth( + forDashboardIds ids: Set + ) async -> (itemsById: [String: TaskActionItem], missingIds: Set) { + guard !ids.isEmpty else { return ([:], []) } + + let sortedIds = ids.sorted() + let results = await DashboardExactTaskFetchLimiter.fetch(ids: sortedIds) { id in + do { + let item = try await APIClient.shared.getActionItem(id: id) + return (id, Result.success(item)) + } catch APIError.httpError(let statusCode, _) where statusCode == 404 { + return (id, Result.success(nil)) + } catch { + return (id, Result.failure(error)) + } + } + + var itemsById: [String: TaskActionItem] = [:] + var missingIds = Set() + var failedCount = 0 + + for (id, result) in results { + switch result { + case .success(.some(let item)): + itemsById[id] = item + case .success(.none): + missingIds.insert(id) + case .failure(let error): + failedCount += 1 + logError("DashboardTaskRefreshService: Exact task refresh failed for \(id)", error: error) + if case APIError.unauthorized = error { + AuthBackoffTracker.shared.reportAuthFailure() + } + } + } + + if failedCount > 0 { + log("DashboardTaskRefreshService: Exact task refresh skipped \(failedCount) stale classifications") + } + + return (itemsById, missingIds) + } +} diff --git a/desktop/macos/Desktop/Sources/Stores/TasksStore.swift b/desktop/macos/Desktop/Sources/Stores/TasksStore.swift index d0ccba631ad..1b40caa2fe4 100644 --- a/desktop/macos/Desktop/Sources/Stores/TasksStore.swift +++ b/desktop/macos/Desktop/Sources/Stores/TasksStore.swift @@ -157,6 +157,10 @@ class TasksStore: ObservableObject { } } + func refreshDashboardTasksFromServer() async { + await DashboardTaskRefreshService.refresh(store: self) + } + var todoCount: Int { incompleteTasks.count } diff --git a/desktop/macos/Desktop/Sources/ViewModelContainer.swift b/desktop/macos/Desktop/Sources/ViewModelContainer.swift index fdf7f90da18..aa42ea746eb 100644 --- a/desktop/macos/Desktop/Sources/ViewModelContainer.swift +++ b/desktop/macos/Desktop/Sources/ViewModelContainer.swift @@ -14,6 +14,15 @@ class ViewModelContainer: ObservableObject { let memoriesViewModel = MemoriesViewModel() let chatProvider: ChatProvider let taskChatCoordinator: TaskChatCoordinator + private lazy var warmupCoordinator = StartupWarmupCoordinator( + tasksStore: tasksStore, + dashboardViewModel: dashboardViewModel, + appProvider: appProvider, + chatProvider: chatProvider, + retryDatabaseInit: { [weak self] in + await self?.retryDatabaseInit() ?? false + } + ) init() { let provider = ChatProvider() @@ -27,14 +36,18 @@ class ViewModelContainer: ObservableObject { @Published var databaseInitFailed = false @Published var initStatusMessage: String = "Preparing your data…" - /// Load all data in parallel at app launch + /// Load critical startup data, then stage warmup work after the first usable window. func loadAllData() async { guard !isLoading else { return } + guard !isInitialLoadComplete else { + schedulePostInteractiveWarmup(dbAvailable: !databaseInitFailed) + return + } isLoading = true let startupStart = CFAbsoluteTimeGetCurrent() let timer = PerfTimer("ViewModelContainer.loadAllData", logCPU: true) - logPerf("DATA LOAD: Starting eager data load for all pages", cpu: true) + logPerf("DATA LOAD: Starting critical startup path", cpu: true) // Configure database for the current user before initialization let userId = UserDefaults.standard.string(forKey: "auth_userId") @@ -70,59 +83,21 @@ class ViewModelContainer: ObservableObject { // to prevent a stampede of retries from each storage actor let dbAvailable = !databaseInitFailed - // Load shared stores first (both Dashboard and Tasks use these) - async let tasks: Void = measurePerfAsync("DATA LOAD: TasksStore") { - guard dbAvailable else { - log("DATA LOAD: Skipping TasksStore (database unavailable)") - return - } - await tasksStore.loadTasks() - } - - // Load page-specific data in parallel - async let dashboard: Void = measurePerfAsync("DATA LOAD: Dashboard") { - guard dbAvailable else { - log("DATA LOAD: Skipping Dashboard (database unavailable)") - return - } - await dashboardViewModel.loadDashboardData() - } - // Apps and Chat don't depend on local DB - async let apps: Void = measurePerfAsync("DATA LOAD: Apps") { await appProvider.fetchApps() } - async let memories: Void = measurePerfAsync("DATA LOAD: Memories") { - guard dbAvailable else { - log("DATA LOAD: Skipping Memories (database unavailable)") - return - } - await memoriesViewModel.loadMemories() - } - async let chat: Void = measurePerfAsync("DATA LOAD: Chat") { - await chatProvider.initialize() - await chatProvider.warmupBridge() - } - - // Wait for all to complete - _ = await (tasks, dashboard, apps, memories, chat) - - // Restore agent sessions from database (reconnect to live tmux sessions) - if dbAvailable { - await TaskAgentManager.shared.restoreSessionsFromDatabase() - // Wire task chat coordinator to view model for delete/purge operations - tasksViewModel.chatCoordinator = taskChatCoordinator - - // Start screen activity sync to backend (Firestore + Pinecone) - await ScreenActivitySyncService.shared.start() - } - + schedulePostInteractiveWarmup(dbAvailable: dbAvailable) isLoading = false timer.stop() - logPerf("DATA LOAD: Complete - all pages loaded", cpu: true) + logPerf("DATA LOAD: Critical startup complete - post-interactive warmup scheduled", cpu: true) } - /// Retry database initialization and reload DB-dependent data - func retryDatabaseInit() async { - guard databaseInitFailed else { return } + private func schedulePostInteractiveWarmup(dbAvailable: Bool) { + tasksViewModel.chatCoordinator = taskChatCoordinator + warmupCoordinator.schedulePostInteractiveWarmup(dbAvailable: dbAvailable) + } + + /// Retry database initialization and schedule the normal staged startup warmup. + func retryDatabaseInit() async -> Bool { + guard databaseInitFailed else { return true } log("ViewModelContainer: Retrying database initialization...") // Re-configure userId in case it changed (e.g. sign-in completed since first attempt) @@ -132,17 +107,13 @@ class ViewModelContainer: ObservableObject { do { try await RewindDatabase.shared.initialize() databaseInitFailed = false - log("ViewModelContainer: Database retry succeeded, loading data...") - - // Load previously skipped DB-dependent data - async let tasks: Void = tasksStore.loadTasks() - async let dashboard: Void = dashboardViewModel.loadDashboardData() - async let memories: Void = memoriesViewModel.loadMemories() - _ = await (tasks, dashboard, memories) - - log("ViewModelContainer: DB-dependent data loaded after retry") + warmupCoordinator.markDatabaseRetryComplete() + log("ViewModelContainer: Database retry succeeded, scheduling staged startup warmup") + schedulePostInteractiveWarmup(dbAvailable: true) + return true } catch { logError("ViewModelContainer: Database retry failed", error: error) + return false } } } diff --git a/desktop/macos/Desktop/Tests/DashboardTaskRefreshPolicyTests.swift b/desktop/macos/Desktop/Tests/DashboardTaskRefreshPolicyTests.swift new file mode 100644 index 00000000000..44f0bdc9215 --- /dev/null +++ b/desktop/macos/Desktop/Tests/DashboardTaskRefreshPolicyTests.swift @@ -0,0 +1,112 @@ +import XCTest + +@testable import Omi_Computer + +final class DashboardTaskRefreshPolicyTests: XCTestCase { + func testDashboardTaskRefreshDoesNotPopulateTasksPageList() { + XCTAssertTrue(DashboardTaskRefreshPolicy.shouldSyncFromServer) + XCTAssertFalse(DashboardTaskRefreshPolicy.shouldMarkIncompleteTasksLoaded) + XCTAssertFalse(DashboardTaskRefreshPolicy.shouldAssignTasksPageList) + XCTAssertGreaterThan(DashboardTaskRefreshPolicy.serverFetchLimit, 0) + } + + func testDashboardExactTaskFetchLimiterCapsConcurrentOperations() async { + let ids = (0..<125).map { "task-\($0)" } + let probe = ExactFetchConcurrencyProbe() + + let fetchedIds = await DashboardExactTaskFetchLimiter.fetch(ids: ids) { id in + await probe.start(id: id) + try? await Task.sleep(nanoseconds: 1_000_000) + await probe.finish() + return id + } + + XCTAssertEqual(Set(fetchedIds), Set(ids)) + XCTAssertEqual(fetchedIds.count, ids.count) + let maxActive = await probe.snapshotMaxActive() + XCTAssertLessThanOrEqual( + maxActive, + DashboardExactTaskFetchPolicy.maxConcurrentRequests + ) + XCTAssertGreaterThan(ids.count, DashboardExactTaskFetchPolicy.maxConcurrentRequests) + } + + func testDashboardTaskReconciliationPlansStaleRemovalWithoutTasksPageHydration() { + let calendar = Calendar(identifier: .gregorian) + let now = Date(timeIntervalSince1970: 1_750_000_000) + let today = calendar.startOfDay(for: now).addingTimeInterval(10 * 60 * 60) + let tomorrow = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now))! + let oldNoDeadline = calendar.date(byAdding: .day, value: -10, to: now)! + + let stillVisible = task(id: "still-visible", createdAt: now, dueAt: today) + let completed = task(id: "completed-remotely", completed: true, createdAt: now, dueAt: today) + let movedOut = task(id: "moved-out", createdAt: oldNoDeadline, dueAt: tomorrow) + let newDashboardTask = task(id: "new-dashboard-task", createdAt: now, dueAt: nil) + + let plan = DashboardTaskReconciliationPlanner.plan( + localDashboardIds: [ + stillVisible.id, + completed.id, + "deleted-remotely", + movedOut.id, + ], + dashboardWindowServerItems: [stillVisible, newDashboardTask], + exactServerItemsById: [ + stillVisible.id: stillVisible, + completed.id: completed, + movedOut.id: movedOut, + ], + missingServerIds: ["deleted-remotely"], + now: now, + calendar: calendar + ) + + XCTAssertEqual(Set(plan.itemsToSync.map(\.id)), [ + stillVisible.id, + completed.id, + movedOut.id, + newDashboardTask.id, + ]) + XCTAssertEqual(plan.backendIdsToHardDelete, ["deleted-remotely"]) + XCTAssertEqual(plan.dashboardVisibleServerIds, [stillVisible.id, newDashboardTask.id]) + XCTAssertTrue(plan.completedServerIds.contains(completed.id)) + XCTAssertTrue(plan.movedOutServerIds.contains(movedOut.id)) + XCTAssertFalse(plan.shouldMarkIncompleteTasksLoaded) + XCTAssertFalse(plan.shouldAssignTasksPageList) + } + + private func task( + id: String, + completed: Bool = false, + createdAt: Date, + dueAt: Date? + ) -> TaskActionItem { + TaskActionItem( + id: id, + description: id, + completed: completed, + createdAt: createdAt, + dueAt: dueAt + ) + } +} + +private actor ExactFetchConcurrencyProbe { + private var activeCount = 0 + private(set) var maxActive = 0 + private var seenIds = Set() + + func start(id: String) { + activeCount += 1 + maxActive = max(maxActive, activeCount) + seenIds.insert(id) + } + + func finish() { + activeCount -= 1 + } + + func snapshotMaxActive() -> Int { + maxActive + } +} diff --git a/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift b/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift new file mode 100644 index 00000000000..1c4c0804800 --- /dev/null +++ b/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift @@ -0,0 +1,156 @@ +import XCTest + +@testable import Omi_Computer + +final class StartupWarmupPolicyTests: XCTestCase { + func testDeferredWarmupStartsAfterImmediateWorkHasAChanceToSettle() { + XCTAssertGreaterThan( + StartupWarmupPolicy.deferredWarmupDelay, + StartupWarmupPolicy.immediateWarmupDelay + ) + } + + func testScreenActivitySyncWaitsUntilAfterDeferredWarmupStarts() { + XCTAssertGreaterThan( + StartupWarmupPolicy.screenActivitySyncInitialDelay, + StartupWarmupPolicy.deferredWarmupDelay + ) + } + + func testCrispInitialPollWaitsUntilAfterDeferredWarmupStarts() { + XCTAssertGreaterThan( + StartupWarmupPolicy.crispInitialPollDelay, + StartupWarmupPolicy.deferredWarmupDelay + ) + } + + func testAgentVMProvisioningWaitsUntilAfterDeferredWarmupStarts() { + XCTAssertGreaterThan( + StartupWarmupPolicy.agentVMProvisioningDelay, + StartupWarmupPolicy.deferredWarmupDelay + ) + } + + func testProactiveAssistantsWaitUntilAfterDeferredWarmupStarts() { + XCTAssertGreaterThan( + StartupWarmupPolicy.proactiveAssistantsStartDelay, + StartupWarmupPolicy.deferredWarmupDelay + ) + } + + func testConversationWarmupWaitsUntilAfterDeferredWarmupStarts() { + XCTAssertGreaterThan( + StartupWarmupPolicy.conversationWarmupDelay, + StartupWarmupPolicy.deferredWarmupDelay + ) + } + + func testInitialFileIndexingWaitsUntilAfterDeferredWarmupStarts() { + XCTAssertGreaterThan( + StartupWarmupPolicy.initialFileIndexingDelay, + StartupWarmupPolicy.deferredWarmupDelay + ) + } + + func testTranscriptionRetryRecoveryWaitsUntilAfterDeferredWarmupStarts() { + XCTAssertGreaterThan( + StartupWarmupPolicy.transcriptionRetryRecoveryDelay, + StartupWarmupPolicy.deferredWarmupDelay + ) + } + + func testRecurringTaskSchedulerWaitsUntilAfterDeferredWarmupStarts() { + XCTAssertGreaterThan( + StartupWarmupPolicy.recurringTaskSchedulerInitialDelay, + StartupWarmupPolicy.deferredWarmupDelay + ) + } + + func testDashboardNetworkRefreshWaitsUntilAfterDeferredWarmupStarts() { + XCTAssertGreaterThan( + StartupWarmupPolicy.dashboardNetworkRefreshDelay, + StartupWarmupPolicy.deferredWarmupDelay + ) + } + + func testFloatingBarPlanFetchWaitsUntilAfterDeferredWarmupStarts() { + XCTAssertGreaterThan( + StartupWarmupPolicy.floatingBarPlanFetchDelay, + StartupWarmupPolicy.deferredWarmupDelay + ) + } + + func testInitialSettingsSyncWaitsUntilAfterDeferredWarmupStarts() { + XCTAssertGreaterThan( + StartupWarmupPolicy.initialSettingsSyncDelay, + StartupWarmupPolicy.deferredWarmupDelay + ) + } + + func testInitialSettingsSyncRunsBeforeProactiveAssistantsStart() { + XCTAssertLessThan( + StartupWarmupPolicy.initialSettingsSyncDelay, + StartupWarmupPolicy.proactiveAssistantsStartDelay + ) + } + + func testAPIKeyFetchWaitsUntilAfterDashboardNetworkRefresh() { + XCTAssertGreaterThan( + StartupWarmupPolicy.apiKeyFetchDelay, + StartupWarmupPolicy.dashboardNetworkRefreshDelay + ) + } + + func testChatPromptContextWarmupWaitsUntilAfterDeferredWarmupStarts() { + XCTAssertGreaterThan( + StartupWarmupPolicy.chatPromptContextWarmupDelay, + StartupWarmupPolicy.deferredWarmupDelay + ) + } + + func testWarmupScheduleStateRunsServiceWarmupWhenDatabaseIsUnavailable() { + var state = StartupWarmupScheduleState() + + XCTAssertTrue(state.reserveServiceWarmup()) + XCTAssertFalse(state.reserveServiceWarmup()) + XCTAssertFalse(state.reserveDatabaseWarmup(dbAvailable: false)) + + XCTAssertTrue(state.reserveDatabaseWarmup(dbAvailable: true)) + XCTAssertFalse(state.reserveDatabaseWarmup(dbAvailable: true)) + } + + func testTasksPageFirstUseLoadsWhenStoreHasNoRenderedTasks() { + XCTAssertTrue( + TasksPageFirstUseLoadPolicy.shouldLoadTasks(hasRenderedTasks: false, isLoading: false) + ) + XCTAssertFalse( + TasksPageFirstUseLoadPolicy.shouldLoadTasks(hasRenderedTasks: true, isLoading: false) + ) + XCTAssertFalse( + TasksPageFirstUseLoadPolicy.shouldLoadTasks(hasRenderedTasks: false, isLoading: true) + ) + } + + func testRetryableDelayedStartGateAllowsFutureAttemptsAfterAttemptFinishes() { + var gate = RetryableDelayedStartGate() + + XCTAssertTrue(gate.reserve()) + XCTAssertFalse(gate.reserve()) + + gate.finishAttempt() + + XCTAssertTrue(gate.reserve()) + } + + func testFileIndexingBackfillMarksCompleteOnlyAfterScanCompletes() { + var backfill = DelayedFileIndexingBackfillState() + + XCTAssertTrue(backfill.reserveIfNeeded(hasCompletedBackfill: false)) + XCTAssertFalse(backfill.shouldMarkComplete) + + backfill.markScanCompleted() + + XCTAssertTrue(backfill.shouldMarkComplete) + XCTAssertFalse(backfill.reserveIfNeeded(hasCompletedBackfill: true)) + } +} From 5a663729c27504c9dd5496aa83f3073d536a9c9a Mon Sep 17 00:00:00 2001 From: Drake Thomsen Date: Thu, 18 Jun 2026 19:40:07 -0400 Subject: [PATCH 02/18] Fix desktop warmup review feedback --- .../Sources/MainWindow/Pages/TasksPage.swift | 10 +++++----- desktop/macos/Desktop/Sources/OmiApp.swift | 9 ++++++++- .../Stores/DashboardTaskRefreshService.swift | 16 +++++++++++----- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift index 824347e1a7d..d467a9dba45 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift @@ -2271,18 +2271,18 @@ struct TasksPage: View { .background(Color.clear) // Modal creation sheet removed — Cmd+N now creates inline at top .onAppear { - Task { + Task { @MainActor in await viewModel.loadTasksForFirstUse() + // If tasks are already loaded, notify sidebar to clear loading indicator + if !viewModel.isLoading { + NotificationCenter.default.post(name: .tasksPageDidLoad, object: nil) + } } // Restore panel UI if coordinator was open when we navigated away if chatCoordinator.isPanelOpen, chatCoordinator.activeTaskId != nil { showChatPanel = true adjustWindowWidth(expand: true) } - // If tasks are already loaded, notify sidebar to clear loading indicator - if !viewModel.isLoading { - NotificationCenter.default.post(name: .tasksPageDidLoad, object: nil) - } // Ensure prioritization service is running (no-op if already started) Task { await TaskPrioritizationService.shared.start() } diff --git a/desktop/macos/Desktop/Sources/OmiApp.swift b/desktop/macos/Desktop/Sources/OmiApp.swift index c0c8035a1f8..196e4229c35 100644 --- a/desktop/macos/Desktop/Sources/OmiApp.swift +++ b/desktop/macos/Desktop/Sources/OmiApp.swift @@ -221,6 +221,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private var audioRecordingSwitch: NSSwitch? private var relaunchOnLoginSuppressedForOnboarding = false private var apiKeyFetchTask: Task? + private var floatingBarPlanFetchTask: Task? private var appLifecycleMaintenanceTask: Task? private var didScheduleInitialSettingsSync = false private var initialSettingsSyncTask: Task? @@ -1169,8 +1170,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { apiKeyFetchTask?.cancel() apiKeyFetchTask = nil + floatingBarPlanFetchTask?.cancel() + floatingBarPlanFetchTask = nil appLifecycleMaintenanceTask?.cancel() appLifecycleMaintenanceTask = nil + initialSettingsSyncTask?.cancel() + initialSettingsSyncTask = nil // Stop transcription retry service TranscriptionRetryService.shared.stop() @@ -1337,11 +1342,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } private func scheduleFloatingBarPlanFetch() { - Task { + floatingBarPlanFetchTask?.cancel() + floatingBarPlanFetchTask = Task { let delay = StartupWarmupPolicy.floatingBarPlanFetchDelay if delay > 0 { try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) } + guard !Task.isCancelled else { return } guard await AuthState.shared.isSignedIn else { return } await FloatingBarUsageLimiter.shared.fetchPlan() } diff --git a/desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshService.swift b/desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshService.swift index eeaf9afe00c..c5b4bb270ba 100644 --- a/desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshService.swift +++ b/desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshService.swift @@ -49,7 +49,9 @@ enum DashboardTaskRefreshService { log( "DashboardTaskRefreshService: Dashboard freshness reconciled sync=\(plan.itemsToSync.count), hardDeleted=\(plan.backendIdsToHardDelete.count), visible=\(plan.dashboardVisibleServerIds.count), completed=\(plan.completedServerIds.count), movedOut=\(plan.movedOutServerIds.count) without loading Tasks page list" ) - AuthBackoffTracker.shared.reportSuccess() + if !serverTruth.hadAuthFailure { + AuthBackoffTracker.shared.reportSuccess() + } } catch { if case APIError.unauthorized = error { AuthBackoffTracker.shared.reportAuthFailure() @@ -62,7 +64,9 @@ enum DashboardTaskRefreshService { private static func fetchDashboardWindowItems(calendar: Calendar) async throws -> [TaskActionItem] { let startOfToday = calendar.startOfDay(for: Date()) - let endOfToday = calendar.date(byAdding: .day, value: 1, to: startOfToday)! + guard let endOfToday = calendar.date(byAdding: .day, value: 1, to: startOfToday) else { + return [] + } let sevenDaysAgo = calendar.date(byAdding: .day, value: -7, to: Date()) ?? Date() let limit = DashboardTaskRefreshPolicy.serverFetchLimit @@ -86,8 +90,8 @@ enum DashboardTaskRefreshService { private static func fetchExactServerTruth( forDashboardIds ids: Set - ) async -> (itemsById: [String: TaskActionItem], missingIds: Set) { - guard !ids.isEmpty else { return ([:], []) } + ) async -> (itemsById: [String: TaskActionItem], missingIds: Set, hadAuthFailure: Bool) { + guard !ids.isEmpty else { return ([:], [], false) } let sortedIds = ids.sorted() let results = await DashboardExactTaskFetchLimiter.fetch(ids: sortedIds) { id in @@ -104,6 +108,7 @@ enum DashboardTaskRefreshService { var itemsById: [String: TaskActionItem] = [:] var missingIds = Set() var failedCount = 0 + var hadAuthFailure = false for (id, result) in results { switch result { @@ -115,6 +120,7 @@ enum DashboardTaskRefreshService { failedCount += 1 logError("DashboardTaskRefreshService: Exact task refresh failed for \(id)", error: error) if case APIError.unauthorized = error { + hadAuthFailure = true AuthBackoffTracker.shared.reportAuthFailure() } } @@ -124,6 +130,6 @@ enum DashboardTaskRefreshService { log("DashboardTaskRefreshService: Exact task refresh skipped \(failedCount) stale classifications") } - return (itemsById, missingIds) + return (itemsById, missingIds, hadAuthFailure) } } From 44629ade2119885c7c11fe62d68061b91945f192 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Mon, 22 Jun 2026 21:36:01 +0000 Subject: [PATCH 03/18] fix: guard delayed desktop warmups --- .../Sources/MainWindow/CrispManager.swift | 13 ++++++++++++- .../Sources/MainWindow/DesktopHomeView.swift | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/CrispManager.swift b/desktop/macos/Desktop/Sources/MainWindow/CrispManager.swift index 58781609f98..c54b177807b 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/CrispManager.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/CrispManager.swift @@ -49,6 +49,9 @@ class CrispManager: ObservableObject { var activationObserver: NSObjectProtocol? var refreshAllObserver: NSObjectProtocol? + /// Retained so a delayed startup poll cannot fire after sign-out/stop. + private var initialPollTask: Task? + /// Counter bumped at the top of `pollForMessages()`, before the auth-backoff /// guard and the network task. Lets `CrispManagerLifecycleTests` prove that /// posting `didBecomeActive` / `.refreshAllData` actually reaches the poll @@ -82,8 +85,10 @@ class CrispManager: ObservableObject { if performInitialPoll { if initialPollDelay > 0 { - Task { [weak self] in + initialPollTask?.cancel() + initialPollTask = Task { [weak self] in try? await Task.sleep(nanoseconds: UInt64(initialPollDelay * 1_000_000_000)) + guard !Task.isCancelled else { return } guard let self, self.isStarted else { return } self.pollForMessages() } @@ -112,6 +117,8 @@ class CrispManager: ObservableObject { /// Stop observing (called on sign-out) func stop() { + initialPollTask?.cancel() + initialPollTask = nil if let obs = activationObserver { NotificationCenter.default.removeObserver(obs) } if let obs = refreshAllObserver { NotificationCenter.default.removeObserver(obs) } activationObserver = nil @@ -226,6 +233,10 @@ class CrispManager: ObservableObject { return [] } + if httpResponse.statusCode == 401 { + throw APIError.unauthorized + } + guard httpResponse.statusCode == 200 else { log("CrispManager: backend returned \(httpResponse.statusCode)") return [] diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index f1790c1ff60..e416df3547d 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -580,6 +580,8 @@ struct DesktopHomeView: View { try? await Task.sleep( nanoseconds: UInt64(StartupWarmupPolicy.agentVMProvisioningDelay * 1_000_000_000) ) + guard !Task.isCancelled else { return } + guard await AuthState.shared.isSignedIn else { return } await AgentVMService.shared.ensureProvisioned() } } @@ -592,6 +594,8 @@ struct DesktopHomeView: View { try? await Task.sleep( nanoseconds: UInt64(StartupWarmupPolicy.conversationWarmupDelay * 1_000_000_000) ) + guard !Task.isCancelled else { return } + guard await AuthState.shared.isSignedIn else { return } async let conversations: Void = loadConversationsIfNeeded() async let folders: Void = loadFoldersIfNeeded() @@ -619,8 +623,12 @@ struct DesktopHomeView: View { try? await Task.sleep( nanoseconds: UInt64(StartupWarmupPolicy.initialFileIndexingDelay * 1_000_000_000) ) + guard !Task.isCancelled else { return } + guard await AuthState.shared.isSignedIn else { return } log("DesktopHomeView: Running delayed background file scan for existing user") await FileIndexerService.shared.backgroundRescan() + guard !Task.isCancelled else { return } + guard await AuthState.shared.isSignedIn else { return } initialFileIndexingBackfill.markScanCompleted() if initialFileIndexingBackfill.shouldMarkComplete { UserDefaults.standard.set(true, forKey: "hasCompletedFileIndexing") @@ -638,6 +646,14 @@ struct DesktopHomeView: View { try? await Task.sleep( nanoseconds: UInt64(StartupWarmupPolicy.proactiveAssistantsStartDelay * 1_000_000_000) ) + guard !Task.isCancelled else { + proactiveMonitoringStartGate.finishAttempt() + return + } + guard await AuthState.shared.isSignedIn else { + proactiveMonitoringStartGate.finishAttempt() + return + } let plugin = ProactiveAssistantsPlugin.shared guard AssistantSettings.shared.screenAnalysisEnabled, !plugin.isMonitoring else { From 1b5a1e180e2f1af07e77f0f0eb7e9c6c3720cf3a Mon Sep 17 00:00:00 2001 From: David Zhang Date: Mon, 22 Jun 2026 22:04:14 +0000 Subject: [PATCH 04/18] fix: release deferred warmup gates --- .../Sources/MainWindow/DesktopHomeView.swift | 44 ++++++++++++++----- .../Sources/StartupWarmupCoordinator.swift | 4 ++ .../Desktop/Sources/StartupWarmupPolicy.swift | 5 +++ .../Stores/DashboardTaskRefreshService.swift | 10 +++-- .../Tests/StartupWarmupPolicyTests.swift | 12 +++++ 5 files changed, 61 insertions(+), 14 deletions(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index e416df3547d..cc2053b72fe 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -576,12 +576,18 @@ struct DesktopHomeView: View { guard !didScheduleAgentVMProvisioning else { return } didScheduleAgentVMProvisioning = true - Task { + Task { @MainActor in try? await Task.sleep( nanoseconds: UInt64(StartupWarmupPolicy.agentVMProvisioningDelay * 1_000_000_000) ) - guard !Task.isCancelled else { return } - guard await AuthState.shared.isSignedIn else { return } + guard !Task.isCancelled else { + didScheduleAgentVMProvisioning = false + return + } + guard await AuthState.shared.isSignedIn else { + didScheduleAgentVMProvisioning = false + return + } await AgentVMService.shared.ensureProvisioned() } } @@ -594,8 +600,14 @@ struct DesktopHomeView: View { try? await Task.sleep( nanoseconds: UInt64(StartupWarmupPolicy.conversationWarmupDelay * 1_000_000_000) ) - guard !Task.isCancelled else { return } - guard await AuthState.shared.isSignedIn else { return } + guard !Task.isCancelled else { + didScheduleConversationWarmup = false + return + } + guard await AuthState.shared.isSignedIn else { + didScheduleConversationWarmup = false + return + } async let conversations: Void = loadConversationsIfNeeded() async let folders: Void = loadFoldersIfNeeded() @@ -619,16 +631,28 @@ struct DesktopHomeView: View { hasCompletedBackfill: UserDefaults.standard.bool(forKey: "hasCompletedFileIndexing")) else { return } - Task { + Task { @MainActor in try? await Task.sleep( nanoseconds: UInt64(StartupWarmupPolicy.initialFileIndexingDelay * 1_000_000_000) ) - guard !Task.isCancelled else { return } - guard await AuthState.shared.isSignedIn else { return } + guard !Task.isCancelled else { + initialFileIndexingBackfill.releaseReservation() + return + } + guard await AuthState.shared.isSignedIn else { + initialFileIndexingBackfill.releaseReservation() + return + } log("DesktopHomeView: Running delayed background file scan for existing user") await FileIndexerService.shared.backgroundRescan() - guard !Task.isCancelled else { return } - guard await AuthState.shared.isSignedIn else { return } + guard !Task.isCancelled else { + initialFileIndexingBackfill.releaseReservation() + return + } + guard await AuthState.shared.isSignedIn else { + initialFileIndexingBackfill.releaseReservation() + return + } initialFileIndexingBackfill.markScanCompleted() if initialFileIndexingBackfill.shouldMarkComplete { UserDefaults.standard.set(true, forKey: "hasCompletedFileIndexing") diff --git a/desktop/macos/Desktop/Sources/StartupWarmupCoordinator.swift b/desktop/macos/Desktop/Sources/StartupWarmupCoordinator.swift index d40641f5330..b685a6c9963 100644 --- a/desktop/macos/Desktop/Sources/StartupWarmupCoordinator.swift +++ b/desktop/macos/Desktop/Sources/StartupWarmupCoordinator.swift @@ -77,6 +77,10 @@ final class StartupWarmupCoordinator { } guard await sleepForStartupDelay(StartupWarmupPolicy.deferredWarmupDelay) else { return } + guard await AuthState.shared.isSignedIn else { + log("DATA LOAD: Skipping DB lifecycle warmup because user is signed out") + return + } await measurePerfAsync("DATA LOAD: DB lifecycle warmup") { await measurePerfAsync("DATA LOAD: Task agent restore") { diff --git a/desktop/macos/Desktop/Sources/StartupWarmupPolicy.swift b/desktop/macos/Desktop/Sources/StartupWarmupPolicy.swift index d86c63c0ba0..2d5707e02ba 100644 --- a/desktop/macos/Desktop/Sources/StartupWarmupPolicy.swift +++ b/desktop/macos/Desktop/Sources/StartupWarmupPolicy.swift @@ -46,6 +46,11 @@ struct DelayedFileIndexingBackfillState { isScheduled = false shouldMarkComplete = true } + + mutating func releaseReservation() { + isScheduled = false + shouldMarkComplete = false + } } enum TasksPageFirstUseLoadPolicy { diff --git a/desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshService.swift b/desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshService.swift index c5b4bb270ba..cd557407a51 100644 --- a/desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshService.swift +++ b/desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshService.swift @@ -30,13 +30,15 @@ enum DashboardTaskRefreshService { do { let calendar = Calendar.current - let windowItems = try await fetchDashboardWindowItems(calendar: calendar) + let now = Date() + let windowItems = try await fetchDashboardWindowItems(now: now, calendar: calendar) let serverTruth = await fetchExactServerTruth(forDashboardIds: dashboardIds) let plan = DashboardTaskReconciliationPlanner.plan( localDashboardIds: dashboardIds, dashboardWindowServerItems: windowItems, exactServerItemsById: serverTruth.itemsById, missingServerIds: serverTruth.missingIds, + now: now, calendar: calendar ) @@ -62,12 +64,12 @@ enum DashboardTaskRefreshService { await store.loadDashboardTasks() } - private static func fetchDashboardWindowItems(calendar: Calendar) async throws -> [TaskActionItem] { - let startOfToday = calendar.startOfDay(for: Date()) + private static func fetchDashboardWindowItems(now: Date, calendar: Calendar) async throws -> [TaskActionItem] { + let startOfToday = calendar.startOfDay(for: now) guard let endOfToday = calendar.date(byAdding: .day, value: 1, to: startOfToday) else { return [] } - let sevenDaysAgo = calendar.date(byAdding: .day, value: -7, to: Date()) ?? Date() + let sevenDaysAgo = calendar.date(byAdding: .day, value: -7, to: now) ?? now let limit = DashboardTaskRefreshPolicy.serverFetchLimit async let dueWindowResponse = APIClient.shared.getActionItems( diff --git a/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift b/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift index 1c4c0804800..3d7e4580b93 100644 --- a/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift +++ b/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift @@ -153,4 +153,16 @@ final class StartupWarmupPolicyTests: XCTestCase { XCTAssertTrue(backfill.shouldMarkComplete) XCTAssertFalse(backfill.reserveIfNeeded(hasCompletedBackfill: true)) } + + func testFileIndexingBackfillCanRescheduleAfterReservationRelease() { + var backfill = DelayedFileIndexingBackfillState() + + XCTAssertTrue(backfill.reserveIfNeeded(hasCompletedBackfill: false)) + XCTAssertFalse(backfill.reserveIfNeeded(hasCompletedBackfill: false)) + + backfill.releaseReservation() + + XCTAssertFalse(backfill.shouldMarkComplete) + XCTAssertTrue(backfill.reserveIfNeeded(hasCompletedBackfill: false)) + } } From 20b21f1cd67edb43281eafa89a04d2e2b0cfac60 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Mon, 22 Jun 2026 22:37:35 +0000 Subject: [PATCH 05/18] fix: retry warmups after auth changes --- .../Sources/MainWindow/DesktopHomeView.swift | 1 + .../Sources/MainWindow/Pages/TasksPage.swift | 2 +- .../Sources/StartupWarmupCoordinator.swift | 8 +++++++- .../Desktop/Sources/StartupWarmupPolicy.swift | 4 ++++ .../Desktop/Sources/ViewModelContainer.swift | 19 +++++++++++++++++-- .../Tests/StartupWarmupPolicyTests.swift | 11 +++++++++++ 6 files changed, 41 insertions(+), 4 deletions(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index cc2053b72fe..0b5601b4d71 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -263,6 +263,7 @@ struct DesktopHomeView: View { log( "DesktopHomeView: userDidSignOut — resetting hasCompletedOnboarding and stopping transcription" ) + viewModelContainer.resetStartupState() appState.hasCompletedOnboarding = false appState.stopTranscription() } diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift index d467a9dba45..6e0620a1ec0 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift @@ -1815,7 +1815,7 @@ class TasksViewModel: ObservableObject { ) else { return } log("TasksPage: First-use loading task list") - await store.loadTasksIfNeeded() + await store.loadTasks() } func loadTasks() async { diff --git a/desktop/macos/Desktop/Sources/StartupWarmupCoordinator.swift b/desktop/macos/Desktop/Sources/StartupWarmupCoordinator.swift index b685a6c9963..d5348b0b797 100644 --- a/desktop/macos/Desktop/Sources/StartupWarmupCoordinator.swift +++ b/desktop/macos/Desktop/Sources/StartupWarmupCoordinator.swift @@ -37,12 +37,16 @@ final class StartupWarmupCoordinator { databaseRetryTask?.cancel() } + func reset() { + cancel() + scheduleState = StartupWarmupScheduleState() + } + func schedulePostInteractiveWarmup(dbAvailable: Bool) { if scheduleState.reserveServiceWarmup() { serviceWarmupTask = Task { [weak self] in await self?.runServiceWarmup() } - scheduleChatPromptContextWarmup() } scheduleDatabaseWarmup(dbAvailable: dbAvailable) @@ -61,6 +65,7 @@ final class StartupWarmupCoordinator { await self?.runDatabaseWarmup() } scheduleDashboardNetworkRefresh(dbAvailable: true) + scheduleChatPromptContextWarmup() } private func runDatabaseWarmup() async { @@ -79,6 +84,7 @@ final class StartupWarmupCoordinator { guard await sleepForStartupDelay(StartupWarmupPolicy.deferredWarmupDelay) else { return } guard await AuthState.shared.isSignedIn else { log("DATA LOAD: Skipping DB lifecycle warmup because user is signed out") + scheduleState.releaseDatabaseWarmup() return } diff --git a/desktop/macos/Desktop/Sources/StartupWarmupPolicy.swift b/desktop/macos/Desktop/Sources/StartupWarmupPolicy.swift index 2d5707e02ba..ee9c7f18933 100644 --- a/desktop/macos/Desktop/Sources/StartupWarmupPolicy.swift +++ b/desktop/macos/Desktop/Sources/StartupWarmupPolicy.swift @@ -15,6 +15,10 @@ struct StartupWarmupScheduleState { didScheduleDatabaseWarmup = true return true } + + mutating func releaseDatabaseWarmup() { + didScheduleDatabaseWarmup = false + } } struct RetryableDelayedStartGate { diff --git a/desktop/macos/Desktop/Sources/ViewModelContainer.swift b/desktop/macos/Desktop/Sources/ViewModelContainer.swift index aa42ea746eb..4953bf8671d 100644 --- a/desktop/macos/Desktop/Sources/ViewModelContainer.swift +++ b/desktop/macos/Desktop/Sources/ViewModelContainer.swift @@ -35,9 +35,15 @@ class ViewModelContainer: ObservableObject { @Published var isLoading = false @Published var databaseInitFailed = false @Published var initStatusMessage: String = "Preparing your data…" + private var loadedUserId: String? /// Load critical startup data, then stage warmup work after the first usable window. func loadAllData() async { + let currentUserId = UserDefaults.standard.string(forKey: "auth_userId") + if loadedUserId != nil, loadedUserId != currentUserId { + resetStartupState() + } + guard !isLoading else { return } guard !isInitialLoadComplete else { schedulePostInteractiveWarmup(dbAvailable: !databaseInitFailed) @@ -50,8 +56,7 @@ class ViewModelContainer: ObservableObject { logPerf("DATA LOAD: Starting critical startup path", cpu: true) // Configure database for the current user before initialization - let userId = UserDefaults.standard.string(forKey: "auth_userId") - await RewindDatabase.shared.configure(userId: userId) + await RewindDatabase.shared.configure(userId: currentUserId) // Pre-initialize database so local SQLite reads are instant let dbInitStart = CFAbsoluteTimeGetCurrent() @@ -68,6 +73,7 @@ class ViewModelContainer: ObservableObject { // Database is ready (or failed) — dismiss the loading screen // API calls and data fetches continue in the background isInitialLoadComplete = true + loadedUserId = currentUserId let timeToInteractive = CFAbsoluteTimeGetCurrent() - startupStart // Track startup timing @@ -95,6 +101,15 @@ class ViewModelContainer: ObservableObject { warmupCoordinator.schedulePostInteractiveWarmup(dbAvailable: dbAvailable) } + func resetStartupState() { + warmupCoordinator.reset() + isInitialLoadComplete = false + isLoading = false + databaseInitFailed = false + initStatusMessage = "Preparing your data…" + loadedUserId = nil + } + /// Retry database initialization and schedule the normal staged startup warmup. func retryDatabaseInit() async -> Bool { guard databaseInitFailed else { return true } diff --git a/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift b/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift index 3d7e4580b93..832e53786c1 100644 --- a/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift +++ b/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift @@ -119,6 +119,17 @@ final class StartupWarmupPolicyTests: XCTestCase { XCTAssertFalse(state.reserveDatabaseWarmup(dbAvailable: true)) } + func testWarmupScheduleStateCanRetryDatabaseWarmupAfterRelease() { + var state = StartupWarmupScheduleState() + + XCTAssertTrue(state.reserveDatabaseWarmup(dbAvailable: true)) + XCTAssertFalse(state.reserveDatabaseWarmup(dbAvailable: true)) + + state.releaseDatabaseWarmup() + + XCTAssertTrue(state.reserveDatabaseWarmup(dbAvailable: true)) + } + func testTasksPageFirstUseLoadsWhenStoreHasNoRenderedTasks() { XCTAssertTrue( TasksPageFirstUseLoadPolicy.shouldLoadTasks(hasRenderedTasks: false, isLoading: false) From e09295caf60df4dd8a81ae15a75232ef88bfd242 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Mon, 22 Jun 2026 22:51:02 +0000 Subject: [PATCH 06/18] fix: reset desktop warmup session state --- .../Sources/MainWindow/DesktopHomeView.swift | 8 +++ .../Sources/MainWindow/Pages/TasksPage.swift | 2 +- .../Sources/StartupWarmupCoordinator.swift | 5 ++ .../Desktop/Sources/Stores/TasksStore.swift | 60 +++++++++++++++---- .../Desktop/Sources/ViewModelContainer.swift | 1 + 5 files changed, 63 insertions(+), 13 deletions(-) diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index 0b5601b4d71..f1e6d3a1e0c 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -264,6 +264,14 @@ struct DesktopHomeView: View { "DesktopHomeView: userDidSignOut — resetting hasCompletedOnboarding and stopping transcription" ) viewModelContainer.resetStartupState() + didScheduleConversationWarmup = false + didScheduleAgentVMProvisioning = false + appState.conversations = [] + appState.folders = [] + appState.totalConversationsCount = nil + appState.conversationsError = nil + appState.isLoadingConversations = false + appState.isLoadingFolders = false appState.hasCompletedOnboarding = false appState.stopTranscription() } diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift index 6e0620a1ec0..d467a9dba45 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/TasksPage.swift @@ -1815,7 +1815,7 @@ class TasksViewModel: ObservableObject { ) else { return } log("TasksPage: First-use loading task list") - await store.loadTasks() + await store.loadTasksIfNeeded() } func loadTasks() async { diff --git a/desktop/macos/Desktop/Sources/StartupWarmupCoordinator.swift b/desktop/macos/Desktop/Sources/StartupWarmupCoordinator.swift index d5348b0b797..64f9d493c93 100644 --- a/desktop/macos/Desktop/Sources/StartupWarmupCoordinator.swift +++ b/desktop/macos/Desktop/Sources/StartupWarmupCoordinator.swift @@ -39,6 +39,11 @@ final class StartupWarmupCoordinator { func reset() { cancel() + serviceWarmupTask = nil + databaseWarmupTask = nil + dashboardNetworkRefreshTask = nil + chatPromptContextWarmupTask = nil + databaseRetryTask = nil scheduleState = StartupWarmupScheduleState() } diff --git a/desktop/macos/Desktop/Sources/Stores/TasksStore.swift b/desktop/macos/Desktop/Sources/Stores/TasksStore.swift index 1b40caa2fe4..256363417a2 100644 --- a/desktop/macos/Desktop/Sources/Stores/TasksStore.swift +++ b/desktop/macos/Desktop/Sources/Stores/TasksStore.swift @@ -48,6 +48,31 @@ class TasksStore: ObservableObject { isLoadingIncomplete || isLoadingCompleted || isLoadingDeleted } + func resetSessionState() { + incompleteTasks = [] + completedTasks = [] + deletedTasks = [] + overdueTasks = [] + todaysTasks = [] + tasksWithoutDueDate = [] + isLoadingIncomplete = false + isLoadingCompleted = false + isLoadingDeleted = false + isLoadingMore = false + hasMoreIncompleteTasks = true + hasMoreCompletedTasks = true + hasMoreDeletedTasks = true + error = nil + incompleteOffset = 0 + completedOffset = 0 + deletedOffset = 0 + hasLoadedIncomplete = false + hasLoadedCompleted = false + hasLoadedDeleted = false + hasScheduledStartupMaintenance = false + lastReconciliationDate = nil + } + // MARK: - Private State private var incompleteOffset = 0 @@ -57,6 +82,7 @@ class TasksStore: ObservableObject { private var hasLoadedIncomplete = false private var hasLoadedCompleted = false private var hasLoadedDeleted = false + private var hasScheduledStartupMaintenance = false /// Whether we're currently showing all tasks (no date filter) or just recent private var cancellables = Set() private var isRetryingUnsynced = false @@ -462,13 +488,15 @@ class TasksStore: ObservableObject { /// Load incomplete tasks if not already loaded (call this on app launch) func loadTasksIfNeeded() async { - guard !hasLoadedIncomplete else { return } - await loadIncompleteTasks() - await loadDashboardTasks() - // Also load deleted tasks in background so the filter count is ready - if !hasLoadedDeleted { - await loadDeletedTasks() + if !hasLoadedIncomplete { + await loadIncompleteTasks() + await loadDashboardTasks() + // Also load deleted tasks in background so the filter count is ready + if !hasLoadedDeleted { + await loadDeletedTasks() + } } + scheduleStartupMaintenanceIfNeeded() } /// Legacy method - loads incomplete tasks @@ -479,23 +507,31 @@ class TasksStore: ObservableObject { if !hasLoadedDeleted { await loadDeletedTasks() } + scheduleStartupMaintenanceIfNeeded() + // Note: no startup task promotion. Promotion happens on the natural + // cadence — when the user completes/deletes a task, or via the + // 5-minute safety-net timer. Bursting up to 5 promotions on every + // launch felt like spam. + } + + private func scheduleStartupMaintenanceIfNeeded() { + guard !hasScheduledStartupMaintenance else { return } + hasScheduledStartupMaintenance = true + // Kick off one-time full sync in background (populates SQLite with all tasks) - // Then retry pushing any locally-created tasks that failed to sync + // Then retry pushing any locally-created tasks that failed to sync. Task { await performFullSyncIfNeeded() await migrateAITasksToStagedIfNeeded() await migrateConversationItemsToStagedIfNeeded() await retryUnsyncedItems() } - // Backfill relevance scores for unscored tasks (independent of full sync) + + // Backfill relevance scores for unscored tasks (independent of full sync). Task { let userId = UserDefaults.standard.string(forKey: "auth_userId") ?? "unknown" await backfillRelevanceScoresIfNeeded(userId: userId) } - // Note: no startup task promotion. Promotion happens on the natural - // cadence — when the user completes/deletes a task, or via the - // 5-minute safety-net timer. Bursting up to 5 promotions on every - // launch felt like spam. } /// Load incomplete tasks (To Do) using local-first pattern (like Memories) diff --git a/desktop/macos/Desktop/Sources/ViewModelContainer.swift b/desktop/macos/Desktop/Sources/ViewModelContainer.swift index 4953bf8671d..404a9f664de 100644 --- a/desktop/macos/Desktop/Sources/ViewModelContainer.swift +++ b/desktop/macos/Desktop/Sources/ViewModelContainer.swift @@ -103,6 +103,7 @@ class ViewModelContainer: ObservableObject { func resetStartupState() { warmupCoordinator.reset() + tasksStore.resetSessionState() isInitialLoadComplete = false isLoading = false databaseInitFailed = false From a0e1010868460bab6c54b6ed2cce0991a8017c4b Mon Sep 17 00:00:00 2001 From: David Zhang Date: Mon, 22 Jun 2026 22:58:13 +0000 Subject: [PATCH 07/18] fix: reset conversation filters on sign out --- desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index f1e6d3a1e0c..1a4dc87a570 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -268,6 +268,9 @@ struct DesktopHomeView: View { didScheduleAgentVMProvisioning = false appState.conversations = [] appState.folders = [] + appState.selectedFolderId = nil + appState.selectedDateFilter = nil + appState.showStarredOnly = false appState.totalConversationsCount = nil appState.conversationsError = nil appState.isLoadingConversations = false From fa578286145101c0d1d8b32f83f5221a9d2dc4e9 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Mon, 22 Jun 2026 23:03:05 +0000 Subject: [PATCH 08/18] test: cover tasks startup maintenance scheduling --- .../Desktop/Sources/Stores/TasksStore.swift | 27 ++++++++---- .../Tests/StartupWarmupPolicyTests.swift | 41 +++++++++++++++++++ 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/desktop/macos/Desktop/Sources/Stores/TasksStore.swift b/desktop/macos/Desktop/Sources/Stores/TasksStore.swift index 256363417a2..ebd4e20563c 100644 --- a/desktop/macos/Desktop/Sources/Stores/TasksStore.swift +++ b/desktop/macos/Desktop/Sources/Stores/TasksStore.swift @@ -82,7 +82,7 @@ class TasksStore: ObservableObject { private var hasLoadedIncomplete = false private var hasLoadedCompleted = false private var hasLoadedDeleted = false - private var hasScheduledStartupMaintenance = false + private(set) var hasScheduledStartupMaintenance = false /// Whether we're currently showing all tasks (no date filter) or just recent private var cancellables = Set() private var isRetryingUnsynced = false @@ -514,23 +514,34 @@ class TasksStore: ObservableObject { // launch felt like spam. } - private func scheduleStartupMaintenanceIfNeeded() { + func scheduleStartupMaintenanceIfNeeded( + fullSyncAndRetry: (@Sendable () async -> Void)? = nil, + relevanceBackfill: (@Sendable () async -> Void)? = nil + ) { guard !hasScheduledStartupMaintenance else { return } hasScheduledStartupMaintenance = true // Kick off one-time full sync in background (populates SQLite with all tasks) // Then retry pushing any locally-created tasks that failed to sync. Task { - await performFullSyncIfNeeded() - await migrateAITasksToStagedIfNeeded() - await migrateConversationItemsToStagedIfNeeded() - await retryUnsyncedItems() + if let fullSyncAndRetry { + await fullSyncAndRetry() + } else { + await performFullSyncIfNeeded() + await migrateAITasksToStagedIfNeeded() + await migrateConversationItemsToStagedIfNeeded() + await retryUnsyncedItems() + } } // Backfill relevance scores for unscored tasks (independent of full sync). Task { - let userId = UserDefaults.standard.string(forKey: "auth_userId") ?? "unknown" - await backfillRelevanceScoresIfNeeded(userId: userId) + if let relevanceBackfill { + await relevanceBackfill() + } else { + let userId = UserDefaults.standard.string(forKey: "auth_userId") ?? "unknown" + await backfillRelevanceScoresIfNeeded(userId: userId) + } } } diff --git a/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift b/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift index 832e53786c1..6c8ac4e9e21 100644 --- a/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift +++ b/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift @@ -176,4 +176,45 @@ final class StartupWarmupPolicyTests: XCTestCase { XCTAssertFalse(backfill.shouldMarkComplete) XCTAssertTrue(backfill.reserveIfNeeded(hasCompletedBackfill: false)) } + + @MainActor + func testTasksStoreStartupMaintenanceSchedulesOnceForFirstUseLoadPath() async { + let store = TasksStore() + let counter = StartupMaintenanceCounter() + + store.scheduleStartupMaintenanceIfNeeded( + fullSyncAndRetry: { await counter.recordFullSyncAndRetry() }, + relevanceBackfill: { await counter.recordRelevanceBackfill() } + ) + + try? await Task.sleep(nanoseconds: 50_000_000) + XCTAssertTrue(store.hasScheduledStartupMaintenance) + XCTAssertEqual(await counter.fullSyncAndRetryCount, 1) + XCTAssertEqual(await counter.relevanceBackfillCount, 1) + + store.scheduleStartupMaintenanceIfNeeded( + fullSyncAndRetry: { await counter.recordFullSyncAndRetry() }, + relevanceBackfill: { await counter.recordRelevanceBackfill() } + ) + + try? await Task.sleep(nanoseconds: 50_000_000) + XCTAssertEqual(await counter.fullSyncAndRetryCount, 1) + XCTAssertEqual(await counter.relevanceBackfillCount, 1) + } +} + +private actor StartupMaintenanceCounter { + private var fullSyncAndRetryTotal = 0 + private var relevanceBackfillTotal = 0 + + var fullSyncAndRetryCount: Int { fullSyncAndRetryTotal } + var relevanceBackfillCount: Int { relevanceBackfillTotal } + + func recordFullSyncAndRetry() { + fullSyncAndRetryTotal += 1 + } + + func recordRelevanceBackfill() { + relevanceBackfillTotal += 1 + } } From 21315cfc7547915b1a374604b73ea5dbceda4954 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 23 Jun 2026 08:47:56 +0700 Subject: [PATCH 09/18] fix: address desktop warmup review feedback --- .../Desktop/Sources/Stores/TasksStore.swift | 10 +++++++-- .../Tests/StartupWarmupPolicyTests.swift | 22 ++++++++++++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/desktop/macos/Desktop/Sources/Stores/TasksStore.swift b/desktop/macos/Desktop/Sources/Stores/TasksStore.swift index ebd4e20563c..9be8c1811f6 100644 --- a/desktop/macos/Desktop/Sources/Stores/TasksStore.swift +++ b/desktop/macos/Desktop/Sources/Stores/TasksStore.swift @@ -234,8 +234,14 @@ class TasksStore: ObservableObject { // Skip if currently loading guard !isLoadingIncomplete, !isLoadingCompleted, !isLoadingDeleted, !isLoadingMore else { return } - // Only refresh if we've already loaded tasks - guard hasLoadedIncomplete else { return } + // Dashboard-only users may never open the full Tasks page, so the + // incomplete task list may not be hydrated. Still keep dashboard task + // slices fresh on app activation / Cmd+R using the scoped dashboard + // refresh path instead of requiring full Tasks-page hydration first. + guard hasLoadedIncomplete else { + await refreshDashboardTasksFromServer() + return + } // Silently sync and reload incomplete tasks (local-first, like Memories) do { diff --git a/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift b/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift index 6c8ac4e9e21..679a395f904 100644 --- a/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift +++ b/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift @@ -179,7 +179,8 @@ final class StartupWarmupPolicyTests: XCTestCase { @MainActor func testTasksStoreStartupMaintenanceSchedulesOnceForFirstUseLoadPath() async { - let store = TasksStore() + let store = TasksStore.shared + store.resetSessionState() let counter = StartupMaintenanceCounter() store.scheduleStartupMaintenanceIfNeeded( @@ -201,6 +202,25 @@ final class StartupWarmupPolicyTests: XCTestCase { XCTAssertEqual(await counter.fullSyncAndRetryCount, 1) XCTAssertEqual(await counter.relevanceBackfillCount, 1) } + + func testDashboardOnlyActivationRefreshDoesNotRequireTasksPageHydration() throws { + let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + let sourceURL = testsURL + .deletingLastPathComponent() + .appendingPathComponent("Sources/Stores/TasksStore.swift") + let source = try String(contentsOf: sourceURL, encoding: .utf8) + + guard let dashboardRefreshRange = source.range(of: "await refreshDashboardTasksFromServer()"), + let hydrationGuardRange = source.range(of: "guard hasLoadedIncomplete else") else { + return XCTFail("TasksStore.refreshTasksIfNeeded must refresh dashboard slices before requiring Tasks page hydration") + } + + XCTAssertLessThan( + dashboardRefreshRange.lowerBound, + hydrationGuardRange.upperBound, + "Dashboard-only activation/Cmd+R refresh must not be blocked by the Tasks page hydration guard" + ) + } } private actor StartupMaintenanceCounter { From 55ced61ab9c7712c7cdb48d955f2d89c82e953ab Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 23 Jun 2026 08:52:55 +0700 Subject: [PATCH 10/18] test: fix dashboard refresh wiring assertion --- desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift b/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift index 679a395f904..a8647512df4 100644 --- a/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift +++ b/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift @@ -216,9 +216,9 @@ final class StartupWarmupPolicyTests: XCTestCase { } XCTAssertLessThan( + hydrationGuardRange.lowerBound, dashboardRefreshRange.lowerBound, - hydrationGuardRange.upperBound, - "Dashboard-only activation/Cmd+R refresh must not be blocked by the Tasks page hydration guard" + "Dashboard-only activation/Cmd+R refresh must fall back to the scoped dashboard refresh from the Tasks page hydration guard" ) } } From ef643176a7a7fc9eda92fe7c23b8589d9c76ef85 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 23 Jun 2026 09:18:13 +0700 Subject: [PATCH 11/18] fix: address startup warmup review feedback --- .../Sources/StartupWarmupCoordinator.swift | 2 ++ .../Desktop/Sources/StartupWarmupPolicy.swift | 2 +- .../Tests/StartupWarmupPolicyTests.swift | 26 +++++++++++++++---- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/desktop/macos/Desktop/Sources/StartupWarmupCoordinator.swift b/desktop/macos/Desktop/Sources/StartupWarmupCoordinator.swift index 64f9d493c93..2175fdfc07e 100644 --- a/desktop/macos/Desktop/Sources/StartupWarmupCoordinator.swift +++ b/desktop/macos/Desktop/Sources/StartupWarmupCoordinator.swift @@ -93,6 +93,8 @@ final class StartupWarmupCoordinator { return } + tasksStore.scheduleStartupMaintenanceIfNeeded() + await measurePerfAsync("DATA LOAD: DB lifecycle warmup") { await measurePerfAsync("DATA LOAD: Task agent restore") { await TaskAgentManager.shared.restoreSessionsFromDatabase() diff --git a/desktop/macos/Desktop/Sources/StartupWarmupPolicy.swift b/desktop/macos/Desktop/Sources/StartupWarmupPolicy.swift index ee9c7f18933..26510fccef3 100644 --- a/desktop/macos/Desktop/Sources/StartupWarmupPolicy.swift +++ b/desktop/macos/Desktop/Sources/StartupWarmupPolicy.swift @@ -73,7 +73,7 @@ enum StartupWarmupPolicy { static let apiKeyFetchDelay: TimeInterval = 9.0 static let chatPromptContextWarmupDelay: TimeInterval = 10.0 static let screenActivitySyncInitialDelay: TimeInterval = 10.0 - static let floatingBarPlanFetchDelay: TimeInterval = 12.0 + static let floatingBarPlanFetchDelay: TimeInterval = 0.0 static let crispInitialPollDelay: TimeInterval = 15.0 static let agentVMProvisioningDelay: TimeInterval = 20.0 static let proactiveAssistantsStartDelay: TimeInterval = 30.0 diff --git a/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift b/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift index a8647512df4..2995f6c41ba 100644 --- a/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift +++ b/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift @@ -73,11 +73,8 @@ final class StartupWarmupPolicyTests: XCTestCase { ) } - func testFloatingBarPlanFetchWaitsUntilAfterDeferredWarmupStarts() { - XCTAssertGreaterThan( - StartupWarmupPolicy.floatingBarPlanFetchDelay, - StartupWarmupPolicy.deferredWarmupDelay - ) + func testFloatingBarPlanFetchRunsImmediatelyForQuotaGate() { + XCTAssertEqual(StartupWarmupPolicy.floatingBarPlanFetchDelay, 0) } func testInitialSettingsSyncWaitsUntilAfterDeferredWarmupStarts() { @@ -221,6 +218,25 @@ final class StartupWarmupPolicyTests: XCTestCase { "Dashboard-only activation/Cmd+R refresh must fall back to the scoped dashboard refresh from the Tasks page hydration guard" ) } + + func testDatabaseWarmupSchedulesTaskMaintenanceWithoutTasksPageHydration() throws { + let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + let sourceURL = testsURL + .deletingLastPathComponent() + .appendingPathComponent("Sources/StartupWarmupCoordinator.swift") + let source = try String(contentsOf: sourceURL, encoding: .utf8) + + guard let maintenanceRange = source.range(of: "tasksStore.scheduleStartupMaintenanceIfNeeded()"), + let lifecycleRange = source.range(of: "DATA LOAD: DB lifecycle warmup") else { + return XCTFail("Startup warmup must schedule task maintenance before DB lifecycle warmup") + } + + XCTAssertLessThan( + maintenanceRange.lowerBound, + lifecycleRange.lowerBound, + "Startup maintenance must run from the startup warmup path, not only after opening Tasks" + ) + } } private actor StartupMaintenanceCounter { From bf42950c1745f619172b497bc0b9a9bfef0865ef Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 23 Jun 2026 15:00:16 -0400 Subject: [PATCH 12/18] Reset memories state on auth changes --- .../MainWindow/Pages/MemoriesPage.swift | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift index 82da9b7279f..360318f2d3b 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift @@ -182,6 +182,42 @@ class MemoriesViewModel: ObservableObject { .store(in: &cancellables) } + func resetSessionState() { + deleteTask?.cancel() + deleteTask = nil + memories = [] + isLoading = false + isLoadingMore = false + hasMoreMemories = true + errorMessage = nil + searchText = "" + isSearching = false + searchResults = [] + selectedTags = [] + filteredFromDatabase = [] + isLoadingFiltered = false + refreshInvocations = 0 + showingAddMemory = false + newMemoryText = "" + editingMemory = nil + editText = "" + selectedMemory = nil + pendingDeleteMemory = nil + undoTimeRemaining = 0 + hasLoadedInitially = false + isActive = false + currentOffset = 0 + showingDeleteAllConfirmation = false + isBulkOperationInProgress = false + linkedConversation = nil + isLoadingConversation = false + isTogglingVisibility = false + totalMemoriesCount = 0 + hasMoreFilteredResults = false + allFilteredResults = [] + displayLimit = pageSize + } + /// Refresh memories if already loaded (for auto-refresh) private func refreshMemoriesIfNeeded() async { refreshInvocations += 1 From 5ce00cdb8ca2ad8188b9d0e532993bde744db0f6 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 23 Jun 2026 15:00:27 -0400 Subject: [PATCH 13/18] Reset dashboard state on auth changes --- .../Desktop/Sources/MainWindow/Pages/DashboardPage.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift index bf543c69f32..a556a1ed2ad 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift @@ -63,6 +63,14 @@ class DashboardViewModel: ObservableObject { await loadGoalsFromLocalSnapshot() } + func resetSessionState() { + scoreResponse = nil + goals = [] + isLoading = false + error = nil + lastGoalRefreshTime = .distantPast + } + private func loadScores() async { do { scoreResponse = try await APIClient.shared.getScores() From e988d731256f7edf430775f853dc677900bc4626 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 23 Jun 2026 15:00:36 -0400 Subject: [PATCH 14/18] Clear per-user view models on startup reset --- desktop/macos/Desktop/Sources/ViewModelContainer.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/desktop/macos/Desktop/Sources/ViewModelContainer.swift b/desktop/macos/Desktop/Sources/ViewModelContainer.swift index 404a9f664de..d64769b0a20 100644 --- a/desktop/macos/Desktop/Sources/ViewModelContainer.swift +++ b/desktop/macos/Desktop/Sources/ViewModelContainer.swift @@ -104,6 +104,8 @@ class ViewModelContainer: ObservableObject { func resetStartupState() { warmupCoordinator.reset() tasksStore.resetSessionState() + dashboardViewModel.resetSessionState() + memoriesViewModel.resetSessionState() isInitialLoadComplete = false isLoading = false databaseInitFailed = false From 8a56107f1009c75ff9cd5a31f30c5afce5bef063 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 23 Jun 2026 15:00:44 -0400 Subject: [PATCH 15/18] Clear chat prompt caches on sign-out --- .../Sources/Providers/ChatProvider.swift | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift index 07ab73e6002..b367a27961a 100644 --- a/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift +++ b/desktop/macos/Desktop/Sources/Providers/ChatProvider.swift @@ -824,15 +824,12 @@ BROWSER TABS: when you use the browser (Playwright), on your FIRST browser actio .sink { [weak self] _ in Task { @MainActor in guard let self = self else { return } - guard self.agentBridgeStarted else { return } - log("ChatProvider: userDidSignOut — stopping agent bridge so the next user gets a fresh subprocess") - await self.agentBridge.stop() - self.agentBridgeStarted = false - self.messages.removeAll() - self.resetMessagesPagination() - self.pendingAttachments.removeAll() - self.sessions.removeAll() - self.currentSession = nil + log("ChatProvider: userDidSignOut — clearing chat state so the next user gets fresh context") + if self.agentBridgeStarted { + await self.agentBridge.stop() + self.agentBridgeStarted = false + } + self.resetSessionStateForAuthChange() } } @@ -1008,6 +1005,26 @@ BROWSER TABS: when you use the browser (Playwright), on your FIRST browser actio await warmupPromptContext() } + private func resetSessionStateForAuthChange() { + messages.removeAll() + resetMessagesPagination() + pendingAttachments.removeAll() + sessions.removeAll() + currentSession = nil + cachedMemories = [] + memoriesLoaded = false + cachedGoals = [] + goalsLoaded = false + cachedTasks = [] + tasksLoaded = false + cachedAIProfile = "" + aiProfileLoaded = false + cachedDatabaseSchema = "" + schemaLoaded = false + cachedMainSystemPrompt = "" + cachedFloatingSystemPrompt = "" + } + /// Switch between bridge modes (Omi AI via piMono, or user's Claude OAuth) func switchBridgeMode(to mode: BridgeMode) async { // Normalize legacy omiAI to piMono From 0e59a9ed89881126b782a896376750fc92a8c792 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 23 Jun 2026 15:00:56 -0400 Subject: [PATCH 16/18] Fetch usage quota after sign-in --- desktop/macos/Desktop/Sources/AuthService.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/desktop/macos/Desktop/Sources/AuthService.swift b/desktop/macos/Desktop/Sources/AuthService.swift index 238464ba544..6b299bb2c30 100644 --- a/desktop/macos/Desktop/Sources/AuthService.swift +++ b/desktop/macos/Desktop/Sources/AuthService.swift @@ -332,6 +332,7 @@ class AuthService { AnalyticsManager.shared.identify() AnalyticsManager.shared.signInCompleted(provider: "apple") APIKeyService.shared.startFetchingKeys() + Task { await FloatingBarUsageLimiter.shared.fetchPlan() } // Start trial polling for the newly signed-in user if let state = AppState.current { @@ -454,6 +455,7 @@ class AuthService { AnalyticsManager.shared.identify() AnalyticsManager.shared.signInCompleted(provider: provider) APIKeyService.shared.startFetchingKeys() + Task { await FloatingBarUsageLimiter.shared.fetchPlan() } // Start trial polling for the newly signed-in user if let state = AppState.current { From b6894874fe1b623d138844329e30bed18ea4bf09 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 23 Jun 2026 15:01:06 -0400 Subject: [PATCH 17/18] Cover auth reset startup regressions --- .../Tests/StartupWarmupPolicyTests.swift | 97 ++++++++++++++++++- 1 file changed, 93 insertions(+), 4 deletions(-) diff --git a/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift b/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift index 2995f6c41ba..97619daae12 100644 --- a/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift +++ b/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift @@ -187,8 +187,10 @@ final class StartupWarmupPolicyTests: XCTestCase { try? await Task.sleep(nanoseconds: 50_000_000) XCTAssertTrue(store.hasScheduledStartupMaintenance) - XCTAssertEqual(await counter.fullSyncAndRetryCount, 1) - XCTAssertEqual(await counter.relevanceBackfillCount, 1) + let initialFullSyncAndRetryCount = await counter.fullSyncAndRetryCount + let initialRelevanceBackfillCount = await counter.relevanceBackfillCount + XCTAssertEqual(initialFullSyncAndRetryCount, 1) + XCTAssertEqual(initialRelevanceBackfillCount, 1) store.scheduleStartupMaintenanceIfNeeded( fullSyncAndRetry: { await counter.recordFullSyncAndRetry() }, @@ -196,8 +198,10 @@ final class StartupWarmupPolicyTests: XCTestCase { ) try? await Task.sleep(nanoseconds: 50_000_000) - XCTAssertEqual(await counter.fullSyncAndRetryCount, 1) - XCTAssertEqual(await counter.relevanceBackfillCount, 1) + let finalFullSyncAndRetryCount = await counter.fullSyncAndRetryCount + let finalRelevanceBackfillCount = await counter.relevanceBackfillCount + XCTAssertEqual(finalFullSyncAndRetryCount, 1) + XCTAssertEqual(finalRelevanceBackfillCount, 1) } func testDashboardOnlyActivationRefreshDoesNotRequireTasksPageHydration() throws { @@ -237,6 +241,91 @@ final class StartupWarmupPolicyTests: XCTestCase { "Startup maintenance must run from the startup warmup path, not only after opening Tasks" ) } + + func testStartupResetClearsPerUserMemoriesState() throws { + let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + let containerURL = testsURL + .deletingLastPathComponent() + .appendingPathComponent("Sources/ViewModelContainer.swift") + let memoriesURL = testsURL + .deletingLastPathComponent() + .appendingPathComponent("Sources/MainWindow/Pages/MemoriesPage.swift") + let containerSource = try String(contentsOf: containerURL, encoding: .utf8) + let memoriesSource = try String(contentsOf: memoriesURL, encoding: .utf8) + + XCTAssertTrue( + containerSource.contains("memoriesViewModel.resetSessionState()"), + "Startup reset must clear MemoriesViewModel so account switches cannot show the previous user's memories" + ) + XCTAssertTrue( + memoriesSource.contains("hasLoadedInitially = false"), + "Memories reset must release the first-use load guard for the next signed-in user" + ) + XCTAssertTrue( + memoriesSource.contains("memories = []"), + "Memories reset must clear any previous user's published memory rows" + ) + } + + func testStartupResetClearsPerUserDashboardState() throws { + let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + let containerURL = testsURL + .deletingLastPathComponent() + .appendingPathComponent("Sources/ViewModelContainer.swift") + let dashboardURL = testsURL + .deletingLastPathComponent() + .appendingPathComponent("Sources/MainWindow/Pages/DashboardPage.swift") + let containerSource = try String(contentsOf: containerURL, encoding: .utf8) + let dashboardSource = try String(contentsOf: dashboardURL, encoding: .utf8) + + XCTAssertTrue( + containerSource.contains("dashboardViewModel.resetSessionState()"), + "Startup reset must clear DashboardViewModel so account switches cannot show the previous user's score or goals" + ) + XCTAssertTrue( + dashboardSource.contains("scoreResponse = nil"), + "Dashboard reset must clear the previous user's score" + ) + XCTAssertTrue( + dashboardSource.contains("goals = []"), + "Dashboard reset must clear the previous user's goals" + ) + } + + func testAuthSignInFetchesFloatingBarPlanImmediately() throws { + let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + let authURL = testsURL + .deletingLastPathComponent() + .appendingPathComponent("Sources/AuthService.swift") + let source = try String(contentsOf: authURL, encoding: .utf8) + + XCTAssertGreaterThanOrEqual( + source.components(separatedBy: "FloatingBarUsageLimiter.shared.fetchPlan()").count - 1, + 2, + "Both Apple and web OAuth sign-in paths must fetch quota immediately after successful sign-in" + ) + } + + func testChatProviderClearsPromptContextOnSignOut() throws { + let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + let chatURL = testsURL + .deletingLastPathComponent() + .appendingPathComponent("Sources/Providers/ChatProvider.swift") + let source = try String(contentsOf: chatURL, encoding: .utf8) + + XCTAssertTrue( + source.contains("resetSessionStateForAuthChange()"), + "Sign-out must clear ChatProvider prompt caches and visible chat state" + ) + XCTAssertTrue( + source.contains("memoriesLoaded = false"), + "ChatProvider auth reset must release the memories-loaded guard" + ) + XCTAssertTrue( + source.contains("cachedMainSystemPrompt = \"\""), + "ChatProvider auth reset must drop cached system prompts built for the previous user" + ) + } } private actor StartupMaintenanceCounter { From fab16b1a5a06325b7647c4c773720d2e788bb956 Mon Sep 17 00:00:00 2001 From: Drake Thomsen Date: Tue, 23 Jun 2026 15:44:53 -0400 Subject: [PATCH 18/18] Fix transcription key fetch startup deferral --- .../Sources/MainWindow/DesktopHomeView.swift | 1 + .../Tests/StartupWarmupPolicyTests.swift | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift index 65e3845f412..9964700d44e 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -162,6 +162,7 @@ struct DesktopHomeView: View { appState.startTranscription() } else { log("DesktopHomeView: Deferring transcription — API keys not yet loaded") + Task { await APIKeyService.shared.waitForKeys() } } } else if !settings.transcriptionEnabled { log("DesktopHomeView: Transcription disabled in settings, skipping auto-start") diff --git a/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift b/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift index 97619daae12..75636d273f1 100644 --- a/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift +++ b/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift @@ -98,6 +98,25 @@ final class StartupWarmupPolicyTests: XCTestCase { ) } + func testTranscriptionDeferralStartsAPIKeyFetchImmediately() throws { + let testsURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + let homeURL = testsURL + .deletingLastPathComponent() + .appendingPathComponent("Sources/MainWindow/DesktopHomeView.swift") + let source = try String(contentsOf: homeURL, encoding: .utf8) + + guard let deferralRange = source.range(of: "DesktopHomeView: Deferring transcription — API keys not yet loaded"), + let immediateFetchRange = source.range(of: "Task { await APIKeyService.shared.waitForKeys() }") else { + return XCTFail("Transcription auto-start deferral must kick off API key fetch immediately") + } + + XCTAssertGreaterThan( + immediateFetchRange.lowerBound, + deferralRange.lowerBound, + "Immediate key fetch should be started from the transcription deferral branch" + ) + } + func testChatPromptContextWarmupWaitsUntilAfterDeferredWarmupStarts() { XCTAssertGreaterThan( StartupWarmupPolicy.chatPromptContextWarmupDelay,