Skip to content

Resync the session when the app returns to the foreground - #17

Open
PanAndDuck wants to merge 4 commits into
xintaofei:mainfrom
PanAndDuck:ios/refresh-session-on-foreground
Open

PanAndDuck wants to merge 4 commits into
xintaofei:mainfrom
PanAndDuck:ios/refresh-session-on-foreground

Conversation

@PanAndDuck

@PanAndDuck PanAndDuck commented Sep 17, 2026 •

Copy link
Copy Markdown

Summary

  • Backgrounding the app suspends (and often kills) the live WebSocket, but nothing observed the scene returning to active, so a session left open during that time just sat frozen — the only way to see new content was to back all the way out to the list and re-enter, which tears down and rebuilds the whole screen via RootView's .id(...).
  • SessionDetailView now watches scenePhase and calls a new refreshOnForeground() on a real background round-trip. A wasBackgrounded latch is used rather than comparing adjacent phases, since returning to the foreground reports .background → .inactive → .active, not a direct .background → .active.
  • refreshOnForeground() branches on whether a turn was actually live:

Related to #10

@Adam-Dalloul's #10 independently diagnosed the same root cause for the "turn was actively streaming" case — iOS suspends the socket without closing it, and the silent-reconnect backoff can't make progress while suspended — and fixed it more surgically (reconnect the same live turn in place, plus a real bug in consume's .snapshot handling that was dropping content produced during the outage). I've adapted that approach into this branch's resumeStreamAfterForeground()/consume() changes rather than duplicating it, with credit in the commit message. This PR's earlier commits also fix a case #10 doesn't cover (nothing was streaming, but the session changed elsewhere while backgrounded) and a duplicate-stuck-"in progress"-state bug that showed up while building the full-refetch fallback. Happy to drop the overlapping part of this PR if you'd rather land #10 as the canonical fix for the live-turn case — just say which shape you'd prefer.

Test plan

  • Builds and runs on iOS Simulator
  • Builds, installs, and runs on a physical device
  • Backgrounding an idle session and returning re-syncs its content without needing to leave and re-enter
  • Backgrounding mid-reply no longer leaves a stuck "in progress" placeholder alongside the (separately fetched) finished reply
  • Reconnecting a still-streaming reply in place (the part adapted from fix(session): recover the live stream after a drop or backgrounding #10) — device test in progress

Master added 4 commits September 18, 2026 03:49
Backgrounding the app suspends (and often kills) the live WebSocket, but
nothing observed the scene returning to active, so a session left open
during that time just sat frozen — the only way to see new content was
to back all the way out to the list and re-enter, which tears down and
rebuilds the whole screen via RootView's `.id(...)`.

`SessionDetailView` now watches `scenePhase` and calls the view model's
new `refreshOnForeground()` on a `.background` -> `.active` transition
specifically (not just any transition to `.active`, which also fires for
the transient `.inactive` blips a sheet/alert/control-center pull
causes).

`load()`'s `.existing` branch is refactored into a shared `syncExisting`
so both paths reuse the exact same fetch + reattach-if-live logic instead
of a parallel implementation; `refreshOnForeground` skips the `.loading`
phase flip and the forced re-pin-to-bottom, so resuming the app doesn't
visibly reload the transcript out from under someone reading history,
and a background failure leaves the existing transcript up rather than
replacing it with an error screen.

Verified: builds and runs on Simulator and a physical device.
Returning from the background almost always lands on .active via an
intermediate .inactive frame first (.background -> .inactive ->
.active), so the original check (comparing only the immediately-prior
phase) matched the direct .background -> .active case but missed the
far more common two-step one. A device only fired the refresh
occasionally, and inconsistently, as a result.

wasBackgrounded now remembers that a .background frame happened at all
since the last refresh, regardless of how many .inactive frames follow
it, and is consumed on the next .active. A sheet/alert/control-center
pull's own .active -> .inactive -> .active blip never sets the flag, so
it still doesn't trigger a spurious refresh.

Verified: builds and runs on Simulator and a physical device.
Backgrounding the app while a turn was streaming leaves liveTurn (and its
stream) in place — iOS suspends the socket rather than closing it, so
nothing locally notices it die. reattachIfLive no-ops whenever liveTurn
!= nil (its "we're already streaming" fast path, correct at a fresh
load() but not when called from refreshOnForeground), so the freshly-
fetched final reply rendered ALONGSIDE a permanently-stuck "still in
progress" placeholder and Stop button — the compose bar never returned
to its send state.

refreshOnForeground now discards that stale local tracking first
(WITHOUT sending the server a cancel — the turn may genuinely still be
running), so reattachIfLive's subsequent discovery reflects the true
current state: a fresh stream if it's still going, or nothing further
if the server-fetched turns already carry its finished reply.

Verified: builds and runs on Simulator and a physical device.
Credit: this reconnect-in-place approach (resumeStreamAfterForeground,
the .snapshot mid-turn-adoption fix in consume) is adapted from
Adam-Dalloul's xintaofei#10 (fix/live-stream-recovery), which independently
diagnosed the same underlying problem this branch's earlier commits
worked around with a blunter full re-fetch: iOS suspends the socket
without closing it, the silent-reconnect backoff can't make progress
while suspended, and a mid-turn reconnect's snapshot wasn't being
adopted into the live turn it belongs to.

refreshOnForeground now branches on whether a turn is actually live:

- If so, resumeStreamAfterForeground() reconnects the SAME liveTurn /
  connection in place (resetting the reconnect budget iOS silently
  burned while suspended) instead of discarding and re-fetching — so
  whatever the agent produced while we were away arrives as a
  continuation of the turn already on screen, not a separate fetch
  racing a stuck "in progress" placeholder (the bug fixed two commits
  ago in this branch).
- Otherwise (nothing was streaming), it falls back to this branch's
  existing full re-sync, which xintaofei#10 does not cover — an idle session
  renamed, or messaged from another client, while backgrounded.

Verified: builds and runs on Simulator and a physical device.
@Adam-Dalloul

Copy link
Copy Markdown

Keep it here, one PR beats two. I'll close #10 once this lands.

Could you put a trailer on that commit so the attribution sticks? Co-authored-by: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com>

One thing worth a look. Now that a live turn takes the resume path, discardStaleLiveState() can only fire on a turn that already ended locally: finalize, failLive and cancel all clear isTurnActive alongside isStreaming, so nothing streaming reaches that branch anymore. What does reach it is a failed or cancelled turn, and failLive keeps that one on screen on purpose, partial output plus the error. Backgrounding and coming back drops it, sendState = .idle takes the banner with it, and the error text is client side so the refetch can't restore it. Same for a reply that finished streaming but hasn't reconciled yet, which the send path guards with promoteUnreconciled. Probably either drop the discard or promote a non-empty turn first.

Jonathan-Asher pushed a commit to Jonathan-Asher/codeg-ios that referenced this pull request Oct 2, 2026
Squashed from upstream xintaofei/codeg-ios PR xintaofei#17 (PanAndDuck), applied on
top of PR xintaofei#10:

- cb09583 Resync the session when the app returns to the foreground
- 2e0b9eb Fix foreground refresh missing most real resumes
- 0eda348 Fix duplicate stuck "in progress" state after backgrounding mid-turn
- 091977d Reconnect a live turn in place on foreground, adapted from xintaofei#10

PR xintaofei#17 already carries xintaofei#10's live-turn recovery (the `.snapshot` adoption
in `consume` and `resumeStreamAfterForeground`) verbatim, so the conflict
is resolved by taking xintaofei#17's version of both files: the view's scenePhase
handler now routes through `refreshOnForeground()`, which reconnects a
live turn in place via xintaofei#10's path and otherwise re-fetches the session.
Jonathan-Asher added a commit to Jonathan-Asher/codeg-ios that referenced this pull request Oct 2, 2026
The README now opens by saying this is a fork of xintaofei/codeg-ios
and why. NOTICE keeps the upstream attribution and states that Codeg
Plus is a modified version. docs/FORK.md records:

- the app identity and where to change it;
- the push entitlements and the Apple-side setup they need;
- each upstream PR carried, with its author, and how xintaofei#10 and xintaofei#17 were
  reconciled;
- how to turn on the TestFlight job, including running it on a
  self-hosted Mac runner.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants