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
+95 -10
View File
@@ -242,6 +242,25 @@
* a successful auto-save re-stamp (both make the row/conversation no
* longer the one the banner describes).
*
* A hidden tab never stops a turn (phase 73, TODO.md L3): a merely-HIDDEN
* tab (switched away from) keeps the stream filling the live bubble and
* the turn completes when the user returns — only tab close, real
* navigation, or the Stop button aborts (the phase-48 teardown contract,
* untouched). Two hardenings: (1) the pagehide partial-persist (phase 20)
* is CORRELATED with the turn's settle — `leavePartialIndex` records the
* index of the brain record the pagehide handler pushed for this turn,
* and the done/stop settle REPLACES that entry in place (identity-guarded
* on the record still being a brain record) instead of appending a second
* brain turn, so the saved record holds exactly one brain turn per
* question even on browsers that fire pagehide on a merely-hidden tab
* (e.g. Safari; Chromium fires only visibilitychange — task 01); a REAL
* navigation never runs a settle, so the leave-save is unchanged there.
* (2) The 120s pre-token guard counts only VISIBLE time: when the tab
* returns to visible with the guard still armed (it clears on the first
* thinking/delta/retry frame), it re-arms with a fresh TURN_TIMEOUT_MS —
* a slow first frame arriving while the tab was hidden past the deadline
* no longer errors the turn (task 01, C2 confirmed).
*
* All DOM ids match frontend/index.html.
*/
@@ -943,6 +962,7 @@ let uiState = UI_STATE.idle;
let thinkingClock = 0; // setInterval id — elapsed-seconds hint
let thinkingStart = 0; // Date.now() when "thinking" began
let turnTimeout = 0; // setTimeout id — 120s pre-token guard
let turnTimeoutCb = null; // the guard's callback — lets visibilitychange re-arm it (phase 73)
/* Turn accumulators + the navigate-away flag (phase 20, owner choice
* 2026-08-24 A1): module scope because the `pagehide` handler reads them
* while a turn is still in flight; reset per turn at the top of
@@ -950,6 +970,18 @@ let turnTimeout = 0; // setTimeout id — 120s pre-token guard
let acc = ""; // accumulated answer text this turn
let thinkingAcc = ""; // accumulated thinking text (persisted with the turn)
let persistedOnLeave = false; // pagehide partial-persist at most once
/* Phase 73 (task 02, TODO.md L3): the pagehide partial is CORRELATED with
* the turn's settle. `leavePartialIndex` is the index in `conversation`
* of the brain record the `pagehide` handler pushed for THIS turn (else
* -1): when the turn later settles (done | stop), its final record
* REPLACES that entry in place (rememberBrainTurn's in-place mode) instead
* of appending a second brain record — one brain turn per question, even
* on browsers that fire pagehide on a merely-hidden tab (C1, task 01).
* Turn-local: reset at the top of runTurn, written only by the pagehide
* handler. The replace is identity-guarded (the index must still point at
* a brain record) — a New-Chat click or restore between pagehide and
* settle falls back to the append. */
let leavePartialIndex = -1; // index of this turn's pagehide partial (-1 = none)
/* Phase 48 (2026-08-29, TODO.md L3): the user-stop machinery.
* `turnAbort` owns the in-flight fetch (the Stop button aborts it; the
* 120s guard aborts the same controller as its backstop); `stoppedByUser`
@@ -981,6 +1013,7 @@ function startThinkingClock() {
function armTurnTimeout(onTimeout) {
clearTurnTimeout();
turnTimeoutCb = onTimeout;
turnTimeout = setTimeout(onTimeout, TURN_TIMEOUT_MS);
}
@@ -989,8 +1022,27 @@ function clearTurnTimeout() {
clearTimeout(turnTimeout);
turnTimeout = 0;
}
turnTimeoutCb = null;
}
/* Phase 73 (task 02; task 01 C2 — confirmed): a merely-HIDDEN tab must
* never stop a turn, but the 120s pre-token guard is a plain setTimeout,
* so hidden time counted toward it: a turn whose first frame lands while
* the tab is hidden past the 120s mark errored with the "stuck" copy
* (the user perceives "switching tabs killed the answer"). When the tab
* returns to VISIBLE with the guard still armed (it is only armed in the
* pre-token window — cleared on the first thinking/delta/retry frame and
* on every terminal transition), re-arm it with a FRESH TURN_TIMEOUT_MS:
* only visible pre-token time counts. While hidden the timer simply runs
* (and the stream itself keeps arriving at full rate — task 01 scenario
* A); real departures (close / navigation / Stop) still abort the fetch
* (phase 48, untouched). */
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible" && turnTimeout && turnTimeoutCb) {
armTurnTimeout(turnTimeoutCb);
}
});
/* The single entry point for chat feedback. Every in-flight state has a
* visible indicator; every terminal state returns the button to "Send".
* Phase 48 (owner-locked 2026-08-29): the button is ONE control with two
@@ -1669,13 +1721,28 @@ async function regenerateStaleChat() {
Phase 17: meta.thinking and phase 37: meta.tools are optional —
`undefined` drops the key from the JSON, so turns without them persist
exactly as before. An empty answer keeps the fallback/"…" text that
was actually rendered — what the user saw is what is stored. */
function rememberBrainTurn(rawText, meta) {
conversation.push({ who: "brain", text: rawText || "…", ...meta });
was actually rendered — what the user saw is what is stored.
Phase 73 (task 02): optional in-place mode — `replaceIndex` >= 0
REPLACES the record at that index instead of appending, so a settle
that follows a pagehide partial (C1) keeps exactly one brain turn per
question. Identity-guarded: 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) — otherwise it falls back to the append.
Either way the record is written once and the save points below run
once (the auto-save refreshes the row exactly once). */
function rememberBrainTurn(rawText, meta, replaceIndex = -1) {
const rec = { who: "brain", text: rawText || "…", ...meta };
if (replaceIndex >= 0 && conversation[replaceIndex]?.who === "brain") {
conversation[replaceIndex] = rec;
} else {
conversation.push(rec);
}
saveConversation();
// Phase 55 (A2): the auto-save rides the brain save point — the row
// updates with the new brain turn + metadata. The pagehide partial
// reuses this helper, so it rides the same path (no extra wiring).
// updates with the (possibly replaced) brain turn + metadata. The
// pagehide partial reuses this helper, so it rides the same path (no
// extra wiring).
persistConversation();
}
@@ -1874,10 +1941,13 @@ async function runTurn(text, { reask = false } = {}) {
let aborted = false; // the 120s guard already took the turn to error
// Phase 20: acc / thinkingAcc / persistedOnLeave live at module scope
// (the pagehide handler reads them) but reset here, so they stay
// turn-scoped exactly like the other turn locals.
// turn-scoped exactly like the other turn locals. Phase 73:
// leavePartialIndex joins them — no pagehide partial exists for this
// turn yet (the pagehide handler records it if one lands).
acc = "";
thinkingAcc = "";
persistedOnLeave = false;
leavePartialIndex = -1;
// Phase 48: a fresh abort owner per turn (cleared in the finally); the
// stop flag resets with the rest of the turn locals.
turnAbort = new AbortController();
@@ -2040,13 +2110,16 @@ async function runTurn(text, { reask = false } = {}) {
// complete (raw text + the done metadata; phase 17: + optional
// thinking, phase 37: + optional tools — `undefined` drops the
// key from the JSON).
// Phase 73 (task 02): if a pagehide partial landed mid-turn, the
// final record REPLACES it in place (leavePartialIndex = -1 when
// there was none — the append, exactly as before).
rememberBrainTurn(finalText || acc, {
thinking: thinkingAcc || undefined,
tools: toolAcc.length ? toolAcc : undefined,
deflected: !!ev.deflected,
sources: ev.sources,
suggestions: ev.suggestions,
});
}, leavePartialIndex);
lastBrainWrap = wrap; // this bubble is now the last brain answer
markLastRetryable(); // phase 49: the Retry button is last-bubble-only
} else if (ev.type === "error") {
@@ -2068,7 +2141,10 @@ async function runTurn(text, { reask = false } = {}) {
const fwrap = addMessage("brain", fallback);
appendTuneButton(fwrap);
appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path
rememberBrainTurn(fallback, {}); // persist what the user actually saw
// Phase 73: correlated like the other settles — unreachable when a
// pagehide partial exists (that needs acc, which means a wrap), but
// passed so EVERY settle write goes through the same correlation.
rememberBrainTurn(fallback, {}, leavePartialIndex); // persist what the user actually saw
lastBrainWrap = fwrap;
markLastRetryable(); // phase 49: the fallback bubble is retryable too
}
@@ -2088,11 +2164,14 @@ async function runTurn(text, { reask = false } = {}) {
persistedOnLeave = true;
appendTuneButton(wrap); // admin-only; parity with the restore path
appendStoppedNote(wrap);
// Phase 73 (task 02): the stopped partial REPLACES a pagehide
// partial (leavePartialIndex) in place — one brain turn for the
// question, marked stopped, never two records.
rememberBrainTurn(acc, {
thinking: thinkingAcc || undefined,
tools: toolAcc.length ? toolAcc : undefined,
stopped: true,
});
}, leavePartialIndex);
lastBrainWrap = wrap; // the stopped partial is the prime retry candidate
markLastRetryable(); // phase 49: Retry on the stopped partial
}
@@ -2158,7 +2237,12 @@ staleRegenBtn?.addEventListener("click", regenerateStaleChat);
* Thinking-only (no answer tokens yet) persists nothing brain-side:
* the question is already saved on send and the user can re-ask.
* `persistedOnLeave` makes this idempotent across pagehide/bfcache
* churn. */
* churn.
* Phase 73 (task 02): the partial is CORRELATED with the turn's settle —
* after the push, `leavePartialIndex` records the record's index, so the
* done/stop settle REPLACES it in place (one brain turn per question,
* C1). A REAL navigation (the page actually unloads) never runs a settle,
* so the partial stays persisted exactly as before. */
window.addEventListener("pagehide", () => {
if (persistedOnLeave) return;
if (uiState !== UI_STATE.thinking && uiState !== UI_STATE.streaming)
@@ -2166,6 +2250,7 @@ window.addEventListener("pagehide", () => {
if (!acc) return; // nothing brain-side to save yet
persistedOnLeave = true;
rememberBrainTurn(acc, { thinking: thinkingAcc || undefined });
leavePartialIndex = conversation.length - 1; // the record it just pushed
});
/* Boot: auth state FIRST — it decides whether the restored conversation