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:
+95
-10
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user