From dfa8612cb3eb4d481a02f07a32b372e0d976c618 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 13:05:18 -0700 Subject: [PATCH] nvrsion: Add: Fix SyncHost.swift timer/sleep to pause refresh at a slow cadence mid-turn without blocking iOS Live Activity alerts. Nucleic-Promote: 1 Co-authored-by: Nucleic --- .../NucleicRemote/LiveActivityManager.swift | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index c0564b6..c9bde83 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -29,6 +29,15 @@ final class LiveActivityManager { /// connectivity, pong, diff ticks…), most of which leave the aggregate identical. Burning the /// budget on those is exactly what makes a *real* change land late and the glance read stale. private var lastState: NucleicSessionAttributes.ContentState? + /// When the last update was applied — the clock for the mid-turn churn refresh. A status change + /// (attention-signature shift) updates immediately regardless; a churn-only change (diff totals + /// ticking as the agent edits) only re-applies once `churnRefreshInterval` has elapsed, so a busy + /// turn refreshes the numbers slowly instead of on every transcript delta. A passive timestamp + /// check on the normal path — no timer sits in front of a status update. + private var lastPushAt: Date? + /// How slowly the mid-turn diff totals refresh while nothing else about a session changes — + /// mirrors the host's `SyncHost.churnRefreshInterval` so the local and pushed glances agree. + private static let churnRefreshInterval: TimeInterval = 45 /// The newest state waiting to be applied, and the single task draining it. Coalescing to the /// latest through one serial task means the newest data always wins — firing an unstructured /// `Task` per `sync` let a later update lose a race to an earlier one and freeze the glance. @@ -127,8 +136,18 @@ final class LiveActivityManager { /// totals on every transcript edit but no status) doesn't spend ActivityKit's update budget. The /// fresh churn still rides along on the next status-driven push. private func push(_ state: NucleicSessionAttributes.ContentState, hostName: String) { - guard state.attentionSignature != lastState?.attentionSignature else { return } + // Apply immediately when the attention signature shifts (start/finish/approval/input — the + // alert-worthy changes). Otherwise the only difference is mid-turn churn (diff totals); let + // that refresh on a slow cadence so it stays roughly current without spending ActivityKit's + // budget on every delta. Elapsed-time gate, not a sleeping timer — a status change is never + // held behind it. + let signatureChanged = state.attentionSignature != lastState?.attentionSignature + let churnRefreshDue = lastPushAt.map { + Date().timeIntervalSince($0) >= Self.churnRefreshInterval + } ?? true + guard signatureChanged || (state != lastState && churnRefreshDue) else { return } lastState = state + lastPushAt = Date() guard let activity else { // Recover an Activity that survived an app relaunch before starting a new one; the @@ -193,6 +212,7 @@ final class LiveActivityManager { dismissTask = nil pendingState = nil lastState = nil + lastPushAt = nil let tracked = activity self.activity = nil if let tracked { onActivityEnded?(tracked.id) } // let the Macs stop pushing to this token