From 19df7df99d68650272c8ab0177ecfee5a6b8184f Mon Sep 17 00:00:00 2001 From: ducoterra Date: Sat, 22 Aug 2026 15:52:53 -0400 Subject: [PATCH] =?UTF-8?q?feat(ui):=20persist=20the=20chat=20conversation?= =?UTF-8?q?=20in=20localStorage=20=E2=80=94=20survives=20refresh=20and=20n?= =?UTF-8?q?avigation,=20with=20a=20New=20chat=20reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/assets/app.js | 155 ++++++++++++++- frontend/assets/styles.css | 51 ++++- frontend/index.html | 5 + tests/e2e/test_chat_persistence.py | 294 ++++++++++++++++++++++++++++ tests/unit/test_chat_persistence.py | 183 +++++++++++++++++ 5 files changed, 681 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/test_chat_persistence.py create mode 100644 tests/unit/test_chat_persistence.py diff --git a/frontend/assets/app.js b/frontend/assets/app.js index aaf04f3..40aec4d 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -20,6 +20,14 @@ * the 120s guard (TURN_TIMEOUT_MS) catches hung pre-token * streams, so the button can never sit zombified. * + * Conversation persistence (phase 14) makes the chat a durable LOCAL + * session: the message list (raw text + turn metadata) lives in + * localStorage under the versioned key `bor.chat.v1` and is re-rendered on + * load — refresh, tab close, and a trip to Sources never lose it. A10 is + * untouched: the API stays stateless, nothing is stored server-side. + * "New chat" (#new-chat-btn) clears the key + the list back to the empty + * state. + * * All DOM ids match frontend/index.html. */ @@ -94,7 +102,7 @@ const USER_AVATAR = ''; /* ---------- messages ---------- */ -function addMessage(who, html) { +function addMessage(who, html, scrollBehavior = SCROLL) { if (emptyState) emptyState.hidden = true; const wrap = document.createElement("div"); wrap.className = `msg ${who}`; @@ -104,7 +112,7 @@ function addMessage(who, html) {
${html}
`; messagesEl.appendChild(wrap); - wrap.scrollIntoView({ behavior: SCROLL, block: "end" }); + wrap.scrollIntoView({ behavior: scrollBehavior, block: "end" }); return wrap; } @@ -330,6 +338,133 @@ function appendMaybeTry(wrap, suggestions) { body.appendChild(group); } +/* ---------- conversation persistence (phase 14) ---------- + * + * A durable LOCAL session (A10 unchanged: the API stays stateless — the + * server stores nothing about the conversation). The whole conversation + * lives in localStorage under a versioned key; a format bump = clean start: + * + * bor.chat.v1 → { v: 1, messages: [{ who: "user"|"brain", text, + * sources?, deflected?, 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 + * 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. + */ +const STORAGE_KEY = "bor.chat.v1"; +export const STORAGE_VERSION = 1; +export const STORAGE_BUDGET_CHARS = 700_000; + +let conversation = []; // in-memory copy of the persisted messages + +function loadStoredConversation() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const data = JSON.parse(raw); + if (!data || data.v !== STORAGE_VERSION || !Array.isArray(data.messages)) return []; + // Legacy/corrupt shape → clean start; keep only well-formed raw-text + // messages (nothing HTML-shaped can survive this filter). + return data.messages.filter( + (m) => + m && + (m.who === "user" || m.who === "brain") && + typeof m.text === "string" && + m.text.length > 0 + ); + } catch { + return []; // unreadable storage: start clean, never throw + } +} + +function trimToBudget(messages) { + let out = messages.slice(); + for (;;) { + let size = Infinity; + try { + size = JSON.stringify({ v: STORAGE_VERSION, messages: out }).length; + } catch { + break; // even one message cannot serialize — keep it in memory only + } + if (size <= STORAGE_BUDGET_CHARS || out.length <= 1) return out; + out = out.slice(1); // drop the oldest until it fits + } + return out; +} + +function saveConversation() { + try { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ v: STORAGE_VERSION, messages: trimToBudget(conversation) }) + ); + } catch { + /* quota/private mode: chat keeps working with in-memory state only */ + } +} + +export function clearStoredConversation() { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + /* nothing was stored */ + } +} + +function renderStoredMessage(m) { + if (m.who === "user") { + addMessage("user", renderMarkdown(m.text), "auto"); + return; + } + const wrap = addMessage("brain", renderMarkdown(m.text), "auto"); + if (m.deflected) { + wrap.classList.add("is-deflected"); + appendMaybeTry(wrap, m.suggestions); + } + appendSources(wrap, m.sources); +} + +/* On load: re-render the stored conversation (markdown, source chips, + deflected styling, maybe-try chips). addMessage hides the empty state, + so a restored conversation starts right where it was left. */ +function restoreConversation() { + conversation = loadStoredConversation(); + for (const m of conversation) renderStoredMessage(m); +} + +/* Brain message save point (on `done`): raw accumulated text only. An + empty stream keeps the "…" placeholder that was actually rendered. */ +function rememberBrainTurn(rawText, meta) { + conversation.push({ who: "brain", text: rawText || "…", ...meta }); + saveConversation(); +} + +/* ---------- new chat (phase 14) ---------- + * Clears the stored conversation + the rendered list and returns to the + * empty state (suggestions included). Ignored while a turn is in flight — + * a live stream must not be hijacked. Confirmation reuses the existing + * #send-status live region (aria-live=polite). */ +const newChatBtn = document.querySelector("#new-chat-btn"); +function startNewChat() { + if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return; + conversation = []; + clearStoredConversation(); + removeTyping(); + messagesEl.querySelectorAll(".msg").forEach((el) => el.remove()); + if (emptyState) emptyState.hidden = false; + clearErrorBanner(); + setUiState(UI_STATE.idle); + input.value = ""; + autoGrow(); + input.focus(); + sendStatus.textContent = "New chat started — previous conversation cleared."; +} +if (newChatBtn) newChatBtn.addEventListener("click", startNewChat); + function showErrorBanner(detail) { banner.hidden = false; banner.classList.add("is-error"); @@ -352,6 +487,10 @@ async function handleSend(e) { if (!text || sendBtn.disabled) return; addMessage("user", renderMarkdown(text)); + // Persistence save point 1: the question is stored the moment it is + // sent, so a failed/interrupted turn never loses it. + conversation.push({ who: "user", text }); + saveConversation(); input.value = ""; autoGrow(); clearErrorBanner(); @@ -406,12 +545,21 @@ async function handleSend(e) { appendMaybeTry(wrap, ev.suggestions); } appendSources(wrap, ev.sources); + // Persistence save point 2: the answer lands only when the turn is + // complete (raw text + the done metadata). + rememberBrainTurn(acc, { + deflected: !!ev.deflected, + sources: ev.sources, + suggestions: ev.suggestions, + }); } else if (ev.type === "error") { throw new Error(ev.detail || "Something went wrong on my side."); } }); if (!aborted && !wrap) { - addMessage("brain", "Hmm — that came back empty. Ask me again?"); + const fallback = "Hmm — that came back empty. Ask me again?"; + addMessage("brain", fallback); + rememberBrainTurn(fallback, {}); // persist what the user actually saw } } catch (err) { if (!aborted) { @@ -441,5 +589,6 @@ input.addEventListener("keydown", (e) => { }); composer.addEventListener("submit", handleSend); +restoreConversation(); // phase 14: the conversation comes back as left loadSuggestions(); loadHealth(); diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 30cb2cc..72fadf7 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -174,11 +174,13 @@ body::after { rgb(34 211 238 / 0.05) 90% ); } +/* margin-left:auto on the nav (not justify-content:space-between) so the + phase-14 "New chat" pill clusters with the nav on the right while the + two-child Sources header keeps the exact same look. */ .header-inner { height: 100%; display: flex; align-items: center; - justify-content: space-between; gap: 1rem; } .brand { @@ -198,7 +200,7 @@ body::after { .brand-mark { width: 22px; height: 22px; flex: 0 0 auto; display: block; } .brand-text strong { color: var(--brand-ink); font-weight: 700; } -.app-nav { display: flex; gap: 0.25rem; } +.app-nav { display: flex; gap: 0.25rem; margin-left: auto; } .nav-link { padding: 0.5rem 0.9rem; border-radius: 999px; @@ -213,6 +215,32 @@ body::after { .nav-link:hover { background: var(--brand-soft); color: var(--brand-ink); } .nav-link.is-active { background: var(--brand); color: var(--bg); } +/* "New chat" reset (phase 14): ghost pill in the chat header, hover like + a nav link. ink-soft on surface ≈6.9:1; hover pair brand-ink/brand-soft + ≈6.9:1 — both WCAG AA. Icon-only below 640px (aria-label keeps the + accessible name); ≥44px touch target at every width. */ +.new-chat-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + min-height: 44px; + padding: 0.5rem 0.9rem; + border-radius: 999px; + border: 1px solid var(--line); + background: transparent; + color: var(--ink-soft); + font: inherit; + font-weight: 600; + font-size: 0.95rem; + white-space: nowrap; + cursor: pointer; +} +.new-chat-btn:hover { background: var(--brand-soft); color: var(--brand-ink); } +/* The plus mark is hidden on desktop (label carries the pill); it is the + whole control below 640px. */ +.new-chat-btn svg { width: 16px; height: 16px; display: none; } + /* ---------- Main frame ---------- */ .app-main { flex: 1; @@ -754,8 +782,23 @@ body::after { @media (max-width: 640px) { :root { --header-h: 58px; } .container { padding-inline: 0.9rem; } - .brand-text { font-size: 0.88rem; } - .nav-link { padding: 0.45rem 0.7rem; font-size: 0.9rem; } + /* Phase 14: the New chat pill joins the header — tighten the bar so + brand + nav + pill fit at 360px without horizontal overflow (the + brand text may ellipsize as the designated squeeze target). */ + .header-inner { gap: 0.6rem; } + .brand { min-width: 0; } + .brand-text { + font-size: 0.8rem; + letter-spacing: 0.04em; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .nav-link { padding: 0.4rem 0.55rem; font-size: 0.88rem; } + .new-chat-btn { padding: 0.4rem 0.55rem; } + .new-chat-label { display: none; } + .new-chat-btn svg { display: block; } .msg-body { max-width: 92%; } .empty-state { padding: 1.75rem 1.1rem; margin-top: 0.25rem; } .empty-state-title { font-size: 1.25rem; } diff --git a/frontend/index.html b/frontend/index.html index 5da3797..37b3837 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -21,6 +21,11 @@ Chat Sources + + diff --git a/tests/e2e/test_chat_persistence.py b/tests/e2e/test_chat_persistence.py new file mode 100644 index 0000000..f710f4e --- /dev/null +++ b/tests/e2e/test_chat_persistence.py @@ -0,0 +1,294 @@ +"""Phase 14 E2E (Playwright): the chat conversation survives a refresh. + +Story: ``.agent/user_stories/chat-persistence.md`` +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_chat_persistence.py -v --no-cov + +The conversation is a durable LOCAL session (localStorage key +``bor.chat.v1`` — A10 keeps the API stateless). Each test gets a fresh +browser context (the shared conftest's ``page`` fixture calls +``browser.new_page``), so localStorage is clean by construction: the +fresh-context tests start with the empty state exactly as before phase 14. + +Test → story mapping (Playwright Mapping Rule): +1. ``test_conversation_survives_reload`` +2. ``test_deflected_turn_restores_styling`` +3. ``test_new_chat_clears_conversation`` +4. ``test_persists_across_page_navigation`` +""" +from __future__ import annotations + +import asyncio +import json +import re +from pathlib import Path +from threading import Thread +from typing import Any + +from playwright.sync_api import Page, expect +from sqlalchemy import text + +from app.config import Settings +from app.db import SessionLocal +from app.rag.importer import ImportSummary, import_sources +from app.rag.llm import LLMClient + +REPO = Path(__file__).resolve().parents[2] +FIXTURES = REPO / "tests" / "fixtures" / "docs" +QUESTION = "How is my Kubernetes cluster set up?" +OFF_TOPIC = "How do I bake sourdough bread?" +MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E" +DEFLECT_PHRASE = r"haven't done anything like that" +STORAGE_KEY = "bor.chat.v1" +#: Phase 10 viewer URL + phase 13 back=/ (the restored chip must be +#: byte-identical to the live-rendered one). +CHIP_HREF = "/document.html?source=docs&path=homelab%2Fkubernetes.md&back=%2F" + + +async def _import_fixtures(mock_port: int) -> ImportSummary: + kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"} + settings = Settings(**kwargs) # pyright: ignore[reportCallIssue] + return await import_sources([FIXTURES], LLMClient(settings)) + + +def _run_in_thread(coro: Any) -> Any: + """Run a coroutine on a worker thread. + + Playwright's sync API keeps an asyncio loop running on the test thread, + so ``asyncio.run`` cannot be called directly from a test body. + """ + box: dict[str, Any] = {} + + def runner() -> None: + try: + box["value"] = asyncio.run(coro) + except BaseException as e: # noqa: BLE001 — re-raised on the test thread + box["error"] = e + + t = Thread(target=runner) + t.start() + t.join() + if "error" in box: + raise box["error"] + return box["value"] + + +def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None: + """Truncate the KB (and query log), then optionally re-import fixtures.""" + with SessionLocal() as db: + db.execute(text("TRUNCATE chunks, documents, query_log")) + db.commit() + if not seed: + return None + return _run_in_thread(_import_fixtures(mock_port)) + + +def _stored(page: Page) -> str | None: + """Raw localStorage payload for the chat (None when the key is absent).""" + return page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')") + + +def _stored_parsed(page: Page) -> dict[str, Any]: + raw = _stored(page) + assert raw is not None, "the conversation key must exist in localStorage" + return json.loads(raw) + + +def _ask(page: Page, question: str) -> None: + """Send one turn and wait until the grounded answer has fully landed.""" + page.fill("#message-input", question) + page.click("#send-btn") + expect(page.locator(".msg.user .bubble").last).to_contain_text(question) + expect(page.locator(".msg.brain .bubble").last).to_contain_text( + MOCK_ANSWER_MARKER, timeout=30_000 + ) + expect(page.locator("#send-btn")).to_be_enabled() + expect(page.locator("#send-label")).to_have_text("Send") + + +def _ask_deflected(page: Page, question: str) -> None: + """Send an off-topic turn and wait until the deflected answer landed.""" + page.fill("#message-input", question) + page.click("#send-btn") + bubble = page.locator(".msg.brain.is-deflected .bubble").first + bubble.wait_for(state="visible", timeout=30_000) + expect(bubble).to_contain_text(re.compile(DEFLECT_PHRASE, re.IGNORECASE), timeout=30_000) + expect(page.locator("#send-btn")).to_be_enabled() + expect(page.locator("#send-label")).to_have_text("Send") + + +# --------------------------------------------------------------------------- +# 1. Refresh: the whole conversation comes back exactly as left +# --------------------------------------------------------------------------- + + +def test_conversation_survives_reload( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + summary = _reset_db(mock_llm, seed=True) + assert summary is not None and summary.added == 8 # A9 formats + page.set_default_timeout(30_000) + page.goto(app_url) + _ask(page, QUESTION) + + # The turn is persisted: versioned payload, RAW text (no HTML), and the + # brain message carries the done metadata. + stored = _stored_parsed(page) + assert stored["v"] == 1 + assert [m["who"] for m in stored["messages"]] == ["user", "brain"] + assert stored["messages"][0]["text"] == QUESTION + brain = stored["messages"][1] + assert MOCK_ANSWER_MARKER in brain["text"] + assert "<" not in brain["text"], "persisted brain text must be raw, not rendered HTML" + assert brain["deflected"] is False + assert any(s["path"] == "homelab/kubernetes.md" for s in brain["sources"]) + + # Refresh — the same context keeps its localStorage. + page.reload() + expect(page.locator("#empty-state")).to_be_hidden() + + # Both bubbles restored: text + the source chip with the exact viewer URL. + expect(page.locator(".msg.user .bubble")).to_have_count(1) + expect(page.locator(".msg.user .bubble")).to_contain_text(QUESTION) + bubble = page.locator(".msg.brain .bubble") + expect(bubble).to_have_count(1) + expect(bubble).to_contain_text(MOCK_ANSWER_MARKER) + chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md") + expect(chip).to_have_count(1) + expect(chip.first).to_have_attribute("href", CHIP_HREF) + expect(chip.first).to_have_attribute("target", "_blank") + + # The restore is read-only: storage still holds the same two messages. + assert [m["who"] for m in _stored_parsed(page)["messages"]] == ["user", "brain"] + + # And the restored chat is live: a follow-up turn extends it. + _ask(page, "What about the nodes?") + expect(page.locator(".msg.user .bubble")).to_have_count(2) + assert len(_stored_parsed(page)["messages"]) == 4 + + +# --------------------------------------------------------------------------- +# 2. Deflected turn: amber styling + "Maybe try" chips survive a refresh +# --------------------------------------------------------------------------- + + +def test_deflected_turn_restores_styling( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + _reset_db(mock_llm, seed=True) + page.set_default_timeout(30_000) + page.goto(app_url) + _ask_deflected(page, OFF_TOPIC) + + # The deflected metadata (suggestions) is persisted with the answer. + stored = _stored_parsed(page) + brain = stored["messages"][-1] + assert brain["who"] == "brain" + assert brain["deflected"] is True + assert len(brain["suggestions"]) >= 2 + + page.reload() + + # Amber deflected bubble + "Maybe try" chips come back, styled. + expect(page.locator("#empty-state")).to_be_hidden() + expect(page.locator(".msg.user .bubble")).to_contain_text(OFF_TOPIC) + restored = page.locator(".msg.brain.is-deflected .bubble") + expect(restored).to_have_count(1) + expect(restored.first).to_contain_text(re.compile(DEFLECT_PHRASE, re.IGNORECASE)) + style = restored.first.evaluate("el => getComputedStyle(el)") + assert style["backgroundColor"] == "rgb(43, 33, 16)" # --accent-bg (dark theme) + assert style["borderTopColor"] == "rgb(245, 158, 11)" # --accent-line + + # The chips are restored from the stored suggestions — same texts, order, + # and still one-tap-submittable (the shared chip component). + chips = page.locator(".msg.brain.is-deflected .maybe-try .suggestion-chip") + expect(chips.first).to_be_visible() + restored_texts = [chips.nth(i).inner_text() for i in range(chips.count())] + assert restored_texts == [s.strip() for s in brain["suggestions"] if s.strip()] + + chips.first.click() + expect(page.locator("#message-input")).to_have_value("") + expect(page.locator(".msg.user .bubble")).to_have_count(2, timeout=30_000) + expect(page.locator(".msg.brain .bubble").nth(1)).to_contain_text( + MOCK_ANSWER_MARKER, timeout=30_000 + ) + + +# --------------------------------------------------------------------------- +# 3. "New chat": clear the conversation, back to the empty state +# --------------------------------------------------------------------------- + + +def test_new_chat_clears_conversation( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + _reset_db(mock_llm, seed=True) + page.set_default_timeout(30_000) + page.goto(app_url) + _ask(page, QUESTION) + expect(page.locator(".msg")).to_have_count(2) + assert _stored(page) is not None + + # The reset control: ghost pill in the chat header, ≥44px, accessible name. + btn = page.locator("#new-chat-btn") + expect(btn).to_be_visible() + expect(btn).to_have_attribute("type", "button") + expect(btn).to_have_attribute("aria-label", "New chat") + box = btn.bounding_box() + assert box is not None and box["height"] >= 44 + + btn.click() + + # Conversation gone, empty state + suggestions back, storage key cleared. + expect(page.locator(".msg")).to_have_count(0) + expect(page.locator("#empty-state")).to_be_visible() + expect(page.locator("#suggestions .suggestion-chip").first).to_be_visible(timeout=15_000) + assert _stored(page) is None, "New chat must clear the localStorage key" + + # Confirmation via the existing live region (#send-status, aria-live=polite). + expect(page.locator("#send-status")).to_contain_text("New chat started") + + # And it is a clean slate: a fresh turn starts a fresh conversation. + _ask(page, QUESTION) + stored = _stored_parsed(page) + assert [m["who"] for m in stored["messages"]] == ["user", "brain"] + assert stored["messages"][0]["text"] == QUESTION + + +# --------------------------------------------------------------------------- +# 4. Navigation: a trip to Sources and back keeps the conversation +# --------------------------------------------------------------------------- + + +def test_persists_across_page_navigation( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + _reset_db(mock_llm, seed=True) + page.set_default_timeout(30_000) + page.goto(app_url) + _ask(page, QUESTION) + _ask_deflected(page, OFF_TOPIC) # mixed conversation: grounded + deflected + + # A trip to Sources — the New chat control is chat-page-only. + page.goto(app_url + "/sources.html") + expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000) + expect(page.locator("#new-chat-btn")).to_have_count(0) + + # Back to the chat: the conversation is exactly as left — both turns, + # the source chip, and the amber deflected bubble with its chips. + page.goto(app_url + "/") + expect(page.locator("#empty-state")).to_be_hidden() + expect(page.locator(".msg.user .bubble")).to_have_count(2) + expect(page.locator(".msg.user .bubble").first).to_contain_text(QUESTION) + expect(page.locator(".msg.user .bubble").nth(1)).to_contain_text(OFF_TOPIC) + expect(page.locator(".msg.brain .bubble").first).to_contain_text(MOCK_ANSWER_MARKER) + expect(page.locator(".msg.brain .source-chip", has_text="kubernetes.md")).to_have_count(1) + deflected = page.locator(".msg.brain.is-deflected .bubble") + expect(deflected).to_have_count(1) + expect(deflected.first).to_contain_text(re.compile(DEFLECT_PHRASE, re.IGNORECASE)) + maybe_chip = page.locator(".msg.brain.is-deflected .maybe-try .suggestion-chip") + expect(maybe_chip.first).to_be_visible() + + # The New chat control is back on the chat page. + expect(page.locator("#new-chat-btn")).to_be_visible() diff --git a/tests/unit/test_chat_persistence.py b/tests/unit/test_chat_persistence.py new file mode 100644 index 0000000..de892aa --- /dev/null +++ b/tests/unit/test_chat_persistence.py @@ -0,0 +1,183 @@ +"""Unit: the chat-persistence contract in the static frontend (phase 14). + +The browser behavior itself is E2E-covered (tests/e2e/test_chat_persistence.py); +here we pin the localStorage persistence markers in app.js/index.html/ +styles.css so a silent regression (key rename, dropped try/catch, missing +restore, New chat control lost) is caught without a browser. + +Pinned design (PLAN §7.4 note / phase 14): +* versioned key ``bor.chat.v1`` → ``{v: 1, messages: [...]}``, raw text only; +* save points: user message on send, brain message on ``done``; +* every ``localStorage`` access wrapped in try/catch (failure-safe); +* size budget ~700k chars, oldest dropped first; +* ``#new-chat-btn`` in the chat header (chat page only), ≥44px, ghost pill. +""" +from __future__ import annotations + +import re +from pathlib import Path + +FRONTEND = Path(__file__).resolve().parents[2] / "frontend" +APP_JS = FRONTEND / "assets" / "app.js" +INDEX_HTML = FRONTEND / "index.html" +SOURCES_HTML = FRONTEND / "sources.html" +DOCUMENT_HTML = FRONTEND / "document.html" +STYLES_CSS = FRONTEND / "assets" / "styles.css" + + +def _js() -> str: + return APP_JS.read_text(encoding="utf-8") + + +def _css() -> str: + return STYLES_CSS.read_text(encoding="utf-8") + + +def _index() -> str: + return INDEX_HTML.read_text(encoding="utf-8") + + +def test_versioned_storage_key_and_v1_payload() -> None: + """`bor.chat.v1` (versioned — a format bump is a clean start) with the + {v, messages} payload shape (A11: raw localStorage JSON, no library).""" + js = _js() + assert 'const STORAGE_KEY = "bor.chat.v1"' in js + assert "export const STORAGE_VERSION = 1" in js + # The payload written to the key is always {v: STORAGE_VERSION, messages} + # (two write paths: saveConversation and the trimToBudget size probe). + assert js.count("v: STORAGE_VERSION, messages") >= 2 + # Restore validates the version before trusting anything. + assert "data.v !== STORAGE_VERSION" in js + + +def test_storage_size_budget_drops_oldest_first() -> None: + """~700k-char serialized budget (far under the ~5MB quota); the loop + drops messages from the FRONT (oldest) until the state fits.""" + js = _js() + assert "export const STORAGE_BUDGET_CHARS = 700_000" in js + assert "out.length <= 1" in js, "never drop the last remaining message" + assert "out = out.slice(1)" in js, "oldest-first drop (slice(1), not pop)" + assert "STORAGE_BUDGET_CHARS" in js + + +def test_every_storage_access_is_failure_safe() -> None: + """AC4: every localStorage access (getItem/setItem/removeItem) must be + inside a try/ that is closer than the enclosing function boundary — + private mode or quota exhaustion must never throw into the UI.""" + js = _js() + accesses = list(re.finditer(r"localStorage\.(?:getItem|setItem|removeItem)", js)) + assert len(accesses) == 3, f"expected exactly 3 localStorage accesses, got {len(accesses)}" + for m in accesses: + try_idx = js.rfind("try {", 0, m.start()) + fn_idx = js.rfind("function ", 0, m.start()) + assert try_idx != -1, f"no try before {m.group(0)!r}" + assert try_idx > fn_idx, ( + f"{m.group(0)!r} is not inside its function's try block " + f"(function boundary at {fn_idx} is after try at {try_idx})" + ) + # Each access has its own catch that degrades silently. + assert js.count("} catch {") >= len(accesses) + + +def test_raw_text_only_stored_and_re_rendered_on_restore() -> None: + """The value is raw text (re-rendered through the escape-first markdown + on restore) — no HTML is ever stored. Restore re-applies the full + brain-message chrome: is-deflected styling, maybe-try chips, sources.""" + js = _js() + assert 'addMessage("user", renderMarkdown(m.text), "auto")' in js + assert 'addMessage("brain", renderMarkdown(m.text), "auto")' in js + assert "wrap.classList.add(\"is-deflected\")" in js + assert "appendMaybeTry(wrap, m.suggestions)" in js + assert "appendSources(wrap, m.sources)" in js + # Restore runs on load (module scope, after the handlers are wired). + assert "restoreConversation();" in js + # Corrupt/legacy payloads degrade to a clean start, never a crash. + assert "Array.isArray(data.messages)" in js + + +def test_save_points_user_on_send_and_brain_on_done() -> None: + """Save points: the user message is stored the moment it is sent (BEFORE + the fetch — a failed turn keeps the question); the brain message is + stored on `done` with the done metadata (sources/deflected/suggestions).""" + js = _js() + user_push = js.find('conversation.push({ who: "user", text })') + assert user_push != -1 + assert user_push < js.find('fetch("/api/chat"'), ( + "the user message must be saved before the turn starts" + ) + # Brain save point is wired into the done handler with full metadata. + done_idx = js.find('ev.type === "done"') + assert done_idx != -1 + done_block = js[done_idx : done_idx + 900] + assert "rememberBrainTurn(acc" in done_block + assert "deflected: !!ev.deflected" in done_block + assert "sources: ev.sources" in done_block + assert "suggestions: ev.suggestions" in done_block + # rememberBrainTurn stores raw text and saves immediately. + assert "text: rawText ||" in js + body = js[js.find("function rememberBrainTurn") :] + assert "saveConversation()" in body[: body.find("\n}\n") + 3] + + +def test_new_chat_clears_key_and_ui() -> None: + """New chat: clears the stored key, the rendered list, restores the + empty state, and reuses the #send-status live region for the + confirmation. A live turn is never hijacked.""" + js = _js() + fn_start = js.find("function startNewChat") + assert fn_start != -1 + body = js[fn_start : js.find("\n}\n", fn_start)] + assert "clearStoredConversation()" in body + assert 'querySelectorAll(".msg")' in body + assert "emptyState.hidden = false" in body + assert "setUiState(UI_STATE.idle)" in body + assert "sendStatus.textContent" in body, "confirmation via the live region" + assert "UI_STATE.thinking" in body and "UI_STATE.streaming" in body, ( + "new chat must be ignored while a turn is in flight" + ) + assert "removeItem(STORAGE_KEY)" in js + + +def test_new_chat_button_in_chat_header_only() -> None: + """#new-chat-btn lives in the chat header (index.html) as a real + type=button with an accessible name — and nowhere else (A10: chat-page + only control).""" + html = _index() + btn = re.search(r']*id="new-chat-btn"[^>]*>', html) + assert btn, "index.html must contain #new-chat-btn" + tag = btn.group(0) + assert 'type="button"' in tag + assert 'aria-label="New chat"' in tag + nav_idx = html.find('