diff --git a/CodegiOS/Features/SessionDetail/SessionDetailView.swift b/CodegiOS/Features/SessionDetail/SessionDetailView.swift index c0b3d29..f932ff6 100644 --- a/CodegiOS/Features/SessionDetail/SessionDetailView.swift +++ b/CodegiOS/Features/SessionDetail/SessionDetailView.swift @@ -20,10 +20,15 @@ struct SessionDetailView: View { @State private var model: SessionDetailViewModel @Environment(\.dismiss) private var dismiss + @Environment(\.scenePhase) private var scenePhase @State private var showRename = false @State private var renameText = "" @State private var showDetails = false @State private var showDeleteConfirm = false + /// Latched when the app is suspended, so returning to the foreground can + /// re-attach a stream iOS killed while we were away (see the `scenePhase` + /// handler below). + @State private var wasBackgrounded = false init(server: ServerProfile, client: CodegClient, conversationID: Int, onOpenSession: ((NewSessionRequest) -> Void)? = nil) { @@ -131,6 +136,29 @@ struct SessionDetailView: View { } .task { await model.load() } .onDisappear { model.teardown() } + // iOS suspends the app on screen lock / app switch, which kills the event + // socket while a reply is still streaming. Nothing else re-attaches, so the + // transcript would sit on a frozen shimmer until the session is reopened. + // + // Only a real background round-trip counts: a trip through `.inactive` + // alone (control center, the app-switcher peek, an alert) never suspends + // us, and reconnecting there would churn a perfectly healthy socket. The + // latch is why this doesn't just compare against the previous phase — + // returning to the foreground can report `.background` → `.inactive` → + // `.active`, so the phase we come back from is not reliably `.background`. + .onChange(of: scenePhase) { _, phase in + switch phase { + case .background: + wasBackgrounded = true + case .active: + if wasBackgrounded { + wasBackgrounded = false + model.resumeStreamAfterForeground() + } + default: + break + } + } // Haptics — the app's marquee "felt" moments, all keyed off existing // @Observable state. Vocabulary: success = a reply completed, error = it // failed, warning = the agent needs you (a permission / question card diff --git a/CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift b/CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift index 1a23eb3..d101eb6 100644 --- a/CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift +++ b/CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift @@ -932,6 +932,11 @@ final class SessionDetailViewModel { /// consumer (its `generation` no longer current) ignores its terminal frames /// so it can't end a turn that a newer stream now owns. private func consume(stream: EventStream, connectionID conn: String, live: LiveTurn, generation: Int) async { + // The turn this consumer feeds. A mid-turn reconnect REPLACES it with the + // turn rebuilt from the fresh attach snapshot (see `.snapshot` below), so + // every frame after that point lands on the turn the transcript is showing + // rather than on an orphaned placeholder. + var live = live for await frame in stream.frames { if Task.isCancelled { return } let isCurrent = generation == streamGeneration @@ -955,7 +960,34 @@ final class SessionDetailViewModel { // with no way to approve. Mirrors `consumeReattach` + the web client. // Skipped during the INITIAL attach handshake (readyContinuation set) // — that snapshot is the pre-prompt state and carries no live card. - if isCurrent, readyContinuation == nil, isTurnActive { restorePending(from: snap) } + if isCurrent, readyContinuation == nil, isTurnActive { + // The agent kept replying while the socket was down and those + // events reached no subscriber. The snapshot's `live_message` is + // the COMPLETE in-flight reply, so adopt it wholesale instead of + // keeping the turn that stopped at the drop — otherwise whatever + // was produced during the outage stays missing from the open + // screen until the user leaves and re-enters the session (the + // reply is there on re-entry, which is exactly the tell). + // + // Deliberately does NOT set `liveTurnFromReattach` — the one + // thing `consumeReattach` does that must not be copied here. That + // flag drops the persisted assistant turns trailing the last user + // prompt, and on the send path those are the PREVIOUS turn's + // finished reply: `turns` is never refetched mid-turn, so it + // holds no partial copy of THIS reply to double-render. + if let rebuilt = buildLiveTurn(from: snap) { + live = rebuilt + liveTurn = rebuilt + // Recovery parked the status line on "connecting"; the reply + // is streaming again, so say so (without stomping a tool run). + if case .running = sendState {} else { sendState = .thinking } + // Follow the new content only for a reader who is still + // pinned — a socket blip must not yank someone who scrolled + // up, unlike a fresh open or the user's own send. + requestScrollToBottom() + } + restorePending(from: snap) + } if isCurrent { resumeReady(throwing: nil) } case .replay: streamReconnects = 0 @@ -981,7 +1013,12 @@ final class SessionDetailViewModel { // re-attach silently, matching the web client. guard isTurnActive else { return } if reason == "connection_gone" { - Task { [weak self] in await self?.reconcileOrFail(live: live, reason: reason) } + // Bind before the closure: `live` is a `var` now (a reconnect can + // adopt the snapshot's turn) and a `Task` may only capture an + // immutable local. `consumeReattach` gets this for free from its + // `guard let live`. + let current = live + Task { [weak self] in await self?.reconcileOrFail(live: current, reason: reason) } } else { reconnectStream(into: live, connectionID: conn, reason: reason) } @@ -1432,6 +1469,36 @@ final class SessionDetailViewModel { // MARK: - Stream recovery (transient drops) + /// Restart stream recovery after the app returns to the foreground. iOS + /// suspends the process on screen lock / app switch, which kills the event + /// socket, and the silent-reconnect backoff is a `Task.sleep` that makes no + /// progress while suspended — so a turn that was streaming comes back to a dead + /// socket having quietly spent its reconnect budget on attempts that never + /// reached the server. The reply then sits frozen mid-stream indefinitely with + /// no error, and only leaving and re-entering the session recovers it. + /// + /// Re-attaching targets the SAME server-side ACP connection (it outlives the + /// WebSocket) and the fresh attach snapshot carries everything the agent + /// produced while we were away, so nothing is lost by reconnecting here. + /// + /// No-op unless a turn is actually streaming: an idle screen has nothing to + /// recover, and a session opened cold is already covered by `reattachIfLive`. + func resumeStreamAfterForeground() { + // A send still shaking hands owns its own recovery (its ready timeout fails + // it cleanly). Closing that socket here would cancel the handshake and + // strand a turn whose prompt the server never received. + guard readyContinuation == nil else { return } + guard isInFlight, isTurnActive, let live = liveTurn, let conn = connectionID else { return } + // The attempts burned while suspended were never real attempts — give + // recovery its full allowance back, or a long stretch in the background + // leaves nothing left to reconnect with. + streamReconnects = 0 + // `liveTurnFromReattach` records which consumer owns this turn: it is true + // only when `consumeReattach` built it from a snapshot. Routing back to the + // same one keeps the transcript's in-flight suppression consistent. + reconnectStream(into: live, connectionID: conn, reason: nil, reattach: liveTurnFromReattach) + } + /// Recover a dropped event socket mid-turn by re-opening it and re-attaching /// to the SAME server-side ACP connection — which outlives the WebSocket. /// Mirrors the web client, whose socket auto-reconnects and re-subscribes its