# Phase 20 — Sources Mid-Stream: an in-flight answer is not lost on navigation **Source:** `TODO.md` L3 — *"Clicking "sources" while chat is generating clears chat and result will never show up"* **Story:** `.agents/user_stories/sources-midstream.md` (created by task 02) **Context:** `frontend/assets/app.js` — the phase-14 persistence block (`STORAGE_KEY = "bor.chat.v1"`, `conversation`, `saveConversation`, `rememberBrainTurn`), the turn state machine (`UI_STATE.thinking` / `.streaming`), the streaming accumulators (`acc` / `thinkingAcc` / `sawThinking`), and the phase-14 restore path; `frontend/index.html` (`#nav-sources` link); `frontend/assets/header.js` (`clearChatStorage` — the deliberate New-Chat clear, NOT this bug). ## Objective When the user leaves the chat page (the Sources nav link, the document viewer, any link) while a turn is still in flight, the answer generated so far must not vanish. Today the brain message is persisted only on `done`, so navigating away aborts the stream and the partial answer is lost — the user returns to their own question with no result, ever. After this phase, returning to the chat shows the question **and** the partial answer that had already streamed (rendered like any brain message, thinking block restored if any). ## Owner-confirmed (2026-08-24, roadmap A1) 1. **A partial answer is persisted as a plain brain message** — no "(partial)" marker, no sources/suggestions (the turn is dead; the user can re-ask for the full answer). 2. Navigation **before the first answer token** (pure thinking) persists nothing brain-side: the question is restored, no empty/partial bubble. 3. The deliberate **New Chat** `clearChatStorage()` (sources/viewer pages) is untouched — that clear is by design (phase 14/19). 4. No server-side resume (A10 stays stateless) and no "leave page?" confirmation dialog. ## Design - **`app.js` — one new `pagehide` handler** (`window.addEventListener( "pagehide", …)` — fires on navigate-away and bfcache store): - Guard: only when a turn is in flight (current `uiState` is `UI_STATE.thinking` or `UI_STATE.streaming`) **and** `acc` is non-empty. - Action: `rememberBrainTurn(acc, { thinking: thinkingAcc || undefined })` — reuse the existing save-point helper, so the partial text is stored raw (the restore path re-renders through the escape-first markdown renderer; the phase-17 `thinking` field restores the collapsed Thinking block). - **Idempotency guard:** a turn-local `persistedOnLeave` flag so a second `pagehide` (or bfcache store+restore churn) never appends the same partial message twice. The `done` save point is unaffected (navigation means the stream is dead; if the user returns via bfcache the turn is already aborted by the unloading page). - **Restore path:** unchanged — a stored partial message is a well-formed brain message and renders exactly like a completed one (minus sources/deflection, which it simply doesn't carry). - **Non-goals:** no resume of the SSE stream, no API changes, no changes to the New Chat buttons, sign-out, or the document-viewer back link. ## Dependencies - `14_chat_persistence` (complete) — `bor.chat.v1` shape, save points, restore, and the `rememberBrainTurn` helper this phase reuses. - `17_thinking_display` (complete) — the `thinking` field on persisted brain messages and the `thinkingAcc` accumulator. - `19_shared_header` (complete) — the `#nav-sources` link (admin-only) the bug report clicks. - `18_follow_bottom_scroll` (complete) — no overlap (scroll gating only). ## Tasks 1. `01_persist_inflight_turn.md` — the `pagehide` partial-persistence handler in `app.js` + source-level unit pins. 2. `02_e2e_story_suite_commit.md` — `tests/e2e/test_sources_midstream_bug.py` (the story gate, isolated), regression suites, story file, final validation, the single atomic commit, phase move to `complete/`. ## Locked decisions - **A10 untouched** — API stays stateless; no resume. **A11 untouched** — vanilla JS, no CDN. **A16 honored** — one new story E2E suite + adapted regressions. No anchor changed. ## Testing & Quality - **Unit (source-level, new `tests/unit/test_sources_midstream.py`, following the repo's source-pin pattern):** `app.js` registers a `pagehide` listener; the guard references the in-flight `uiState` and a non-empty `acc`; the partial path calls `rememberBrainTurn` with `thinking: thinkingAcc || undefined`; a turn-local idempotency flag exists; `STORAGE_KEY`/save-point comments updated to list the new save point. - **Integration:** none (no `app/` changes) — the `uv run pytest --cov=app` number must stay at today's. - **Coverage:** frontend-only; the >90% `app/` gate is unaffected, re-run to prove it. - **E2E:** `tests/e2e/test_sources_midstream_bug.py` (task 02), green **in isolation** (prereq `podman compose up -d db`). - **Lint/types:** `uv run ruff check . && uv run pyright` clean. ## Completion Criteria - [ ] Admin, mid-stream, clicks **Sources** → returns to `/`: the question **and** the already-streamed partial answer are both rendered; no error banner; `bor.chat.v1` holds the partial brain message. - [ ] Navigate away before the first token → back: question restored, no empty/partial brain bubble. - [ ] A completed turn is persisted exactly as before (sources, deflection, suggestions intact). - [ ] New Chat from the sources page still clears the conversation. - [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` ≥ today's number. - [ ] `uv run pytest tests/e2e/test_sources_midstream_bug.py -v --no-cov` green in isolation; regressions green in isolation (one command each): `test_chat_persistence.py`, `test_thinking_display.py`, `test_shared_header.py`. - [ ] `uv run ruff check . && uv run pyright` clean. - [ ] UI Structure Check (AGENTS.md rule 5): no new UI surface — the restored partial renders through the existing bubble/thinking contract. - [ ] `.agents/user_stories/sources-midstream.md` exists. - [ ] One `--no-gpg-sign` commit (below); `.agents/phases/todo/20_sources_midstream_bug/` moved to `.agents/phases/complete/`. ## Commit ```bash git add -A .agents/ frontend/ tests/ && git commit --no-gpg-sign -m "fix(chat): keep the in-flight answer when navigating away mid-turn — partial answer restored on return" ```