Merge branch 'dev' into canary

This commit is contained in:
2026-07-09 16:55:01 -07:00
2 changed files with 166 additions and 8 deletions
@@ -38,6 +38,11 @@ final class RemoteStore: ObservableObject {
if case .connected = self { return true } if case .connected = self { return true }
return false 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 @Published private(set) var connectivity: Connectivity = .unpaired
@@ -198,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 [:] }
@@ -324,6 +444,9 @@ final class RemoteStore: ObservableObject {
if isPaired { if isPaired {
// Show the saved chat history immediately, before any host connects. // Show the saved chat history immediately, before any host connects.
if sessions.isEmpty { sessions = cachedSummaries } 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() reconnect()
} }
} }
@@ -359,6 +482,11 @@ final class RemoteStore: ObservableObject {
func onForeground() { func onForeground() {
endBackgroundHold() endBackgroundHold()
guard !demoMode, isPaired else { return } 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 // 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 // push-to-starting it (UX_IOS §5.3). Sent to already-live hosts; ones still reconnecting get
// it via `didUpdate` once live. // it via `didUpdate` once live.
@@ -641,6 +769,9 @@ final class RemoteStore: ObservableObject {
self.rebuildAggregate() self.rebuildAggregate()
self.refreshAggregate() self.refreshAggregate()
self.flushPendingNotificationDecision() 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 // 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 // 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. // the glance cold when work starts before the app is opened.
@@ -796,18 +927,35 @@ final class RemoteStore: ObservableObject {
?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])) ?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []))
setIfChanged(\.modelCatalog, ctx?.modelCatalog ?? .empty) setIfChanged(\.modelCatalog, ctx?.modelCatalog ?? .empty)
var targetConnectivity: Connectivity
var targetScope: DeviceScope
if let id = openSessionHostID, let conn = connections[id] { if let id = openSessionHostID, let conn = connections[id] {
// While a transcript is open, the composer/controls act on *that* Mac — its connectivity // While a transcript is open, the composer/controls act on *that* Mac — its connectivity
// gates send and its scope drives the control affordances. // gates send and its scope drives the control affordances.
setIfChanged(\.connectivity, conn.connectivity) targetConnectivity = conn.connectivity
setIfChanged(\.grantedScope, conn.grantedScope) targetScope = conn.grantedScope
} else { } else {
setIfChanged(\.connectivity, aggregateConnectivity()) targetConnectivity = aggregateConnectivity()
// Optimistic new-chat gating: enabled if *any* Mac grants control (the owning Mac still // Optimistic new-chat gating: enabled if *any* Mac grants control (the owning Mac still
// enforces scope when the intent lands there). // enforces scope when the intent lands there).
setIfChanged(\.grantedScope, connections.values targetScope = connections.values
.filter { $0.connectivity.isLive }.map(\.grantedScope).max() ?? .approve) .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 /// 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 { if demoMode {
demoHandle(.approvalRespond(approval.id, decision)) demoHandle(.approvalRespond(approval.id, decision))
} else { } 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 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. /// to the first live Mac. Demo has no connections and mutates the seeded aggregate directly.
private func send(_ msg: ClientMsg) { private func send(_ msg: ClientMsg) {
if demoMode { demoHandle(msg); return } 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 { switch msg {
// Session-owning intents → the Mac that has this session. // Session-owning intents → the Mac that has this session.
case .subscribe(let s): case .subscribe(let s):
@@ -153,7 +153,7 @@ struct SettingsView: View {
Button { Button {
showScanner = true showScanner = true
} label: { } label: {
Label("Join an Orbital mesh", systemImage: "qrcode.viewfinder") Label("Join an Antimatter mesh", systemImage: "qrcode.viewfinder")
} }
Button { Button {
showManualPair = true showManualPair = true
@@ -454,7 +454,7 @@ struct AddDeviceView: View {
} header: { } header: {
Text("Scan to join") Text("Scan to join")
} footer: { } footer: {
Text("On a new iPhone or iPad, open Nucleic Remote ▸ Join an Orbital mesh and scan this. " Text("On a new iPhone or iPad, open Nucleic Remote ▸ Join an Antimatter mesh and scan this. "
+ "It joins the whole group — every Mac and device here.") + "It joins the whole group — every Mac and device here.")
} }