Live Activity Launch Consistency

Nucleic-Session: 9D2EE9AB-2E44-485B-A196-54D250F9D89B
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-06 19:44:00 -07:00
co-authored by Nucleic
parent 5cb7e6659f
commit 0a236f447b
@@ -148,22 +148,15 @@ final class LiveActivityManager {
/// totals on every transcript edit but no status) doesn't spend ActivityKit's update budget. The /// 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. /// fresh churn still rides along on the next status-driven push.
private func push(_ state: NucleicSessionAttributes.ContentState, hostName: String) { private func push(_ state: NucleicSessionAttributes.ContentState, hostName: String) {
// 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 { guard let activity else {
// Recover an Activity that survived an app relaunch before starting a new one; the // No Activity yet — recover one that survived an app relaunch, or start a fresh one. The
// recovered one still needs the fresh state, so fall through to the update pipeline. // dedup gate below only guards *updates* to a live Activity; creation must never sit
// behind it. ActivityKit refuses a start unless the app has a foreground/background-
// assertion window, and committing the dedup state *before* the request meant a refused
// start left `lastState` matching the aggregate — so the next `sync` deduped the glance
// away and it never activated until the session's state changed. Record the pushed state
// only once an Activity actually exists; a refused start leaves `lastState` untouched so
// the next sync simply retries creation.
if let existing = Activity<NucleicSessionAttributes>.activities.first { if let existing = Activity<NucleicSessionAttributes>.activities.first {
activity = existing activity = existing
observePushToken(existing) observePushToken(existing)
@@ -173,19 +166,35 @@ final class LiveActivityManager {
// expanding reveals the fresh one, and `end()` on completion would leave it lingering // expanding reveals the fresh one, and `end()` on completion would leave it lingering
// on the last "needs attention" glance. Keep exactly one. // on the last "needs attention" glance. Keep exactly one.
endStrays(keeping: existing.id) endStrays(keeping: existing.id)
lastState = state
lastPushAt = Date()
enqueue(state) enqueue(state)
} else { } else if let started = try? Activity.request(
// `pushType: .token` opts the activity into APNs updates — the Macs push new // `pushType: .token` opts the activity into APNs updates — the Macs push new
// content-state to the token so the glance stays fresh while the phone is locked. // content-state to the token so the glance stays fresh while the phone is locked.
let started = try? Activity.request(
attributes: NucleicSessionAttributes(hostName: hostName), attributes: NucleicSessionAttributes(hostName: hostName),
content: ActivityContent(state: state, staleDate: nil), content: ActivityContent(state: state, staleDate: nil),
pushType: .token) pushType: .token) {
activity = started activity = started
if let started { observePushToken(started) } observePushToken(started)
lastState = state
lastPushAt = Date()
} }
return return
} }
// A live Activity exists — spend ActivityKit's update budget only when the glance actually
// moved: immediately when the attention signature shifts (start/finish/approval/input — the
// alert-worthy changes), otherwise on a slow cadence for mid-turn churn (diff totals ticking
// on every transcript 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()
enqueue(state) enqueue(state)
} }