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/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 { diff --git a/desktop/macos/Desktop/Sources/MainWindow/CrispManager.swift b/desktop/macos/Desktop/Sources/MainWindow/CrispManager.swift index a0e9928f8a5..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 @@ -62,11 +65,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 +84,17 @@ class CrispManager: ObservableObject { } if performInitialPoll { - pollForMessages() + if initialPollDelay > 0 { + 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() + } + } else { + pollForMessages() + } } // Refresh on app activation and Cmd+R (no periodic timer) @@ -101,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 @@ -117,6 +135,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 +184,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] @@ -209,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 af5441c9d9c..9964700d44e 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift @@ -39,6 +39,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 @@ -145,11 +149,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 @@ -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") @@ -188,15 +189,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" @@ -207,7 +200,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( @@ -223,14 +216,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( @@ -249,8 +237,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") } } } @@ -265,15 +253,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) @@ -289,6 +269,18 @@ struct DesktopHomeView: View { log( "DesktopHomeView: userDidSignOut — resetting hasCompletedOnboarding and stopping transcription" ) + viewModelContainer.resetStartupState() + didScheduleConversationWarmup = false + didScheduleAgentVMProvisioning = false + appState.conversations = [] + appState.folders = [] + appState.selectedFolderId = nil + appState.selectedDateFilter = nil + appState.showStarredOnly = false + appState.totalConversationsCount = nil + appState.conversationsError = nil + appState.isLoadingConversations = false + appState.isLoadingFolders = false appState.hasCompletedOnboarding = false appState.stopTranscription() } @@ -565,7 +557,7 @@ struct DesktopHomeView: View { updatedAt: ISO8601DateFormatter().string(from: Date()) ) - Task { + Task { @MainActor in await DesktopAutomationStateStore.shared.update(snapshot) } } @@ -653,6 +645,139 @@ struct DesktopHomeView: View { } } + private func scheduleAgentVMProvisioning() { + guard !didScheduleAgentVMProvisioning else { return } + didScheduleAgentVMProvisioning = true + + Task { @MainActor in + try? await Task.sleep( + nanoseconds: UInt64(StartupWarmupPolicy.agentVMProvisioningDelay * 1_000_000_000) + ) + guard !Task.isCancelled else { + didScheduleAgentVMProvisioning = false + return + } + guard await AuthState.shared.isSignedIn else { + didScheduleAgentVMProvisioning = false + return + } + 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) + ) + 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() + _ = 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 { @MainActor in + try? await Task.sleep( + nanoseconds: UInt64(StartupWarmupPolicy.initialFileIndexingDelay * 1_000_000_000) + ) + 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 { + initialFileIndexingBackfill.releaseReservation() + return + } + guard await AuthState.shared.isSignedIn else { + initialFileIndexingBackfill.releaseReservation() + return + } + 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) + ) + 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 { + 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 536fe1845db..a556a1ed2ad 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/DashboardPage.swift @@ -51,7 +51,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) @@ -59,6 +59,18 @@ class DashboardViewModel: ObservableObject { isLoading = false } + func loadCachedDashboardData() async { + await loadGoalsFromLocalSnapshot() + } + + func resetSessionState() { + scoreResponse = nil + goals = [] + isLoading = false + error = nil + lastGoalRefreshTime = .distantPast + } + private func loadScores() async { do { scoreResponse = try await APIClient.shared.getScores() @@ -95,11 +107,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..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 @@ -470,6 +506,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 +995,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..d467a9dba45 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,15 +2271,18 @@ struct TasksPage: View { .background(Color.clear) // Modal creation sheet removed — Cmd+N now creates inline at top .onAppear { + 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 f207d1652b9..5aa258ef6c2 100644 --- a/desktop/macos/Desktop/Sources/OmiApp.swift +++ b/desktop/macos/Desktop/Sources/OmiApp.swift @@ -221,6 +221,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private var screenCaptureSwitch: NSSwitch? 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? func applicationDidFinishLaunching(_ notification: Notification) { if ViewExporter.shouldExport() { @@ -420,21 +425,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 { @@ -447,14 +445,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 { @@ -1185,6 +1180,15 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { sentryHeartbeatTimer?.invalidate() sentryHeartbeatTimer = nil + apiKeyFetchTask?.cancel() + apiKeyFetchTask = nil + floatingBarPlanFetchTask?.cancel() + floatingBarPlanFetchTask = nil + appLifecycleMaintenanceTask?.cancel() + appLifecycleMaintenanceTask = nil + initialSettingsSyncTask?.cancel() + initialSettingsSyncTask = nil + // Stop transcription retry service TranscriptionRetryService.shared.stop() @@ -1349,8 +1353,85 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } } + private func scheduleFloatingBarPlanFetch() { + 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() + } + } + + 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 e0c8f83947b..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() } } @@ -1005,11 +1002,27 @@ 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() + } + + 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) @@ -1955,6 +1968,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 } @@ -1986,6 +2005,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..2175fdfc07e --- /dev/null +++ b/desktop/macos/Desktop/Sources/StartupWarmupCoordinator.swift @@ -0,0 +1,192 @@ +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 reset() { + cancel() + serviceWarmupTask = nil + databaseWarmupTask = nil + dashboardNetworkRefreshTask = nil + chatPromptContextWarmupTask = nil + databaseRetryTask = nil + scheduleState = StartupWarmupScheduleState() + } + + func schedulePostInteractiveWarmup(dbAvailable: Bool) { + if scheduleState.reserveServiceWarmup() { + serviceWarmupTask = Task { [weak self] in + await self?.runServiceWarmup() + } + } + + 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) + scheduleChatPromptContextWarmup() + } + + 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 } + guard await AuthState.shared.isSignedIn else { + log("DATA LOAD: Skipping DB lifecycle warmup because user is signed out") + scheduleState.releaseDatabaseWarmup() + return + } + + tasksStore.scheduleStartupMaintenanceIfNeeded() + + 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..26510fccef3 --- /dev/null +++ b/desktop/macos/Desktop/Sources/StartupWarmupPolicy.swift @@ -0,0 +1,84 @@ +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 + } + + mutating func releaseDatabaseWarmup() { + didScheduleDatabaseWarmup = false + } +} + +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 + } + + mutating func releaseReservation() { + isScheduled = false + shouldMarkComplete = false + } +} + +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 = 0.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..cd557407a51 --- /dev/null +++ b/desktop/macos/Desktop/Sources/Stores/DashboardTaskRefreshService.swift @@ -0,0 +1,137 @@ +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 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 + ) + + 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" + ) + if !serverTruth.hadAuthFailure { + 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(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: now) ?? now + 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, hadAuthFailure: Bool) { + guard !ids.isEmpty else { return ([:], [], false) } + + 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 + var hadAuthFailure = false + + 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 { + hadAuthFailure = true + AuthBackoffTracker.shared.reportAuthFailure() + } + } + } + + if failedCount > 0 { + log("DashboardTaskRefreshService: Exact task refresh skipped \(failedCount) stale classifications") + } + + return (itemsById, missingIds, hadAuthFailure) + } +} diff --git a/desktop/macos/Desktop/Sources/Stores/TasksStore.swift b/desktop/macos/Desktop/Sources/Stores/TasksStore.swift index 49a8e94004f..ece8515ea30 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(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 @@ -157,6 +183,10 @@ class TasksStore: ObservableObject { } } + func refreshDashboardTasksFromServer() async { + await DashboardTaskRefreshService.refresh(store: self) + } + var todoCount: Int { incompleteTasks.count } @@ -204,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 { @@ -475,13 +511,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 @@ -492,23 +530,42 @@ 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. + } + + 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 + // 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) + + // 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) + } } - // 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 fdf7f90da18..d64769b0a20 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() @@ -26,19 +35,28 @@ class ViewModelContainer: ObservableObject { @Published var isLoading = false @Published var databaseInitFailed = false @Published var initStatusMessage: String = "Preparing your data…" + private var loadedUserId: String? - /// Load all data in parallel at app launch + /// 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) + 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") - await RewindDatabase.shared.configure(userId: userId) + await RewindDatabase.shared.configure(userId: currentUserId) // Pre-initialize database so local SQLite reads are instant let dbInitStart = CFAbsoluteTimeGetCurrent() @@ -55,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 @@ -70,59 +89,33 @@ 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) + schedulePostInteractiveWarmup(dbAvailable: dbAvailable) + isLoading = false - // 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 + timer.stop() + logPerf("DATA LOAD: Critical startup complete - post-interactive warmup scheduled", cpu: true) + } - // Start screen activity sync to backend (Firestore + Pinecone) - await ScreenActivitySyncService.shared.start() - } + private func schedulePostInteractiveWarmup(dbAvailable: Bool) { + tasksViewModel.chatCoordinator = taskChatCoordinator + warmupCoordinator.schedulePostInteractiveWarmup(dbAvailable: dbAvailable) + } + func resetStartupState() { + warmupCoordinator.reset() + tasksStore.resetSessionState() + dashboardViewModel.resetSessionState() + memoriesViewModel.resetSessionState() + isInitialLoadComplete = false isLoading = false - - timer.stop() - logPerf("DATA LOAD: Complete - all pages loaded", cpu: true) + databaseInitFailed = false + initStatusMessage = "Preparing your data…" + loadedUserId = nil } - /// Retry database initialization and reload DB-dependent data - func retryDatabaseInit() async { - guard databaseInitFailed else { return } + /// 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 +125,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..75636d273f1 --- /dev/null +++ b/desktop/macos/Desktop/Tests/StartupWarmupPolicyTests.swift @@ -0,0 +1,364 @@ +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 testFloatingBarPlanFetchRunsImmediatelyForQuotaGate() { + XCTAssertEqual(StartupWarmupPolicy.floatingBarPlanFetchDelay, 0) + } + + func testInitialSettingsSyncWaitsUntilAfterDeferredWarmupStarts() { + XCTAssertGreaterThan( + StartupWarmupPolicy.initialSettingsSyncDelay, + StartupWarmupPolicy.deferredWarmupDelay + ) + } + + func testInitialSettingsSyncRunsBeforeProactiveAssistantsStart() { + XCTAssertLessThan( + StartupWarmupPolicy.initialSettingsSyncDelay, + StartupWarmupPolicy.proactiveAssistantsStartDelay + ) + } + + func testAPIKeyFetchWaitsUntilAfterDashboardNetworkRefresh() { + XCTAssertGreaterThan( + StartupWarmupPolicy.apiKeyFetchDelay, + StartupWarmupPolicy.dashboardNetworkRefreshDelay + ) + } + + 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, + 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 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) + ) + 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)) + } + + 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)) + } + + @MainActor + func testTasksStoreStartupMaintenanceSchedulesOnceForFirstUseLoadPath() async { + let store = TasksStore.shared + store.resetSessionState() + let counter = StartupMaintenanceCounter() + + store.scheduleStartupMaintenanceIfNeeded( + fullSyncAndRetry: { await counter.recordFullSyncAndRetry() }, + relevanceBackfill: { await counter.recordRelevanceBackfill() } + ) + + try? await Task.sleep(nanoseconds: 50_000_000) + XCTAssertTrue(store.hasScheduledStartupMaintenance) + let initialFullSyncAndRetryCount = await counter.fullSyncAndRetryCount + let initialRelevanceBackfillCount = await counter.relevanceBackfillCount + XCTAssertEqual(initialFullSyncAndRetryCount, 1) + XCTAssertEqual(initialRelevanceBackfillCount, 1) + + store.scheduleStartupMaintenanceIfNeeded( + fullSyncAndRetry: { await counter.recordFullSyncAndRetry() }, + relevanceBackfill: { await counter.recordRelevanceBackfill() } + ) + + try? await Task.sleep(nanoseconds: 50_000_000) + let finalFullSyncAndRetryCount = await counter.fullSyncAndRetryCount + let finalRelevanceBackfillCount = await counter.relevanceBackfillCount + XCTAssertEqual(finalFullSyncAndRetryCount, 1) + XCTAssertEqual(finalRelevanceBackfillCount, 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( + hydrationGuardRange.lowerBound, + dashboardRefreshRange.lowerBound, + "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" + ) + } + + 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 { + private var fullSyncAndRetryTotal = 0 + private var relevanceBackfillTotal = 0 + + var fullSyncAndRetryCount: Int { fullSyncAndRetryTotal } + var relevanceBackfillCount: Int { relevanceBackfillTotal } + + func recordFullSyncAndRetry() { + fullSyncAndRetryTotal += 1 + } + + func recordRelevanceBackfill() { + relevanceBackfillTotal += 1 + } +}