From 2afc77ee56ac5dbc833d3c94462014ac65547ddc Mon Sep 17 00:00:00 2001 From: ducoterra Date: Mon, 24 Aug 2026 12:32:45 -0400 Subject: [PATCH] =?UTF-8?q?feat(ui):=20shared=20header=20=E2=80=94=20Sign?= =?UTF-8?q?=20in/Sign=20out=20and=20New=20Chat=20on=20every=20page;=20hide?= =?UTF-8?q?=20the=20Sources=20nav=20link=20from=20anonymous=20users?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/assets/app.js | 48 ++-- frontend/assets/document.js | 29 +++ frontend/assets/header.js | 96 ++++++++ frontend/assets/login.js | 28 ++- frontend/assets/sources.js | 32 ++- frontend/assets/styles.css | 13 + frontend/document.html | 25 ++ frontend/index.html | 9 +- frontend/login.html | 10 +- frontend/sources.html | 26 +- tests/e2e/test_chat_persistence.py | 10 +- tests/e2e/test_shared_header.py | 363 ++++++++++++++++++++++++++++ tests/unit/test_chat_persistence.py | 26 +- tests/unit/test_shared_header.py | 248 +++++++++++++++++++ 14 files changed, 904 insertions(+), 59 deletions(-) create mode 100644 frontend/assets/header.js create mode 100644 tests/e2e/test_shared_header.py create mode 100644 tests/unit/test_shared_header.py diff --git a/frontend/assets/app.js b/frontend/assets/app.js index 84a4075..699a3e5 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -63,6 +63,8 @@ * All DOM ids match frontend/index.html. */ +import { fetchIsAdmin, initSharedHeader } from "/assets/header.js"; + const messagesEl = document.querySelector("#messages"); const emptyState = document.querySelector("#empty-state"); const suggestionsEl = document.querySelector("#suggestions"); @@ -756,9 +758,19 @@ function rememberBrainTurn(rawText, meta) { * tuning surface at all — the Tuning toggle + panel are removed from the * DOM (the story says "absent", not just hidden), /api/steering is never * fetched, and appendTuneButton injects nothing (new or restored - * messages). Admin → Sign out (POST /api/logout + reload) + the full - * phase-15 UI. Whoami is awaited BEFORE the phase-14 restore, so restored - * brain bubbles never flash a Tune button that should not be there. + * messages). Admin → Sign out + the full phase-15 UI. Whoami is awaited + * BEFORE the phase-14 restore, so restored brain bubbles never flash a + * Tune button that should not be there. + * + * Phase 19: the whoami fetch, the Sign in / Sign out / Sources-nav + * toggling, and the #sign-out-btn click binding (POST /api/logout + + * reload) all moved to the shared header module (assets/header.js) — + * initSharedHeader() does the header toggling on every page, and + * fetchIsAdmin() is the single cached whoami, so this page still makes + * exactly one request per load. applyAuthState keeps only the + * chat-page-specific work (removing the tuning surface for anonymous + * visitors) — idempotent alongside the header module's own link/button + * toggling. */ const signInLink = document.querySelector("#sign-in-link"); const signOutBtn = document.querySelector("#sign-out-btn"); @@ -773,27 +785,6 @@ function applyAuthState() { } } -async function loadAuthState() { - try { - const r = await fetch("/api/whoami"); - if (r.ok) isAdmin = (await r.json()).authenticated === true; - } catch { - isAdmin = false; // API unreachable: anonymous-safe defaults - } - applyAuthState(); - return isAdmin; -} - -if (signOutBtn) { - signOutBtn.addEventListener("click", async () => { - signOutBtn.disabled = true; - try { - await fetch("/api/logout", { method: "POST" }); - } catch { /* the reload resets the UI either way */ } - window.location.reload(); - }); -} - const newChatBtn = document.querySelector("#new-chat-btn"); function startNewChat() { if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return; @@ -983,9 +974,14 @@ composer.addEventListener("submit", handleSend); /* 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 14: the conversation then comes back exactly as left. Phase 19: + the shared header module runs the whoami (cached — exactly one + request per page load) and toggles the Sign in/out pair + the Sources + nav link; applyAuthState() then applies the chat-page-only gating. */ (async () => { - await loadAuthState(); + await initSharedHeader(); // header.js: whoami + Sign in/out + #nav-sources + isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami + applyAuthState(); // chat page: the admin-only tuning surface restoreConversation(); loadSuggestions(); loadHealth(); diff --git a/frontend/assets/document.js b/frontend/assets/document.js index c1a5b23..6989029 100644 --- a/frontend/assets/document.js +++ b/frontend/assets/document.js @@ -15,8 +15,16 @@ * * A missing document (unknown pair, missing params, network error) shows * the designed not-found card with a link back to the Sources page. + * + * Phase 19: the viewer joins the shared header (assets/header.js) — the + * whoami fetch is the module's cached promise (one request per page, + * shared with initSharedHeader's toggling), and the bar gains the New + * chat button: on a non-chat page "new chat" means going to the chat, + * fresh (clear the phase-14 conversation key, then navigate to "/"). */ +import { clearChatStorage, fetchIsAdmin, initSharedHeader } from "/assets/header.js"; + const params = new URLSearchParams(window.location.search); const source = params.get("source") || ""; const path = params.get("path") || ""; @@ -111,6 +119,27 @@ function showNotFound() { notFoundEl.hidden = false; } +/* Phase 19: the shared header controls (Sign in / Sign out — exactly one + * visible) are toggled here; the viewer has no nav, so there is no + * #nav-sources for the module to touch. Independent of the doc fetch + * (its own IIFE — load() below never waits on it). + * (fetchIsAdmin is imported for parity with the other header consumers — + * the module's cached promise is the single whoami per page either way.) */ +(async () => { + await initSharedHeader(); +})(); + +/* Phase 19: New chat on a non-chat page means "go to the chat, fresh": + * clear the phase-14 conversation key, then land on the chat page — its + * empty state, since the conversation is gone from storage. */ +const newChatBtn = document.querySelector("#new-chat-btn"); +if (newChatBtn) { + newChatBtn.addEventListener("click", () => { + clearChatStorage(); + window.location.href = "/"; + }); +} + async function load() { try { if (!source || !path) { diff --git a/frontend/assets/header.js b/frontend/assets/header.js new file mode 100644 index 0000000..fc0e465 --- /dev/null +++ b/frontend/assets/header.js @@ -0,0 +1,96 @@ +/* Brain of Reese — shared header module (phase 19). + * + * Owner report 2026-08-23: clicking "Sources" made New Chat and Sign in + * vanish — the user expects ONE consistent bar on every page. This module + * is the single owner of the shared header controls: + * + * • the Sign in / Sign out auth pair (phase 16, exactly one visible — + * decided by /api/whoami at load); + * • the "Sources" nav link (#nav-sources) — phase 19 UX revision + * (owner permission 2026-08-23): hidden for anonymous on every page + * that has a nav (chat, sources, login), revealed for admin. The + * link SHIPS hidden in the HTML (anonymous-safe default — the + * phase-16 "absent, not hidden" spirit), so no anonymous user ever + * sees it for a frame; + * • the sign-out click binding (POST /api/logout → reload) — moved + * here from app.js so there is exactly one implementation; + * • clearChatStorage() — the phase-14 conversation key, for the + * New Chat buttons on the NON-CHAT pages (sources / document + * viewer): a new chat means going to the chat, fresh. + * + * Every page loads this module (type="module", before its page script) + * and its page script calls initSharedHeader() once at boot. init… + * toggles ONLY the controls that exist on the page — a missing element + * is a no-op, which is how the login page reuses the module without + * gaining chat controls (no #new-chat-btn / #sign-in-link / + * #sign-out-btn in its markup → none appear). + * + * whoami is fetched at most ONCE per page load: the promise is cached in + * the module-level `adminPromise`, so app.js's tuning gate, the sources + * page's catalog gate, and the header toggling all share one request. + * Anonymous-safe: any network failure resolves to false (the anonymous + * UI), mirroring the per-page catch the pages used before phase 19. + * + * A10/A11 untouched: no API change, no CDN, no state beyond the cached + * promise; the soft gate page and the A10 API split are unchanged — + * this is UI visibility only. + */ + +let adminPromise = null; + +/* The SINGLE /api/whoami call site for the whole frontend. First call + stores the promise in `adminPromise`; every later call — on this page + — returns the same promise, i.e. exactly one request per page load. + Anonymous-safe: non-2xx or a network failure resolves to false. */ +export function fetchIsAdmin() { + if (!adminPromise) { + adminPromise = fetch("/api/whoami") + .then(async (r) => (r.ok ? (await r.json()).authenticated === true : false)) + .catch(() => false); + } + return adminPromise; +} + +/* Toggle the shared header controls, only the ones present on this page + (querySelector, null-safe — missing → no-op). Returns the admin flag + so callers can reuse it instead of awaiting fetchIsAdmin() again (the + cached promise makes both awaits the same single request). */ +export async function initSharedHeader() { + const admin = await fetchIsAdmin(); + const signIn = document.querySelector("#sign-in-link"); + if (signIn) signIn.hidden = admin; + const signOut = document.querySelector("#sign-out-btn"); + if (signOut) signOut.hidden = !admin; + const navSources = document.querySelector("#nav-sources"); + if (navSources) navSources.hidden = !admin; + return admin; +} + +/* Remove the phase-14 conversation key — same key + fail-silence + contract as app.js's clearStoredConversation: private mode or a + storage error is swallowed, the navigation still happens. */ +export function clearChatStorage() { + try { + localStorage.removeItem("bor.chat.v1"); + } catch { + /* nothing was stored */ + } +} + +/* Sign-out binding (phase 16 behavior, now module-owned): runs at module + import, so every page that loads header.js gets it exactly once. + Disable during the call, POST /api/logout (the result is ignored — + the reload resets the UI either way), then reload so the header + re-resolves to the anonymous state (Sign in back, Sources gone). */ +const signOutBtn = document.querySelector("#sign-out-btn"); +if (signOutBtn) { + signOutBtn.addEventListener("click", async () => { + signOutBtn.disabled = true; + try { + await fetch("/api/logout", { method: "POST" }); + } catch { + /* the reload resets the UI either way */ + } + window.location.reload(); + }); +} diff --git a/frontend/assets/login.js b/frontend/assets/login.js index b71745e..91145be 100644 --- a/frontend/assets/login.js +++ b/frontend/assets/login.js @@ -7,10 +7,18 @@ * role=alert error region. On load, /api/whoami already says admin → * straight to `next`, no form. * + * Phase 19: the whoami check runs on the shared header module's cached + * promise (assets/header.js) — one request per page, and the module's + * initSharedHeader() toggles the (admin-only) Sources nav link. The + * login page carries no chat controls, so the module's missing-element + * no-op keeps this page control-free. + * * No CDN, no state in this file: the signed cookie is the whole session. * All DOM ids match frontend/login.html. */ +import { fetchIsAdmin, initSharedHeader } from "/assets/header.js"; + const form = document.querySelector("#login-form"); const passwordInput = document.querySelector("#login-password"); const submitBtn = document.querySelector("#login-submit"); @@ -33,14 +41,12 @@ function showError(message) { passwordInput.select(); } -async function alreadySignedIn() { - try { - const r = await fetch("/api/whoami"); - if (!r.ok) return false; - return (await r.json()).authenticated === true; - } catch { - return false; // API unreachable: stay on the form — submit will explain - } +/* Phase 19: the shared header module IS the whoami call site (cached + * promise, anonymous-safe) — same result as the private fetch it + * replaces: a network failure stays on the form (submit will explain). + */ +function alreadySignedIn() { + return fetchIsAdmin(); } form.addEventListener("submit", async (e) => { @@ -70,11 +76,15 @@ form.addEventListener("submit", async (e) => { } }); -/* Already the admin? Skip the form and go straight to the target. */ +/* Already the admin? Skip the form and go straight to the target. + * (For anonymous visitors, initSharedHeader toggles the Sources nav + * link — the only shared control this page carries; for the signed-in + * case the redirect above makes the toggle moot.) */ (async () => { if (await alreadySignedIn()) { window.location.replace(safeNext()); return; } + await initSharedHeader(); // phase 19: Sources link toggle (no chat controls here) passwordInput.focus(); })(); diff --git a/frontend/assets/sources.js b/frontend/assets/sources.js index 6ce2302..acdeb87 100644 --- a/frontend/assets/sources.js +++ b/frontend/assets/sources.js @@ -4,8 +4,16 @@ * full-width document table, or the designed empty state when nothing is * indexed yet. Cells are built with DOM APIs (textContent) — never * innerHTML with document-derived data (XSS-safe by construction). + * + * Phase 19: the page joins the shared header (assets/header.js) — the + * whoami gate below runs on the module's cached promise (one request per + * page, shared with the header toggling), and the header gains the New + * chat button: on a non-chat page "new chat" means going to the chat, + * fresh (clear the phase-14 conversation key, then navigate to "/"). */ +import { clearChatStorage, fetchIsAdmin, initSharedHeader } from "/assets/header.js"; + const tbody = document.querySelector("#docs-tbody"); const emptyEl = document.querySelector("#sources-empty"); const tableWrap = document.querySelector(".table-wrap"); @@ -18,13 +26,22 @@ const statLast = document.querySelector("#stat-last"); /* Phase 16: whoami BEFORE the docs fetch. Anonymous visitors get the * sign-in gate (stat cards + table hidden) and NO /api/docs call — the * catalog is admin-only. The document viewer itself stays public (the - * soft rule), so the gate copy points at what keeps working. */ -async function isAdmin() { - try { - const r = await fetch("/api/whoami"); - if (r.ok) return (await r.json()).authenticated === true; - } catch { /* API unreachable: anonymous-safe gate */ } - return false; + * soft rule), so the gate copy points at what keeps working. + * Phase 19: the whoami request is the shared header module's cached + * promise — the same single request initSharedHeader() awaited. */ +function isAdmin() { + return fetchIsAdmin(); +} + +/* Phase 19: New chat on a non-chat page means "go to the chat, fresh": + * clear the phase-14 conversation key, then land on the chat page — its + * empty state, since the conversation is gone from storage. */ +const newChatBtn = document.querySelector("#new-chat-btn"); +if (newChatBtn) { + newChatBtn.addEventListener("click", () => { + clearChatStorage(); + window.location.href = "/"; + }); } function fmtDate(iso) { @@ -112,6 +129,7 @@ function showEmpty() { } (async () => { + await initSharedHeader(); // phase 19: Sign in/out + Sources link in the shared bar if (!(await isAdmin())) { // Anonymous: gate in, catalog out, and no /api/docs request at all. if (statCards) statCards.hidden = true; diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 3a3944d..52b6637 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -998,6 +998,19 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } .doc-back:hover { background: #2a345f; } .doc-back svg { width: 16px; height: 16px; display: block; } .doc-title-block { min-width: 0; } +/* Phase 19: the shared header controls reach the viewer bar (New chat + + Sign in / Sign out) — margin-left:auto pushes them to the right; + the title block keeps clipping (min-width: 0 above) so the two pills + fit while the bar still measures exactly --header-h. The reused + .new-chat-btn / .auth-link classes already carry the ≤640px icon-only + rules, so at 360px the bar is back pill + clipping title + two icon + pills (no overflow — test_responsive_polish pins scrollWidth). */ +.doc-header-actions { + margin-left: auto; + display: flex; + gap: 0.5rem; + align-items: center; +} #doc-title { margin: 0; font-size: 1.3rem; diff --git a/frontend/document.html b/frontend/document.html index 745cfe5..c993872 100644 --- a/frontend/document.html +++ b/frontend/document.html @@ -21,6 +21,27 @@

