6.3 KiB
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: .agent/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)
- 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).
- Navigation before the first answer token (pure thinking) persists nothing brain-side: the question is restored, no empty/partial bubble.
- The deliberate New Chat
clearChatStorage()(sources/viewer pages) is untouched — that clear is by design (phase 14/19). - No server-side resume (A10 stays stateless) and no "leave page?" confirmation dialog.
Design
app.js— one newpagehidehandler (window.addEventListener( "pagehide", …)— fires on navigate-away and bfcache store):- Guard: only when a turn is in flight (current
uiStateisUI_STATE.thinkingorUI_STATE.streaming) andaccis 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-17thinkingfield restores the collapsed Thinking block). - Idempotency guard: a turn-local
persistedOnLeaveflag so a secondpagehide(or bfcache store+restore churn) never appends the same partial message twice. Thedonesave point is unaffected (navigation means the stream is dead; if the user returns via bfcache the turn is already aborted by the unloading page).
- Guard: only when a turn is in flight (current
- 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.v1shape, save points, restore, and therememberBrainTurnhelper this phase reuses.17_thinking_display(complete) — thethinkingfield on persisted brain messages and thethinkingAccaccumulator.19_shared_header(complete) — the#nav-sourceslink (admin-only) the bug report clicks.18_follow_bottom_scroll(complete) — no overlap (scroll gating only).
Tasks
01_persist_inflight_turn.md— thepagehidepartial-persistence handler inapp.js+ source-level unit pins.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 tocomplete/.
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.jsregisters apagehidelistener; the guard references the in-flightuiStateand a non-emptyacc; the partial path callsrememberBrainTurnwiththinking: 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) — theuv run pytest --cov=appnumber 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 (prereqpodman compose up -d db). - Lint/types:
uv run ruff check . && uv run pyrightclean.
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.v1holds 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 pytestgreen;uv run pytest --cov=app --cov-report=term-missing≥ today's number.uv run pytest tests/e2e/test_sources_midstream_bug.py -v --no-covgreen 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 pyrightclean.- UI Structure Check (AGENTS.md rule 5): no new UI surface — the restored partial renders through the existing bubble/thinking contract.
.agent/user_stories/sources-midstream.mdexists.- One
--no-gpg-signcommit (below);.agent/phases/todo/20_sources_midstream_bug/moved to.agent/phases/complete/.
Commit
git add -A .agent/ 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"