fix(chat): keep the in-flight answer when navigating away mid-turn — partial answer restored on return

This commit is contained in:
2026-08-24 15:53:07 -04:00
parent 824914ca3d
commit 76c6a01199
5 changed files with 578 additions and 91 deletions
+38 -5
View File
@@ -501,6 +501,13 @@ 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
/* 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
* handleSend, so they stay turn-scoped like the rest of the turn locals. */
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
function stopThinkingClock() {
if (thinkingClock) {
@@ -640,8 +647,11 @@ function appendMaybeTry(wrap, suggestions) {
*
* Only RAW TEXT is stored — restore re-renders it through the escape-first
* markdown renderer, so no HTML is ever persisted. Save points: the user
* message on send (a failed turn keeps the question) and the brain message
* on `done` (with sources/deflected/suggestions). Every localStorage access
* message on send (a failed turn keeps the question), the brain message on
* `done` (with sources/deflected/suggestions), and the PARTIAL brain
* message on navigate-away (`pagehide`, phase 20 — an in-flight turn keeps
* whatever had already streamed; thinking-only turns persist nothing
* brain-side). Every localStorage access
* is try/catch'd — private mode or quota exhaustion degrades silently to
* in-memory-only chat. If the serialized state outgrows the budget (~700k
* chars, far under the ~5MB quota) the oldest messages are dropped first.
@@ -833,11 +843,14 @@ async function handleSend(e) {
clearErrorBanner();
let wrap = null;
let acc = "";
let res = null;
let aborted = false; // the 120s guard already took the turn to error
// Phase 17 (thinking display): turn-local reasoning state.
let thinkingAcc = ""; // accumulated thinking text (persisted with the turn)
// 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.
acc = "";
thinkingAcc = "";
persistedOnLeave = false;
let sawThinking = false; // did any `thinking` frame arrive this turn?
let sawDone = false; // did the stream end with a `done` event?
@@ -972,6 +985,26 @@ input.addEventListener("keydown", (e) => {
});
composer.addEventListener("submit", handleSend);
/* Navigate-away save point (phase 20, owner choice 2026-08-24 A1):
* leaving the chat mid-turn would otherwise drop the in-flight
* answer — the brain message persists only on `done`, and
* navigation aborts the stream. On `pagehide`, if a turn is in
* flight and answer text has streamed, persist the partial raw text
* (reusing the save-point helper, so restore re-renders it exactly
* like a completed answer — no "(partial)" marker, no sources).
* 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. */
window.addEventListener("pagehide", () => {
if (persistedOnLeave) return;
if (uiState !== UI_STATE.thinking && uiState !== UI_STATE.streaming)
return;
if (!acc) return; // nothing brain-side to save yet
persistedOnLeave = true;
rememberBrainTurn(acc, { thinking: thinkingAcc || undefined });
});
/* Boot: auth state FIRST — it decides whether the restored conversation
gets Tune buttons and whether the steering UI exists at all (phase 16).
Phase 14: the conversation then comes back exactly as left. Phase 19: