From 7978e96228f62dcc8d30f3947e919afcd8dcc984 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 9 Jul 2026 14:36:42 -0700 Subject: [PATCH] Foreground Connection Delay Adjustment Nucleic-Session: CB658C4F-A19F-447C-B313-7CB21783BD94 Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index d78892a..802c3fd 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -203,6 +203,121 @@ final class RemoteStore: ObservableObject { 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? + /// 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 func loadLastOpened() -> [SessionID: Date] { guard let raw = UserDefaults.standard.dictionary(forKey: lastOpenedKey) else { return [:] }