Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions desktop/macos/Desktop/Sources/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions desktop/macos/Desktop/Sources/AuthService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
40 changes: 34 additions & 6 deletions desktop/macos/Desktop/Sources/MainWindow/CrispManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?

/// 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
Expand All @@ -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

Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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 }
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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 []
Expand Down
195 changes: 160 additions & 35 deletions desktop/macos/Desktop/Sources/MainWindow/DesktopHomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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"
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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")
}
}
}
Expand All @@ -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)
Expand All @@ -289,6 +269,18 @@ struct DesktopHomeView: View {
log(
"DesktopHomeView: userDidSignOut — resetting hasCompletedOnboarding and stopping transcription"
)
viewModelContainer.resetStartupState()
didScheduleConversationWarmup = false
didScheduleAgentVMProvisioning = false
appState.conversations = []
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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()
}
Expand Down Expand Up @@ -565,7 +557,7 @@ struct DesktopHomeView: View {
updatedAt: ISO8601DateFormatter().string(from: Date())
)

Task {
Task { @MainActor in
await DesktopAutomationStateStore.shared.update(snapshot)
}
}
Expand Down Expand Up @@ -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 }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reset conversation warmups on account switch

After a successful startup this flag stays true, while the sign-out handler below does not clear appState.conversations/folders. If a user signs out and signs into another account in the same app process, the next main-content .task returns here and never reloads the new user's conversation/folder lists; the Conversations page also skips loading because the old arrays are non-empty, so the previous account's data can remain visible. Reset these per-user warmup flags/caches on sign-out or key them by auth_userId.

Useful? React with 👍 / 👎.

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
Expand Down
Loading
Loading