Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
5c55045
fix(desktop): reduce chat message polling from 15s to 120s (#6500)
beastoin Apr 10, 2026
30af865
fix(desktop): reduce task polling from 30s to 120s (#6500)
beastoin Apr 10, 2026
899b8a8
fix(desktop): reduce memories polling from 30s to 120s (#6500)
beastoin Apr 10, 2026
e611e85
fix(desktop): reduce conversation polling to 120s with activation coo…
beastoin Apr 10, 2026
d834ea3
fix(desktop): add skipCount parameter to refreshConversations (#6500)
beastoin Apr 10, 2026
6a31ed7
fix(desktop): add activation refresh for chat messages (#6500)
beastoin Apr 10, 2026
261c84c
fix(desktop): centralize polling intervals in PollingConfig (#6500)
beastoin Apr 10, 2026
64ff419
fix(desktop): wire ChatProvider to PollingConfig (#6500)
beastoin Apr 10, 2026
73ebc8a
fix(desktop): wire TasksStore to PollingConfig (#6500)
beastoin Apr 10, 2026
c30f4bf
fix(desktop): wire MemoriesPage to PollingConfig (#6500)
beastoin Apr 10, 2026
2b4051a
fix(desktop): wire DesktopHomeView to PollingConfig (#6500)
beastoin Apr 10, 2026
e64dd98
test(desktop): add polling frequency and cooldown unit tests (#6500)
beastoin Apr 10, 2026
70d1483
fix(desktop): scope activation cooldown to conversation refresh only …
beastoin Apr 10, 2026
60f14b8
refactor(desktop): remove polling intervals from PollingConfig (#6500)
beastoin Apr 14, 2026
a565e1d
feat(desktop): add refreshAllData notification for Cmd+R (#6500)
beastoin Apr 14, 2026
1a48cbe
feat(desktop): add Cmd+R keyboard shortcut for manual refresh (#6500)
beastoin Apr 14, 2026
5f0351b
fix(desktop): remove chat polling timer, add Cmd+R observer (#6500)
beastoin Apr 14, 2026
6b6a791
fix(desktop): replace conversation polling timer with Cmd+R observer …
beastoin Apr 14, 2026
469eaff
fix(desktop): replace task polling timer with activation + Cmd+R (#6500)
beastoin Apr 14, 2026
c47d15a
fix(desktop): replace memories polling timer with activation + Cmd+R …
beastoin Apr 14, 2026
c4e802c
test(desktop): update tests for event-driven refresh architecture (#6…
beastoin Apr 14, 2026
7e3ec82
fix(desktop): remove CrispManager polling timer, use activation + Cmd…
beastoin Apr 14, 2026
1dbe931
fix(desktop): add in-flight guard to chat message sync (#6500)
beastoin Apr 14, 2026
d1b8335
test(desktop): improve polling frequency test coverage per tester fee…
beastoin Apr 14, 2026
4fbf7c8
refactor(desktop): extract activation cooldown predicate to PollingCo…
beastoin Apr 14, 2026
0c9eeed
refactor(desktop): remove unused skipCount from refreshConversations …
beastoin Apr 15, 2026
8cea4e1
feat(desktop): add ReentrancyGate helper for in-flight guards (#6500)
beastoin Apr 15, 2026
9ee4165
refactor(desktop): use ReentrancyGate for pollForNewMessages (#6500)
beastoin Apr 15, 2026
335d843
refactor(desktop): expose CrispManager lifecycle fields for tests (#6…
beastoin Apr 15, 2026
8c56cb0
test(desktop): add ReentrancyGate unit tests (#6500)
beastoin Apr 15, 2026
2c6478a
test(desktop): add CrispManager lifecycle tests (#6500)
beastoin Apr 15, 2026
457a6a2
docs(desktop): tighten ReentrancyGate ownership contract (#6500)
beastoin Apr 15, 2026
24fafaf
test(desktop): replace unsafe exit test with guard/defer pattern test…
beastoin Apr 15, 2026
fc2f581
refactor(desktop): add performInitialPoll flag to CrispManager.start(…
beastoin Apr 15, 2026
6be95f4
test(desktop): make CrispManager lifecycle tests hermetic (#6500)
beastoin Apr 15, 2026
2ce8d46
test(desktop): overlap non-owner with in-flight owner in gate test (#…
beastoin Apr 15, 2026
745d8c7
feat(desktop): add pollInvocations counter to CrispManager for test o…
beastoin Apr 15, 2026
542dbc1
feat(desktop): add refreshInvocations counter to TasksStore for obser…
beastoin Apr 15, 2026
21722c4
feat(desktop): add refreshInvocations counter to MemoriesViewModel fo…
beastoin Apr 15, 2026
2ce00ce
test(desktop): assert CrispManager observers fire pollForMessages() (…
beastoin Apr 15, 2026
55b4db9
test(desktop): add TasksStore observer-firing tests (#6500)
beastoin Apr 15, 2026
ae2ddd9
test(desktop): add MemoriesViewModel observer-firing tests (#6500)
beastoin Apr 15, 2026
6a0f271
refactor(desktop): drop @Published from CrispManager.pollInvocations …
beastoin Apr 15, 2026
b272e37
refactor(desktop): drop @Published from TasksStore.refreshInvocations…
beastoin Apr 15, 2026
f22079a
refactor(desktop): drop @Published from MemoriesViewModel.refreshInvo…
beastoin Apr 15, 2026
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: 3 additions & 2 deletions desktop/Desktop/Sources/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1995,7 +1995,7 @@ class AppState: ObservableObject {
NotificationCenter.default.post(name: .conversationsPageDidLoad, object: nil)
}

/// Refresh conversations silently (for auto-refresh timer and app-activate).
/// Refresh conversations silently (for app-activation and Cmd+R event-driven refreshes).
/// Fetches from API only, merges in-place, and only triggers @Published if data actually changed.
func refreshConversations() async {
// Skip if user is signed out (tokens are cleared)
Expand Down Expand Up @@ -2057,7 +2057,6 @@ class AppState: ObservableObject {
}
}

// Update total count
do {
let count = try await APIClient.shared.getConversationsCount(includeDiscarded: false)
if totalConversationsCount != count {
Expand Down Expand Up @@ -3065,6 +3064,8 @@ extension Notification.Name {
static let navigateToTasks = Notification.Name("navigateToTasks")
/// Posted by keyboard shortcuts to navigate sidebar. userInfo: ["rawValue": Int]
static let navigateToSidebarItem = Notification.Name("navigateToSidebarItem")
/// Posted by Cmd+R to refresh all data (conversations, chat, tasks, memories)
static let refreshAllData = Notification.Name("refreshAllData")
/// Posted by the local desktop automation bridge to request semantic navigation.
static let desktopAutomationNavigateRequested = Notification.Name(
"desktopAutomationNavigateRequested")
Expand Down
73 changes: 50 additions & 23 deletions desktop/Desktop/Sources/MainWindow/CrispManager.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import AppKit
import Foundation

/// Polls the backend Crisp API endpoint for unread operator messages,
/// Fetches Crisp operator messages on app activation and Cmd+R,
/// fires macOS notifications, and tracks unread count for the sidebar badge.
@MainActor
class CrispManager: ObservableObject {
Expand All @@ -23,31 +24,49 @@ class CrispManager: ObservableObject {
/// Timestamp of the most recent operator message we've already notified about.
/// Persisted to UserDefaults so unread messages survive app restarts.
/// Stored as Double because UserDefaults can't round-trip UInt64.
private var lastSeenTimestamp: UInt64 {
/// Non-`private` so `CrispManagerLifecycleTests` can assert `markAsRead()` advances it.
var lastSeenTimestamp: UInt64 {
get { UInt64(UserDefaults.standard.double(forKey: "crisp_lastSeenTimestamp")) }
set { UserDefaults.standard.set(Double(newValue), forKey: "crisp_lastSeenTimestamp") }
}

/// Track the latest operator message timestamp from any poll.
/// Persisted to UserDefaults so we don't re-notify after restart.
private var latestOperatorTimestamp: UInt64 {
/// Non-`private` so `CrispManagerLifecycleTests` can seed it before `markAsRead()`.
var latestOperatorTimestamp: UInt64 {
get { UInt64(UserDefaults.standard.double(forKey: "crisp_latestOperatorTimestamp")) }
set { UserDefaults.standard.set(Double(newValue), forKey: "crisp_latestOperatorTimestamp") }
}

/// Track message texts we've already sent notifications for (to avoid duplicates)
private var notifiedMessages = Set<String>()

/// Polling timer
private var pollTimer: Timer?

/// Whether polling has started
private var isStarted = false

private init() {}

/// Call once after sign-in to start polling for Crisp messages
func start() {
/// Whether start() has been called. Non-`private` so lifecycle tests can
/// assert idempotency after calling `start()` twice.
var isStarted = false

/// Non-`private` so lifecycle tests can assert `stop()` clears both observers.
var activationObserver: NSObjectProtocol?
var refreshAllObserver: NSObjectProtocol?

/// 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
/// method — if an observer subscribes to the wrong notification name or a
/// future edit drops the wiring, the counter stays flat and the test fails.
/// Deliberately **not** `@Published` — publishing on every activation/Cmd+R
/// refresh would emit `objectWillChange` and invalidate any SwiftUI view
/// observing `CrispManager`, which is a pure production cost for a value
/// nothing drives UI from.
private(set) var pollInvocations: Int = 0

/// 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) {
guard !isStarted else { return }
isStarted = true

Expand All @@ -58,15 +77,20 @@ class CrispManager: ObservableObject {
lastSeenTimestamp = UInt64(Date().timeIntervalSince1970)
}

// Poll immediately, then every 2 minutes
pollForMessages()
pollTimer = Timer.scheduledTimer(withTimeInterval: 120, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.pollForMessages()
}
if performInitialPoll {
pollForMessages()
}

log("CrispManager: started polling for operator messages")
// Refresh on app activation and Cmd+R (no periodic timer)
activationObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main
) { [weak self] _ in Task { @MainActor in self?.pollForMessages() } }

refreshAllObserver = NotificationCenter.default.addObserver(
forName: .refreshAllData, object: nil, queue: .main
) { [weak self] _ in Task { @MainActor in self?.pollForMessages() } }

log("CrispManager: started (event-driven, no polling timer)")
}

/// Mark messages as read (called when user opens Help tab)
Expand All @@ -75,10 +99,12 @@ class CrispManager: ObservableObject {
lastSeenTimestamp = latestOperatorTimestamp
}

/// Stop polling (called on sign-out)
/// Stop observing (called on sign-out)
func stop() {
pollTimer?.invalidate()
pollTimer = nil
if let obs = activationObserver { NotificationCenter.default.removeObserver(obs) }
if let obs = refreshAllObserver { NotificationCenter.default.removeObserver(obs) }
activationObserver = nil
refreshAllObserver = nil
isStarted = false
unreadCount = 0
// Clear persisted timestamps so next sign-in starts fresh
Expand All @@ -90,6 +116,7 @@ class CrispManager: ObservableObject {
// MARK: - Private

private func pollForMessages() {
pollInvocations += 1
Task {
// Skip if in auth backoff period (recent 401 errors)
guard !AuthBackoffTracker.shared.shouldSkipRequest() else { return }
Expand Down
12 changes: 9 additions & 3 deletions desktop/Desktop/Sources/MainWindow/DesktopHomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ struct DesktopHomeView: View {
@State private var showTryAskingPopup = false
@State private var previousIndexBeforeSettings: Int = 0
@State private var logoPulse = false
@State private var lastActivationRefresh = Date.distantPast

// Pre-loaded hero logo to avoid NSImage init crashes during SwiftUI body evaluation
private static let heroLogoImage: NSImage? = {
Expand Down Expand Up @@ -199,7 +200,12 @@ struct DesktopHomeView: View {
.onReceive(
NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)
) { _ in
Task { await appState.refreshConversations() }
// Cooldown: only refresh conversations if last activation was 60+ seconds ago
let now = Date()
if PollingConfig.shouldAllowActivationRefresh(now: now, lastRefresh: lastActivationRefresh) {
lastActivationRefresh = now
Task { await appState.refreshConversations() }
}
// Auto-start monitoring when returning to app if screen analysis is enabled
// but monitoring is not running. Handles the case where the user granted
// screen recording permission in System Settings and switched back.
Expand Down Expand Up @@ -234,8 +240,8 @@ struct DesktopHomeView: View {
}
}
}
// Periodic refresh every 30s to pick up conversations from other devices (e.g. Omi Glass)
.onReceive(Timer.publish(every: 30, on: .main, in: .common).autoconnect()) { _ in
// Cmd+R: refresh all data (conversations, chat, tasks, memories)
.onReceive(NotificationCenter.default.publisher(for: .refreshAllData)) { _ in
Task { await appState.refreshConversations() }
}
// On sign-out: reset @AppStorage-backed onboarding flag and stop transcription.
Expand Down
24 changes: 21 additions & 3 deletions desktop/Desktop/Sources/MainWindow/Pages/MemoriesPage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,17 @@ class MemoriesViewModel: ObservableObject {
/// Memories loaded from SQLite with filters applied
@Published private(set) var filteredFromDatabase: [ServerMemory] = []
@Published private(set) var isLoadingFiltered = false

/// Counter bumped at the top of `refreshMemoriesIfNeeded()`, before any of
/// the early-exit guards. Lets `MemoriesViewModelObserverTests` prove that
/// posting `didBecomeActive` / `.refreshAllData` actually reaches the refresh
/// method — if the observer rewire regresses, the counter stays flat and the
/// test fails.
/// Deliberately **not** `@Published` — publishing on every activation/Cmd+R
/// refresh would emit `objectWillChange` and invalidate any SwiftUI view
/// observing `MemoriesViewModel`, which is a pure production cost for a
/// value nothing drives UI from.
private(set) var refreshInvocations: Int = 0
@Published var showingAddMemory = false
@Published var newMemoryText = ""
@Published var editingMemory: ServerMemory? = nil
Expand Down Expand Up @@ -206,9 +217,15 @@ class MemoriesViewModel: ObservableObject {
// MARK: - Initialization

init() {
// Auto-refresh memories every 30 seconds
Timer.publish(every: 30.0, on: .main, in: .common)
.autoconnect()
// Refresh memories when app becomes active
NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)
.sink { [weak self] _ in
Task { await self?.refreshMemoriesIfNeeded() }
}
.store(in: &cancellables)

// Cmd+R: refresh memories on demand
NotificationCenter.default.publisher(for: .refreshAllData)
.sink { [weak self] _ in
Task { await self?.refreshMemoriesIfNeeded() }
}
Expand All @@ -217,6 +234,7 @@ class MemoriesViewModel: ObservableObject {

/// Refresh memories if already loaded (for auto-refresh)
private func refreshMemoriesIfNeeded() async {
refreshInvocations += 1
// Skip if user is signed out (tokens are cleared)
guard AuthState.shared.isSignedIn else { return }
// Skip if in auth backoff period (recent 401 errors)
Expand Down
7 changes: 7 additions & 0 deletions desktop/Desktop/Sources/OmiApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,13 @@ struct OMIApp: App {
}
.keyboardShortcut(",", modifiers: .command)
}

CommandGroup(after: .toolbar) {
Button("Refresh") {
NotificationCenter.default.post(name: .refreshAllData, object: nil)
}
.keyboardShortcut("r", modifiers: .command)
}
}

// Note: Menu bar is now handled by NSStatusBar in AppDelegate.setupMenuBar()
Expand Down
18 changes: 18 additions & 0 deletions desktop/Desktop/Sources/PollingConfig.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Foundation

/// Centralized configuration for event-driven data refresh.
/// All periodic polling timers have been removed — data refreshes on
/// app activation (didBecomeActiveNotification) and manual Cmd+R.
enum PollingConfig {
/// Minimum time between app-activation conversation refreshes (seconds).
/// Prevents cmd-tab spam from flooding the API.
static let activationCooldown: TimeInterval = 60.0

/// Returns `true` when enough time has passed since `lastRefresh` to allow
/// another activation-triggered refresh. Used by DesktopHomeView to throttle
/// didBecomeActiveNotification bursts. Shared between production and tests
/// so a regression (e.g. `>=` → `>`) is caught by the unit tests.
static func shouldAllowActivationRefresh(now: Date = Date(), lastRefresh: Date) -> Bool {
now.timeIntervalSince(lastRefresh) >= activationCooldown
}
}
30 changes: 20 additions & 10 deletions desktop/Desktop/Sources/Providers/ChatProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -537,12 +537,9 @@ A screenshot may be attached — use it silently only if relevant. Never mention
private var multiChatObserver: AnyCancellable?
private var playwrightExtensionObserver: AnyCancellable?
private var sessionGroupingObserver: AnyCancellable?
private var activationObserver: AnyCancellable?

// MARK: - Cross-Platform Message Polling
/// Polls for new messages from other platforms (mobile) every 15 seconds.
/// Similar to TasksStore's 30-second polling pattern.
private var messagePollTimer: AnyCancellable?
private static let messagePollInterval: TimeInterval = 15.0
private var refreshAllObserver: AnyCancellable?

// MARK: - Streaming Buffer
/// Accumulates text deltas during streaming and flushes them to the published
Expand Down Expand Up @@ -638,9 +635,16 @@ A screenshot may be attached — use it silently only if relevant. Never mention
}
}

// Poll for new messages from other platforms (mobile) every 15 seconds
messagePollTimer = Timer.publish(every: Self.messagePollInterval, on: .main, in: .common)
.autoconnect()
// Refresh messages when app becomes active
activationObserver = NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)
.sink { [weak self] _ in
Task { @MainActor in
await self?.pollForNewMessages()
}
}

// Cmd+R: refresh messages on demand
refreshAllObserver = NotificationCenter.default.publisher(for: .refreshAllData)
.sink { [weak self] _ in
Task { @MainActor in
await self?.pollForNewMessages()
Expand Down Expand Up @@ -1920,11 +1924,17 @@ A screenshot may be attached — use it silently only if relevant. Never mention
isLoading = false
}

// MARK: - Cross-Platform Message Polling
// MARK: - Cross-Platform Message Sync

/// Prevents overlapping fetches when activation + Cmd+R fire back-to-back.
private let pollGate = ReentrancyGate()

/// Poll for new messages from other platforms (e.g. mobile).
/// Fetch new messages from other platforms (e.g. mobile).
/// Merges new messages into the existing array without disrupting the UI.
private func pollForNewMessages() async {
// Prevent overlapping fetches from activation + Cmd+R firing together
guard pollGate.tryEnter() else { return }
defer { pollGate.exit() }
// Skip if user is signed out (tokens are cleared)
guard AuthState.shared.isSignedIn else { return }
// Skip if in auth backoff period (recent 401 errors)
Expand Down
45 changes: 45 additions & 0 deletions desktop/Desktop/Sources/ReentrancyGate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Foundation

/// Single-entry reentrancy gate for preventing overlapping async operations.
///
/// Use when two independent triggers (e.g. `didBecomeActive` + Cmd+R) can fire
/// back-to-back and would otherwise cause duplicate fetches/inserts. Call
/// `tryEnter()` at the start of the critical section — if it returns `false`,
/// another caller is already in-flight and the current caller should bail out
/// **without** calling `exit()`. Only the caller that got `true` from
/// `tryEnter()` owns the gate and must release it.
///
/// The canonical usage is a `guard` + `defer` pair, which ensures `exit()` is
/// only scheduled once the guard has admitted the caller:
///
/// ```swift
/// guard gate.tryEnter() else { return } // non-owners return here, no exit()
/// defer { gate.exit() } // only the owner reaches this line
/// // … critical section …
/// ```
///
/// `exit()` does not validate ownership — a stray call will reopen the gate
/// while another caller is still inside the critical section. Follow the
/// `guard`/`defer` pattern above and the contract holds.
///
/// Tested in `ReentrancyGateTests`.
@MainActor
final class ReentrancyGate {
private var isInFlight = false

/// Attempts to enter the critical section.
/// - Returns: `true` if the caller acquired the gate (must call `exit()` when done),
/// `false` if another operation is already in-flight (caller must **not** call `exit()`).
func tryEnter() -> Bool {
guard !isInFlight else { return false }
isInFlight = true
return true
}

/// Releases the gate. **Caller contract:** only call this after a matching
/// `tryEnter()` returned `true`. Calling `exit()` without ownership will
/// reopen the gate while another caller is still inside the critical section.
func exit() {
isInFlight = false
}
}
Loading
Loading