Foreground Connection Delay Adjustment

Nucleic-Session: CB658C4F-A19F-447C-B313-7CB21783BD94
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-09 14:36:42 -07:00
co-authored by Nucleic
parent 6b096ffe18
commit 7978e96228
@@ -203,6 +203,121 @@ final class RemoteStore: ObservableObject {
for conn in live { conn.send(.approvalRespond(id, decision)) } for conn in live { conn.send(.approvalRespond(id, decision)) }
} }
// MARK: - Optimistic connect window
//
// iOS suspends the app on background and silently kills its sockets; the phone can also wake with
// a socket the OS already tore down but that still reads `.connected`. Either way the link takes a
// beat to re-handshake (fast — see `HostConnection.revalidate` — but not instant). Flashing
// "Disconnected" and graying every action for that second reads as jank. So for a few seconds
// after launch/foreground the store *pretends* it's still connected (see `rebuildAggregate`): the
// chip stays green and the composer/controls stay live, presenting the last known-good state.
// Actions the user takes meanwhile are held in `pendingActions` and replayed the instant a real
// link comes up — or discarded if the window lapses without one (UX_IOS §6, §11.5).
/// How long to keep presenting the last-live state after a launch/foreground before revealing the
/// truth. The fast foreground reconnect is typically sub-second, so this is a safe ceiling.
private static let optimisticWindow: TimeInterval = 5
/// Deadline of the current pretend-connected window; nil when not pretending.
private var optimisticUntil: Date?
private var optimisticExpiryTask: Task<Void, Never>?
/// User intents taken during the optimistic window while no link was live yet — replayed on
/// connect, discarded on expiry. Each carries the time it was queued as a staleness guard.
private var pendingActions: [(msg: ClientMsg, at: Date)] = []
/// The last known-good live projection, kept so the optimistic overlay (and a cold launch, where
/// no connection has re-formed yet) can present "connected" before the socket actually
/// re-handshakes. Only transport + granted scope are needed: capabilities/catalog are already
/// retained on the `HostConnection` across a drop, and it's connectivity + scope that gate the UI.
private struct LastLiveSnapshot: Codable, Equatable {
var transport: SyncTransportHint
var grantedScope: DeviceScope
}
private var lastLive: LastLiveSnapshot? = RemoteStore.loadLastLive()
private static let lastLiveKey = "nucleic.lastLiveProjection"
private static func loadLastLive() -> LastLiveSnapshot? {
guard let data = UserDefaults.standard.data(forKey: lastLiveKey) else { return nil }
return try? JSONDecoder().decode(LastLiveSnapshot.self, from: data)
}
private static func saveLastLive(_ snap: LastLiveSnapshot) {
guard let data = try? JSONEncoder().encode(snap) else { return }
UserDefaults.standard.set(data, forKey: lastLiveKey)
}
/// Whether we're currently inside the pretend-connected window.
private var isOptimisticActive: Bool {
guard let until = optimisticUntil else { return false }
return Date() < until
}
/// Snapshot the current known-good live projection so the overlay can present it later. Cheap: a
/// no-op unless transport or scope actually changed, and persisted so even a cold launch paints
/// connected before the first handshake.
private func captureLastLive(transport: SyncTransportHint, scope: DeviceScope) {
let snap = LastLiveSnapshot(transport: transport, grantedScope: scope)
guard snap != lastLive else { return }
lastLive = snap
Self.saveLastLive(snap)
}
/// Enter the pretend-connected window (launch / foreground). No-op in demo or when unpaired —
/// there's nothing to pretend a connection to. Safe to call when already live: the overlay only
/// engages if the link reads not-live within the window (the woken-zombie-socket case), and the
/// window simply expires harmlessly if the connection stays up.
private func beginOptimisticWindow() {
guard !demoMode, isPaired else { return }
optimisticUntil = Date().addingTimeInterval(Self.optimisticWindow)
optimisticExpiryTask?.cancel()
optimisticExpiryTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(Self.optimisticWindow))
guard let self, !Task.isCancelled else { return }
self.expireOptimisticWindow()
}
}
/// The window lapsed. If a link came up we send whatever was queued; if not, we throw the queued
/// actions away (never fire them late — UX_IOS §11.5) and re-render the true, honest state so the
/// chip and composer stop pretending.
private func expireOptimisticWindow() {
optimisticUntil = nil
optimisticExpiryTask = nil
if hasLiveConnection {
flushPendingActions()
} else {
pendingActions.removeAll()
}
guard !demoMode else { return }
rebuildAggregate()
refreshAggregate()
}
/// Replay everything queued during the optimistic window now that a link is live, oldest first.
/// Called from `didUpdate` on every connectivity change and on window expiry. A stale entry (older
/// than the window plus slack, e.g. the link flapped up-and-down) is dropped rather than fired late.
private func flushPendingActions() {
guard hasLiveConnection, !pendingActions.isEmpty else { return }
let queued = pendingActions
pendingActions.removeAll()
for (msg, at) in queued where Date().timeIntervalSince(at) < Self.optimisticWindow + 5 {
send(msg)
}
}
/// Whether an intent should be held during the optimistic window (a genuine user write/control
/// action worth replaying) versus dropped (host-agnostic pulls and connection-internal traffic,
/// which the reconnect re-issues on its own — subscribe/list/fetch are re-sent on `.ready`).
private func isQueueableIntent(_ msg: ClientMsg) -> Bool {
switch msg {
case .sendInput, .startChat, .captureTodo, .dispatchTodo, .setTodoStatus, .deleteTodo,
.renameSession, .setFavorite, .setArchived, .deleteSession, .discard, .integrate,
.interrupt, .cancelQueuedMessage, .setSessionModel, .setSessionEffort, .setSessionAuto,
.setSessionAutoShip, .setSessionShipBranch, .approvalRespond:
return true
default:
return false
}
}
private static let lastOpenedKey = "nucleic.lastOpenedAt" private static let lastOpenedKey = "nucleic.lastOpenedAt"
private static func loadLastOpened() -> [SessionID: Date] { private static func loadLastOpened() -> [SessionID: Date] {
guard let raw = UserDefaults.standard.dictionary(forKey: lastOpenedKey) else { return [:] } guard let raw = UserDefaults.standard.dictionary(forKey: lastOpenedKey) else { return [:] }