fix(chat): keep generating while the tab is hidden

Root cause (task 01): none of C1-C3 - in Chromium 151 (real mode) a
merely-hidden tab neither stops the stream (frames arrive at full rate;
turn completes) nor fires pagehide on tab switch; C1's double-record
path was proven latent via a synthetic pagehide (trigger is
browser-dependent, e.g. Safari) and C2 (the 120s pre-token guard) was
confirmed to fire while hidden.

- C1: the pagehide partial-persist is correlated with the turn's settle
  (leavePartialIndex) - the done/stop settle REPLACES it in place
  (identity-guarded rememberBrainTurn in-place mode), so bor.chat.v1
  and the auto-saved saved_chats row keep exactly ONE brain turn per
  question; a real navigation never runs a settle, so the leave-save
  is unchanged.
- C2: the visibility re-arm gives the still-armed pre-token guard a
  fresh TURN_TIMEOUT_MS when the tab returns to visible - hidden time
  no longer counts toward the 120s guard.
- Phase-48 teardown contract untouched: Stop / tab close / real
  navigation still cancel the fetch and stop the model.
- Unit pins: tests/unit/test_frontend_hidden_tab.py (the app.js
  mechanisms without a browser).
- E2E pins: tests/e2e/test_hidden_tab_stream.py - synthetic pagehide
  mid-stream completes exactly once with one brain turn (localStorage
  + auto-saved row), reload restores one bubble, no-event baseline,
  and the fake-clock pre-token guard re-arm (discriminating: fails
  with the re-arm disabled).