Loading…

+ +
+ + + +
@@ -53,6 +74,10 @@ + + diff --git a/frontend/index.html b/frontend/index.html index 4be0c36..45aae95 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -19,7 +19,10 @@ @@ -110,6 +113,10 @@ + + diff --git a/frontend/login.html b/frontend/login.html index 2ecd1d3..4853620 100644 --- a/frontend/login.html +++ b/frontend/login.html @@ -20,7 +20,11 @@ @@ -57,6 +61,10 @@ + + diff --git a/frontend/sources.html b/frontend/sources.html index c1c665a..40caa42 100644 --- a/frontend/sources.html +++ b/frontend/sources.html @@ -19,8 +19,28 @@ + + + + @@ -99,6 +119,10 @@ + + diff --git a/tests/e2e/test_chat_persistence.py b/tests/e2e/test_chat_persistence.py index a2259e4..26f1f9f 100644 --- a/tests/e2e/test_chat_persistence.py +++ b/tests/e2e/test_chat_persistence.py @@ -271,12 +271,14 @@ def test_persists_across_page_navigation( _ask(page, QUESTION) _ask_deflected(page, OFF_TOPIC) # mixed conversation: grounded + deflected - # A trip to Sources — the New chat control is chat-page-only. - # (Phase 16: the catalog is admin-only — the trip starts with a - # real form login.) + # A trip to Sources (phase 16: the catalog is admin-only — the trip + # starts with a real form login). Phase 19 (owner permission + # 2026-08-23): the New chat control is part of the SHARED bar, so it + # is present on the sources page too — only clicking it would clear + # the conversation (pinned in tests/e2e/test_shared_header.py). login(page, app_url, next="/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) + expect(page.locator("#new-chat-btn")).to_be_visible() # Back to the chat: the conversation is exactly as left — both turns, # the source chip, and the amber deflected bubble with its chips. diff --git a/tests/e2e/test_shared_header.py b/tests/e2e/test_shared_header.py new file mode 100644 index 0000000..a924808 --- /dev/null +++ b/tests/e2e/test_shared_header.py @@ -0,0 +1,363 @@ +"""Phase 19 E2E (Playwright): the shared header bar on every page. + +Story: ``.agent/user_stories/shared-header.md`` +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_shared_header.py -v --no-cov + +Contract under test (owner report 2026-08-23, phase 19) — ONE bar per +page, the same controls everywhere: + +* chat / sources: brand + nav [Chat, Sources — admin only] + New Chat + + Sign in / Sign out; +* document viewer: back + title + New Chat + Sign in / Sign out (the + viewer has no nav, so no Sources link at all); +* the "Sources" nav link (``#nav-sources``) is HIDDEN for anonymous + users on every page that has a nav and shown for admin (phase-16 UX + revision with owner permission; the soft-gate page and the A10 API + split are untouched); +* the bar height never moves: 64px desktop / 58px at ≤640px (phase-12 + ``--header-h`` contract, bounding-box measurement convention). + +Determinism note: every assertion is settled-state — ``assert_shared_bar`` +first waits for the whoami toggle to land (exactly one of Sign in / +Sign out visible), and the viewer waits for the document to render. No +streaming is involved in this story: the chat page is opened at most for +its header; no turn is ever submitted. + +Test → story mapping (Playwright Mapping Rule): +1. ``test_anonymous_bar_on_all_pages`` +2. ``test_admin_bar_on_all_pages`` +3. ``test_sources_nav_hidden_for_anonymous_everywhere`` +4. ``test_new_chat_from_sources_clears_and_navigates`` +5. ``test_sign_out_from_viewer_returns_to_anonymous`` +6. ``test_mobile_bar_fits_and_heights_held`` +""" +from __future__ import annotations + +import asyncio +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 +from e2e.auth_helpers import login + +REPO = Path(__file__).resolve().parents[2] +FIXTURES = REPO / "tests" / "fixtures" / "docs" +#: A seeded fixture doc (source=docs), URL-encoded — the same document +#: every viewer suite uses (title "Kubernetes Homelab Cluster"). +VIEWER_URL = "/document.html?source=docs&path=homelab%2Fkubernetes.md" +SOURCES_URL = "/sources.html" +DOC_TITLE = "Kubernetes Homelab Cluster" + +#: The shared header-bar token values (frontend/assets/styles.css :root +#: and the ≤640px media query) — phase 12, pinned here as a regression. +DESKTOP_HEADER_H = 64 +MOBILE_HEADER_H = 58 + + +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 owns the test loop).""" + 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 _seed_db(mock_port: int) -> None: + """Fresh KB with the fixture docs (needed for the viewer URL and the + admin sources catalog).""" + with SessionLocal() as db: + db.execute(text("TRUNCATE chunks, documents, query_log")) + db.commit() + _run_in_thread(_import_fixtures(mock_port)) + + +# --------------------------------------------------------------------------- +# The heart of the suite: one helper, the full shared-bar contract +# --------------------------------------------------------------------------- + + +def _expected_h(page: Page) -> int: + """The phase-12 bar height for the current viewport (≤640 → 58).""" + viewport = page.viewport_size + assert viewport is not None, "every test here sets an explicit viewport" + return MOBILE_HEADER_H if viewport["width"] <= 640 else DESKTOP_HEADER_H + + +def _bar_selector(page_kind: str) -> str: + return ".doc-header" if page_kind == "viewer" else ".app-header" + + +def assert_shared_bar(page: Page, admin: bool, page_kind: str) -> None: + """Assert the phase-19 shared-bar contract on the page the ``page`` + is already showing. + + ``page_kind`` is ``"chat"``, ``"sources"``, or ``"viewer"``. The + helper waits for the SETTLED state — both auth controls ship hidden + in the HTML, so "exactly one is visible" means /api/whoami resolved + and header.js (``initSharedHeader``) did its toggle — before any + assertion runs. + """ + # Settled auth state: exactly one of Sign in / Sign out is visible + # (phase-16 semantics, now owned by the shared module). + if admin: + expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000) + expect(page.locator("#sign-in-link")).to_be_hidden() + else: + expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000) + expect(page.locator("#sign-out-btn")).to_be_hidden() + + # New Chat is on the bar on every page kind (the owner's ask). + expect(page.locator("#new-chat-btn")).to_be_visible() + + if page_kind == "viewer": + # The viewer has no nav — no Sources link in the DOM at all. + assert page.locator("#nav-sources").count() == 0, ( + "the viewer bar must not carry a Sources nav link" + ) + # The document itself has settled (rendered, not Loading…/not-found) + # so the bar is being measured on the real page. + expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000) + else: + # The Sources nav link: admin-only (phase-16 revision, owner + # permission 2026-08-23) — hidden for anonymous, shown for admin. + nav = page.locator("#nav-sources") + assert nav.count() == 1, f"one #nav-sources expected on the {page_kind} page" + if admin: + expect(nav).to_be_visible() + else: + expect(nav).to_be_hidden() + + # The bar height never moves: 64px desktop / 58px ≤640px (phase 12), + # bounding-box measurement — the new pills must fit inside it. + box = page.locator(_bar_selector(page_kind)).bounding_box() + assert box is not None, f"{_bar_selector(page_kind)} not rendered" + assert box["height"] == _expected_h(page), ( + f"{page_kind} bar is {box['height']}px, expected {_expected_h(page)}px" + ) + + +def _assert_no_overflow(page: Page, label: str) -> None: + """No horizontal page overflow (the responsive-polish convention).""" + scroll, client = page.evaluate( + "() => [document.documentElement.scrollWidth, document.documentElement.clientWidth]" + ) + assert scroll <= client, f"horizontal overflow on {label}: {scroll} > {client}" + + +# --------------------------------------------------------------------------- +# 1. Anonymous: the bar exists on all three pages, in the anonymous state +# --------------------------------------------------------------------------- + + +def test_anonymous_bar_on_all_pages( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + page.set_viewport_size({"width": 1280, "height": 800}) + _seed_db(mock_llm) + + page.goto(app_url + "/") + assert_shared_bar(page, admin=False, page_kind="chat") + + page.goto(app_url + SOURCES_URL) + # Phase 16's soft gate is unchanged for direct-URL visitors — the + # bar above it is what this suite pins. + expect(page.locator("#sources-gate")).to_be_visible() + assert_shared_bar(page, admin=False, page_kind="sources") + + page.goto(app_url + VIEWER_URL) + assert_shared_bar(page, admin=False, page_kind="viewer") + + +# --------------------------------------------------------------------------- +# 2. Admin: the bar on all three pages flips to the signed-in state +# --------------------------------------------------------------------------- + + +def test_admin_bar_on_all_pages( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + page.set_viewport_size({"width": 1280, "height": 800}) + _seed_db(mock_llm) + + # Real form login with next=/ — the phase-16 redirect flow still + # lands the admin on the chat page. + login(page, app_url, next="/") + expect(page).to_have_url(app_url + "/") + assert_shared_bar(page, admin=True, page_kind="chat") + + page.goto(app_url + SOURCES_URL) + expect(page.locator("#sources-gate")).to_be_hidden() + expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000) + assert_shared_bar(page, admin=True, page_kind="sources") + + page.goto(app_url + VIEWER_URL) + assert_shared_bar(page, admin=True, page_kind="viewer") + + +# --------------------------------------------------------------------------- +# 3. The Sources nav link: hidden for anonymous everywhere, revealed +# after a real login (a toggle, not just initial state) +# --------------------------------------------------------------------------- + + +def test_sources_nav_hidden_for_anonymous_everywhere( + page: Page, app_url: str, db_ready: None +) -> None: + page.set_viewport_size({"width": 1280, "height": 800}) + + for path in ("/", SOURCES_URL): + page.goto(app_url + path) + # Settled anonymous state, then the nav-link contract. + expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000) + nav = page.locator("#nav-sources") + assert nav.count() == 1 + expect(nav).to_be_hidden() + + # The login page has no chat controls — header.js only toggles the + # nav link there; for anonymous it stays hidden (it ships hidden). + page.goto(app_url + "/login.html") + page.wait_for_load_state("networkidle") # the whoami round-trip has settled + nav = page.locator("#nav-sources") + assert nav.count() == 1 + expect(nav).to_be_hidden() + + # And the toggle works, not just the initial state: after a real + # form login on the chat page the link appears. + login(page, app_url, next="/") + expect(page).to_have_url(app_url + "/") + expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000) + expect(page.locator("#nav-sources")).to_be_visible() + + +# --------------------------------------------------------------------------- +# 4. New Chat from a non-chat page: clear the conversation, land on the +# chat empty state +# --------------------------------------------------------------------------- + + +def test_new_chat_from_sources_clears_and_navigates( + page: Page, app_url: str, db_ready: None +) -> None: + page.set_viewport_size({"width": 1280, "height": 800}) + + # Seed the phase-14 conversation before any page script runs. The + # init script runs on EVERY navigation, so it is scoped to the + # sources page — the post-click navigation to "/" must start clean. + page.add_init_script( + """(() => { + if (location.pathname !== "/sources.html") return; + try { + localStorage.setItem("bor.chat.v1", JSON.stringify({ + v: 1, + messages: [ + { who: "user", text: "hello brain" }, + { who: "brain", text: "hey there" } + ] + })); + } catch {} + })();""" + ) + + page.goto(app_url + SOURCES_URL) + # The seeded conversation is in storage… + assert ( + page.evaluate("() => localStorage.getItem('bor.chat.v1')") is not None + ), "init script must have seeded the phase-14 conversation key" + + # New Chat from the sources page: a new chat means going to the + # chat — fresh. + page.click("#new-chat-btn") + expect(page).to_have_url(app_url + "/", timeout=30_000) + + # …and the chat lands on its empty state with the key removed. + expect(page.locator("#empty-state")).to_be_visible() + expect(page.locator(".msg")).to_have_count(0) + assert page.evaluate("() => localStorage.getItem('bor.chat.v1')") is None, ( + "New Chat from a non-chat page must clear the localStorage key" + ) + + +# --------------------------------------------------------------------------- +# 5. Sign out from the viewer: the same page comes back anonymous +# --------------------------------------------------------------------------- + + +def test_sign_out_from_viewer_returns_to_anonymous( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + page.set_viewport_size({"width": 1280, "height": 800}) + _seed_db(mock_llm) + + # Log in with next=/sources.html — lands on the admin sources bar… + login(page, app_url, next="/sources.html") + expect(page).to_have_url(app_url + "/sources.html") + expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000) + + # …and open the viewer directly: the admin bar is there too. + page.goto(app_url + VIEWER_URL) + assert_shared_bar(page, admin=True, page_kind="viewer") + + # Sign out from the viewer: header.js POSTs /api/logout and reloads; + # after the reload the same page shows the anonymous bar. + page.click("#sign-out-btn") + assert_shared_bar(page, admin=False, page_kind="viewer") + expect(page).to_have_url(app_url + VIEWER_URL) + + +# --------------------------------------------------------------------------- +# 6. Mobile (375×812): 58px bars, no horizontal overflow, in BOTH auth +# states — the new pills never grow the bar +# --------------------------------------------------------------------------- + + +def test_mobile_bar_fits_and_heights_held( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + page.set_viewport_size({"width": 375, "height": 812}) + _seed_db(mock_llm) + + def check_all(admin: bool) -> None: + for kind, path in ( + ("chat", "/"), + ("sources", SOURCES_URL), + ("viewer", VIEWER_URL), + ): + page.goto(app_url + path) + # 58px at 375px is asserted inside assert_shared_bar… + assert_shared_bar(page, admin=admin, page_kind=kind) + # …and the pills (icon-only at ≤640px) fit without overflow. + _assert_no_overflow(page, f"{kind} @375px (admin={admin})") + + # Anonymous: the two icon pills are Sign in + New chat. + check_all(admin=False) + + # Signed in: Sign out + the Sources nav link join the bars — and the + # bar never grows. + login(page, app_url, next="/") + expect(page).to_have_url(app_url + "/") + check_all(admin=True) diff --git a/tests/unit/test_chat_persistence.py b/tests/unit/test_chat_persistence.py index dd05651..8514c37 100644 --- a/tests/unit/test_chat_persistence.py +++ b/tests/unit/test_chat_persistence.py @@ -10,7 +10,8 @@ Pinned design (PLAN §7.4 note / phase 14): * 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. +* ``#new-chat-btn`` in the shared header bar (chat, sources, viewer — + phase 19, owner permission 2026-08-23), ≥44px, ghost pill. """ from __future__ import annotations @@ -144,24 +145,29 @@ def test_new_chat_clears_key_and_ui() -> None: 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).""" +def test_new_chat_button_in_the_shared_header_bar() -> None: + """#new-chat-btn is a real type=button with an accessible name in the + chat header (index.html). Phase 19 (owner permission 2026-08-23): it + is part of the SHARED bar — it also appears in sources.html and the + document viewer, where it means "go to the chat, fresh" (the + clear-storage + navigate binding is pinned in test_shared_header.py).""" + for html in (INDEX_HTML, SOURCES_HTML, DOCUMENT_HTML): + text = html.read_text(encoding="utf-8") + btn = re.search(r']*id="new-chat-btn"[^>]*>', text) + assert btn, f"{html.name} must contain #new-chat-btn" + tag = btn.group(0) + assert 'type="button"' in tag + assert 'aria-label="New chat"' in tag + # Chat page specifics: the button sits after the nav, inside the header. 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('