diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index cc7e44f..252ad64 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -38,6 +38,11 @@ final class RemoteStore: ObservableObject { if case .connected = self { return true } return false } + /// The live transport when connected — for the optimistic overlay's "Connected · …" label. + var transport: SyncTransportHint? { + if case .connected(let t) = self { return t } + return nil + } } @Published private(set) var connectivity: Connectivity = .unpaired @@ -198,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 [:] } @@ -324,6 +444,9 @@ final class RemoteStore: ObservableObject { if isPaired { // Show the saved chat history immediately, before any host connects. if sessions.isEmpty { sessions = cachedSummaries } + // Assume connected while the first handshake completes, so launch doesn't open on a + // grayed-out "Disconnected" shell for the second it takes to come up. + beginOptimisticWindow() reconnect() } } @@ -359,6 +482,11 @@ final class RemoteStore: ObservableObject { func onForeground() { endBackgroundHold() guard !demoMode, isPaired else { return } + // Pretend we're still connected for a few seconds while the (possibly OS-killed) sockets + // re-handshake — the reconnect below is fast, and flashing "Disconnected" in the meantime + // just feels janky. Set before `reconnect()` so the connecting/reconnecting states it + // produces are overlaid with the last-live projection (see `rebuildAggregate`). + beginOptimisticWindow() // Foreground again: the app self-creates its own Live Activity now, so tell the hosts to stop // push-to-starting it (UX_IOS §5.3). Sent to already-live hosts; ones still reconnecting get // it via `didUpdate` once live. @@ -641,6 +769,9 @@ final class RemoteStore: ObservableObject { self.rebuildAggregate() self.refreshAggregate() self.flushPendingNotificationDecision() + // A link just came up — replay any intents the user took while we were optimistically + // pretending to be connected. + self.flushPendingActions() // A host that just connected (or reconnected) needs the current Live Activity token // so it can push while the phone is away — and the push-to-start token so it can create // the glance cold when work starts before the app is opened. @@ -796,18 +927,35 @@ final class RemoteStore: ObservableObject { ?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])) setIfChanged(\.modelCatalog, ctx?.modelCatalog ?? .empty) + var targetConnectivity: Connectivity + var targetScope: DeviceScope if let id = openSessionHostID, let conn = connections[id] { // While a transcript is open, the composer/controls act on *that* Mac — its connectivity // gates send and its scope drives the control affordances. - setIfChanged(\.connectivity, conn.connectivity) - setIfChanged(\.grantedScope, conn.grantedScope) + targetConnectivity = conn.connectivity + targetScope = conn.grantedScope } else { - setIfChanged(\.connectivity, aggregateConnectivity()) + targetConnectivity = aggregateConnectivity() // Optimistic new-chat gating: enabled if *any* Mac grants control (the owning Mac still // enforces scope when the intent lands there). - setIfChanged(\.grantedScope, connections.values - .filter { $0.connectivity.isLive }.map(\.grantedScope).max() ?? .approve) + targetScope = connections.values + .filter { $0.connectivity.isLive }.map(\.grantedScope).max() ?? .approve } + if targetConnectivity.isLive { + // Truly live — remember this projection so the optimistic overlay can present it across + // the next foreground/relaunch. + captureLastLive(transport: targetConnectivity.transport ?? .lan, scope: targetScope) + } else if isOptimisticActive, let last = lastLive { + // Right after launch/foreground the link is re-handshaking (or a suspended socket woke up + // looking dead). Rather than flash "Disconnected" for the beat it takes to reconnect, keep + // presenting the last known-good live state — the app assumes it's connected. Actions + // taken now are queued in `send` and replayed once a link is up (or discarded when the + // window lapses without one). + targetConnectivity = .connected(last.transport) + targetScope = last.grantedScope + } + setIfChanged(\.connectivity, targetConnectivity) + setIfChanged(\.grantedScope, targetScope) } /// Assign a `@Published` property only when the value actually differs, so a no-op rebuild @@ -1348,7 +1496,10 @@ final class RemoteStore: ObservableObject { if demoMode { demoHandle(.approvalRespond(approval.id, decision)) } else { - connection(owningSession: approval.sessionID)?.send(.approvalRespond(approval.id, decision)) + // Through `send` so an Allow/Deny tapped during the optimistic window is queued and + // replayed on connect (a live host resolves it; others no-op on the unknown id), rather + // than dropped because the owning Mac's socket is mid-reconnect. + send(.approvalRespond(approval.id, decision)) } openApprovals.removeAll { $0.id == approval.id } // optimistic dismiss; host confirms } @@ -1476,6 +1627,13 @@ final class RemoteStore: ObservableObject { /// to the first live Mac. Demo has no connections and mutates the seeded aggregate directly. private func send(_ msg: ClientMsg) { if demoMode { demoHandle(msg); return } + // Optimistic window (post launch/foreground): if no Mac is live yet but we're presenting a + // pretend-connected UI, hold the user's intent and replay it the instant a link comes up + // rather than dropping it silently. Discarded if the window lapses without a connection. + if !hasLiveConnection, isOptimisticActive, isQueueableIntent(msg) { + pendingActions.append((msg, Date())) + return + } switch msg { // Session-owning intents → the Mac that has this session. case .subscribe(let s):