This commit is contained in:
2026-09-05 14:34:33 -04:00
parent 45c3fa2863
commit a16130c71d
28 changed files with 4127 additions and 10 deletions
@@ -1,30 +0,0 @@
# Task 01 — Repro + root-cause decision tree (no permanent code changes)
**Phase:** `73_hidden_tab_stream` · **Source:** `TODO.md:3` — "Clicking on another tab while an answer is generating stops that answer from being generated. Reponses should continue to generate unless you outright close the tab."
**Story:** n/a (TODO-derived)
## Objective
Pin down the mechanism that stops a generating answer when the user switches to another browser tab, with concrete evidence (client events, network lifecycle, server log) — so task 02 fixes the right thing instead of guessing.
## Work
1. Manual repro (dev server `uv run uvicorn app.main:app --reload` + a real Chromium/Firefox with DevTools; use a question that streams for 30–60s+ so the window is easy to hit): start the answer, click ANOTHER browser tab for 30–120s, return. Record: does the bubble keep growing while hidden? Does it complete on return? Screenshot each state.
2. Client instrumentation (DevTools console — temporary, all removed before commit): log with timestamps, while switching tabs mid-turn,
- `window` `pagehide` / `pageshow` / `visibilitychange` (capture `event.persisted` and `document.visibilityState`),
- each SSE frame arrival inside `readSSE` (`frontend/assets/app.js` ~L1044) vs `document.visibilityState` at arrival (do frames keep arriving while hidden?),
- whether the error copy "That's taking a long time — the answer may be stuck." (`TURN_TIMEOUT_MS` guard, `armTurnTimeout` ~L982) or "The stream ended before my answer finished — try again?" (stream-drop guard ~L2060) appears.
3. Server evidence: watch the uvicorn log for the phase-48 line `chat: turn cancelled question=… total_ms=…` (`app/api/chat.py`, the stream `finally`). That line proves the SSE connection was really torn down (browser or proxy cancelled it); its ABSENCE while the tab is merely hidden proves the client JS side stopped consuming/accepting the frames.
4. Decision tree — record which fired:
- **C1 — `pagehide` on tab switch:** the browser fires `pagehide` when a tab is merely hidden (spec-compliant behavior; the app's handler at `frontend/assets/app.js` ~L2162 then persists the partial through `rememberBrainTurn`). When `done` later appends the full answer (~L2041), the `conversation` record (`bor.chat.v1` in localStorage) holds TWO brain turns for the one question — the partial AND the full answer. Restore/share renders both; the saved chat (`saved_chats.messages`) is corrupted the same way. This is the corruption branch task 02 fixes unconditionally.
- **C2 — 120s guard while hidden:** the pre-token `TURN_TIMEOUT_MS` timer fired during the hidden window (only possible before the first thinking/delta/retry frame, which is when it clears — background timer throttling can delay it past the moment the first token would have arrived). The catch reads the guard's abort as "stuck" and errors the turn.
- **C3 — real connection cancel:** the network panel shows `POST /api/chat` cancelled (or the server logged `turn cancelled`) while the tab was merely hidden → the browser (or an intermediate proxy, e.g. a `proxy_read_timeout` on a deployment path) dropped the connection. On desktop Chrome/Firefox this should NOT happen; if it does only through a specific proxy, record that and keep the app-side fix to C1/C2 (deployment fixes are out of scope — name the proxy in the commit message).
- **C4 — none of the above:** document exactly what was observed (frames stopped arriving while hidden with no error frames, etc.) in the commit message; task 02 then applies the C1 hardening + the E2E pin only.
5. Leave the repo clean: instrumentation removed, `git status` shows no source changes from this task (only the phase-dir planning files, which task 03 commits together with the fix).
## Testing & Quality
- No permanent code in this task — `uv run pytest` must stay green unchanged (the gate is still run).
- Evidence to keep: the identified candidate (C1/C2/C3/C4 + one-line rationale) — it goes into the phase-73 commit message body (written in task 03).
## Completion Criteria
- [ ] Repro performed in a real browser with DevTools; the event/frame/log evidence above is captured (screenshots may live in `.agents/screenshots/`).
- [ ] One candidate (C1–C4) is identified with its evidence; C1's double-record path (if `pagehide` fires on tab switch in the test browser) is demonstrated by inspecting the `bor.chat.v1` record after a tab switch + completion.
- [ ] No permanent code changes; full suite green.
@@ -1,28 +0,0 @@
# Task 02 — Fix: a hidden tab never stops a turn; the pagehide partial is replaced, never duplicated
**Phase:** `73_hidden_tab_stream` · **Source:** `TODO.md:3` — "Clicking on another tab while an answer is generating stops that answer from being generated. Reponses should continue to generate unless you outright close the tab."
**Story:** n/a (TODO-derived)
## Objective
Make the client survive a merely-hidden tab: the in-flight stream keeps filling the live bubble and, when the turn settles, the `pagehide` partial is *replaced in place* by the final record — so `conversation` (and the auto-saved `saved_chats` row) holds exactly one brain turn per question, whatever events the browser fired while the tab was away.
## Work
1. `frontend/assets/app.js` — correlate the pagehide partial with the turn's settle (the C1 hardening; applies unconditionally, it is a real latent bug):
- Add one module-scope variable next to the other phase-20/48 turn locals (`persistedOnLeave` ~L952 area): `leavePartialIndex = -1` — the index in `conversation` of the brain record the `pagehide` handler pushed for THIS turn, else `-1`.
- In the `pagehide` handler (~L2162): after `rememberBrainTurn(acc, { thinking: thinkingAcc || undefined })` runs, set `leavePartialIndex = conversation.length - 1` (the record it just pushed). Reset `leavePartialIndex = -1` at the top of `runTurn` (turn-local, like the other locals).
- In the `done` settle (~L2041) and in the stop-path settle (~L2079, where the partial is persisted with the `stopped` marker): if `leavePartialIndex >= 0` AND `conversation[leavePartialIndex]?.who === "brain"` AND the turn is settling with its OWN final record, REPLACE that entry in place (`conversation[leavePartialIndex] = { …settledRecord }`) instead of `push`ing a second brain record. `rememberBrainTurn` currently pushes — either add an optional in-place mode to it or inline the replace at the two settle sites; keep `saveConversation()` + `persistConversation()` running on the replaced record (the auto-save refreshes the row exactly once).
- Invariant to keep: a REAL navigation (page actually unloads) never runs a settle, so the partial stays persisted exactly as today — the leave-save behavior is unchanged.
- Identity guard: the replace only fires when the recorded index still points at a brain record — a New-Chat click or restore between pagehide and settle (impossible today, but the guard makes the invariant explicit) falls back to the append.
2. `frontend/assets/app.js` — ONLY IF task 01 identified C2 (guard fired during a hidden pre-token window): make hidden time not count toward `TURN_TIMEOUT_MS`. On `document.addEventListener("visibilitychange", …)`: when the page becomes visible again and `turnTimeout` is still armed (the pre-token window — it is cleared on the first thinking/delta/retry frame), re-arm it with a fresh `TURN_TIMEOUT_MS` (so only visible pre-token time counts). Skip this step entirely if C2 did not fire — the guard's behavior is owner-locked phase-17/48 territory and must not move without the evidence.
3. `frontend/assets/app.js` — do NOT touch: the Stop-button abort (`turnAbort.abort()`), `cancelStream(res)`, the phase-48 teardown contract (real tab close / navigation still cancels the fetch and the server's `chat_stream` teardown still stops the model — that is the TODO's "unless you outright close the tab" and it already works).
4. - ASSUMPTION A1 (owner-confirmed 2026-09-08): the fix scope is client-side — a merely-HIDDEN tab must never stop a turn; only tab close, real navigation, or the Stop button abort. The pagehide partial-persist stays for real navigations/bfcache but is correlated so the settle replaces it. No server changes in this phase unless task 01's C3 evidence names a server defect (if so, record it; deployment/proxy tuning is out of scope).
## Testing & Quality
- Frontend-only: no `app/` logic changes — the >90% `app/` coverage floor is unaffected (still run the full suite).
- The behavioral pin lands in task 03's E2E (there is no JS unit-test infra in this repo — Playwright is the frontend gate).
- Manual re-verify against the task-01 repro steps in the real browser (switch tabs mid-stream → answer completes; `bor.chat.v1` shows one brain turn; reload → one bubble).
## Completion Criteria
- [ ] `conversation` can never hold two brain records for one question via the pagehide→settle path (read the code path: the replace is the only write after a pagehide partial; the identity guard is present).
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
- [ ] Real-departure behavior unchanged (Stop / close / navigation still cancel — verified by the phase-48 and persistence E2E suites in task 03).