fix(chat): keep in-flight answers alive across in-app view switches
Root cause (owner repro, verified in a real browser 2026-09-06): the five navbar views (Chat, RAG, Sources, Tuning, History) were separate HTML documents, so a navbar click was a REAL cross-document navigation — the chat page unloaded, the in-flight SSE fetch was aborted, and the phase-48 teardown (app/api/chat.py `finally`, "chat: turn cancelled") stopped the model. Observed: send question -> click RAG mid-stream -> click Chat -> the answer never finished: no `query_log` row, and on return a dangling question with no brain record (the pre-token pagehide partial persist skips because `acc` is empty). Phase-48 LOCKED-DECISION REFINEMENT (owner-confirmed 2026-09-06, flagged per AGENTS.md rule 3, not silently deviated): "real navigation cancels the fetch" now means LEAVING THE APP — tab close, external/other-document navigation, the Stop button. In-app navbar switches are client-side view switches and no longer cancel. Fix — Option A (SPA shell), chosen over B (Service Worker owns the stream) and C (server-side turn registry + resume): - frontend/index.html is the shell: ONE `<main id="main">` holds the five `<section class="view">` blocks; hidden views carry BOTH `hidden` and `inert` (WCAG — no focus/keyboard traversal). The shared header, the single `doc-modal-*` skeleton, and the `#app-version` footer each exist exactly once; the per-view copies from the four folded pages are dropped. - New frontend/assets/router.js (vanilla module — no framework, no bundler, No-CDN rule intact): lazy-imports a view module on FIRST show only (mount-once, hide-forever — the chat view's in-flight SSE reader persists across switches; that persistence IS the fix); intercepts same-shell navbar links with preventDefault + history.pushState (never a document load); handles popstate; single writer of `.nav-link` active state (is-active + aria-current), document.title, and the per-view meta description (values carried over from the old pages' heads, brand-resolved at write time). - Each folded page's JS becomes `export async function mount(root)` — root-scoped queries; `initSharedHeader()` dropped (the header boots once in the shell via the chat module; the admin flag comes from the same cached `fetchIsAdmin()` promise — zero extra requests). - app/main.py: a small list-driven route factory serves the shell for /tuning.html, /sources.html, /git-sources.html, /history.html — registered AFTER the API routers and BEFORE the static catch-all (routes-first). The phase-33 caching middleware applies no-cache + `?v=` rewriting unchanged; app/core/caching.py needed NO change (the view paths did not change — pinned by the integration tests). - The four old view .html files are DELETED (one source of truth); deep links to the old URLs keep working (the router picks the view from the pathname); `/?chat=<id>` is unaffected; the Containerfile bundles router.js (inlining the lazy view modules) and drops the folded page files. - app/schemas.py: HistoryTurn.text cap 4000 -> 32000 — the shell keeps long saved answers in the chat, and the old cap (stricter than the 24_000-char total history budget) 422-rejected any second turn in such a chat (found by the phase-42 E2E suite on the shell). Boundaries: login.html, shared.html, doc-edit.html, document.html REMAIN separate documents (flow pages, not navbar tabs); a mid-stream navigation to doc-edit/document.html still cancels per phase 48 (follow-up candidate, out of scope). The SSE API is unchanged. Real departures still cancel the turn — phase 48 intact (pinned by tests/e2e/test_stop_generation.py, unchanged, and by the new suite's real-departure control). Tests: - Phase-20 suite REWRITTEN to the new semantics (tests/e2e/test_sources_midstream_bug.py): a navbar switch no longer cancels — the stream survives the switch and the FULL answer settles; the pagehide partial persist REMAINS for real departures (the partial's exact shape — first streamed chunk prefix, no done metadata — is still pinned there). - NEW story suite tests/e2e/test_nav_switch_keeps_stream.py (mock LLM): the owner repro (send -> RAG mid-stream -> Chat: window sentinel survives = same document, FULL answer, exactly one brain turn in bor.chat.v1, exactly one settled query_log row, auto-saved row matches) + the same mid-stream switch against the other three views + the real-departure-still-cancels control + the no-switch baseline. - tests/unit/test_frontend_router.py: source-level pins of the router invariants (click interceptor targets ONLY same-shell view paths, pushState-only switches, mount-once guard, hidden+inert pair, single-writer active state/title); shell-route integration tests (each folded path serves the shell with no-cache + `?v=` body; a non-view path still 404s); the file-reading unit pins re-pointed at the shell (the four view files are gone — the shell is the source of truth). Verification (this commit): full suite green — 1565 unit+integration tests, app/ coverage 99% (>90% floor); ruff + pyright clean; the phase's E2E suites green in isolation (house protocol, AGENTS.md rule 9). Owner repro verified in a real browser against the real LLM (dev server :8010, headful Chromium): "tell me about everquest" -> RAG mid-stream -> Chat — the answer completed with one brain bubble and no error banner, `query_log` gained exactly one settled row (deflected=True: the dev KB holds no EverQuest docs — the settle, not the topic, is the proof), zero "chat: turn cancelled" lines for that turn; the control (real navigation to /shared.html mid-stream) still cancelled (no settled row, the cancel line logged, the partial persisted on return). Screenshots: .agents/screenshots/76_manual_*. Phase 76 (76_spa_nav_shell) complete — moved to .agents/phases/complete/.
@@ -0,0 +1,50 @@
|
||||
# Phase 76 — In-app view switches never halt a generating answer (the navbar views become one document)
|
||||
|
||||
**Source:** Owner repro, verified in a real browser 2026-09-06: send a question → click **RAG** in the navbar mid-stream → click **Chat** → the answer never finishes (turn cancelled: no `query_log` row, and on return a dangling question with no brain record — the pre-token `pagehide` partial persist skips because `acc` is empty). Root cause: every navbar view is a separate HTML document (`RAG` = `<a href="/sources.html">`), so a navbar click is a REAL cross-document navigation — the chat page unloads, the in-flight `fetch` is aborted, and the phase-48 teardown (`app/api/chat.py` `finally`, `chat: turn cancelled`) stops the model.
|
||||
**Story:** n/a (TODO-derived — descendant of `.agents/user_stories/sources-midstream.md` (phase 20) and `TODO.md` L3 (phase 73))
|
||||
**Context:** `frontend/index.html` (chat page; the shared `<header class="app-header">` markup is copy-pasted into every page), `frontend/assets/app.js` (the chat logic — owns the in-flight SSE stream that must SURVIVE view switches; phase-20/48/73 machinery must stay intact and its source-level unit pins in `tests/unit/test_frontend_*.py` must keep passing), `frontend/assets/{sources,git-sources,tuning,history}.js` (page modules that boot at document load and call `initSharedHeader()`), `frontend/assets/header.js` (shared header: auth-gated links, active state, mobile hamburger), `app/main.py` (explicit routes + catch-all `StaticFiles` mount, API routes first), `app/core/caching.py` (`HTML_PAGES` list — the view PATHS do not change, so the no-cache + `?v=` rewrite contract applies to the shell routes untouched), `tests/e2e/test_sources_midstream_bug.py` (phase-20 suite that encodes the OLD "partial survives the navigation" semantics — rewritten in task 02), `tests/e2e/test_hidden_tab_stream.py` (phase 73 — must stay green unchanged), `tests/e2e/test_stop_generation.py` (phase 48 — must stay green unchanged).
|
||||
|
||||
## Objective
|
||||
Collapse the five navbar views (Chat, RAG, Sources, Tuning, History) into views of ONE HTML shell so a navbar click is a client-side view switch (`history.pushState` + show/hide), never a document load. An in-flight answer keeps streaming through any navbar switch and completes when the user returns to Chat — the owner repro (send → RAG → Chat) must finish the FULL answer, settled server-side (one `query_log` row), with exactly one brain turn persisted. Real departures (tab close, leaving the app, the Stop button) still cancel the fetch and stop the model — the phase-48 contract, intact.
|
||||
|
||||
## Owner decisions (chat, 2026-09-06 — recorded per AGENTS.md rule 3)
|
||||
- **Option A** (SPA-ify the navbar views) chosen over B (Service Worker owns the stream) and C (server-side turn registry + resume).
|
||||
- **LOCKED-DECISION REFINEMENT (phase 48):** "real navigation cancels the fetch" now means **leaving the app** (tab close, external/other-document navigation, Stop). In-app navbar switches no longer cancel. Owner-confirmed refinement — flagged, not silently deviated.
|
||||
- **Boundaries:** `login.html`, `shared.html`, `doc-edit.html`, `document.html` REMAIN separate documents (flow pages, not navbar tabs). A mid-stream navigation to doc-edit/document.html still cancels per phase 48 (follow-up candidate, out of scope). The SSE API is unchanged.
|
||||
- **No new technology:** vanilla JS modules, no framework, no bundler — consistent with the existing architecture and the No-CDN rule (AGENTS.md #6).
|
||||
|
||||
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||
- **Shell:** `frontend/index.html` becomes the shell. The shared header stays at the top of the shell (it is copy-pasted into every page today; in the shell it exists ONCE, so the header/nav/auth/steering ids — `#app-nav`, `#nav-*`, `#sign-in-link*`, `#sign-out-btn*`, `#steering-*`, `#nav-toggle` — deduplicate automatically). ONE `<main id="main" class="app-main" tabindex="-1">` container (skip-link target unchanged) holds the five `<section class="view" id="view-<name>" aria-label="…" tabindex="-1">` blocks; each folded page's own `<main class="app-main">` wrapper (all five pages carry the identical one — layout CSS is class-based, so nothing is lost) is DROPPED when its content moves. Only the active view is rendered: hidden views carry BOTH `hidden` AND `inert` (WCAG — hidden views must not receive focus or keyboard traversal; AGENTS.md #5). ONE shell footer (the chat page's `#app-version` footer): per-page footers from folded views are dropped (`app-version` otherwise duplicates — it exists in both `index.html` and `history.html`).
|
||||
- **Duplicate-id resolution (audited 2026-09-06, whole-file id scan of the five pages, comments stripped):** the only cross-page id collisions in VIEW markup are (a) the per-view `<main id="main">` — resolved by the single-main container above, and (b) the `doc-modal-*` family (9 ids: `doc-modal`, `-backdrop`, `-close`, `-content`, `-desc`, `-meta`, `-open`, `-panel`, `-title`), present in BOTH `index.html` and `sources.html` — the shell keeps EXACTLY ONE instance (the chat's, already in `index.html`), placed at body level (the modal is a `position: fixed` overlay; its DOM position is cosmetic). `document-modal.js` resolves all of them with document-level `querySelector` at MODULE IMPORT (its lines 44–52), so the single skeleton must be static markup present before any import — it is. `app.js` (chat chips) and `sources.js` (RAG rows) both call `openDocumentModal(...)` and work UNCHANGED against the one shared instance. The moved sources view markup DROPS its skeleton copy (task 02).
|
||||
- **Same-document proof (canonical E2E assertion for "no document load"):** set a `window` sentinel before the nav click (`window.__shell_boot = "phase76"`) and assert it is still readable after the switch + return — a real navigation wipes `window` globals. Do NOT use `performance.getEntriesByType("navigation").length` — a real load resets that counter to 1 in the fresh document, so it cannot distinguish pushState from a reload.
|
||||
- **View-scoped E2E absence pattern:** in the shell, hidden views' elements REMAIN IN THE DOM (hidden+inert). Any E2E absence assertion (`to_have_count(0)`) on a view-scoped id must be re-scoped to the visible view (e.g. `#view-rag #new-chat-btn`) or switched to a visibility assertion (`to_be_hidden`). Known instance: `tests/e2e/test_chat_persistence.py::test_persists_across_page_navigation` asserts `#new-chat-btn` count 0 on the Sources page (task 02 fixes it).
|
||||
- **Router (new `frontend/assets/router.js`, module, ~200 lines):** a `VIEW` map of `pathname → view name` (`"/"` → chat, plus one entry per folded view); on boot it reads `location.pathname`, lazy-`import()`s the view module on FIRST show only, calls `await module.mount(root)`, then shows it. **Mount-once, hide-forever:** a view's DOM (and JS state — for chat, the in-flight SSE reader) persists across switches; that persistence IS the fix. A delegated click handler on the navbar intercepts same-shell view links (`preventDefault` + `pushState` + switch — no document load); `popstate` switches views for back/forward. The router is the SINGLE WRITER of `.nav-link` active state (`is-active` + `aria-current="page"`), `document.title`, and the per-view `<meta name="description">` (values carried over from the old pages' `<head>`s).
|
||||
- **View modules:** each folded page's JS changes from top-level boot to an exported `async function mount(root)`; DOM queries scope to `root` (view element ids stay unique across the shell — verify, don't rename, to keep E2E selectors stable); the `initSharedHeader()` call is dropped (the header boots once in the shell via the chat module, as today).
|
||||
- **Boot order in the shell:** `brand.js` (classic) → `app.js` (module — the chat view; runs at shell boot exactly as today, so its boot/restore/streaming behavior is untouched) → `router.js` (module — lazy-imports non-chat views only).
|
||||
- **Server:** one small route factory in `app/main.py` serves the shell (`frontend/index.html`) for the folded view paths, registered BEFORE the static catch-all mount (API routes first, per existing comment). The phase-33 caching middleware already wraps the whole app and lists these paths in `HTML_PAGES`, so no-cache + `?v=` asset rewriting applies unchanged — verify, don't rewire. The old per-view `.html` files are DELETED in the same task that lands their shell route (one source of truth).
|
||||
- **Deep links:** every old view URL (`/sources.html`, …) keeps working as a direct load — the server serves the shell, the router picks the view from the pathname. `/?chat=<id>` (the saved-chat deep link read by `app.js` at boot) is unaffected — it already lives on the shell's own path.
|
||||
- **Auth flag in view modules:** the folded view modules today call `await initSharedHeader()` and (in `sources.js`, `git-sources.js`) USE ITS RETURN VALUE as the admin flag. In the shell the header boots once via the chat module — so each view module DROPS `initSharedHeader()` and reads the admin flag from `fetchIsAdmin()` instead (the SAME cached `/api/whoami` promise `header.js` exports — zero extra requests; `sources.js`/`history.js`/`tuning.js` already import it, `git-sources.js` must add it to its import).
|
||||
|
||||
## Dependencies
|
||||
— (none)
|
||||
|
||||
## Tasks
|
||||
1. `01_shell_router_tuning.md` — shell + router + shell-route factory; fold Tuning as the first non-chat view (proves the pattern end-to-end, gate included).
|
||||
2. `02_fold_rag_sources_views.md` — fold RAG (sources) + Sources (git-sources); REWRITE the phase-20 `test_sources_midstream_bug.py` to the new "the stream survives" semantics.
|
||||
3. `03_fold_history_view.md` — fold History; verify the `/?chat=<id>` deep link and history suites.
|
||||
4. `04_header_shell_wiring.md` — header is shell-owned (single-writer active state, auth gate, hamburger, per-view title/meta); the surviving documents keep their header copies; update the header/nav consistency suites.
|
||||
5. `05_stream_survival_e2e.md` — new story suite `test_nav_switch_keeps_stream.py`: the owner repro pinned on the mock LLM (mid-stream switch → FULL answer, one brain turn, settled turn) + the real-departure-still-cancels control.
|
||||
6. `06_regression_sweep_commit.md` — full suite + coverage floor + ruff/pyright; manual verification of the owner repro in a real browser; atomic commit; phase → complete.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_frontend_router.py` — source-level pins of the router invariants (house pattern: `tests/unit/test_frontend_hidden_tab.py` reads JS source without a browser): the click interceptor targets ONLY same-shell view paths; switches use `pushState` (no document load); mount-once guard; hidden views get `hidden` + `inert`; the router writes active state/title.
|
||||
- Integration: the shell-route tests — each folded path serves the shell (200, `text/html`, body is the index page carrying `?v=`-tagged asset refs, `Cache-Control: no-cache`), and a non-view path (e.g. `/nonexistent.html`) still 404s. `app/core/caching.py` must need NO change (the paths are unchanged) — pin that in the test's assertion set.
|
||||
- Coverage: **>90%** on `app/` (server delta is the small route factory + tests).
|
||||
- E2E: new story suite `tests/e2e/test_nav_switch_keeps_stream.py` run in isolation (`uv run pytest tests/e2e/test_nav_switch_keeps_stream.py -v --no-cov`) against the deterministic mock LLM (the ~8 s long stream gives the guaranteed mid-stream window — house pattern from `tests/e2e/test_hidden_tab_stream.py`). The phase-20 suite is REWRITTEN in task 02 (its premise — navbar click = navigation — is the behavior this phase removes); phase 73 (`test_hidden_tab_stream.py`) and phase 48 (`test_stop_generation.py`) must stay green UNCHANGED.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] Owner repro passes in a REAL browser (real LLM): send → RAG mid-stream → Chat → the FULL answer completes, one brain turn, one `query_log` row (settled, no `turn cancelled`).
|
||||
- [ ] All five navbar views render in the shell; direct loads of `/`, `/sources.html`, `/git-sources.html`, `/tuning.html`, `/history.html` deep-link to the right view; the four old view `.html` files are deleted; the cache-busting E2E (`test_cache_busting.py`, `test_asset_cache_revalidation.py`) is green with the shell routes.
|
||||
- [ ] `uv run pytest tests/e2e/test_nav_switch_keeps_stream.py -v --no-cov` green in isolation, including the real-departure control (external navigation / Stop still cancels per phase 48).
|
||||
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] One atomic `--no-gpg-sign` Conventional-Commits commit (e.g. `fix(chat): keep in-flight answers alive across in-app view switches`) whose body records the owner repro + the phase-48 refinement; phase dir moved to `.agents/phases/complete/`.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Task 01 — Shell + router + shell-route factory; Tuning becomes the first non-chat view
|
||||
|
||||
**Phase:** `76_spa_nav_shell` · **Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
Prove the whole architecture end-to-end with ONE folded view: the shell structure, the new router (pushState view switches, mount-once, hidden+inert, single-writer active state/title), the server shell-route factory, and `tuning.js` as a `mount(root)` module. After this task, clicking Tuning in the navbar switches views WITHOUT a document load, and direct loads of `/tuning.html` still deep-link to the Tuning view.
|
||||
|
||||
## Work
|
||||
1. `app/main.py` — add a small shell-route factory: `def _shell_routes(app, static_dir, paths)` registering `GET <path>` handlers that return `frontend/index.html` (`text/html`), added AFTER `app.include_router(...)` calls and BEFORE the `app.mount("/", StaticFiles(...))` catch-all (API/routes-first invariant). Call it with `("/tuning.html",)` for this task (tasks 02/03 extend the list — keep the factory list-driven). VERIFY (curl + the integration test) that the phase-33 caching middleware already applies to these responses: `Cache-Control: no-cache` and `?v=<token>`-tagged asset refs (the path is already in `app/core/caching.py` `HTML_PAGES` — do NOT edit that file; if the header is missing from the route response, set it on the route instead of rewiring the middleware).
|
||||
2. `frontend/index.html` — restructure the body per the phase design: the existing `<main id="main" class="app-main" tabindex="-1">` becomes the single view container; INSIDE it, wrap the chat content in `<section class="view" id="view-chat" aria-label="Chat" tabindex="-1">` and add `<section class="view" id="view-tuning" hidden inert aria-label="Global Tuning" tabindex="-1">`. The tuning section gets the content of `frontend/tuning.html`'s `<main>` with its `<main>` wrapper DROPPED (the single container main is the shared layout; the class-based `.app-main` styling is unaffected — all five pages' mains carry the identical class). Drop the tuning file's `<head>`/`<header>`/`<script>` blocks when moving. Verify the moved markup's element ids against the shell's (the id audit in the phase overview is the reference: the only cross-page view-markup collisions are `#main` and the `doc-modal-*` family — neither is in the tuning content; do not rename any id, E2E selectors depend on them).
|
||||
3. `frontend/assets/router.js` (NEW, ES module, ~200 lines) — per the phase design: `VIEW` map (`"/" → "chat"`, `"/tuning.html" → "tuning"`); boot from `location.pathname`; lazy `import("./tuning.js")` on first show; `await module.mount(root)`; show = remove `hidden` + `inert`, hide = add both; focus the target section (its `tabindex="-1"`) ONLY on user-initiated switches (navbar click / popstate) — never on initial boot (no focus steal on load); delegated `click` handler on the navbar: same-origin `a.nav-link` whose `pathname` is in `VIEW` → `preventDefault()` + `history.pushState` + switch (never a document load); `popstate` → switch; SINGLE WRITER of `.nav-link` `is-active`/`aria-current` and of `document.title` (chat: `"Brain of Reese"`, tuning: `"Global Tuning · Brain of Reese"`) + `<meta name="description">` (carry the old tuning value over). The shell markup starts with ONLY the Chat link statically `is-active` (the default view) — no view other than chat may carry a static active stamp. The chat view needs NO module import — `app.js` already runs at shell boot.
|
||||
4. `frontend/index.html` — load `router.js` as a module AFTER `app.js` (boot order per phase design: `brand.js` classic → `app.js` module → `router.js` module).
|
||||
5. `frontend/assets/tuning.js` — convert the top-level boot into `export async function mount(root)`: wrap the existing boot body (today's `(async () => { await initSharedHeader(); if (await fetchIsAdmin()) loadNotes(); })()` at the bottom); scope its DOM lookups to `root` where they are not already unique-id-based; DROP the `initSharedHeader()` call (the shell's header boots via the chat module, as today) but KEEP `fetchIsAdmin()` for the admin gate (same cached whoami promise — see the phase overview's auth-flag note; tuning.js already imports it).
|
||||
6. DELETE `frontend/tuning.html` (the shell route now serves the shell for that path).
|
||||
7. `tests/unit/test_frontend_router.py` (NEW, house source-pin pattern — read `tests/unit/test_frontend_hidden_tab.py` for the idiom) — pin: the interceptor matches only `VIEW`-map paths (no other links are intercepted); the switch uses `history.pushState` and NOT `location.assign`/`location.href`/`reload`; a mount-once guard exists per view; hidden views get both `hidden` and `inert`; the router sets `is-active`/`aria-current` and `document.title`.
|
||||
8. `tests/integration/test_api.py` — extend the page-serving tests (see `test_index_html_variant_no_cache_versioned`, ~L211, and the per-page `<title>` table ~L153–158): (a) NEW test — `GET /tuning.html`: 200, `text/html`, `Cache-Control: no-cache`, `?v=`-tagged asset refs, body is the SHELL (contains `id="view-tuning"`, not the old page's content); `GET /nonexistent.html` still 404s (the catch-all is intact). (b) The title table entry `("/tuning.html", "Global Tuning")` no longer holds — the shell route serves the shell whose static `<title>` is `"Brain of Reese"` (the per-view title is set CLIENT-side by the router, invisible to httpx): change the entry to assert the body is the shell (e.g. contains `id="view-tuning"`) instead of the page title.
|
||||
9. `tests/integration/test_caching_revalidation.py` — `_page_file(path)` maps a page path to its backing file on disk and asserts `file.is_file()`; it breaks the moment `tuning.html` is deleted. Add the shell-served paths to a path→backing-file override mapping (`"/tuning.html" → "index.html"`; tasks 02/03 extend it) so the etag computation uses the file that actually backs the response. The page CONTRACT itself (200, no-cache, `?v=`, no validators on the response) is unchanged — the phase-33 middleware wraps the whole app and lists the path in `HTML_PAGES`, so the shell-route response is normalized the same way as a static page (verify this in the new test: no `etag`/`last-modified` on the shell-route response).
|
||||
10. `tests/unit/test_chat_persistence.py::test_new_chat_button_lives_only_on_the_chat_page` — the per-file negative check iterates `(SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML)`; the file-level "only on the chat page" semantics die with the shell. Convert to the final shell form in ONE step (later tasks then leave it alone): positive — the button markup sits inside the shell's chat view section (`id="view-chat"` region of `index.html`); negative — the button markup is absent from the SURVIVING separate documents (`document.html`, `login.html`, `shared.html`) — and stop iterating the to-be-folded view files. Also `tests/unit/test_history_page.py` (it enumerates all eight page files — the shared-markers audit): update its page list to the post-shell set (the shell + surviving documents; drop the folded view files as they are deleted across tasks 01–03 — in THIS task, drop `TUNING_HTML`).
|
||||
11. Run the tuning-adjacent E2E in isolation and fix ONLY what genuinely breaks from the view fold (URL assertions like `to_have_url(app_url + "/tuning.html")` should still pass — pushState sets the same URL): `uv run pytest tests/e2e/test_global_tuning.py tests/e2e/test_tuning_nav_link.py tests/e2e/test_tuning_toggle_flash.py tests/e2e/test_cache_busting.py tests/e2e/test_asset_cache_revalidation.py -v --no-cov` (DB up: `podman compose up -d db`).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_frontend_router.py` (new invariants above).
|
||||
- Integration: shell-route test (item 8); `app/core/caching.py` unchanged (assert the path list was not edited — the test's own expectations are the pin).
|
||||
- Coverage: **>90%** on the new/modified `app/` code (the route factory is exercised by the integration test).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] In a browser: `GET /tuning.html` deep-links to the Tuning view (admin); the Chat view is hidden (`hidden` + `inert`); clicking Chat in the navbar returns to chat with NO document load (window-sentinel pattern from the phase overview — `window.__shell_boot` set before the click is still readable after the switch); clicking Tuning again re-shows the cached view (no refetch).
|
||||
- [ ] The full suite is green (`uv run pytest --cov=app`) and `uv run ruff check . && uv run pyright` is clean — the validation gate runs after this task, so NOTHING may be left half-migrated.
|
||||
- [ ] `tests/e2e/test_global_tuning.py`, `test_tuning_nav_link.py`, `test_tuning_toggle_flash.py`, `test_cache_busting.py`, `test_asset_cache_revalidation.py` green in isolation.
|
||||
- [ ] No behavior change in completed phases: chat boot/restore/streaming (phases 20/48/49/50/55/66/73/74) untouched — `tests/e2e/test_chat_rag.py` and `tests/e2e/test_hidden_tab_stream.py` still green.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Task 02 — Fold RAG (sources) + Sources (git-sources); rewrite the phase-20 midstream suite to the new semantics
|
||||
|
||||
**Phase:** `76_spa_nav_shell` · **Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
Fold the two biggest admin views — RAG (`sources.js`, 559 lines, includes the document modal mount points) and Sources (`git-sources.js`, 889 lines, live upload-progress UI) — into the shell, and REWRITE `tests/e2e/test_sources_midstream_bug.py` (the phase-20 suite): from this task on, a mid-stream navbar click NO LONGER navigates, so the pinned behavior is "the stream survives and the answer completes" instead of "a partial survives the navigation".
|
||||
|
||||
## Work
|
||||
1. `app/main.py` — extend the task-01 shell-route factory call to `("/tuning.html", "/sources.html", "/git-sources.html")`; extend the integration work from task 01 items 8–9 to the two new paths (shell-route test + the title-table entry for each path changed to the shell-body assertion + the `_page_file` override map).
|
||||
2. `frontend/index.html` — add `<section class="view" id="view-rag" hidden inert aria-label="RAG" tabindex="-1">` and `<section class="view" id="view-git-sources" hidden inert aria-label="Sources" tabindex="-1">` with the view content moved out of `frontend/sources.html` / `frontend/git-sources.html` — their `<main>` wrappers DROPPED (single container main, per the phase design). CRITICAL dedup while moving: the sources view markup carries a SECOND copy of the `doc-modal-*` skeleton (9 ids — the audit in the phase overview) — DROP that copy entirely: the shell keeps the ONE chat skeleton (already in `index.html`, body level), and `document-modal.js` (document-level `querySelector` at import, its lines 44–52) plus `openDocumentModal(...)` calls from BOTH `app.js` and `sources.js` target that single instance UNCHANGED. Drop any per-page footer from the moved markup (only `index`/`history` have one — history lands in task 03). Verify the moved views' ids against the shell (the only collisions the audit found are `#main` and `doc-modal-*` — both resolved above; do not rename any other id, E2E selectors depend on them). Extend the router `VIEW` map (`"/sources.html" → "rag"`, `"/git-sources.html" → "git-sources"`) and the title/meta table (carry the old `<title>`/`<meta name="description">` values; the meta descriptions are `"Documents indexed in Brain of Reese."` and `"Add and remove the git repositories Brain of Reese syncs and indexes (admin-only)."`).
|
||||
3. `frontend/assets/sources.js` — top-level boot → `export async function mount(root)`; scope DOM lookups to `root`; DROP the `initSharedHeader()` call (line ~547: `const admin = await initSharedHeader()`) and read the flag from `fetchIsAdmin()` instead — sources.js ALREADY imports both from `header.js`, so this is a one-line change with zero extra whoami requests (see the phase overview's auth-flag note). The document modal needs NO wiring change (single shared skeleton — item 2).
|
||||
4. `frontend/assets/git-sources.js` — same conversion: top-level boot → `export async function mount(root)`, scope DOM lookups to `root`, and line ~876 `const admin = await initSharedHeader()` → `const admin = await fetchIsAdmin()` (ADD `fetchIsAdmin` to the `header.js` import — it currently imports only `initSharedHeader`). The upload-progress state machine (phase 64/65 UI) must work when the view is mounted ONCE and re-shown without re-mount: its poller is a self-chaining `setTimeout(tick, UPLOAD_POLL_MS)` started when an upload begins (not at boot) — it keeps running across view switches in the same document (progress continues while the user is on another view), and nothing may refetch on re-show.
|
||||
5. DELETE `frontend/sources.html` and `frontend/git-sources.html`.
|
||||
6. **REWRITE `tests/e2e/test_sources_midstream_bug.py`** (keep the file + story mapping; update the docstring: from this phase on, a navbar click is a client-side view switch — the stream SURVIVES; the phase-20 pagehide partial persist remains for REAL departures only, and its coverage home is scenario 1 below in its renamed form). The rewrite, mapped to the suite's CURRENT tests:
|
||||
- `test_partial_answer_survives_real_departure_midstream` (RENAMED from `test_partial_answer_survives_sources_nav_midstream` — the phase-20 partial-persist pin, now exercised via a genuine departure): keep the test body and ALL of its assertions (mid-stream state, the partial rendered on return, and the storage shape: `whos == ["user", "brain"]`, text `startswith(FIRST_CHUNK_RAW)`, shorter than `FULL_LONG`, NO done metadata — no `sources`/`deflected`/`suggestions`/`thinking` keys), but change the departure: `page.click("#nav-sources")` becomes `page.goto(app_url + "/shared.html")` (a REAL cross-document departure — the fetch is aborted by the unload, which is the point; DO NOT use `/login.html` here — the test session is already signed in and `login.js` auto-redirects an admin (`window.location.replace(safeNext())`), so the login page bounces straight back into the shell; `/shared.html` is a real document with a stable state for every session state — assert its marker `#shared-title` visible instead of the old "landed on the catalog" assertion (`#docs-tbody tr` visible)), and ADD: `query_log` gained NO settled row for the question (the turn was cancelled — the phase-48 line). Return via `page.goto(app_url + "/")` exactly as today.
|
||||
- `test_full_answer_completes_after_rag_nav_midstream` (NEW — the phase-76 semantics): admin login; the mock's `write a long answer` question (~9 s stream, house pattern from `tests/e2e/test_hidden_tab_stream.py`); wait for visible streaming (the `FIRST_LINE_DOM` + `Stop`-button pattern scenario 1 already uses); set the window sentinel (`page.evaluate("() => { window.__shell_boot = 'phase76'; }")`); `page.click("#nav-sources")` MID-STREAM; assert the URL is `app_url + "/sources.html"` AND the SAME document (the sentinel is still readable — the canonical pattern from the phase overview; do NOT use the navigation-entries length, it resets on a real load) AND the RAG view is visible (`#docs-tbody tr` first visible); wait ~2 s on RAG; click Chat back; assert the bubble carries the FULL mock answer (every step line + `LONG-ANSWER-END`), no error banner, `bor.chat.v1` holds EXACTLY ONE brain turn with the full text (done metadata present — it is the settle, not a partial), and `query_log` gained a settled row (no phase-48 `turn cancelled`).
|
||||
- `test_nav_switch_before_first_token_completes` (NEW — navbar switch in the pre-token window): the `think out loud then hesitate` question; during the thinking window (button `Stop`, no answer bubble yet), set the sentinel and `page.click("#nav-sources")`; back to Chat; the answer completes (full text) and `bor.chat.v1` holds EXACTLY ONE brain turn — the no-orphan invariant in its new form (a pre-token navbar switch neither kills the turn nor persists a partial).
|
||||
- `test_no_orphan_brain_message_when_navigated_before_first_token` (KEPT — it already uses a direct `page.goto(app_url + "/sources.html")`, which REMAINS a real departure in the SPA): update only its docstring to say it now pins the real-departure pre-token convention (nothing brain-side persisted before the first token).
|
||||
- scenarios 3–4 (`test_completed_turn_unaffected`, `test_new_chat_still_clears_conversation`) — keep, verify still green (they use direct gotos / the New Chat button — unaffected by the fold).
|
||||
7. `tests/e2e/test_chat_persistence.py::test_persists_across_page_navigation` — it lands on the Sources page via `login(page, app_url, next="/sources.html")` and asserts `expect(page.locator("#new-chat-btn")).to_have_count(0)`: in the shell the chat view (with the button) is in the DOM on every view (hidden+inert), so the element EXISTS — change the assertion to `to_be_hidden()` (the view-scoped absence pattern from the phase overview). The rest of that test (conversation integrity across a real navigation) must pass UNCHANGED — it is now the pin that real departures still work for the conversation store.
|
||||
8. `tests/unit/test_document_viewer.py::test_both_pages_carry_the_modal_skeleton` — it loops `(INDEX_HTML, SOURCES_HTML)` asserting each carries the skeleton; after the fold there is ONE page and ONE skeleton: assert `index.html` (the shell) contains the skeleton and that `id="doc-modal"` occurs EXACTLY ONCE in it (the dedup pin). Sibling unit tests in that file that read `SOURCES_JS`/`APP_JS` (e.g. `test_viewer_url_builder_present_in_chat_and_sources`) read JS modules, not the deleted HTML — verify they pass unchanged. Also `tests/unit/test_history_page.py`'s page-file list drops `SOURCES_HTML` + `GIT_SOURCES_HTML` (both files deleted in this task — task 01 dropped `TUNING_HTML`, task 03 drops `HISTORY_HTML`), and any file-level constant for the deleted pages in `tests/unit/test_chat_persistence.py` that the task-01 conversion left behind is removed (ruff flags the unused imports).
|
||||
9. Run the affected E2E in isolation (DB up): `uv run pytest tests/e2e/test_sources_midstream_bug.py tests/e2e/test_chat_persistence.py tests/e2e/test_chat_rag.py tests/e2e/test_git_sources_admin.py tests/e2e/test_local_directory_sources.py tests/e2e/test_archive_upload_sources.py tests/e2e/test_edit_summaries.py tests/e2e/test_source_removal_cleanup.py -v --no-cov` — direct `goto` to the two paths still works (shell served → view rendered); fix ONLY fold breakage (e.g. title/meta assertions, header expectations, hidden-view absence assertions per the phase overview's pattern), not semantics.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_frontend_router.py` stays mechanism-level (pins the interceptor/pushState/mount-once/inert behavior, not the map size) — no edit expected; if a pin hard-codes the map contents, generalize it to the mechanism in this task.
|
||||
- Integration: shell-route assertions extended (item 1).
|
||||
- Coverage: **>90%** on modified `app/` code.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] Mid-stream `#nav-sources` click: no document load, RAG view renders, and on return the FULL answer has completed (the rewritten phase-20 test passes in isolation).
|
||||
- [ ] `sources.html` / `git-sources.html` deleted; direct loads of both paths render the right view with the old titles; the cache-busting suites still green.
|
||||
- [ ] The full suite green (`uv run pytest --cov=app`), `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
|
||||
- [ ] No behavior change in completed phases: `tests/e2e/test_stop_generation.py` (phase 48) and `tests/e2e/test_hidden_tab_stream.py` (phase 73) green UNCHANGED.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Task 03 — Fold History; verify the `/?chat=<id>` deep link and the history suites
|
||||
|
||||
**Phase:** `76_spa_nav_shell` · **Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
Fold the History view (phase 50, `history.js`, 462 lines) into the shell the same way as tasks 01–02, delete `frontend/history.html`, and prove the saved-chat deep link `/?chat=<id>` (read by `app.js` at boot) still works — it already lives on the shell's own path, so it must be untouched by the fold.
|
||||
|
||||
## Work
|
||||
1. `app/main.py` — extend the shell-route factory call to include `"/history.html"`.
|
||||
2. `frontend/index.html` — add `<section class="view" id="view-history" hidden inert aria-label="History" tabindex="-1">` with the view content moved out of `frontend/history.html` — its `<main>` wrapper DROPPED (single container main, per the phase design) and its `<footer>` DROPPED: the history page carries the `#app-version` footer, which would DUPLICATE the shell's single (chat) footer — the shell keeps the chat's one. Verify the remaining moved ids against the shell (the audit in the phase overview found no other cross-page view-markup collisions; do not rename any id, E2E selectors depend on them); extend the router `VIEW` map (`"/history.html" → "history"`) and the title/meta table (carry the old values: `"Saved chats — every conversation is saved automatically, one click back."`).
|
||||
3. `frontend/assets/history.js` — top-level boot → `export async function mount(root)`; scope DOM lookups to `root`; the boot's `await initSharedHeader(); if (!(await fetchIsAdmin())) { …gate… }` becomes `if (!(await fetchIsAdmin())) { …gate… }` (history.js already imports `fetchIsAdmin` — the same cached whoami promise, zero extra requests; see the phase overview's auth-flag note). The row actions (open `/?chat=<id>` link, copy-link, delete) are plain anchors/`location.assign` targets that REMAIN real navigations — do not route them through the router (opening a saved chat is a chat-view concern handled by `app.js` at boot via `?chat=`; leaving them as document loads is the existing behavior and is out of scope).
|
||||
4. DELETE `frontend/history.html`.
|
||||
5. Verify the deep link: a direct load of `/?chat=<id>` boots the CHAT view with the saved conversation opened (existing `app.js` boot behavior — `app.js` reads `new URLSearchParams(window.location.search).get("chat")` at module load, and `"/"` is the chat view in the router). No code change expected; the E2E below is the proof.
|
||||
6. Extend the integration/unit updates from tasks 01–02 to the third path: the `tests/integration/test_api.py` title-table entry for `/history.html` becomes the shell-body assertion (per task 01 item 8b); the `tests/integration/test_caching_revalidation.py::_page_file` override map gains `/history.html -> index.html`; `tests/unit/test_history_page.py`'s page-file list drops `HISTORY_HTML` (the file is deleted) — its shared-markers assertions then run over the shell + surviving documents only.
|
||||
7. Run the affected E2E in isolation (DB up): `uv run pytest tests/e2e/test_chat_history.py tests/e2e/test_history_copy.py tests/e2e/test_stale_saved_chats.py -v --no-cov` — fix ONLY fold breakage (titles, header expectations), not semantics.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_frontend_router.py` mechanism pins unchanged (the map grew — the pins are mechanism-level).
|
||||
- Integration: shell-route assertion extended (item 1).
|
||||
- Coverage: **>90%** on modified `app/` code.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] Direct load of `/history.html` renders the History view (admin) with the old title; `frontend/history.html` is deleted; the cache-busting suites still green.
|
||||
- [ ] `test_chat_history.py` (which includes the `/?chat=<id>` open flow) green in isolation — the deep link is intact.
|
||||
- [ ] Full suite green (`uv run pytest --cov=app`), `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
|
||||
- [ ] All FIVE navbar views now live in the shell; the four old view `.html` files are gone.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Task 04 — Header is shell-owned; the surviving documents keep their header copies; consistency suites updated
|
||||
|
||||
**Phase:** `76_spa_nav_shell` · **Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
Settle the header ownership now that four of the five pages with copy-pasted headers are gone: the shell's header is the canonical one (auth-gated links, router-driven active state, mobile hamburger — all unchanged in behavior), the surviving separate documents (`login.html`, `shared.html`, `doc-edit.html`, `document.html`) keep their own header copies and full-load the shell when their navbar links are clicked, and the header/nav consistency E2E suites are updated to the new page set.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/header.js` — NO active-state change is needed here: header.js never writes `is-active` (each old page stamped it statically in its own markup, which is gone — only the shell markup remains). The router (task 01) is the SINGLE WRITER of `is-active`/`aria-current` after boot; the shell markup starts with exactly ONE static `is-active` (the Chat link, the default view). PIN that: extend `tests/unit/test_frontend_router.py` (or add a small source-pin) asserting the shell's `index.html` contains `is-active` exactly once, on the Chat link, and that `header.js` contains no `is-active` write. Keep untouched in header.js: the `whoami` auth-gate that reveals the admin links (`#nav-sources`, `#nav-git-sources`, `#nav-tuning`, `#nav-history`) — the whoami promise is cached per page load and every auth transition in the SPA is a real load (sign-in via the login document, sign-out via reload), so the cache never goes stale — and the mobile hamburger toggle (phase 46). Those behaviors are owner-locked and E2E-pinned.
|
||||
2. Sign-out in the shell: header.js's sign-out handler does `POST /api/logout` then `window.location.reload()` (~L175–183) — in the SPA that reload is a REAL departure: `pagehide` fires, so an in-flight turn is cancelled per phase 48 (acceptable and documented: signing out leaves the app, per the phase-76 owner decision). No code change; confirm the auth E2E still passes.
|
||||
3. Verify the surviving documents' navbar links: `login.html`, `shared.html`, `doc-edit.html`, `document.html` keep their copy-pasted headers; their `href="/sources.html"` etc. links full-load the shell, whose router then renders the target view (works by construction — the shell route + router pathname read). Add ONE E2E assertion (in the existing shared-header or nav-consistency suite, cheapest home): from `/document.html?…` (viewer), clicking the RAG nav link lands on the RAG view at URL `/sources.html`.
|
||||
4. Update the consistency suites to the new page set (shell + the 4 surviving documents; the 4 folded view pages no longer exist as documents): `tests/e2e/test_header_consistency.py`, `tests/e2e/test_shared_header.py`, `tests/e2e/test_nav_consistency.py`, `tests/e2e/test_sticky_navbar.py`, `tests/e2e/test_mobile_hamburger_nav.py`, `tests/e2e/test_nav_rename_sources.py` — replace per-page iteration with the new set; keep every behavioral assertion (link labels/hrefs, hidden-until-admin gating, hamburger, sticky positioning) identical.
|
||||
5. Run the suites in isolation (DB up): `uv run pytest tests/e2e/test_header_consistency.py tests/e2e/test_shared_header.py tests/e2e/test_nav_consistency.py tests/e2e/test_sticky_navbar.py tests/e2e/test_mobile_hamburger_nav.py tests/e2e/test_nav_rename_sources.py tests/e2e/test_admin_auth.py -v --no-cov` — fix ONLY the page-set changes, not behaviors.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the active-state pin from item 1 (shell markup: exactly one static `is-active` on the Chat link; `header.js` carries no `is-active` write; the router is the runtime writer). Check `tests/unit/test_hamburger_nav.py` and any other header source-pins for assumptions this task breaks — they read source, so they pass as long as the pinned strings survive; fix only genuine breakage.
|
||||
- Integration: unchanged (no `app/` delta in this task).
|
||||
- Coverage: **>90%** floor holds (run the suite; no `app/` change expected).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] In the shell: after sign-in the admin links appear (whoami gate) and the active link tracks view switches (router-driven); the hamburger works ≤640 px; sign-out still works and is a real departure.
|
||||
- [ ] From `/document.html` (or any surviving document), a navbar click full-loads the shell into the right view (new E2E assertion green).
|
||||
- [ ] All six suites in item 5 green in isolation; `uv run pytest --cov=app` green; `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Task 05 — New E2E story suite: in-app view switches keep the stream alive (the owner repro, pinned)
|
||||
|
||||
**Phase:** `76_spa_nav_shell` · **Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
Pin the phase-76 contract in a dedicated Playwright story suite against the deterministic mock LLM: a mid-stream navbar switch (any of the four non-chat views) never stops the answer — on return the bubble carries the FULL answer, `bor.chat.v1` holds exactly one brain turn, and the SERVER settled the turn (a `query_log` row, i.e. no phase-48 `turn cancelled`) — while the real-departure control proves the phase-48 teardown still fires for a genuine navigation away.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_nav_switch_keeps_stream.py` (NEW — app-boot + mock-LLM + KB-seeding house pattern: copy the boilerplate from `tests/e2e/test_hidden_tab_stream.py` (`_import_fixtures`, `_reset_db`, `LONG_QUESTION` = the on-topic `write a long answer` phrasing, `LONG_ANSWER`/`LONG_ANSWER_END` from `e2e.mock_llm`, admin `login()` from `e2e.auth_helpers`, `_admin_cookies`/`_wait_row_full` for the auto-saved row):
|
||||
- `test_rag_switch_mid_stream_completes` — THE OWNER REPRO: admin login; send `LONG_QUESTION`; wait until visibly streaming (the `_wait_streaming` house pattern — ≥8 words rendered AND button `is-stop`); set the window sentinel (`page.evaluate("() => { window.__shell_boot = 'phase76'; }")`); mid-stream `page.click("#nav-sources")`; assert URL `app_url + "/sources.html"` AND the SAME document (the sentinel is still readable — the canonical pattern from the phase overview; do NOT use the navigation-entries length, a real load resets it to 1) AND the RAG view is visible (its documents table/heading in the viewport); wait ~2 s ON RAG (the stream fills the hidden chat view meanwhile); click Chat back (`a.nav-link[href="/"]`); assert: the bubble carries the FULL mock answer (every `Step N: configure node-N` line + `LONG-ANSWER-END`), no error banner, `bor.chat.v1` holds EXACTLY ONE brain turn with text == the full answer (the settle, not a partial), `query_log` gained exactly one row (settled — the phase-48 cancel line did NOT fire), and the auto-saved row (admin context, the `persistConversation` path — `_wait_row_full`) carries the same single full brain turn (clean it up in a `finally`, house pattern).
|
||||
- `test_every_nav_view_keeps_stream` — the same mid-stream switch, but against the other views: loop `#nav-git-sources`, `#nav-tuning`, `#nav-history` (fresh turn per view: one send, one switch, one return, full-answer assertion each time — the mock's long stream is ~8 s and the switch window is wide).
|
||||
- `test_real_departure_still_cancels` — the phase-48 CONTROL (the locked contract survives this phase): send `LONG_QUESTION`; wait for streaming; `page.goto(app_url + "/shared.html")` (a REAL cross-document departure — not a navbar link; `/shared.html` renders a stable state for a signed-in session — DO NOT use `/login.html`: the admin session auto-redirects it straight back into the shell; assert the shared page's `#shared-title` marker is visible as proof of the landing); assert: the turn is cancelled (NO new `query_log` row), return via `page.goto(app_url + "/")`, and the page-20/73 leave-save behavior is intact — the partial is persisted as exactly one brain turn with the EXACT shape pinned by `tests/e2e/test_sources_midstream_bug.py::test_partial_answer_survives_real_departure_midstream` (the task-02 renamed scenario 1: text starts with the first streamed chunk, is shorter than the full answer, and carries NO done metadata — no `sources`/`deflected`/`suggestions`/`thinking` keys). Mirror that assertion; do not invent a new shape. (This overlaps the phase-20 suite on purpose — different stories: phase 20 pins the partial shape, this phase pins that the navbar-switch path no longer cancels while real departures still do.)
|
||||
- `test_baseline_no_switch_still_completes` — the long question with NO navigation completes identically (guards against the router/view fold changing the ordinary path).
|
||||
2. Run in isolation (DB up): `uv run pytest tests/e2e/test_nav_switch_keeps_stream.py -v --no-cov`.
|
||||
3. Regression runs in isolation (the suites whose premises this phase touched): `uv run pytest tests/e2e/test_stop_generation.py tests/e2e/test_chat_persistence.py tests/e2e/test_hidden_tab_stream.py tests/e2e/test_sources_midstream_bug.py -v --no-cov` — all must pass UNCHANGED (the phase-20 suite in its task-02 rewritten form).
|
||||
|
||||
## Testing & Quality
|
||||
- E2E: the suite above IS this task's test artifact (Playwright is the frontend gate — no JS unit infra in this repo).
|
||||
- Integration: the `query_log` assertions double as server-side proof (settle vs cancel) — no new `app/` logic, coverage floor unaffected (still run the full suite).
|
||||
- Coverage: **>90%** on `app/` (held by the full suite; no `app/` delta expected in this task).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_nav_switch_keeps_stream.py -v --no-cov` green in isolation: all four tests pass, including the same-document assertion and the settled-`query_log` proof.
|
||||
- [ ] The four regression suites in item 3 green in isolation, unchanged.
|
||||
- [ ] Full suite green; `uv run ruff check . && uv run pyright` clean.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Task 06 — Full regression sweep, manual verification of the owner repro, atomic commit
|
||||
|
||||
**Phase:** `76_spa_nav_shell` · **Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
Run the complete quality gates, verify the OWNER'S EXACT repro in a real browser against the real LLM (not just the mock), and land the phase as one atomic Conventional-Commits commit with the phase-48 refinement and root cause in the message body.
|
||||
|
||||
## Work
|
||||
1. Full gates (DB up):
|
||||
- `uv run pytest` (unit + integration + E2E) green. WATCH LIST (the suites most likely to catch a fold regression late — the full suite runs them, but if one fails, start there): the file-reading unit pins `tests/unit/test_chat_persistence.py`, `tests/unit/test_history_page.py`, `tests/unit/test_document_viewer.py`, `tests/unit/test_caching.py` (they read `frontend/*.html` from disk — the four view files are gone and the shell is the new source of truth), the integration page tests `tests/integration/test_api.py` + `tests/integration/test_caching_revalidation.py`, and the E2E nav/header/cache suites.
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` — **>90%** on `app/`.
|
||||
- `uv run ruff check . && uv run pyright` clean.
|
||||
- Isolation spot-checks of the suites whose premises this phase touched (AGENTS.md rule 9): `test_nav_switch_keeps_stream.py`, `test_sources_midstream_bug.py`, `test_hidden_tab_stream.py`, `test_stop_generation.py`, `test_chat_persistence.py`, `test_chat_history.py`, `test_header_consistency.py`, `test_cache_busting.py`.
|
||||
2. Manual verification of the owner repro (real browser, visible — e.g. the interactive-browser skill). Dev server: the owner may already run one on :8000 — if so, run the verification against the committed code on a DIFFERENT port (`uv run uvicorn app.main:app --port 8010`), or restart the owner's dev server from the committed tree first (it runs with `--reload`, so uncommitted test scratch could skew the check):
|
||||
- Signed in as admin: send `tell me about everquest`; while it is generating, click **RAG** in the navbar; stay a few seconds; click **Chat**. The answer MUST finish — full answer rendered, one brain bubble, no error banner.
|
||||
- `query_log` gains a settled row for the question (no `chat: turn cancelled` line in the server log for that turn).
|
||||
- Control: repeat with a REAL departure (open `/login.html` in a fresh navigation mid-stream, or the Stop button) — the turn is cancelled there (phase-48 intact).
|
||||
- Screenshot each state into `.agents/screenshots/` (tracked, house pattern from phase 73's reports).
|
||||
3. Commit (AGENTS.md rule 8 — one atomic, professional commit, `--no-gpg-sign`), e.g.:
|
||||
`fix(chat): keep in-flight answers alive across in-app view switches`
|
||||
Body MUST record: (a) the root cause — navbar views were separate documents, so a navbar click was a real navigation whose phase-48 teardown cancelled the turn (owner repro verified 2026-09-06: RAG → Chat mid-stream, no `query_log` row, dangling question); (b) the phase-48 LOCKED-DECISION REFINEMENT, owner-confirmed 2026-09-06 — "real navigation cancels" now means leaving the app (tab close, external navigation, Stop); in-app navbar switches are client-side view switches; (c) the Option-A decision (SPA shell) chosen over the Service-Worker and server-resume alternatives; (d) boundaries — `login/shared/doc-edit/document` remain documents; mid-stream navigation to doc-edit/document.html still cancels (follow-up candidate); (e) the rewritten phase-20 suite semantics (stream survives navbar switches; the pagehide partial persist remains for real departures).
|
||||
4. Move `.agents/phases/todo/76_spa_nav_shell/` → `.agents/phases/complete/` (include the move in the same commit; `.agents/` is tracked per AGENTS.md rule 8).
|
||||
|
||||
## Testing & Quality
|
||||
- This task adds no new logic — it is the verification + hand-off pass; the gate is the gate set in item 1.
|
||||
- Coverage: **>90%** on `app/` (item 1).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] All item-1 gates green (full suite, coverage >90%, ruff + pyright, isolation spot-checks).
|
||||
- [ ] The owner repro passes in a real browser against the real LLM, with the settled-`query_log` proof and screenshots in `.agents/screenshots/`.
|
||||
- [ ] One atomic `--no-gpg-sign` Conventional-Commits commit with the body items (a)–(e); phase directory in `.agents/phases/complete/`; `git status` clean.
|
||||
@@ -0,0 +1,14 @@
|
||||
All gates green. Final report:
|
||||
|
||||
**Task 01 complete — shell + router + shell-route factory; Tuning folded as the first non-chat view.**
|
||||
|
||||
- `app/main.py`: list-driven `_shell_routes()` factory (GET+HEAD, routes-first, before the static catch-all) serving the shell for `("/tuning.html",)`; phase-33 middleware applies no-cache + `?v=` untouched (`app/core/caching.py` unchanged).
|
||||
- `frontend/index.html`: single `#main` now holds `#view-chat` + `#view-tuning` (hidden+inert, chat's steering panel kept, per-view copies dropped, no duplicate ids — verified well-formed); `router.js` loads after `app.js`.
|
||||
- `frontend/assets/router.js` (new): VIEW map (incl. `/index.html`→chat), boot deep-link without focus steal, pushState-only switches, mount-once lazy `import("./tuning.js")`, hidden+inert pair, single-writer `is-active`/`aria-current`/title/meta, focus only on user-initiated switches.
|
||||
- `tuning.js` → `export async function mount(root)` (root-scoped lookups, `initSharedHeader()` dropped, `fetchIsAdmin()` gate kept); `tuning.html` deleted; Containerfile builds `router.js` (bundles the lazy view module) and drops the stale `tuning.html`/`tuning.js` lines.
|
||||
- Tests: new `tests/unit/test_frontend_router.py` (11 source pins), shell-route + title-table update in `test_api.py`, `SHELL_BACKED_PAGES` in `test_caching_revalidation.py`, post-shell page lists in `test_chat_persistence`/`test_history_page`/`test_shared_header`/brand/hamburger/steering-toggle/stale-copy/save-chat-ui, containerfile pin 4 excludes the router; e2e `test_steering` script count 3→4.
|
||||
- Results: `uv run pytest --cov=app --cov-report=term-missing` → **1560 passed, 99%**; `ruff` + `pyright` → clean; gated E2E (global_tuning, tuning_nav_link, tuning_toggle_flash, cache_busting, asset_cache_revalidation) → **23/23**; `test_chat_rag` 3/3 + `test_hidden_tab_stream` 4/4 green in isolation; manual browser: sentinel survives switches, no refetch on re-show, title/meta/focus correct.
|
||||
- Decisions: router bundled (keeps the phase-23 bundle⇄HTML pin exact); note — in the *image* build, first tuning mount re-evaluates header.js's top-level bindings (esbuild inlines it per bundle); dev is unaffected (module dedupe) — flagged for task 04's header-shell wiring.
|
||||
- Known non-gated (task-04 owned, per phase design): `test_nav_consistency.py` (2) + `test_shared_header.py::test_new_chat_button_is_chat_page_only` fail on the `#new-chat-btn` view-scoped absence pattern only; all other tests in those suites pass.
|
||||
|
||||
Next pending task: `.agents/phases/todo/76_spa_nav_shell/02_fold_rag_sources_views.md`
|
||||
@@ -0,0 +1,78 @@
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 96%]
|
||||
................................................ [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
-----------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 22 0 100%
|
||||
app/api/chat.py 177 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 7 0 100%
|
||||
app/api/doc_drafts.py 93 0 100%
|
||||
app/api/docs.py 50 0 100%
|
||||
app/api/git_sources.py 212 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 8 0 100%
|
||||
app/api/sync.py 101 0 100%
|
||||
app/config.py 140 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 20 0 100%
|
||||
app/core/caching.py 108 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/db.py 21 0 100%
|
||||
app/main.py 60 0 100%
|
||||
app/models.py 86 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 222 0 100%
|
||||
app/rag/archive_upload.py 128 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 180 3 98%
|
||||
app/rag/llm.py 216 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 150 3 98%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 24 0 100%
|
||||
app/schemas.py 210 0 100%
|
||||
-----------------------------------------------
|
||||
TOTAL 2997 12 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
validation OK
|
||||
@@ -0,0 +1 @@
|
||||
The router's lazy `switchTo` writes the title *after* brand.js's one-shot DOM pass, with a hardcoded literal. Let me check how task 01 handled this for `/tuning.html`:
|
||||
@@ -0,0 +1,78 @@
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 96%]
|
||||
.................................................. [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
-----------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 22 0 100%
|
||||
app/api/chat.py 177 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 7 0 100%
|
||||
app/api/doc_drafts.py 93 0 100%
|
||||
app/api/docs.py 50 0 100%
|
||||
app/api/git_sources.py 212 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 8 0 100%
|
||||
app/api/sync.py 101 0 100%
|
||||
app/config.py 140 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 20 0 100%
|
||||
app/core/caching.py 108 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/db.py 21 0 100%
|
||||
app/main.py 60 0 100%
|
||||
app/models.py 86 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 222 0 100%
|
||||
app/rag/archive_upload.py 128 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 180 3 98%
|
||||
app/rag/llm.py 216 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 150 3 98%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 24 0 100%
|
||||
app/schemas.py 210 0 100%
|
||||
-----------------------------------------------
|
||||
TOTAL 2997 12 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
validation OK
|
||||
@@ -0,0 +1,11 @@
|
||||
Everything is green. Final report:
|
||||
|
||||
**Task 02 (fold RAG + Sources views into the SPA shell) — complete.**
|
||||
|
||||
Implemented: `sources.html`/`git-sources.html` views folded into `index.html` shell (`#view-rag`/`#view-git-sources`), both standalone pages deleted, shell routes added in `app/main.py`, `sources.js`/`git-sources.js` converted to `mount(root)` view modules (tuning.js house pattern), `Containerfile` asset set updated, phase-20 midstream e2e rewritten to new semantics (6 tests).
|
||||
|
||||
Fixed this round: (1) fold breakage — router stamped hardcoded titles past brand.js's one-time pass; now composes title/meta from `window.BOR_BRAND` at write time (`test_configurable_brand` red → green); (2) fold breakage — new `#view-chat` wrapper broke the full-height flex chain; one CSS rule restores it (composer-pinning/autoscroll suites red → green); (3) pre-existing defect (fails on HEAD, verified via worktree) — `HistoryTurn.text` 4000-char cap stricter than the 24k config budget, 422-ing any second turn in a chat with a long answer; raised to the 32k boundary cap; (4) pre-existing — nav test expected 4 links vs the phase-53 5th admin-only History link; (5) e2e locators scoped to `#view-rag` where the shell carries both views' `.table-wrap`/`.page-sub`.
|
||||
|
||||
Gates: `uv run pytest --cov=app --cov-report=term-missing` → 1562 passed, app coverage 99% (>90%); `uv run ruff check .` → clean; `uv run pyright` → 0 errors; `uv run pytest tests/e2e/test_sources_midstream_bug.py -v --no-cov` → 6/6 in isolation; all 19 bulk-failed e2e suites re-verified green in isolated runs.
|
||||
|
||||
Next pending task: `.agents/phases/todo/76_spa_nav_shell/03_fold_history_view.md`.
|
||||
@@ -0,0 +1,78 @@
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 96%]
|
||||
.................................................. [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
-----------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 22 0 100%
|
||||
app/api/chat.py 177 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 7 0 100%
|
||||
app/api/doc_drafts.py 93 0 100%
|
||||
app/api/docs.py 50 0 100%
|
||||
app/api/git_sources.py 212 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 8 0 100%
|
||||
app/api/sync.py 101 0 100%
|
||||
app/config.py 140 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 20 0 100%
|
||||
app/core/caching.py 108 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/db.py 21 0 100%
|
||||
app/main.py 60 0 100%
|
||||
app/models.py 86 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 222 0 100%
|
||||
app/rag/archive_upload.py 128 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 180 3 98%
|
||||
app/rag/llm.py 216 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 150 3 98%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 24 0 100%
|
||||
app/schemas.py 210 0 100%
|
||||
-----------------------------------------------
|
||||
TOTAL 2997 12 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
validation OK
|
||||
@@ -0,0 +1,24 @@
|
||||
Everything is green and verified. Final state confirmed — all remaining `history.html` references are the legitimate path constants (caching `HTML_PAGES`, the shell route, the router map) and docstring history.
|
||||
|
||||
## Task 03 — Fold History: complete
|
||||
|
||||
**Implemented**
|
||||
- `app/main.py`: `/history.html` added to the shell-route factory — all four non-chat navbar views now serve the shell (routes-first, caching middleware untouched).
|
||||
- `frontend/index.html`: new `#view-history` section (`hidden` + `inert` + `tabindex="-1"`), content moved from the old page with its `<main>` wrapper, header-owned steering-panel/announcer copies, and per-page footer dropped (shell keeps its single `#app-version` footer). ID audit: every moved id occurs exactly once in the shell — no collisions, no renames.
|
||||
- `frontend/assets/router.js`: `VIEW`/`VIEW_PATH`/`VIEW_MODULES` + `TITLES`/`DESCRIPTIONS` extended with the carried-over values (`"Saved chats · Brain of Reese"`, `"Saved chats — every conversation is saved automatically, one click back."`).
|
||||
- `frontend/assets/history.js`: rewritten to `export async function mount(root)` — DOM lookups scoped to `root`, `initSharedHeader()` dropped (gate uses the cached `fetchIsAdmin()`); row actions (`/?chat=<id>` links, copy-link) remain real navigations, untouched by the router.
|
||||
- Deleted `frontend/history.html`; `Containerfile` dropped the history.js esbuild line (router lazy-imports it) and the history.html `cp` — containerfile parity pins hold.
|
||||
- Tests extended per the tasks 01–02 patterns: integration (shell-route assertion + `_page_file` override), unit (`test_history_page.py` shell form + brace-balanced `_fn`, `test_history_copy.py` redirected to shell+router, page lists in brand/hamburger/steering-toggle/stale-copy, shared-header view-module pin, router map pin, save-chat-ui negatives), E2E `test_history_copy.py` re-scoped page-wide `.page-sub`/`h1` to `#view-history`.
|
||||
|
||||
**Test / lint / coverage**
|
||||
- `uv run pytest` — 1563 passed; `uv run pytest --cov=app` — **99%** (`app/main.py` 100%), floor >90% met.
|
||||
- `uv run ruff check .` clean; `uv run pyright` — 0 errors.
|
||||
- `uv run pytest tests/e2e/test_chat_history.py tests/e2e/test_history_copy.py tests/e2e/test_stale_saved_chats.py -v --no-cov` — **9 passed** (incl. the `/?chat=<id>` deep-link open flow — deep link intact).
|
||||
- 25 further E2E suites verified (cache-busting, nav-consistency, shared-header, midstream/stop/hidden-tab stream, tuning, save/share, sources, git-sources, etc.) — all green in isolation.
|
||||
- Manual browser check: direct load of `/history.html` renders the History view (admin, old title/meta, gate lifted, nav active); mid-view navbar switch is same-document (window sentinel survives).
|
||||
|
||||
**Notable**
|
||||
- Pre-existing e2e infra limitation found (reproduced on the pre-task-03 baseline, NOT caused by this fold): module-app suites share the session app's port, so batching them in one pytest process after a session-app suite runs the module suite against the wrong app. Project protocol runs each suite in isolation, where everything is green — left as-is (out of task scope).
|
||||
- `.agents/PLAN.md` is absent from the repo (referenced by AGENTS.md but never committed); the phase overview `00_phase.md` served as the binding design.
|
||||
|
||||
**Next pending task:** `.agents/phases/todo/76_spa_nav_shell/04_header_shell_wiring.md`
|
||||
@@ -0,0 +1,78 @@
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 96%]
|
||||
................................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
-----------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 22 0 100%
|
||||
app/api/chat.py 177 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 7 0 100%
|
||||
app/api/doc_drafts.py 93 0 100%
|
||||
app/api/docs.py 50 0 100%
|
||||
app/api/git_sources.py 212 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 8 0 100%
|
||||
app/api/sync.py 101 0 100%
|
||||
app/config.py 140 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 20 0 100%
|
||||
app/core/caching.py 108 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/db.py 21 0 100%
|
||||
app/main.py 60 0 100%
|
||||
app/models.py 86 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 222 0 100%
|
||||
app/rag/archive_upload.py 128 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 180 3 98%
|
||||
app/rag/llm.py 216 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 150 3 98%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 24 0 100%
|
||||
app/schemas.py 210 0 100%
|
||||
-----------------------------------------------
|
||||
TOTAL 2997 12 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
validation OK
|
||||
@@ -0,0 +1,19 @@
|
||||
Task 04 is complete and fully green.
|
||||
|
||||
## Report — Task 04: header shell wiring
|
||||
|
||||
**Implemented** (test-only task — the shell header was already canonical and `header.js` already a non-writer, per the task's "no code change" items):
|
||||
- `tests/unit/test_frontend_router.py`: two new single-writer pins — `is-active` occurs **exactly once** in the shell `index.html` (on the Chat link, `href="/"`) and **no** `is-active` write exists in `header.js` (router is the runtime writer, already pinned)
|
||||
- `tests/e2e/test_nav_consistency.py`: new test 7 `test_viewer_nav_click_full_loads_the_shell_rag_view` — from `/document.html?…` (a surviving document with its own header copy) clicking the RAG nav link is a **real departure**: full-loads the shell at `/sources.html` (window sentinel set in the viewer doc is wiped — inverse of the phase-76 same-document pattern), RAG view renders (first table row visible), Chat view hidden+inert, `#nav-sources` carries the router's active stamp
|
||||
- Item 4 page-set update: already done in tasks 01–03 for the suites that iterated files (unit suites + shared_header/nav_consistency/nav_rename_sources); `test_header_consistency.py`, `test_sticky_navbar.py`, `test_mobile_hamburger_nav.py` visit URLs only (still valid shell routes) — behavioral assertions kept identical
|
||||
- Item 2 (sign-out in shell = real departure): no code change; verified via `test_admin_auth.py` (sign-out on shell `/` and viewer)
|
||||
|
||||
**Test / lint / coverage results:**
|
||||
- `uv run pytest tests/e2e/test_header_consistency.py test_shared_header.py test_nav_consistency.py test_sticky_navbar.py test_mobile_hamburger_nav.py test_nav_rename_sources.py test_admin_auth.py -v --no-cov` → **36 passed** (isolated, DB up)
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → **1565 passed**, `app/` TOTAL **99%** (>90% floor; no `app/` delta this task)
|
||||
- `uv run ruff check . && uv run pyright` → **clean** (All checks passed / 0 errors)
|
||||
- Regression sweep (phases 20/48/73/14/54/50/27/40: `test_sources_midstream_bug`, `test_stop_generation`, `test_hidden_tab_stream`, `test_chat_persistence`, `test_cache_busting`, `test_asset_cache_revalidation`, `test_chat_history`, `test_global_tuning`, `test_tuning_nav_link`, `test_tuning_toggle_flash`) → **45 passed**
|
||||
|
||||
**Decisions/deviations:** none — no defects found in prior work; unit+integration baseline was green before my changes.
|
||||
|
||||
**Next pending task:** `.agents/phases/todo/76_spa_nav_shell/05_stream_survival_e2e.md` (new story suite `test_nav_switch_keeps_stream.py`).
|
||||
@@ -0,0 +1,78 @@
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 96%]
|
||||
..................................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
-----------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 22 0 100%
|
||||
app/api/chat.py 177 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 7 0 100%
|
||||
app/api/doc_drafts.py 93 0 100%
|
||||
app/api/docs.py 50 0 100%
|
||||
app/api/git_sources.py 212 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 8 0 100%
|
||||
app/api/sync.py 101 0 100%
|
||||
app/config.py 140 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 20 0 100%
|
||||
app/core/caching.py 108 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/db.py 21 0 100%
|
||||
app/main.py 60 0 100%
|
||||
app/models.py 86 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 222 0 100%
|
||||
app/rag/archive_upload.py 128 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 180 3 98%
|
||||
app/rag/llm.py 216 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 150 3 98%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 24 0 100%
|
||||
app/schemas.py 210 0 100%
|
||||
-----------------------------------------------
|
||||
TOTAL 2997 12 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
validation OK
|
||||
@@ -0,0 +1,19 @@
|
||||
All gates are green. The task is complete.
|
||||
|
||||
**Task 05 — Stream survival E2E (phase 76) — report**
|
||||
|
||||
Implemented:
|
||||
- NEW `tests/e2e/test_nav_switch_keeps_stream.py` (4 tests, house boilerplate copied from `test_hidden_tab_stream.py`):
|
||||
- `test_rag_switch_mid_stream_completes` — the owner repro: send → `#nav-sources` mid-stream → Chat; window-sentinel same-document proof, RAG view visible, FULL answer (all 40 steps + `LONG-ANSWER-END`), exactly one full brain turn in `bor.chat.v1`, one settled `query_log` row, auto-saved row carries the same single full turn (cleaned up in `finally`)
|
||||
- `test_every_nav_view_keeps_stream` — same mid-stream switch for git-sources / tuning / history: one send/switch/return each, full answer, cumulative settled-row count (one per turn), latest storage pair = question + full brain turn
|
||||
- `test_real_departure_still_cancels` — phase-48 control: real `page.goto("/shared.html")` (asserts `#shared-title`), NO `query_log` row, partial persisted in the EXACT phase-20 shape (mirrored, not re-invented)
|
||||
- `test_baseline_no_switch_still_completes` — ordinary path unchanged
|
||||
|
||||
Results:
|
||||
- `uv run pytest tests/e2e/test_nav_switch_keeps_stream.py -v --no-cov` → 4 passed (55s)
|
||||
- `uv run pytest tests/e2e/test_stop_generation.py tests/e2e/test_chat_persistence.py tests/e2e/test_hidden_tab_stream.py tests/e2e/test_sources_midstream_bug.py -v --no-cov` → 17 passed, unchanged (80s)
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → 1565 passed, TOTAL 99% (>90% floor)
|
||||
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings
|
||||
|
||||
Decisions: error-banner pin uses the shell-aware `[role="alert"]:visible` count-0 form (hidden views carry inert alert surfaces); DB reset truncates `steering_notes`/`kb_overview` so exact-text holds. No `app/`/frontend changes in this task.
|
||||
Next pending task: `06_regression_sweep_commit.md`.
|
||||
@@ -0,0 +1,78 @@
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 96%]
|
||||
..................................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
-----------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 22 0 100%
|
||||
app/api/chat.py 177 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 7 0 100%
|
||||
app/api/doc_drafts.py 93 0 100%
|
||||
app/api/docs.py 50 0 100%
|
||||
app/api/git_sources.py 212 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 8 0 100%
|
||||
app/api/sync.py 101 0 100%
|
||||
app/config.py 140 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 20 0 100%
|
||||
app/core/caching.py 108 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/db.py 21 0 100%
|
||||
app/main.py 60 0 100%
|
||||
app/models.py 86 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 222 0 100%
|
||||
app/rag/archive_upload.py 128 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 180 3 98%
|
||||
app/rag/llm.py 216 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 150 3 98%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 24 0 100%
|
||||
app/schemas.py 210 0 100%
|
||||
-----------------------------------------------
|
||||
TOTAL 2997 12 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
validation OK
|
||||
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 302 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 309 KiB |
|
After Width: | Height: | Size: 313 KiB |
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 288 KiB |
@@ -15,19 +15,16 @@ RUN npm install --no-audit --no-fund -g esbuild@0.25.5
|
||||
COPY frontend ./
|
||||
RUN mkdir -p /out/assets \
|
||||
&& esbuild ./assets/app.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/app.js \
|
||||
&& esbuild ./assets/sources.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/sources.js \
|
||||
&& esbuild ./assets/router.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/router.js \
|
||||
&& esbuild ./assets/document.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/document.js \
|
||||
&& esbuild ./assets/login.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/login.js \
|
||||
&& esbuild ./assets/tuning.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/tuning.js \
|
||||
&& esbuild ./assets/git-sources.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/git-sources.js \
|
||||
&& esbuild ./assets/history.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/history.js \
|
||||
&& esbuild ./assets/shared.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/shared.js \
|
||||
&& esbuild ./assets/doc-edit.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/doc-edit.js \
|
||||
&& esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js \
|
||||
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
|
||||
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
|
||||
&& cp -r ./assets/themes /out/assets/themes \
|
||||
&& cp ./index.html ./sources.html ./document.html ./login.html ./tuning.html ./git-sources.html ./history.html ./shared.html ./doc-edit.html /out/
|
||||
&& cp ./index.html ./document.html ./login.html ./shared.html ./doc-edit.html /out/
|
||||
|
||||
# ---------- Stage 2: python dependencies ----------
|
||||
FROM docker.io/python:3.12-slim AS python
|
||||
|
||||
@@ -18,6 +18,7 @@ from pathlib import Path
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.responses import FileResponse
|
||||
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.chat import router as chat_router
|
||||
@@ -51,6 +52,39 @@ settings = get_settings()
|
||||
logger = logging.getLogger("app")
|
||||
|
||||
|
||||
def _shell_routes(app: FastAPI, static_dir: Path, paths: tuple[str, ...]) -> None:
|
||||
"""Phase 76: the navbar views are views of ONE shell document.
|
||||
|
||||
Every registered path serves ``frontend/index.html`` (the shell)
|
||||
instead of its own page file: the client-side router
|
||||
(``frontend/assets/router.js``) reads ``location.pathname`` at boot
|
||||
and shows the matching view, so a direct load of e.g.
|
||||
``/tuning.html`` deep-links to the Tuning view. Registered AFTER the
|
||||
API routers and BEFORE the static catch-all mount (routes-first),
|
||||
so the phase-33 caching middleware — which wraps the whole app and
|
||||
already lists every one of these paths in ``HTML_PAGES`` — applies
|
||||
the no-cache + ``?v=<token>`` contract to the response untouched.
|
||||
The list is driven by the caller: tasks 02/03 fold the remaining
|
||||
views in by extending the tuple (task 03 lands History — all four
|
||||
non-chat navbar views are in; the old per-view ``.html`` files are
|
||||
deleted in the same change as their shell route lands — one source
|
||||
of truth).
|
||||
"""
|
||||
shell_file = static_dir / "index.html"
|
||||
|
||||
async def _shell_view() -> FileResponse:
|
||||
return FileResponse(shell_file, media_type="text/html")
|
||||
|
||||
for path in paths:
|
||||
# GET (document loads, the browser path) + HEAD — the pre-fold
|
||||
# static file answered both, so the shell route keeps that
|
||||
# method parity (the body is the same FileResponse; HEAD ships
|
||||
# headers only).
|
||||
app.api_route(
|
||||
path, methods=["GET", "HEAD"], include_in_schema=False
|
||||
)(_shell_view)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
# Fail loud BEFORE serving anything (phase 16): missing
|
||||
# BOR_ADMIN_PASSWORD / BOR_SESSION_SECRET raises at boot, naming the
|
||||
@@ -98,6 +132,20 @@ def create_app() -> FastAPI:
|
||||
|
||||
static_dir = Path(settings.static_dir).resolve()
|
||||
if static_dir.is_dir():
|
||||
# Phase 76: the folded navbar views serve the shell — the
|
||||
# router picks the view from the pathname. Task 01 landed
|
||||
# Tuning; task 02 folds RAG + Sources; task 03 lands History
|
||||
# (list-driven — all four non-chat navbar views are in).
|
||||
_shell_routes(
|
||||
app,
|
||||
static_dir,
|
||||
(
|
||||
"/tuning.html",
|
||||
"/sources.html",
|
||||
"/git-sources.html",
|
||||
"/history.html",
|
||||
),
|
||||
)
|
||||
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
|
||||
else:
|
||||
logger.warning("static dir %s not found — serving API only", static_dir)
|
||||
|
||||
@@ -40,16 +40,20 @@ class HistoryTurn(BaseModel):
|
||||
``thinking`` travels to the model as ``reasoning_content`` on the
|
||||
assistant message (the wire convention :mod:`app.rag.llm` already
|
||||
documents for the response side) — only when non-empty (A4).
|
||||
``text`` mirrors :attr:`ChatMessage.text`'s answer shape; the
|
||||
thinking cap is looser (scratchpads run longer than answers). These
|
||||
are boundary sanity caps only — the real trimming budget is the
|
||||
settings pair ``history_max_turns`` / ``history_max_chars``
|
||||
(``app.config``, A3: a capped-out turn is dropped whole, never
|
||||
truncated).
|
||||
``text`` mirrors :attr:`ChatMessage.text`'s answer shape; long
|
||||
answers (and the scratchpads that ride along as ``thinking``) both
|
||||
run to tens of kilobytes of text, so both share the same loose
|
||||
boundary cap. These are boundary sanity caps only — the real
|
||||
trimming budget is the settings pair ``history_max_turns`` /
|
||||
``history_max_chars`` (``app.config``, A3: a capped-out turn is
|
||||
dropped whole, never truncated). The old 4000-char text cap was
|
||||
stricter than the 24_000-char default total budget and rejected
|
||||
any second turn in a chat whose history held a long answer (422 —
|
||||
found by the phase-42 E2E suite on the phase-76 shell).
|
||||
"""
|
||||
|
||||
who: Literal["user", "brain"]
|
||||
text: str = Field(min_length=1, max_length=4000)
|
||||
text: str = Field(min_length=1, max_length=32_000)
|
||||
thinking: str | None = Field(default=None, max_length=32000)
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/* Brain of Reese — History page (saved chats, phase 50 task 04).
|
||||
/* Brain of Reese — History view (saved chats, phase 50 task 04;
|
||||
* phase 76 task 03: shell view module).
|
||||
*
|
||||
* TODO.md L5 (owner 2026-08-29): "Need a way to save and view chat
|
||||
* history in a new page, then return to that history with a click."
|
||||
*
|
||||
* Wires the admin-only `GET /api/chats` + `POST /api/chats/<id>/share`
|
||||
* + `POST /api/chats/<id>/unshare` + `DELETE /api/chats/<id>`
|
||||
* endpoints (phase 50 task 02; phase 51 task 01+02) into the page's
|
||||
* endpoints (phase 50 task 02; phase 51 task 01+02) into the view's
|
||||
* full-width table:
|
||||
*
|
||||
* • Title — an `<a href="/?chat=<id>">`: Open IS the title link
|
||||
@@ -55,408 +56,428 @@
|
||||
* • admin → the gate hides and `loadChats()` renders the rows; a
|
||||
* 0-row fetch reveals the empty-state row.
|
||||
*
|
||||
* Phase 19/34: the page joins the shared header — initSharedHeader()
|
||||
* runs first (whoami + nav reveal + the steering panel), and the gate
|
||||
* below reuses the SAME cached /api/whoami promise (one request per
|
||||
* page).
|
||||
* Phase 76 (task 03) — shell view module (the "History" view of the
|
||||
* ONE-document shell; /history.html now serves the shell, and
|
||||
* assets/router.js lazy-imports THIS module on first show):
|
||||
*
|
||||
* • the top-level boot is now `export async function mount(root)` —
|
||||
* root is the view's <section id="view-history">, and every DOM
|
||||
* lookup scopes to root (the view ids stay unique across the
|
||||
* shell — scoped lookups keep the module honest and testable).
|
||||
* The router mounts a view ONCE (mount-once, hide-forever), so
|
||||
* the binding + state survive every switch.
|
||||
* • the initSharedHeader() call is DROPPED: in the shell the shared
|
||||
* header boots exactly once, via the chat module (app.js) at shell
|
||||
* boot — the view never re-boots it. The admin gate keeps
|
||||
* fetchIsAdmin() — the SAME cached /api/whoami promise header.js
|
||||
* exports (zero extra requests; the flag decides whether the
|
||||
* table loads at all, the Sources-page gate pattern).
|
||||
* • the row actions stay REAL navigations: the Open link
|
||||
* (?chat=<id>) and the copy-link field are plain anchor targets —
|
||||
* opening a saved chat is a chat-view concern handled by app.js
|
||||
* at boot via ?chat=, and the router never intercepts them (they
|
||||
* are not navbar links, and their query string keeps them out of
|
||||
* the VIEW map).
|
||||
*/
|
||||
|
||||
import { fetchIsAdmin, initSharedHeader } from "./header.js";
|
||||
import { fetchIsAdmin } from "./header.js";
|
||||
|
||||
const tableWrap = document.querySelector("#history-table-wrap");
|
||||
const tbody = document.querySelector("#history-tbody");
|
||||
const emptyRow = document.querySelector("#history-empty-row");
|
||||
const gateEl = document.querySelector("#history-gate");
|
||||
const statusEl = document.querySelector("#history-status");
|
||||
export async function mount(root) {
|
||||
/* ---------- view elements (the view's section, scoped to root) ---------- */
|
||||
const tableWrap = root.querySelector("#history-table-wrap");
|
||||
const tbody = root.querySelector("#history-tbody");
|
||||
const emptyRow = root.querySelector("#history-empty-row");
|
||||
const gateEl = root.querySelector("#history-gate");
|
||||
const statusEl = root.querySelector("#history-status");
|
||||
|
||||
/* Action feedback — the role="status" live region above the table
|
||||
(the "never stale" contract: every row action lands a line here,
|
||||
success or failure alike). */
|
||||
function announce(message) {
|
||||
if (statusEl) statusEl.textContent = message;
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
/* One row. The Title cell carries the Open link (/?chat=<id> — the
|
||||
"return to that history with a click" requirement); the Updated
|
||||
cell renders the locale date+time with the full ISO on hover. */
|
||||
function makeRow(chat) {
|
||||
const tr = document.createElement("tr");
|
||||
|
||||
const titleTd = document.createElement("td");
|
||||
titleTd.className = "history-title-cell";
|
||||
titleTd.title = chat.title; // full title on hover (the column ellipsizes)
|
||||
const link = document.createElement("a");
|
||||
link.className = "history-title-link";
|
||||
link.href = "/?chat=" + chat.id; // Open: the chat page boots into this chat
|
||||
link.textContent = chat.title; // user-derived — textContent only
|
||||
titleTd.appendChild(link);
|
||||
tr.appendChild(titleTd);
|
||||
|
||||
const countTd = document.createElement("td");
|
||||
countTd.className = "history-count-cell";
|
||||
countTd.textContent = String(chat.message_count);
|
||||
tr.appendChild(countTd);
|
||||
|
||||
const updatedTd = document.createElement("td");
|
||||
updatedTd.className = "history-updated-cell";
|
||||
updatedTd.title = chat.updated_at; // full ISO on hover
|
||||
updatedTd.textContent = fmtDate(chat.updated_at);
|
||||
tr.appendChild(updatedTd);
|
||||
|
||||
// Phase 53 (task 04): the Stale cell (between Updated and Share) —
|
||||
// the READ-ONLY staleness marker. `chat.stale` is computed server-
|
||||
// side (task 03), so this branches on the flag, never on versions.
|
||||
// Stale rows get the rose pill (the exact hover copy points at the
|
||||
// Regenerate action on the chat page, task 05); fresh rows get a
|
||||
// plain em-dash. The <td> carries its own aria-label in BOTH states
|
||||
// — the marker must be conveyed without the visual (WCAG 2.1 AA).
|
||||
const staleTd = document.createElement("td");
|
||||
staleTd.className = "history-stale-cell";
|
||||
if (chat.stale) {
|
||||
staleTd.setAttribute("aria-label", "Stale — sources have changed since this chat was saved");
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "stale-pill";
|
||||
pill.title = "Sources have changed since this chat was saved — open the chat to Regenerate";
|
||||
pill.textContent = "Stale";
|
||||
staleTd.appendChild(pill);
|
||||
} else {
|
||||
staleTd.setAttribute("aria-label", "Current — saved against the latest sources");
|
||||
staleTd.textContent = "—"; // the em-dash: fresh rows' marker
|
||||
}
|
||||
tr.appendChild(staleTd);
|
||||
|
||||
// Phase 51: the Share cell (between Updated and Actions) — the
|
||||
// three-state share control (unshared / shared / confirming-unshare).
|
||||
const shareTd = document.createElement("td");
|
||||
shareTd.className = "history-share-cell";
|
||||
shareTd.appendChild(makeShareControl(chat));
|
||||
tr.appendChild(shareTd);
|
||||
|
||||
const actionsTd = document.createElement("td");
|
||||
actionsTd.className = "history-actions-cell";
|
||||
actionsTd.appendChild(makeDeleteControl(chat, tr));
|
||||
tr.appendChild(actionsTd);
|
||||
return tr;
|
||||
}
|
||||
|
||||
/* The inline two-step Delete (owner-locked 2026-08-29 — NO native
|
||||
confirm dialog anywhere on this page). The Delete button is
|
||||
replaced, in place, by the "Delete? [Yes] [No]" pair; focus moves
|
||||
to Yes (keyboard-reachable confirm). Yes → DELETE /api/chats/<id>
|
||||
→ the row is removed + the live region line; No or a failed
|
||||
request keeps the row (+ the error line on failure). */
|
||||
function makeDeleteControl(chat, row) {
|
||||
const cell = document.createElement("span");
|
||||
cell.className = "history-actions";
|
||||
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "history-delete";
|
||||
del.setAttribute("aria-label", `Delete saved chat: ${chat.title}`);
|
||||
del.textContent = "Delete";
|
||||
|
||||
function restoreDelete() {
|
||||
cell.replaceChildren(del);
|
||||
del.focus(); // focus returns to the (restored) control
|
||||
/* Action feedback — the role="status" live region above the table
|
||||
(the "never stale" contract: every row action lands a line here,
|
||||
success or failure alike). */
|
||||
function announce(message) {
|
||||
if (statusEl) statusEl.textContent = message;
|
||||
}
|
||||
|
||||
del.addEventListener("click", () => {
|
||||
const label = document.createElement("span");
|
||||
label.className = "history-confirm-text";
|
||||
label.textContent = "Delete?";
|
||||
const yes = document.createElement("button");
|
||||
yes.type = "button";
|
||||
yes.className = "history-confirm-yes";
|
||||
yes.textContent = "Yes";
|
||||
const no = document.createElement("button");
|
||||
no.type = "button";
|
||||
no.className = "history-confirm-no";
|
||||
no.textContent = "No";
|
||||
yes.addEventListener("click", () =>
|
||||
confirmDelete(chat, row, yes, restoreDelete));
|
||||
no.addEventListener("click", restoreDelete);
|
||||
cell.replaceChildren(label, yes, no);
|
||||
yes.focus(); // the confirm pair takes over the focus
|
||||
});
|
||||
|
||||
cell.appendChild(del); // the shipped state IS the Delete button
|
||||
return cell;
|
||||
}
|
||||
|
||||
/* The confirmed delete: DELETE /api/chats/<id> → the row is removed
|
||||
(+ the empty-state row reappears when it was the last one) and the
|
||||
live region gets `Deleted "<title>".` A 404 means the row is gone
|
||||
(deleted elsewhere) — drop the stale row and say so. Any other
|
||||
failure or a network error keeps the row, restores the Delete
|
||||
button (retryable), and lands the error line. */
|
||||
async function confirmDelete(chat, row, yesBtn, restoreDelete) {
|
||||
yesBtn.disabled = true; // no double-fire while the request is in flight
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(`/api/chats/${chat.id}`, { method: "DELETE" });
|
||||
} catch {
|
||||
announce(`Couldn't delete "${chat.title}" — is the app reachable?`);
|
||||
restoreDelete();
|
||||
return;
|
||||
function fmtDate(iso) {
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
if (r.status === 404) {
|
||||
|
||||
/* One row. The Title cell carries the Open link (/?chat=<id> — the
|
||||
"return to that history with a click" requirement); the Updated
|
||||
cell renders the locale date+time with the full ISO on hover. */
|
||||
function makeRow(chat) {
|
||||
const tr = document.createElement("tr");
|
||||
|
||||
const titleTd = document.createElement("td");
|
||||
titleTd.className = "history-title-cell";
|
||||
titleTd.title = chat.title; // full title on hover (the column ellipsizes)
|
||||
const link = document.createElement("a");
|
||||
link.className = "history-title-link";
|
||||
link.href = "/?chat=" + chat.id; // Open: the chat page boots into this chat
|
||||
link.textContent = chat.title; // user-derived — textContent only
|
||||
titleTd.appendChild(link);
|
||||
tr.appendChild(titleTd);
|
||||
|
||||
const countTd = document.createElement("td");
|
||||
countTd.className = "history-count-cell";
|
||||
countTd.textContent = String(chat.message_count);
|
||||
tr.appendChild(countTd);
|
||||
|
||||
const updatedTd = document.createElement("td");
|
||||
updatedTd.className = "history-updated-cell";
|
||||
updatedTd.title = chat.updated_at; // full ISO on hover
|
||||
updatedTd.textContent = fmtDate(chat.updated_at);
|
||||
tr.appendChild(updatedTd);
|
||||
|
||||
// Phase 53 (task 04): the Stale cell (between Updated and Share) —
|
||||
// the READ-ONLY staleness marker. `chat.stale` is computed server-
|
||||
// side (task 03), so this branches on the flag, never on versions.
|
||||
// Stale rows get the rose pill (the exact hover copy points at the
|
||||
// Regenerate action on the chat page, task 05); fresh rows get a
|
||||
// plain em-dash. The <td> carries its own aria-label in BOTH states
|
||||
// — the marker must be conveyed without the visual (WCAG 2.1 AA).
|
||||
const staleTd = document.createElement("td");
|
||||
staleTd.className = "history-stale-cell";
|
||||
if (chat.stale) {
|
||||
staleTd.setAttribute("aria-label", "Stale — sources have changed since this chat was saved");
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "stale-pill";
|
||||
pill.title = "Sources have changed since this chat was saved — open the chat to Regenerate";
|
||||
pill.textContent = "Stale";
|
||||
staleTd.appendChild(pill);
|
||||
} else {
|
||||
staleTd.setAttribute("aria-label", "Current — saved against the latest sources");
|
||||
staleTd.textContent = "—"; // the em-dash: fresh rows' marker
|
||||
}
|
||||
tr.appendChild(staleTd);
|
||||
|
||||
// Phase 51: the Share cell (between Updated and Actions) — the
|
||||
// three-state share control (unshared / shared / confirming-unshare).
|
||||
const shareTd = document.createElement("td");
|
||||
shareTd.className = "history-share-cell";
|
||||
shareTd.appendChild(makeShareControl(chat));
|
||||
tr.appendChild(shareTd);
|
||||
|
||||
const actionsTd = document.createElement("td");
|
||||
actionsTd.className = "history-actions-cell";
|
||||
actionsTd.appendChild(makeDeleteControl(chat, tr));
|
||||
tr.appendChild(actionsTd);
|
||||
return tr;
|
||||
}
|
||||
|
||||
/* The inline two-step Delete (owner-locked 2026-08-29 — NO native
|
||||
confirm dialog anywhere on this page). The Delete button is
|
||||
replaced, in place, by the "Delete? [Yes] [No]" pair; focus moves
|
||||
to Yes (keyboard-reachable confirm). Yes → DELETE /api/chats/<id>
|
||||
→ the row is removed + the live region line; No or a failed
|
||||
request keeps the row (+ the error line on failure). */
|
||||
function makeDeleteControl(chat, row) {
|
||||
const cell = document.createElement("span");
|
||||
cell.className = "history-actions";
|
||||
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "history-delete";
|
||||
del.setAttribute("aria-label", `Delete saved chat: ${chat.title}`);
|
||||
del.textContent = "Delete";
|
||||
|
||||
function restoreDelete() {
|
||||
cell.replaceChildren(del);
|
||||
del.focus(); // focus returns to the (restored) control
|
||||
}
|
||||
|
||||
del.addEventListener("click", () => {
|
||||
const label = document.createElement("span");
|
||||
label.className = "history-confirm-text";
|
||||
label.textContent = "Delete?";
|
||||
const yes = document.createElement("button");
|
||||
yes.type = "button";
|
||||
yes.className = "history-confirm-yes";
|
||||
yes.textContent = "Yes";
|
||||
const no = document.createElement("button");
|
||||
no.type = "button";
|
||||
no.className = "history-confirm-no";
|
||||
no.textContent = "No";
|
||||
yes.addEventListener("click", () =>
|
||||
confirmDelete(chat, row, yes, restoreDelete));
|
||||
no.addEventListener("click", restoreDelete);
|
||||
cell.replaceChildren(label, yes, no);
|
||||
yes.focus(); // the confirm pair takes over the focus
|
||||
});
|
||||
|
||||
cell.appendChild(del); // the shipped state IS the Delete button
|
||||
return cell;
|
||||
}
|
||||
|
||||
/* The confirmed delete: DELETE /api/chats/<id> → the row is removed
|
||||
(+ the empty-state row reappears when it was the last one) and the
|
||||
live region gets `Deleted "<title>".` A 404 means the row is gone
|
||||
(deleted elsewhere) — drop the stale row and say so. Any other
|
||||
failure or a network error keeps the row, restores the Delete
|
||||
button (retryable), and lands the error line. */
|
||||
async function confirmDelete(chat, row, yesBtn, restoreDelete) {
|
||||
yesBtn.disabled = true; // no double-fire while the request is in flight
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(`/api/chats/${chat.id}`, { method: "DELETE" });
|
||||
} catch {
|
||||
announce(`Couldn't delete "${chat.title}" — is the app reachable?`);
|
||||
restoreDelete();
|
||||
return;
|
||||
}
|
||||
if (r.status === 404) {
|
||||
row.remove();
|
||||
showEmptyIfLast();
|
||||
announce("That chat was already deleted.");
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
announce(`Couldn't delete "${chat.title}" — try again.`);
|
||||
restoreDelete();
|
||||
return;
|
||||
}
|
||||
row.remove();
|
||||
showEmptyIfLast();
|
||||
announce("That chat was already deleted.");
|
||||
return;
|
||||
announce(`Deleted "${chat.title}".`);
|
||||
}
|
||||
if (!r.ok) {
|
||||
announce(`Couldn't delete "${chat.title}" — try again.`);
|
||||
restoreDelete();
|
||||
return;
|
||||
|
||||
/* ---------- share column (phase 51, owner-locked 2026-08-29) ---------- */
|
||||
|
||||
/* Select every text node in the link field (an <a> has no .select();
|
||||
a range does the job) — best-effort: a selection failure only means
|
||||
the user copies by hand. */
|
||||
function selectAllInField(el) {
|
||||
try {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
} catch {
|
||||
/* selection is best-effort — the field still shows the full URL */
|
||||
}
|
||||
}
|
||||
row.remove();
|
||||
showEmptyIfLast();
|
||||
announce(`Deleted "${chat.title}".`);
|
||||
}
|
||||
|
||||
/* ---------- share column (phase 51, owner-locked 2026-08-29) ---------- */
|
||||
|
||||
/* Select every text node in the link field (an <a> has no .select();
|
||||
a range does the job) — best-effort: a selection failure only means
|
||||
the user copies by hand. */
|
||||
function selectAllInField(el) {
|
||||
try {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
} catch {
|
||||
/* selection is best-effort — the field still shows the full URL */
|
||||
/* Clipboard + the owner-locked inline-link fallback: a non-secure
|
||||
(http) homelab origin rejects navigator.clipboard, so the failure
|
||||
path renders a TRANSIENT <a> link field in the row's share cell —
|
||||
input-like, it selects its full URL on focus (click or Tab, then
|
||||
Ctrl/Cmd+C). One field at a time (a new offer replaces the old).
|
||||
Returns true when the clipboard took it. (The per-page duplication
|
||||
house style — this is history.js's OWN copy of the ~10-line helper;
|
||||
app.js keeps the chat page's, no new shared module.) */
|
||||
async function copyShareLink(cell, absoluteUrl) {
|
||||
cell.querySelectorAll(".share-link-fallback").forEach((el) => el.remove());
|
||||
try {
|
||||
await navigator.clipboard.writeText(absoluteUrl);
|
||||
return true;
|
||||
} catch {
|
||||
const field = document.createElement("a");
|
||||
field.className = "share-link-fallback";
|
||||
field.href = absoluteUrl; // carries the full URL (copy link address works too)
|
||||
field.textContent = absoluteUrl; // the URL is data — textContent, never innerHTML
|
||||
field.title = "Share link — click, then copy (Ctrl/Cmd+C)";
|
||||
field.addEventListener("focus", () => selectAllInField(field));
|
||||
cell.appendChild(field);
|
||||
field.focus({ preventScroll: true }); // selects the URL — ready to copy
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Clipboard + the owner-locked inline-link fallback: a non-secure
|
||||
(http) homelab origin rejects navigator.clipboard, so the failure
|
||||
path renders a TRANSIENT <a> link field in the row's share cell —
|
||||
input-like, it selects its full URL on focus (click or Tab, then
|
||||
Ctrl/Cmd+C). One field at a time (a new offer replaces the old).
|
||||
Returns true when the clipboard took it. (The per-page duplication
|
||||
house style — this is history.js's OWN copy of the ~10-line helper;
|
||||
app.js keeps the chat page's, no new shared module.) */
|
||||
async function copyShareLink(cell, absoluteUrl) {
|
||||
cell.querySelectorAll(".share-link-fallback").forEach((el) => el.remove());
|
||||
try {
|
||||
await navigator.clipboard.writeText(absoluteUrl);
|
||||
return true;
|
||||
} catch {
|
||||
const field = document.createElement("a");
|
||||
field.className = "share-link-fallback";
|
||||
field.href = absoluteUrl; // carries the full URL (copy link address works too)
|
||||
field.textContent = absoluteUrl; // the URL is data — textContent, never innerHTML
|
||||
field.title = "Share link — click, then copy (Ctrl/Cmd+C)";
|
||||
field.addEventListener("focus", () => selectAllInField(field));
|
||||
cell.appendChild(field);
|
||||
field.focus({ preventScroll: true }); // selects the URL — ready to copy
|
||||
return false;
|
||||
/* The unshared state: the [Create link] button (a failed share keeps
|
||||
the cell here — Create link is retryable). */
|
||||
function renderShareUnshared(chat, cell) {
|
||||
const create = document.createElement("button");
|
||||
create.type = "button";
|
||||
create.className = "history-share-create";
|
||||
create.setAttribute("aria-label", `Create share link: ${chat.title}`);
|
||||
create.textContent = "Create link";
|
||||
create.addEventListener("click", () => void createShareLink(chat, cell, create));
|
||||
cell.replaceChildren(create);
|
||||
}
|
||||
}
|
||||
|
||||
/* The unshared state: the [Create link] button (a failed share keeps
|
||||
the cell here — Create link is retryable). */
|
||||
function renderShareUnshared(chat, cell) {
|
||||
const create = document.createElement("button");
|
||||
create.type = "button";
|
||||
create.className = "history-share-create";
|
||||
create.setAttribute("aria-label", `Create share link: ${chat.title}`);
|
||||
create.textContent = "Create link";
|
||||
create.addEventListener("click", () => void createShareLink(chat, cell, create));
|
||||
cell.replaceChildren(create);
|
||||
}
|
||||
|
||||
/* The shared state: [Copy] [Unshare]. Unshare is the inline two-step
|
||||
(the phase-50 Delete-confirm pattern — same .history-confirm-* CSS,
|
||||
focus moves to Yes so the confirm is keyboard-reachable); No or a
|
||||
failed request restores this state (retryable). */
|
||||
function renderShareShared(chat, cell) {
|
||||
const copy = document.createElement("button");
|
||||
copy.type = "button";
|
||||
copy.className = "history-share-copy";
|
||||
copy.setAttribute("aria-label", `Copy share link: ${chat.title}`);
|
||||
copy.textContent = "Copy";
|
||||
copy.addEventListener("click", () => void copyRowShareLink(chat, cell));
|
||||
const unshare = document.createElement("button");
|
||||
unshare.type = "button";
|
||||
unshare.className = "history-unshare";
|
||||
unshare.setAttribute("aria-label", `Unshare saved chat: ${chat.title}`);
|
||||
unshare.textContent = "Unshare";
|
||||
function restoreShared() {
|
||||
/* The shared state: [Copy] [Unshare]. Unshare is the inline two-step
|
||||
(the phase-50 Delete-confirm pattern — same .history-confirm-* CSS,
|
||||
focus moves to Yes so the confirm is keyboard-reachable); No or a
|
||||
failed request restores this state (retryable). */
|
||||
function renderShareShared(chat, cell) {
|
||||
const copy = document.createElement("button");
|
||||
copy.type = "button";
|
||||
copy.className = "history-share-copy";
|
||||
copy.setAttribute("aria-label", `Copy share link: ${chat.title}`);
|
||||
copy.textContent = "Copy";
|
||||
copy.addEventListener("click", () => void copyRowShareLink(chat, cell));
|
||||
const unshare = document.createElement("button");
|
||||
unshare.type = "button";
|
||||
unshare.className = "history-unshare";
|
||||
unshare.setAttribute("aria-label", `Unshare saved chat: ${chat.title}`);
|
||||
unshare.textContent = "Unshare";
|
||||
function restoreShared() {
|
||||
cell.replaceChildren(copy, unshare);
|
||||
unshare.focus({ preventScroll: true }); // focus returns to the (restored) control
|
||||
}
|
||||
unshare.addEventListener("click", () => {
|
||||
const label = document.createElement("span");
|
||||
label.className = "history-confirm-text";
|
||||
label.textContent = "Unshare?";
|
||||
const yes = document.createElement("button");
|
||||
yes.type = "button";
|
||||
yes.className = "history-confirm-yes";
|
||||
yes.textContent = "Yes";
|
||||
const no = document.createElement("button");
|
||||
no.type = "button";
|
||||
no.className = "history-confirm-no";
|
||||
no.textContent = "No";
|
||||
yes.addEventListener("click", () => void confirmUnshare(chat, cell, yes, restoreShared));
|
||||
no.addEventListener("click", restoreShared);
|
||||
cell.replaceChildren(label, yes, no);
|
||||
yes.focus({ preventScroll: true }); // the confirm pair takes over the focus
|
||||
});
|
||||
cell.replaceChildren(copy, unshare);
|
||||
unshare.focus({ preventScroll: true }); // focus returns to the (restored) control
|
||||
}
|
||||
unshare.addEventListener("click", () => {
|
||||
const label = document.createElement("span");
|
||||
label.className = "history-confirm-text";
|
||||
label.textContent = "Unshare?";
|
||||
const yes = document.createElement("button");
|
||||
yes.type = "button";
|
||||
yes.className = "history-confirm-yes";
|
||||
yes.textContent = "Yes";
|
||||
const no = document.createElement("button");
|
||||
no.type = "button";
|
||||
no.className = "history-confirm-no";
|
||||
no.textContent = "No";
|
||||
yes.addEventListener("click", () => void confirmUnshare(chat, cell, yes, restoreShared));
|
||||
no.addEventListener("click", restoreShared);
|
||||
cell.replaceChildren(label, yes, no);
|
||||
yes.focus({ preventScroll: true }); // the confirm pair takes over the focus
|
||||
});
|
||||
cell.replaceChildren(copy, unshare);
|
||||
}
|
||||
|
||||
/* The Share cell (phase 51): the span the row's Share <td> carries.
|
||||
The shipped state comes from the row's share_url (the list endpoint
|
||||
populates it — no second fetch): shared → Copy + Unshare, unshared →
|
||||
Create link. No share action ever removes the ROW (only Delete
|
||||
does) — the cell just re-renders between its states. */
|
||||
function makeShareControl(chat) {
|
||||
const cell = document.createElement("span");
|
||||
cell.className = "history-share";
|
||||
if (chat.share_url) {
|
||||
/* The Share cell (phase 51): the span the row's Share <td> carries.
|
||||
The shipped state comes from the row's share_url (the list endpoint
|
||||
populates it — no second fetch): shared → Copy + Unshare, unshared →
|
||||
Create link. No share action ever removes the ROW (only Delete
|
||||
does) — the cell just re-renders between its states. */
|
||||
function makeShareControl(chat) {
|
||||
const cell = document.createElement("span");
|
||||
cell.className = "history-share";
|
||||
if (chat.share_url) {
|
||||
renderShareShared(chat, cell);
|
||||
} else {
|
||||
renderShareUnshared(chat, cell);
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
/* Create the link: POST /api/chats/<id>/share → the response's
|
||||
share_url becomes the row's data (chat.share_url — the later Copy
|
||||
uses it), the cell re-renders to the shared state, and the ABSOLUTE
|
||||
link (the row's own origin supplies the scheme/host) is offered for
|
||||
copying — clipboard → the inline-field fallback in the cell. A
|
||||
non-2xx (a 404 — the row was deleted behind our back — or 5xx) or a
|
||||
network error keeps the unshared state (Create link re-enabled,
|
||||
retryable) and lands the error line. */
|
||||
async function createShareLink(chat, cell, createBtn) {
|
||||
createBtn.disabled = true; // no double-fire while the request is in flight
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(`/api/chats/${chat.id}/share`, { method: "POST" });
|
||||
} catch {
|
||||
announce(`Couldn't share "${chat.title}" — is the app reachable?`);
|
||||
createBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
announce(`Couldn't share "${chat.title}" — try again.`);
|
||||
createBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const { share_url } = await r.json();
|
||||
chat.share_url = share_url; // the row is shared from now on
|
||||
renderShareShared(chat, cell);
|
||||
} else {
|
||||
const copied = await copyShareLink(
|
||||
cell,
|
||||
new URL(share_url, window.location.origin).toString(),
|
||||
);
|
||||
announce(copied ? "Share link copied." : "Share link ready — copy it from the field.");
|
||||
}
|
||||
|
||||
/* Copy (shared state): re-copy the row's share_url — the per-page
|
||||
clipboard + fallback helper; the live region lands the outcome. */
|
||||
async function copyRowShareLink(chat, cell) {
|
||||
const copied = await copyShareLink(
|
||||
cell,
|
||||
new URL(chat.share_url, window.location.origin).toString(),
|
||||
);
|
||||
announce(copied ? "Share link copied." : "Share link ready — copy it from the field.");
|
||||
}
|
||||
|
||||
/* The confirmed unshare: POST /api/chats/<id>/unshare → the token is
|
||||
NULL (revoked — the public link 404s from now on), the cell
|
||||
re-renders to the unshared state (Create link) and the live region
|
||||
gets `Unshared "<title>".` A non-2xx / a network error keeps the
|
||||
shared state (restoreShared — Copy + Unshare, retryable) and lands
|
||||
the error line. */
|
||||
async function confirmUnshare(chat, cell, yesBtn, restoreShared) {
|
||||
yesBtn.disabled = true; // no double-fire while the request is in flight
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(`/api/chats/${chat.id}/unshare`, { method: "POST" });
|
||||
} catch {
|
||||
announce(`Couldn't unshare "${chat.title}" — is the app reachable?`);
|
||||
restoreShared();
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
announce(`Couldn't unshare "${chat.title}" — try again.`);
|
||||
restoreShared();
|
||||
return;
|
||||
}
|
||||
chat.share_url = null; // revoked: the row is unshared again
|
||||
renderShareUnshared(chat, cell);
|
||||
announce(`Unshared "${chat.title}".`);
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
/* Create the link: POST /api/chats/<id>/share → the response's
|
||||
share_url becomes the row's data (chat.share_url — the later Copy
|
||||
uses it), the cell re-renders to the shared state, and the ABSOLUTE
|
||||
link (the row's own origin supplies the scheme/host) is offered for
|
||||
copying — clipboard → the inline-field fallback in the cell. A
|
||||
non-2xx (a 404 — the row was deleted behind our back — or 5xx) or a
|
||||
network error keeps the unshared state (Create link re-enabled,
|
||||
retryable) and lands the error line. */
|
||||
async function createShareLink(chat, cell, createBtn) {
|
||||
createBtn.disabled = true; // no double-fire while the request is in flight
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(`/api/chats/${chat.id}/share`, { method: "POST" });
|
||||
} catch {
|
||||
announce(`Couldn't share "${chat.title}" — is the app reachable?`);
|
||||
createBtn.disabled = false;
|
||||
return;
|
||||
/* The empty-state row reappears exactly when the last data row was
|
||||
removed (the empty row itself ships in the tbody, hidden). */
|
||||
function showEmptyIfLast() {
|
||||
if (!emptyRow || !tbody) return;
|
||||
emptyRow.hidden = tbody.querySelectorAll("tr").length > 1;
|
||||
}
|
||||
if (!r.ok) {
|
||||
announce(`Couldn't share "${chat.title}" — try again.`);
|
||||
createBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const { share_url } = await r.json();
|
||||
chat.share_url = share_url; // the row is shared from now on
|
||||
renderShareShared(chat, cell);
|
||||
const copied = await copyShareLink(
|
||||
cell,
|
||||
new URL(share_url, window.location.origin).toString(),
|
||||
);
|
||||
announce(copied ? "Share link copied." : "Share link ready — copy it from the field.");
|
||||
}
|
||||
|
||||
/* Copy (shared state): re-copy the row's share_url — the per-page
|
||||
clipboard + fallback helper; the live region lands the outcome. */
|
||||
async function copyRowShareLink(chat, cell) {
|
||||
const copied = await copyShareLink(
|
||||
cell,
|
||||
new URL(chat.share_url, window.location.origin).toString(),
|
||||
);
|
||||
announce(copied ? "Share link copied." : "Share link ready — copy it from the field.");
|
||||
}
|
||||
/* 0-row fetches, non-2xx, and network failures all land on the
|
||||
empty-state row (the sources.js house fallback — the safe state
|
||||
in every case). */
|
||||
function showEmptyState() {
|
||||
if (!tbody) return;
|
||||
tbody.replaceChildren(emptyRow);
|
||||
if (emptyRow) emptyRow.hidden = false;
|
||||
}
|
||||
|
||||
/* The confirmed unshare: POST /api/chats/<id>/unshare → the token is
|
||||
NULL (revoked — the public link 404s from now on), the cell
|
||||
re-renders to the unshared state (Create link) and the live region
|
||||
gets `Unshared "<title>".` A non-2xx / a network error keeps the
|
||||
shared state (restoreShared — Copy + Unshare, retryable) and lands
|
||||
the error line. */
|
||||
async function confirmUnshare(chat, cell, yesBtn, restoreShared) {
|
||||
yesBtn.disabled = true; // no double-fire while the request is in flight
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(`/api/chats/${chat.id}/unshare`, { method: "POST" });
|
||||
} catch {
|
||||
announce(`Couldn't unshare "${chat.title}" — is the app reachable?`);
|
||||
restoreShared();
|
||||
return;
|
||||
/* GET /api/chats → render the rows (latest activity first — the
|
||||
server's order). A 0-row fetch shows the empty-state row. */
|
||||
async function loadChats() {
|
||||
if (emptyRow) emptyRow.hidden = true;
|
||||
let r;
|
||||
try {
|
||||
r = await fetch("/api/chats");
|
||||
} catch {
|
||||
showEmptyState();
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
showEmptyState();
|
||||
return;
|
||||
}
|
||||
const { chats } = await r.json();
|
||||
if (!chats.length) {
|
||||
showEmptyState();
|
||||
return;
|
||||
}
|
||||
for (const chat of chats) {
|
||||
tbody.appendChild(makeRow(chat));
|
||||
}
|
||||
}
|
||||
if (!r.ok) {
|
||||
announce(`Couldn't unshare "${chat.title}" — try again.`);
|
||||
restoreShared();
|
||||
return;
|
||||
}
|
||||
chat.share_url = null; // revoked: the row is unshared again
|
||||
renderShareUnshared(chat, cell);
|
||||
announce(`Unshared "${chat.title}".`);
|
||||
}
|
||||
|
||||
/* The empty-state row reappears exactly when the last data row was
|
||||
removed (the empty row itself ships in the tbody, hidden). */
|
||||
function showEmptyIfLast() {
|
||||
if (!emptyRow || !tbody) return;
|
||||
emptyRow.hidden = tbody.querySelectorAll("tr").length > 1;
|
||||
}
|
||||
|
||||
/* 0-row fetches, non-2xx, and network failures all land on the
|
||||
empty-state row (the sources.js house fallback — the safe state
|
||||
in every case). */
|
||||
function showEmptyState() {
|
||||
if (!tbody) return;
|
||||
tbody.replaceChildren(emptyRow);
|
||||
if (emptyRow) emptyRow.hidden = false;
|
||||
}
|
||||
|
||||
/* GET /api/chats → render the rows (latest activity first — the
|
||||
server's order). A 0-row fetch shows the empty-state row. */
|
||||
async function loadChats() {
|
||||
if (emptyRow) emptyRow.hidden = true;
|
||||
let r;
|
||||
try {
|
||||
r = await fetch("/api/chats");
|
||||
} catch {
|
||||
showEmptyState();
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
showEmptyState();
|
||||
return;
|
||||
}
|
||||
const { chats } = await r.json();
|
||||
if (!chats.length) {
|
||||
showEmptyState();
|
||||
return;
|
||||
}
|
||||
for (const chat of chats) {
|
||||
tbody.appendChild(makeRow(chat));
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// Phase 19/34: the shared header first (whoami + nav reveal + the
|
||||
// steering panel) — the whoami promise is cached, so the gate below
|
||||
// reuses the SAME single /api/whoami request.
|
||||
await initSharedHeader();
|
||||
/* ---------- view boot (phase 76 task 03) ----------
|
||||
* The shared header is NOT booted here — in the shell it runs
|
||||
* exactly once, via the chat module (app.js) at shell boot. The
|
||||
* whoami gate reads fetchIsAdmin() — the SAME cached whoami promise
|
||||
* the header uses (zero extra requests). Anonymous: the gate in,
|
||||
* the table out — and NO /api/chats request at all (the router
|
||||
* 403s anonymous, so the view must never call it; the story E2E
|
||||
* pins the request log). */
|
||||
if (!(await fetchIsAdmin())) {
|
||||
// Anonymous: the gate in, the table out — and NO /api/chats
|
||||
// request at all: the router 403s anonymous, so the page must
|
||||
// never call it (the story E2E pins the request log).
|
||||
if (tableWrap) tableWrap.hidden = true;
|
||||
if (gateEl) gateEl.hidden = false;
|
||||
return;
|
||||
}
|
||||
if (gateEl) gateEl.hidden = true;
|
||||
loadChats();
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/* Brain of Reese — shell router (phase 76, task 01).
|
||||
*
|
||||
* The five navbar views are views of ONE HTML shell (index.html), not
|
||||
* five documents: this module makes a navbar click a CLIENT-SIDE view
|
||||
* switch — history.pushState + show/hide — never a document load, so
|
||||
* the in-flight chat stream in the hidden view keeps streaming
|
||||
* through any switch and completes when the user returns to Chat.
|
||||
* Real departures (tab close, leaving the app, the Stop button) still
|
||||
* cancel the fetch and stop the model — the phase-48 contract, owned
|
||||
* by app.js and untouched here. The phase-48 LOCKED refinement
|
||||
* (owner-confirmed 2026-09-06): "real navigation cancels the fetch"
|
||||
* now means LEAVING THE APP — in-app navbar switches no longer cancel.
|
||||
*
|
||||
* The contract (pinned at source level in
|
||||
* tests/unit/test_frontend_router.py):
|
||||
*
|
||||
* • VIEW — the pathname → view name map for the folded views
|
||||
* ("/" → chat, "/index.html" → chat — the shell's own two URLs,
|
||||
* "/tuning.html" → tuning, "/sources.html" → rag, "/git-sources.html"
|
||||
* → git-sources, "/history.html" → history). Only a link whose href
|
||||
* is IN this map is intercepted; every other link (login, document
|
||||
* viewer, a /?chat=<id> deep link — its query string keeps it out
|
||||
* of the map) still performs its real, document-level navigation.
|
||||
* • boot from location.pathname: the matching view is shown WITHOUT
|
||||
* focus (no focus steal on load) — a direct load of /tuning.html
|
||||
* deep-links to the Tuning view (the shell route in app/main.py
|
||||
* serves this shell for that path).
|
||||
* • mount-once, hide-forever: a non-chat view's module is
|
||||
* lazy-imported on FIRST show only, and `await module.mount(root)`
|
||||
* runs once (the `mounted` guard) — the view's DOM and JS state
|
||||
* (for chat, the in-flight SSE reader; for the Sources view, the
|
||||
* upload-progress poller) persist across every switch; that
|
||||
* persistence IS the phase-76 fix. The chat view needs no module:
|
||||
* app.js already ran at shell boot.
|
||||
* • show = drop hidden + inert, hide = add BOTH (WCAG: a hidden view
|
||||
* must not receive focus or keyboard traversal — the inert pair
|
||||
* pins the [hidden] contract in the a11y tree, AGENTS.md rule 5).
|
||||
* • SINGLE WRITER of the .nav-link active state (is-active +
|
||||
* aria-current="page"), of document.title, and of the per-view
|
||||
* <meta name="description"> (values carried over from the old
|
||||
* pages' <head>s) — no page script stamps any of these.
|
||||
* • focus the target view (its tabindex="-1") ONLY on
|
||||
* user-initiated switches (navbar click / popstate back-forward);
|
||||
* a switch also lands the viewport at the top of the document,
|
||||
* the same way the old per-view page loads did (user intent — the
|
||||
* no-reply-autoscroll contract is about streaming frames, not
|
||||
* navigation the user performs).
|
||||
*
|
||||
* Boot order (index.html): brand.js (classic) → app.js (module — the
|
||||
* chat view, runs at shell boot exactly as before) → router.js
|
||||
* (module — this file). No CDN, no framework, no bundler dependency:
|
||||
* a plain ES module whose dynamic imports (./tuning.js, task 01;
|
||||
* ./sources.js + ./git-sources.js, task 02; ./history.js in task 03)
|
||||
* resolve relatively in dev and are inlined by the Containerfile's
|
||||
* esbuild stage in the image.
|
||||
*/
|
||||
|
||||
/* ---------- the view map (pathname → view name) ----------
|
||||
* The shell's own two URLs are the chat view (the shell IS the chat
|
||||
* page — app.js boots it); every folded view adds one entry. The
|
||||
* values are the <section class="view" id="view-<name>"> slugs in
|
||||
* index.html. */
|
||||
const VIEW = {
|
||||
"/": "chat",
|
||||
"/index.html": "chat", // the shell's alternate URL (HTML_PAGES)
|
||||
"/tuning.html": "tuning", // phase 76 task 01: the first folded view
|
||||
"/sources.html": "rag", // phase 76 task 02: the RAG view (knowledge base)
|
||||
"/git-sources.html": "git-sources", // phase 76 task 02: the Sources view
|
||||
"/history.html": "history", // phase 76 task 03: the History view (saved chats)
|
||||
};
|
||||
|
||||
/* The nav-link href the router stamps active for each view (the
|
||||
Chat link is href="/", the RAG link href="/sources.html", …). */
|
||||
const VIEW_PATH = {
|
||||
chat: "/",
|
||||
tuning: "/tuning.html",
|
||||
rag: "/sources.html",
|
||||
"git-sources": "/git-sources.html",
|
||||
history: "/history.html",
|
||||
};
|
||||
|
||||
/* The lazy view modules — ONLY the non-chat views (chat needs no
|
||||
import: app.js already ran at shell boot). Static specifiers so the
|
||||
Containerfile's esbuild stage can inline each module into the
|
||||
router bundle (the browser still defers its code until the first
|
||||
import() — mount-once semantics are preserved in the image). */
|
||||
const VIEW_MODULES = {
|
||||
tuning: () => import("./tuning.js"),
|
||||
rag: () => import("./sources.js"), // phase 76 task 02
|
||||
"git-sources": () => import("./git-sources.js"), // phase 76 task 02
|
||||
history: () => import("./history.js"), // phase 76 task 03
|
||||
};
|
||||
|
||||
/* Per-view document.head values, carried over from the old pages'
|
||||
<head>s (the router is the single writer of both). The values are
|
||||
the DEFAULT-deployment form: at write time they are composed through
|
||||
brandName() (below) so a configured deployment keeps its name. */
|
||||
const TITLES = {
|
||||
chat: "Brain of Reese",
|
||||
tuning: "Global Tuning · Brain of Reese",
|
||||
rag: "Sources · Brain of Reese", // old sources.html <title>
|
||||
"git-sources": "Git sources · Brain of Reese", // old git-sources.html <title>
|
||||
history: "Saved chats · Brain of Reese", // old history.html <title>
|
||||
};
|
||||
const DESCRIPTIONS = {
|
||||
chat:
|
||||
"Ask anything about your indexed documents — every answer cites the exact doc.",
|
||||
tuning:
|
||||
"Manage the global tuning notes that steer every Brain of Reese answer.",
|
||||
rag: "Documents indexed in Brain of Reese.", // old sources.html meta
|
||||
"git-sources":
|
||||
"Add and remove the git repositories Brain of Reese syncs and indexes (admin-only).",
|
||||
history:
|
||||
"Saved chats — every conversation is saved automatically, one click back.", // old history.html meta
|
||||
};
|
||||
|
||||
/* The brand-resolved display name (phase 39 — brand.js is the single
|
||||
owner: window.BOR_BRAND is "Brain of Reese" from parse time and is
|
||||
updated once /api/config settles). The router composes the per-view
|
||||
title/meta from it instead of stamping the hardcoded literal: the
|
||||
lazy view import defers switchTo PAST brand.js's one-time
|
||||
DOMContentLoaded pass, so a literal stamp would overwrite a
|
||||
configured deployment's name (e.g. "Brain of Testy") in the
|
||||
client-side head. Composing at write time keeps the name correct
|
||||
for every config/switch ordering (an unset deployment — the name IS
|
||||
the literal — stays byte-identical: replaceAll is a no-op). */
|
||||
const brandName = () => window.BOR_BRAND || "Brain of Reese";
|
||||
const titleFor = (view) => TITLES[view].replaceAll("Brain of Reese", brandName());
|
||||
const descFor = (view) => DESCRIPTIONS[view].replaceAll("Brain of Reese", brandName());
|
||||
|
||||
/* The view sections — one per view name (index.html: #view-chat is
|
||||
visible at boot, the folded views ship hidden + inert). */
|
||||
const viewEls = {};
|
||||
for (const name of new Set(Object.values(VIEW))) {
|
||||
viewEls[name] = document.getElementById(`view-${name}`);
|
||||
}
|
||||
|
||||
/* The mount-once guard: a view is imported + mounted at most ONCE per
|
||||
document life — re-shows are show/hide only (no refetch, no
|
||||
re-mount; the view's state persists). Chat starts mounted: app.js
|
||||
owns it and ran at shell boot. */
|
||||
const mounted = { chat: true };
|
||||
|
||||
const nav = document.getElementById("app-nav");
|
||||
const metaDesc = document.querySelector('meta[name="description"]');
|
||||
|
||||
let current = null; // the visible view name (null until boot resolves)
|
||||
|
||||
/* ---------- show / hide (the single writer of the view state) ---------- */
|
||||
|
||||
/* Show `name`, hide every other view, and write the single-writer
|
||||
head/nav state. `userInitiated` marks navbar-click / popstate
|
||||
switches: only those focus the target view (its tabindex="-1") and
|
||||
land the viewport at the top — a boot switch never steals focus. */
|
||||
async function switchTo(name, { userInitiated }) {
|
||||
const root = viewEls[name];
|
||||
if (!root) return;
|
||||
|
||||
/* Mount-once: the lazy module is imported on FIRST show only, then
|
||||
mounted into the view's section. The guard runs BEFORE the import
|
||||
(a re-show never re-imports) and is set only after mount resolves
|
||||
(a failed mount may retry on the next show). */
|
||||
if (!mounted[name]) {
|
||||
const load = VIEW_MODULES[name];
|
||||
if (load) {
|
||||
const mod = await load();
|
||||
await mod.mount(root);
|
||||
}
|
||||
mounted[name] = true;
|
||||
}
|
||||
|
||||
/* Show = drop hidden AND inert; hide = add BOTH (a hidden view must
|
||||
not receive focus or keyboard traversal — the inert pair makes the
|
||||
[hidden] contract hold in the a11y tree, not just the layout). */
|
||||
for (const [viewName, el] of Object.entries(viewEls)) {
|
||||
el.hidden = viewName !== name;
|
||||
el.inert = viewName !== name;
|
||||
}
|
||||
|
||||
/* SINGLE WRITER: the active nav link (is-active + aria-current),
|
||||
the document title, and the per-view meta description. */
|
||||
const path = VIEW_PATH[name];
|
||||
if (nav) {
|
||||
for (const a of nav.querySelectorAll("a.nav-link")) {
|
||||
const active = (a.getAttribute("href") || "") === path;
|
||||
a.classList.toggle("is-active", active);
|
||||
if (active) a.setAttribute("aria-current", "page");
|
||||
else a.removeAttribute("aria-current");
|
||||
}
|
||||
}
|
||||
document.title = titleFor(name);
|
||||
if (metaDesc) metaDesc.content = descFor(name);
|
||||
|
||||
current = name;
|
||||
|
||||
/* Focus the target view ONLY on user-initiated switches (navbar
|
||||
click / popstate) — never on initial boot (no focus steal on
|
||||
load). The top landing mirrors what the old per-view page loads
|
||||
did (user intent, not a streaming-frame autoscroll). */
|
||||
if (userInitiated) {
|
||||
window.scrollTo(0, 0);
|
||||
root.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- navbar click: same-shell links become view switches ----------
|
||||
* Delegated on the nav (covers the mobile dropdown too — it is the
|
||||
* same #app-nav element): a same-shell a.nav-link (href in VIEW) is
|
||||
* intercepted — preventDefault + history.pushState + switch, so the
|
||||
* click is a view switch, NEVER a document load. Every other link
|
||||
* (login, the document viewer, the not-yet-folded views in tasks
|
||||
* 02/03) keeps its real navigation untouched. */
|
||||
if (nav) {
|
||||
nav.addEventListener("click", (e) => {
|
||||
const a = e.target instanceof Element ? e.target.closest("a.nav-link") : null;
|
||||
if (!a) return;
|
||||
const href = a.getAttribute("href") || "";
|
||||
if (!(href in VIEW)) return; // not a same-shell view — real navigation
|
||||
e.preventDefault();
|
||||
const name = VIEW[href];
|
||||
if (name === current) return; // already visible (the menu still closes)
|
||||
history.pushState({ view: name }, "", href);
|
||||
switchTo(name, { userInitiated: true });
|
||||
});
|
||||
|
||||
/* Back / forward: popstate switches views (the history entries were
|
||||
written by the pushState above — same-document, no page load). */
|
||||
window.addEventListener("popstate", () => {
|
||||
const name = VIEW[window.location.pathname];
|
||||
if (name && name !== current) switchTo(name, { userInitiated: true });
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- boot: deep-link from the pathname, no focus steal ---------- */
|
||||
|
||||
/* A direct load of any shell path shows its view (chat for "/" and
|
||||
"/index.html", tuning for "/tuning.html"); an unexpected pathname
|
||||
falls back to chat (the shell's default view). userInitiated:false
|
||||
— boot never focuses (no focus steal on load). */
|
||||
const bootName = VIEW[window.location.pathname] ?? "chat";
|
||||
switchTo(bootName, { userInitiated: false });
|
||||
@@ -404,6 +404,21 @@ html::after {
|
||||
padding-block: 1.25rem;
|
||||
}
|
||||
|
||||
/* Phase 76 (task 02): the shell's #view-chat wrapper sits between
|
||||
.app-main and .chat-shell — it must CONTINUE the full-height column
|
||||
chain (body's min-height: 100dvh flex → .app-main → #view-chat →
|
||||
.chat-shell, phase 52) or .chat-shell's flex:1 loses its flex
|
||||
parent and the column sizes to content: the composer's sticky pin
|
||||
(phases 43/55/65) then has no tall scroll range and the empty chat
|
||||
stops resting at the screen bottom. The folded document views
|
||||
(tuning/rag/git-sources) size to content in normal flow — they need
|
||||
no chain. */
|
||||
#view-chat {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Chat is a vertical conversation: a centered, capped column is the
|
||||
correct layout here (PLAN §UI/UX). The surrounding frame keeps it
|
||||
from collapsing into a hairline on wide screens. The cap is the
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* Brain of Reese — Global Tuning page (phase 27, task 03).
|
||||
/* Brain of Reese — Global Tuning view (phase 27; phase 76 task 01:
|
||||
* shell view module).
|
||||
*
|
||||
* The standalone manager for steering notes: create / list / edit /
|
||||
* delete WITHOUT a chat conversation. This module is the single owner
|
||||
* of the page's behaviour:
|
||||
* of the view's behaviour:
|
||||
*
|
||||
* • loadNotes() — GET /api/steering → the newest-first note list
|
||||
* (#tune-list) + the empty state. A failed fetch (API down, or the
|
||||
@@ -25,317 +26,327 @@
|
||||
* announce a retry. The empty state is re-checked on every removal.
|
||||
* • announce(msg) — #tune-announcer (role=status, aria-live=polite),
|
||||
* the screen-reader confirmation for create / edit / delete.
|
||||
* • header boot (task 02) — initSharedHeader(): Sign in / Sign out,
|
||||
* the admin-only Sources link, and this page's own admin-only
|
||||
* "Tuning" nav link (#nav-tuning), all decided by the module's
|
||||
* cached whoami promise (exactly one /api/whoami request per
|
||||
* page). Phase 34 task 02: the New chat binding is module-owned
|
||||
* (assets/header.js, the SINGLE one) — on a non-chat page "new
|
||||
* chat" means going to the chat, fresh (the module clears the
|
||||
* phase-14 conversation key and navigates to "/").
|
||||
*
|
||||
* Anonymous-safe (task 03): the header already hides the "Tuning" nav
|
||||
* link for anonymous visitors; a DIRECT anonymous URL still gets a safe
|
||||
* page — loadNotes() only runs when the cached whoami says admin (the
|
||||
* Sources page gate pattern), the list stays on its empty state, and
|
||||
* the create form 403s gracefully on submit (the inline error carries
|
||||
* the API detail). Note text is always rendered with textContent —
|
||||
* never innerHTML (XSS-safe, like app.js's steering panel).
|
||||
* Phase 76 (task 01) — shell view module (the "Global Tuning" view of
|
||||
* the ONE-document shell; /tuning.html now serves the shell, and
|
||||
* assets/router.js lazy-imports THIS module on first show):
|
||||
*
|
||||
* • the top-level boot is now `export async function mount(root)` —
|
||||
* root is the view's <section id="view-tuning">, and every DOM
|
||||
* lookup scopes to root (the view ids stay unique across the
|
||||
* shell — scoped lookups keep the module honest and testable).
|
||||
* The router mounts a view ONCE (mount-once, hide-forever), so
|
||||
* the binding + state survive every switch.
|
||||
* • the initSharedHeader() call is DROPPED: in the shell the shared
|
||||
* header boots exactly once, via the chat module (app.js) at shell
|
||||
* boot — the view never re-boots it. The admin gate keeps
|
||||
* fetchIsAdmin() — the SAME cached /api/whoami promise header.js
|
||||
* exports (zero extra requests; the flag decides whether the note
|
||||
* list loads at all, the Sources-page gate pattern).
|
||||
*
|
||||
* Anonymous-safe (phase 27 task 03, unchanged in the shell): the
|
||||
* header hides the "Tuning" nav link for anonymous visitors; a DIRECT
|
||||
* anonymous URL still gets a safe view — loadNotes() only runs when
|
||||
* the cached whoami says admin, the list stays on its empty state,
|
||||
* and the create form 403s gracefully on submit (the inline error
|
||||
* carries the API detail). Note text is always rendered with
|
||||
* textContent — never innerHTML (XSS-safe, like app.js's steering
|
||||
* panel).
|
||||
*
|
||||
* The shared header module loads through this script's own relative
|
||||
* import ("./header.js") — a hoisted import evaluated before this body
|
||||
* runs (single-evaluation design: no direct <script> tag; esbuild
|
||||
* inlines it into the page bundle in the image build).
|
||||
* runs (single-evaluation design: no direct <script> tag; in the
|
||||
* image the Containerfile's esbuild stage inlines it — today into the
|
||||
* router bundle, phase 76 task 01).
|
||||
*/
|
||||
|
||||
import { fetchIsAdmin, initSharedHeader } from "./header.js";
|
||||
import { fetchIsAdmin } from "./header.js";
|
||||
|
||||
/* ---------- page elements (tuning.html, task 02) ---------- */
|
||||
const tuneForm = document.querySelector("#tune-form");
|
||||
const tuneNote = document.querySelector("#tune-note");
|
||||
const tuneSave = document.querySelector("#tune-save");
|
||||
const tuneList = document.querySelector("#tune-list");
|
||||
const tuneEmpty = document.querySelector("#tune-empty");
|
||||
const tuneAnnouncer = document.querySelector("#tune-announcer");
|
||||
export async function mount(root) {
|
||||
/* ---------- page elements (the view's section, scoped to root) ---------- */
|
||||
const tuneForm = root.querySelector("#tune-form");
|
||||
const tuneNote = root.querySelector("#tune-note");
|
||||
const tuneSave = root.querySelector("#tune-save");
|
||||
const tuneList = root.querySelector("#tune-list");
|
||||
const tuneEmpty = root.querySelector("#tune-empty");
|
||||
const tuneAnnouncer = root.querySelector("#tune-announcer");
|
||||
|
||||
/* The create form's inline error (role=alert) — created once, hidden
|
||||
by default, and kept between attempts: a failed POST keeps the form
|
||||
AND its message until the next submit. */
|
||||
const createError = document.createElement("p");
|
||||
createError.className = "tuning-error";
|
||||
createError.setAttribute("role", "alert");
|
||||
createError.hidden = true;
|
||||
if (tuneForm) tuneForm.appendChild(createError);
|
||||
/* The create form's inline error (role=alert) — created once, hidden
|
||||
by default, and kept between attempts: a failed POST keeps the
|
||||
form AND its message until the next submit. */
|
||||
const createError = document.createElement("p");
|
||||
createError.className = "tuning-error";
|
||||
createError.setAttribute("role", "alert");
|
||||
createError.hidden = true;
|
||||
if (tuneForm) tuneForm.appendChild(createError);
|
||||
|
||||
/* Polite live region: the screen-reader confirmation for create /
|
||||
edit / delete (task 03). */
|
||||
function announce(message) {
|
||||
if (tuneAnnouncer) tuneAnnouncer.textContent = message;
|
||||
}
|
||||
|
||||
/* Row-action icons — inline SVG constants (aria-hidden; the buttons
|
||||
carry their own labels), the same marks as app.js's steering panel. */
|
||||
const EDIT_ICON =
|
||||
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20l1.2-4.2L16.7 4.3a2.1 2.1 0 0 1 3 3L8.2 18.8 4 20Z"/><path d="M14.7 6.3l3 3"/></svg>';
|
||||
const DELETE_ICON =
|
||||
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 7h14M10 7V5h4v2M8.5 7l.7 12h5.6l.7-12"/></svg>';
|
||||
|
||||
/* FastAPI error bodies: a string detail or the validation-error array
|
||||
(the first entry's msg is the human line). Same extraction as app.js. */
|
||||
async function apiDetail(r, fallback) {
|
||||
try {
|
||||
const data = await r.json();
|
||||
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
|
||||
return String(data.detail[0].msg);
|
||||
}
|
||||
if (typeof data.detail === "string" && data.detail) return data.detail;
|
||||
} catch {
|
||||
/* non-JSON error body */
|
||||
/* Polite live region: the screen-reader confirmation for create /
|
||||
edit / delete (task 03). */
|
||||
function announce(message) {
|
||||
if (tuneAnnouncer) tuneAnnouncer.textContent = message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/* ---------- load / render (newest first — the API's list order) ---------- */
|
||||
/* Row-action icons — inline SVG constants (aria-hidden; the buttons
|
||||
carry their own labels), the same marks as app.js's steering panel. */
|
||||
const EDIT_ICON =
|
||||
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20l1.2-4.2L16.7 4.3a2.1 2.1 0 0 1 3 3L8.2 18.8 4 20Z"/><path d="M14.7 6.3l3 3"/></svg>';
|
||||
const DELETE_ICON =
|
||||
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 7h14M10 7V5h4v2M8.5 7l.7 12h5.6l.7-12"/></svg>';
|
||||
|
||||
/* GET /api/steering → render. A failed fetch (API down, or the 403 on
|
||||
an anonymous direct-URL visit) keeps the last rendered list —
|
||||
progressive enhancement, never a blanked panel. */
|
||||
async function loadNotes() {
|
||||
let r;
|
||||
try {
|
||||
r = await fetch("/api/steering");
|
||||
} catch {
|
||||
return; // API unreachable: keep the last rendered list
|
||||
}
|
||||
if (!r.ok) return; // e.g. anonymous 403: keep the last rendered list
|
||||
let notes;
|
||||
try {
|
||||
notes = (await r.json()).notes || [];
|
||||
} catch {
|
||||
return; // corrupt body: keep the last rendered list
|
||||
}
|
||||
renderNotes(notes);
|
||||
}
|
||||
|
||||
function renderNotes(notes) {
|
||||
if (!tuneList) return;
|
||||
tuneList.textContent = "";
|
||||
for (const n of notes) tuneList.appendChild(makeNoteRow(n));
|
||||
syncEmptyState(notes.length);
|
||||
}
|
||||
|
||||
/* The empty state tracks the list's rendered rows (the HTML ships on
|
||||
the "No tuning notes yet" text; it hides as soon as one row shows). */
|
||||
function syncEmptyState(count) {
|
||||
if (!tuneEmpty || !tuneList) return;
|
||||
const rows = typeof count === "number" ? count : tuneList.children.length;
|
||||
tuneEmpty.hidden = rows > 0;
|
||||
}
|
||||
|
||||
/* One list row: the note text (textContent — XSS-safe, never
|
||||
innerHTML) + the Edit and Delete buttons. */
|
||||
function makeNoteRow(n) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "tuning-note";
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.className = "tuning-note-text";
|
||||
text.textContent = n.note; // rendered as text, never as HTML
|
||||
li.appendChild(text);
|
||||
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.type = "button";
|
||||
editBtn.className = "tuning-edit";
|
||||
editBtn.innerHTML = EDIT_ICON + "<span>Edit</span>";
|
||||
editBtn.addEventListener("click", () => openEditForm(li, n));
|
||||
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.type = "button";
|
||||
delBtn.className = "tuning-delete";
|
||||
delBtn.setAttribute("aria-label", `Delete tuning note: ${n.note}`);
|
||||
delBtn.innerHTML = DELETE_ICON + "<span>Delete</span>";
|
||||
delBtn.addEventListener("click", () => deleteNote(n.id, delBtn, li));
|
||||
|
||||
li.append(editBtn, delBtn);
|
||||
return li;
|
||||
}
|
||||
|
||||
/* ---------- create (POST /api/steering) ---------- */
|
||||
|
||||
if (tuneForm) {
|
||||
tuneForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (tuneSave) tuneSave.disabled = true; // one note per click
|
||||
createError.hidden = true;
|
||||
/* FastAPI error bodies: a string detail or the validation-error array
|
||||
(the first entry's msg is the human line). Same extraction as app.js. */
|
||||
async function apiDetail(r, fallback) {
|
||||
try {
|
||||
const r = await fetch("/api/steering", {
|
||||
method: "POST",
|
||||
const data = await r.json();
|
||||
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
|
||||
return String(data.detail[0].msg);
|
||||
}
|
||||
if (typeof data.detail === "string" && data.detail) return data.detail;
|
||||
} catch {
|
||||
/* non-JSON error body */
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/* ---------- load / render (newest first — the API's list order) ---------- */
|
||||
|
||||
/* GET /api/steering → render. A failed fetch (API down, or the 403 on
|
||||
an anonymous direct-URL visit) keeps the last rendered list —
|
||||
progressive enhancement, never a blanked panel. */
|
||||
async function loadNotes() {
|
||||
let r;
|
||||
try {
|
||||
r = await fetch("/api/steering");
|
||||
} catch {
|
||||
return; // API unreachable: keep the last rendered list
|
||||
}
|
||||
if (!r.ok) return; // e.g. anonymous 403: keep the last rendered list
|
||||
let notes;
|
||||
try {
|
||||
notes = (await r.json()).notes || [];
|
||||
} catch {
|
||||
return; // corrupt body: keep the last rendered list
|
||||
}
|
||||
renderNotes(notes);
|
||||
}
|
||||
|
||||
function renderNotes(notes) {
|
||||
if (!tuneList) return;
|
||||
tuneList.textContent = "";
|
||||
for (const n of notes) tuneList.appendChild(makeNoteRow(n));
|
||||
syncEmptyState(notes.length);
|
||||
}
|
||||
|
||||
/* The empty state tracks the list's rendered rows (the HTML ships on
|
||||
the "No tuning notes yet" text; it hides as soon as one row shows). */
|
||||
function syncEmptyState(count) {
|
||||
if (!tuneEmpty || !tuneList) return;
|
||||
const rows = typeof count === "number" ? count : tuneList.children.length;
|
||||
tuneEmpty.hidden = rows > 0;
|
||||
}
|
||||
|
||||
/* One list row: the note text (textContent — XSS-safe, never
|
||||
innerHTML) + the Edit and Delete buttons. */
|
||||
function makeNoteRow(n) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "tuning-note";
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.className = "tuning-note-text";
|
||||
text.textContent = n.note; // rendered as text, never as HTML
|
||||
li.appendChild(text);
|
||||
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.type = "button";
|
||||
editBtn.className = "tuning-edit";
|
||||
editBtn.innerHTML = EDIT_ICON + "<span>Edit</span>";
|
||||
editBtn.addEventListener("click", () => openEditForm(li, n));
|
||||
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.type = "button";
|
||||
delBtn.className = "tuning-delete";
|
||||
delBtn.setAttribute("aria-label", `Delete tuning note: ${n.note}`);
|
||||
delBtn.innerHTML = DELETE_ICON + "<span>Delete</span>";
|
||||
delBtn.addEventListener("click", () => deleteNote(n.id, delBtn, li));
|
||||
|
||||
li.append(editBtn, delBtn);
|
||||
return li;
|
||||
}
|
||||
|
||||
/* ---------- create (POST /api/steering) ---------- */
|
||||
|
||||
if (tuneForm) {
|
||||
tuneForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (tuneSave) tuneSave.disabled = true; // one note per click
|
||||
createError.hidden = true;
|
||||
try {
|
||||
const r = await fetch("/api/steering", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ note: tuneNote ? tuneNote.value : "" }),
|
||||
});
|
||||
if (r.ok) {
|
||||
if (tuneNote) tuneNote.value = ""; // 201: the note is stored
|
||||
announce("Tuning note added. Future answers will follow it.");
|
||||
await loadNotes(); // the new note lands in the list, newest first
|
||||
} else {
|
||||
createError.textContent = await apiDetail(r, "Could not add the note — try again.");
|
||||
createError.hidden = false; // form kept — the instruction survives
|
||||
}
|
||||
} catch {
|
||||
createError.textContent = "Could not add the note — is the app reachable?";
|
||||
createError.hidden = false;
|
||||
} finally {
|
||||
if (tuneSave) tuneSave.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- edit (inline form → PUT /api/steering/{id}) ----------
|
||||
* The row swaps to the inline form — the is-editing class does the
|
||||
* visual swap (styles.css hides the text + the row buttons). One open
|
||||
* form view-wide: opening a new one reverts the others. Cancel reverts
|
||||
* to the text span; a failed save keeps the form + the inline error.
|
||||
*/
|
||||
let editSeq = 0; // unique ids for the edit forms' labeled textareas
|
||||
|
||||
function openEditForm(li, n) {
|
||||
if (li.classList.contains("is-editing")) return; // one per row
|
||||
// One open form view-wide: close any other row's first.
|
||||
root.querySelectorAll(".tuning-note.is-editing").forEach((other) => {
|
||||
other.classList.remove("is-editing");
|
||||
other.querySelector(".tuning-edit-form")?.remove();
|
||||
});
|
||||
li.querySelector(".tuning-saved")?.remove(); // a stale "Saved" pill
|
||||
li.classList.add("is-editing");
|
||||
|
||||
editSeq += 1;
|
||||
const inputId = `tuning-edit-input-${editSeq}`;
|
||||
const form = document.createElement("form");
|
||||
form.className = "tuning-edit-form";
|
||||
form.dataset.noteId = n.id; // the note id rides on the form
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.className = "visually-hidden";
|
||||
label.htmlFor = inputId;
|
||||
label.textContent = `Edit tuning note: ${n.note}`;
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.id = inputId;
|
||||
textarea.className = "tuning-edit-input";
|
||||
textarea.rows = 2;
|
||||
textarea.maxLength = 2000; // client-side 1–2000 contract (server re-validates)
|
||||
textarea.required = true;
|
||||
textarea.value = n.note; // prefilled with the current text
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "tuning-edit-form-actions";
|
||||
const saveBtn = document.createElement("button");
|
||||
saveBtn.type = "submit";
|
||||
saveBtn.className = "tune-save";
|
||||
saveBtn.textContent = "Save";
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.type = "button";
|
||||
cancelBtn.className = "tune-cancel";
|
||||
cancelBtn.textContent = "Cancel";
|
||||
actions.append(saveBtn, cancelBtn);
|
||||
|
||||
const error = document.createElement("p");
|
||||
error.className = "tuning-error";
|
||||
error.setAttribute("role", "alert");
|
||||
error.hidden = true;
|
||||
|
||||
form.append(label, textarea, actions, error);
|
||||
form.addEventListener("submit", (e) => handleEditSave(e, li, form, textarea, saveBtn, error));
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
li.classList.remove("is-editing"); // revert to the text span
|
||||
form.remove();
|
||||
li.querySelector(".tuning-edit")?.focus();
|
||||
});
|
||||
|
||||
li.insertBefore(form, li.querySelector(".tuning-edit"));
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
async function handleEditSave(e, li, form, textarea, saveBtn, error) {
|
||||
e.preventDefault();
|
||||
saveBtn.disabled = true;
|
||||
error.hidden = true;
|
||||
const id = form.dataset.noteId;
|
||||
try {
|
||||
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ note: tuneNote ? tuneNote.value : "" }),
|
||||
body: JSON.stringify({ note: textarea.value }),
|
||||
});
|
||||
if (r.ok) {
|
||||
if (tuneNote) tuneNote.value = ""; // 201: the note is stored
|
||||
announce("Tuning note added. Future answers will follow it.");
|
||||
await loadNotes(); // the new note lands in the list, newest first
|
||||
} else {
|
||||
createError.textContent = await apiDetail(r, "Could not add the note — try again.");
|
||||
createError.hidden = false; // form kept — the instruction survives
|
||||
let saved = textarea.value.trim();
|
||||
try {
|
||||
saved = (await r.json()).note ?? saved;
|
||||
} catch {
|
||||
/* keep the trimmed local text */
|
||||
}
|
||||
const textEl = li.querySelector(".tuning-note-text");
|
||||
if (textEl) textEl.textContent = saved; // the list shows the stored text
|
||||
const savedPill = document.createElement("p");
|
||||
savedPill.className = "tuning-saved";
|
||||
savedPill.setAttribute("role", "status");
|
||||
savedPill.textContent = "Saved";
|
||||
form.replaceWith(savedPill);
|
||||
li.classList.remove("is-editing"); // updated text + row buttons come back
|
||||
announce("Tuning note updated.");
|
||||
return;
|
||||
}
|
||||
error.textContent = await apiDetail(r, "Could not update the note — try again.");
|
||||
error.hidden = false; // form kept — the edit survives the failure
|
||||
saveBtn.disabled = false;
|
||||
} catch {
|
||||
createError.textContent = "Could not add the note — is the app reachable?";
|
||||
createError.hidden = false;
|
||||
} finally {
|
||||
if (tuneSave) tuneSave.disabled = false;
|
||||
error.textContent = "Could not update the note — is the app reachable?";
|
||||
error.hidden = false;
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- edit (inline form → PUT /api/steering/{id}) ----------
|
||||
* The row swaps to the inline form — the is-editing class does the
|
||||
* visual swap (styles.css hides the text + the row buttons). One open
|
||||
* form page-wide: opening a new one reverts the others. Cancel reverts
|
||||
* to the text span; a failed save keeps the form + the inline error.
|
||||
*/
|
||||
let editSeq = 0; // unique ids for the edit forms' labeled textareas
|
||||
|
||||
function openEditForm(li, n) {
|
||||
if (li.classList.contains("is-editing")) return; // one per row
|
||||
// One open form page-wide: close any other row's first.
|
||||
document.querySelectorAll(".tuning-note.is-editing").forEach((other) => {
|
||||
other.classList.remove("is-editing");
|
||||
other.querySelector(".tuning-edit-form")?.remove();
|
||||
});
|
||||
li.querySelector(".tuning-saved")?.remove(); // a stale "Saved" pill
|
||||
li.classList.add("is-editing");
|
||||
|
||||
editSeq += 1;
|
||||
const inputId = `tuning-edit-input-${editSeq}`;
|
||||
const form = document.createElement("form");
|
||||
form.className = "tuning-edit-form";
|
||||
form.dataset.noteId = n.id; // the note id rides on the form
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.className = "visually-hidden";
|
||||
label.htmlFor = inputId;
|
||||
label.textContent = `Edit tuning note: ${n.note}`;
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.id = inputId;
|
||||
textarea.className = "tuning-edit-input";
|
||||
textarea.rows = 2;
|
||||
textarea.maxLength = 2000; // client-side 1–2000 contract (server re-validates)
|
||||
textarea.required = true;
|
||||
textarea.value = n.note; // prefilled with the current text
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "tuning-edit-form-actions";
|
||||
const saveBtn = document.createElement("button");
|
||||
saveBtn.type = "submit";
|
||||
saveBtn.className = "tune-save";
|
||||
saveBtn.textContent = "Save";
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.type = "button";
|
||||
cancelBtn.className = "tune-cancel";
|
||||
cancelBtn.textContent = "Cancel";
|
||||
actions.append(saveBtn, cancelBtn);
|
||||
|
||||
const error = document.createElement("p");
|
||||
error.className = "tuning-error";
|
||||
error.setAttribute("role", "alert");
|
||||
error.hidden = true;
|
||||
|
||||
form.append(label, textarea, actions, error);
|
||||
form.addEventListener("submit", (e) => handleEditSave(e, li, form, textarea, saveBtn, error));
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
li.classList.remove("is-editing"); // revert to the text span
|
||||
form.remove();
|
||||
li.querySelector(".tuning-edit")?.focus();
|
||||
});
|
||||
|
||||
li.insertBefore(form, li.querySelector(".tuning-edit"));
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
async function handleEditSave(e, li, form, textarea, saveBtn, error) {
|
||||
e.preventDefault();
|
||||
saveBtn.disabled = true;
|
||||
error.hidden = true;
|
||||
const id = form.dataset.noteId;
|
||||
try {
|
||||
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ note: textarea.value }),
|
||||
});
|
||||
if (r.ok) {
|
||||
let saved = textarea.value.trim();
|
||||
try {
|
||||
saved = (await r.json()).note ?? saved;
|
||||
} catch {
|
||||
/* keep the trimmed local text */
|
||||
/* ---------- delete (DELETE /api/steering/{id}, optimistic) ----------
|
||||
* The row leaves the DOM the moment the server agrees (204); a 404
|
||||
* (already gone) also drops the row and reloads to resync; any other
|
||||
* failure re-enables the button and says to retry. */
|
||||
async function deleteNote(id, btn, li) {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
if (r.status === 404) {
|
||||
li.remove(); // already gone on the server — drop it and resync
|
||||
syncEmptyState();
|
||||
announce("That note was already removed.");
|
||||
await loadNotes();
|
||||
return;
|
||||
}
|
||||
const textEl = li.querySelector(".tuning-note-text");
|
||||
if (textEl) textEl.textContent = saved; // the list shows the stored text
|
||||
const savedPill = document.createElement("p");
|
||||
savedPill.className = "tuning-saved";
|
||||
savedPill.setAttribute("role", "status");
|
||||
savedPill.textContent = "Saved";
|
||||
form.replaceWith(savedPill);
|
||||
li.classList.remove("is-editing"); // updated text + row buttons come back
|
||||
announce("Tuning note updated.");
|
||||
return;
|
||||
}
|
||||
error.textContent = await apiDetail(r, "Could not update the note — try again.");
|
||||
error.hidden = false; // form kept — the edit survives the failure
|
||||
saveBtn.disabled = false;
|
||||
} catch {
|
||||
error.textContent = "Could not update the note — is the app reachable?";
|
||||
error.hidden = false;
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- delete (DELETE /api/steering/{id}, optimistic) ----------
|
||||
* The row leaves the DOM the moment the server agrees (204); a 404
|
||||
* (already gone) also drops the row and reloads to resync; any other
|
||||
* failure re-enables the button and says to retry. */
|
||||
async function deleteNote(id, btn, li) {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
if (r.status === 404) {
|
||||
li.remove(); // already gone on the server — drop it and resync
|
||||
if (!r.ok) {
|
||||
announce("Could not delete the note — try again.");
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
li.remove(); // 204: the server confirmed — the row goes now
|
||||
syncEmptyState();
|
||||
announce("That note was already removed.");
|
||||
await loadNotes();
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
announce("Could not delete the note — try again.");
|
||||
announce("Tuning note deleted.");
|
||||
} catch {
|
||||
announce("Could not delete the note — is the app reachable?");
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
li.remove(); // 204: the server confirmed — the row goes now
|
||||
syncEmptyState();
|
||||
announce("Tuning note deleted.");
|
||||
} catch {
|
||||
announce("Could not delete the note — is the app reachable?");
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- header boot (task 02) ---------- */
|
||||
|
||||
/* The New chat binding is module-owned (assets/header.js, phase 34
|
||||
* task 02 — the SINGLE binding): on this non-chat page it clears the
|
||||
* phase-14 conversation key and navigates to the chat's empty state.
|
||||
*
|
||||
* Boot: the shared header FIRST (Sign in/out + the admin-only nav
|
||||
* links — one cached whoami), then the note list — admin data only
|
||||
(the Sources page gate pattern): an anonymous visitor gets the page
|
||||
frame with the empty state, and the create form 403s gracefully on
|
||||
submit if one tries. */
|
||||
(async () => {
|
||||
await initSharedHeader(); // phase 19: whoami + Sign in/out + nav links
|
||||
/* ---------- view boot (phase 76 task 01) ----------
|
||||
* The New chat binding is module-owned (assets/header.js, phase 34
|
||||
* task 02 — the SINGLE binding) and lives in the chat view only.
|
||||
*
|
||||
* Boot: the shared header is NOT booted here — in the shell it runs
|
||||
* exactly once, via the chat module (app.js) at shell boot. The note
|
||||
* list is admin data (the Sources page gate pattern): the gate reads
|
||||
* fetchIsAdmin() — the SAME cached whoami promise the header uses
|
||||
* (zero extra requests). An anonymous visitor gets the view frame
|
||||
* with the empty state, and the create form 403s gracefully on
|
||||
* submit if one tries. */
|
||||
if (await fetchIsAdmin()) loadNotes(); // phase 27: the list is admin-only
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="description" content="Add and remove the git repositories Brain of Reese syncs and indexes (admin-only).">
|
||||
<title>Git sources · Brain of Reese</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<!-- Phase 35: the SAME full header block every other page ships
|
||||
(phase 34, owner confirmation 2026-08-26) — one shared owner of
|
||||
the controls (assets/header.js via git-sources.js's relative
|
||||
import). The admin-only "Git sources" nav link (#nav-git-sources)
|
||||
joins this nav in phase 35 task 05, so it is NOT in this file
|
||||
yet — the page lands without it, exactly like the other pages
|
||||
land without the links task 05 adds to them. -->
|
||||
<header class="app-header">
|
||||
<div class="container header-inner">
|
||||
<span class="brand">
|
||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||
</span>
|
||||
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||
mobile hamburger — visible ≤640px only (CSS); opens the nav as
|
||||
an animated dropdown. Behavior: assets/header.js. -->
|
||||
<button type="button" class="nav-toggle" id="nav-toggle"
|
||||
aria-expanded="false" aria-controls="app-nav" aria-label="Menu">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||
</button>
|
||||
<nav class="app-nav" id="app-nav" aria-label="Primary">
|
||||
<a href="/" class="nav-link">Chat</a>
|
||||
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Sources link is admin-only (owner
|
||||
permission 2026-08-23) — hidden by default, header.js
|
||||
reveals it once whoami says admin. -->
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
|
||||
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||
link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above; this page IS the current one, so the
|
||||
link carries is-active + aria-current like Tuning on
|
||||
tuning.html. -->
|
||||
<a href="/git-sources.html" class="nav-link is-active" aria-current="page" id="nav-git-sources" hidden>Sources</a>
|
||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Global Tuning link is admin-only (owner
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
reveals it once whoami says admin. -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
<!-- Phase 50 (owner permission 2026-08-29, `TODO.md` L5): the
|
||||
History link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Tuning link above. -->
|
||||
<a href="/history.html" class="nav-link" id="nav-history" hidden>History</a>
|
||||
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign out</span>
|
||||
</button>
|
||||
</nav>
|
||||
<!-- Phase 15: the tuning-notes panel (stored in Postgres, read
|
||||
into every system prompt) — owned by the shared header
|
||||
module (assets/header.js); the #steering-panel section
|
||||
ships in every page's <main>. The navbar toggle was
|
||||
removed at owner request (2026-08-28): note management
|
||||
lives on /tuning.html. -->
|
||||
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
||||
out is visible; /api/whoami decides at load (the shared
|
||||
header module). Icon-only below 640px (aria-labels keep the
|
||||
accessible names). -->
|
||||
<a href="/login.html?next=/git-sources.html" class="auth-link sign-in-link" id="sign-in-link" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
<button type="button" class="auth-link sign-out-btn" id="sign-out-btn" aria-label="Sign out" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main" class="app-main" tabindex="-1">
|
||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the tuning-notes panel — first child of <main>
|
||||
on the non-chat pages, rendered + driven by assets/header.js
|
||||
(shared), not the page script. -->
|
||||
<section class="steering-panel" id="steering-panel" role="region"
|
||||
aria-label="Tuning notes" hidden>
|
||||
<div class="steering-panel-head">
|
||||
<h2 class="steering-panel-title">Tuning notes</h2>
|
||||
<p class="steering-panel-sub">Every note below steers all future answers.</p>
|
||||
</div>
|
||||
<ul class="steering-list" id="steering-list"></ul>
|
||||
<p class="steering-empty" id="steering-empty">No tuning notes yet — press “Tune” under any answer to add one.</p>
|
||||
</section>
|
||||
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||
<div class="container git-sources-shell">
|
||||
<!-- Phase 35: anonymous sign-in gate — the EXACT #sources-gate
|
||||
pattern (phase 16) and the same .sources-gate visual
|
||||
language: the page is the same shape as Sources. Visible
|
||||
for anonymous, hidden for the admin (git-sources.js). The
|
||||
catalog of git sources is what the login locks — chat stays
|
||||
open to everyone (the soft rule). -->
|
||||
<section class="sources-gate" id="git-sources-gate" aria-labelledby="git-sources-gate-title" hidden>
|
||||
<div class="sources-gate-glyph" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg>
|
||||
</div>
|
||||
<h2 id="git-sources-gate-title">Sign in to manage the git sources</h2>
|
||||
<p class="sources-gate-sub">
|
||||
The list of repositories cloned and indexed by the sync service
|
||||
clones and indexes is admin-only. Chat — and any document an
|
||||
answer cites — stays open to everyone.
|
||||
</p>
|
||||
<a class="sources-gate-link" href="/login.html?next=/git-sources.html">Sign in</a>
|
||||
</section>
|
||||
|
||||
<!-- Phase 35: the manager — SHIPS hidden (anonymous-safe; the
|
||||
gate is what anonymous visitors see). git-sources.js
|
||||
reveals it once the cached whoami says admin, then loads
|
||||
the list. Full-width table on the 72rem frame — the
|
||||
Sources-page pattern, no skinny single-column list. -->
|
||||
<div id="git-sources-content" hidden>
|
||||
<div class="page-head">
|
||||
<h1>Git sources</h1>
|
||||
<p class="page-sub">
|
||||
The git repositories and local directories the Sync button
|
||||
imports. Add or remove them here — no <code>.env</code>, no
|
||||
restart.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Load failure (role=alert) with a retry — a GET /api/git-sources
|
||||
non-2xx or network failure must never leave a stuck page.
|
||||
git-sources.js fills #git-sources-load-error-text. -->
|
||||
<div class="git-source-load-error" id="git-sources-load-error" role="alert" hidden>
|
||||
<span id="git-sources-load-error-text"></span>
|
||||
<button type="button" id="git-sources-retry">Try again</button>
|
||||
</div>
|
||||
|
||||
<!-- Env-fallback note (phase locked decision): while the
|
||||
git_sources table is EMPTY the list above comes from
|
||||
BOR_GIT_SOURCES in .env (from_env: true) — the note says
|
||||
so, and that adding or removing here switches management
|
||||
to the database. Hidden by default; git-sources.js shows
|
||||
it off the API's from_env flag. -->
|
||||
<p class="git-source-env-note" id="git-sources-env-note" role="note" hidden>
|
||||
These sources currently come from <code>BOR_GIT_SOURCES</code> in
|
||||
<code>.env</code> — adding or removing one here switches management
|
||||
to the database.
|
||||
</p>
|
||||
|
||||
<!-- Add form: visible label + mono URL input + brand button
|
||||
(dark ink on brand 5.2:1). §7.4 never-stale: the button
|
||||
disables + relabels "Adding…" while the POST is in flight
|
||||
and re-enables on success AND failure (the input is kept
|
||||
on failure, same as the tuning forms). -->
|
||||
<form id="git-source-form">
|
||||
<label for="git-source-url">Add a git source</label>
|
||||
<input
|
||||
id="git-source-url"
|
||||
name="url"
|
||||
type="text"
|
||||
maxlength="500"
|
||||
autocomplete="off"
|
||||
placeholder="https://github.com/you/your-repo.git"
|
||||
required
|
||||
>
|
||||
<button type="submit" id="git-source-add">Add source</button>
|
||||
<p class="git-source-error" id="git-source-error" role="alert" hidden></p>
|
||||
</form>
|
||||
|
||||
<!-- Phase 49 (owner permission 2026-08-28): the archive upload
|
||||
form replaces the phase-38 local-directory form — an
|
||||
uploaded .tar/.tar.gz/.tgz/.zip is unpacked under
|
||||
BOR_UPLOAD_DIR and scanned; the same filename replaces the
|
||||
source in place (no new folder, no duplicate row). The file
|
||||
control is labeled (visible <label for=…> — WCAG
|
||||
input-label rule); the button runs the §7.4 never-stale
|
||||
lifecycle ("Uploading…" while the POST is out). Phase 64
|
||||
(task 05) reworks the rest to the 202 contract (the
|
||||
phase-49 synchronous 200 paragraph is superseded): the 202
|
||||
arrives the moment the archive is safely on disk (A1) — a
|
||||
JS-created "Successfully uploaded — <file>" toast fires
|
||||
then (A2 — the phase-55 .toast node, no markup here; safe
|
||||
to navigate away) and the button settles into the live
|
||||
"Processing… <file> (n/m)" label (A4 — the full path rides
|
||||
the button title) driven by the 2 s poll of
|
||||
GET /api/git-sources/upload/status, until the success line
|
||||
(role=status) or the sanitized error banner (role=alert)
|
||||
lands; 409 re-attaches to the in-flight run — no error
|
||||
banner; the other non-2xx still show the server detail
|
||||
inline. -->
|
||||
<form id="archive-upload-form">
|
||||
<label for="archive-upload-file">Upload a source archive (.tar, .tar.gz, .tgz, .zip)</label>
|
||||
<input id="archive-upload-file" name="file" type="file"
|
||||
accept=".tar,.tar.gz,.tgz,.zip" required>
|
||||
<button type="submit" id="archive-upload-btn">Upload & scan</button>
|
||||
<p class="git-source-error" id="archive-upload-error" role="alert" hidden></p>
|
||||
<p class="git-source-result" id="archive-upload-result" role="status"
|
||||
aria-live="polite" hidden></p>
|
||||
</form>
|
||||
|
||||
<div class="table-wrap" id="git-sources-table-wrap" role="region" aria-label="Sources" tabindex="0">
|
||||
<table class="git-sources-table" id="git-sources-table">
|
||||
<caption class="visually-hidden">Sources the Sync button imports — git repositories it clones, local directories it walks, and uploaded archives (unpacked under the upload directory)</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Source</th>
|
||||
<th scope="col">Added</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="git-sources-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Empty state — no stored rows AND no env fallback. With
|
||||
from_env, the env note above already explains where the
|
||||
active list comes from. -->
|
||||
<p class="git-sources-empty" id="git-sources-empty" hidden>No sources stored yet.</p>
|
||||
|
||||
<!-- Phase 69 (owner request 2026-09-02): removal is a TOTAL
|
||||
removal — the row, the source's indexed documents, and —
|
||||
for git clones and uploaded archives — the files on the
|
||||
server's disk, all immediately (the confirmation modal
|
||||
below spells it out; foreign local directories are never
|
||||
touched). Adding still does not clone — the Sync button
|
||||
mirrors the remaining sources (upstream file churn is
|
||||
pruned on that run); the phase-49 upload is the
|
||||
in-place exception (it unpacks and scans, and a
|
||||
same-name re-upload replaces the source in place). -->
|
||||
<p class="git-source-hint" id="git-sources-hint" role="note">
|
||||
Removing a source is a total removal, done immediately: its
|
||||
entry, its indexed documents, and — for git clones and
|
||||
uploaded archives — its files on the server's disk (the
|
||||
confirmation modal spells out exactly what will be deleted;
|
||||
files in your own local directories are never touched).
|
||||
Uploads unpack and scan immediately — re-uploading the same
|
||||
filename replaces that source in place (no new folder, no
|
||||
duplicate row). The Sync button still mirrors the remaining
|
||||
sources (files removed upstream are pruned on that run).
|
||||
</p>
|
||||
|
||||
<!-- Phase 69 (owner request 2026-09-02): the remove
|
||||
confirmation — a real in-app alertdialog (the native
|
||||
confirm() retired): a row's Remove button opens it
|
||||
(git-sources.js).
|
||||
It names the source (#remove-confirm-source — ALWAYS
|
||||
populated via textContent: URLs may embed user:pass@
|
||||
credentials, the phase-32 masking discipline) and states
|
||||
the full-removal policy. Focus lands on Cancel (the safe
|
||||
default for a destructive action); Escape, the Cancel
|
||||
button, and the dim backdrop all close as cancel (no
|
||||
request — focus returns to the row's Remove button); only
|
||||
"Remove source" sends the DELETE, in the §7.4 "Removing…"
|
||||
in-flight state. The .doc-modal overlay contract: a fixed
|
||||
full-viewport dim backdrop + a centered panel (no blur).
|
||||
Static markup so the E2E suite gets stable selectors (the
|
||||
#git-sources-hint / gate convention). -->
|
||||
<div class="remove-confirm" id="remove-confirm-dialog" role="alertdialog"
|
||||
aria-modal="true" aria-labelledby="remove-confirm-title"
|
||||
aria-describedby="remove-confirm-copy" hidden>
|
||||
<div class="remove-confirm-backdrop" aria-hidden="true"></div>
|
||||
<div class="remove-confirm-panel">
|
||||
<h2 class="remove-confirm-title" id="remove-confirm-title">Remove this source?</h2>
|
||||
<code class="remove-confirm-source" id="remove-confirm-source"></code>
|
||||
<p class="remove-confirm-copy" id="remove-confirm-copy">
|
||||
This permanently removes the source entry, all of its
|
||||
indexed documents from the knowledge base, and — for git
|
||||
clones and uploaded archives — the files on the server's
|
||||
disk. Files in your own local directories are never
|
||||
touched. This cannot be undone.
|
||||
</p>
|
||||
<p class="remove-confirm-error" id="remove-confirm-error" role="alert" hidden></p>
|
||||
<div class="remove-confirm-actions">
|
||||
<button type="button" class="remove-confirm-btn remove-confirm-cancel"
|
||||
id="remove-confirm-cancel">Cancel</button>
|
||||
<button type="button" class="remove-confirm-btn remove-confirm-remove"
|
||||
id="remove-confirm-remove">Remove source</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Polite live region: the screen-reader confirmation for list
|
||||
loads, adds, and removals (git-sources.js owns the text). -->
|
||||
<p class="visually-hidden" id="git-sources-announcer" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<div class="container footer-inner">
|
||||
<span class="footer-text">Powered by self-hosted models</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Phase 35: the page module loads the shared header through its
|
||||
own `import "./header.js"` — a hoisted import evaluated before
|
||||
this body runs (the single-evaluation design: no direct
|
||||
header.js <script> tag; esbuild inlines it into the page
|
||||
bundle in the image build). -->
|
||||
<!-- Phase 39: the brand layer — classic script, first on the page:
|
||||
window.BOR_BRAND at parse time, refreshed from /api/config. -->
|
||||
<script src="assets/brand.js"></script>
|
||||
<script type="module" src="/assets/git-sources.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,186 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="description" content="Saved chats — every conversation is saved automatically, one click back.">
|
||||
<title>Saved chats · Brain of Reese</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<header class="app-header">
|
||||
<div class="container header-inner">
|
||||
<span class="brand">
|
||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||
</span>
|
||||
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||
mobile hamburger — visible ≤640px only (CSS); opens the nav as
|
||||
an animated dropdown. Behavior: assets/header.js. -->
|
||||
<button type="button" class="nav-toggle" id="nav-toggle"
|
||||
aria-expanded="false" aria-controls="app-nav" aria-label="Menu">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||
</button>
|
||||
<nav class="app-nav" id="app-nav" aria-label="Primary">
|
||||
<a href="/" class="nav-link">Chat</a>
|
||||
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Sources link is admin-only (owner
|
||||
permission 2026-08-23) — hidden by default, header.js
|
||||
reveals it once whoami says admin. The soft-gated page
|
||||
itself is unchanged. -->
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
|
||||
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||
link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>
|
||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Global Tuning link is admin-only (owner
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
<!-- Phase 50 (owner permission 2026-08-29, `TODO.md` L5): the
|
||||
History link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Tuning link above. This page IS the current one, so the
|
||||
link carries is-active + aria-current like Tuning on
|
||||
tuning.html. -->
|
||||
<a href="/history.html" class="nav-link is-active" aria-current="page" id="nav-history" hidden>History</a>
|
||||
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign out</span>
|
||||
</button>
|
||||
</nav>
|
||||
<!-- Phase 15: the tuning-notes panel (stored in Postgres, read
|
||||
into every system prompt) — owned by the shared header
|
||||
module (assets/header.js); the #steering-panel section
|
||||
ships in every page's <main>. The navbar toggle was
|
||||
removed at owner request (2026-08-28): note management
|
||||
lives on /tuning.html. -->
|
||||
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
||||
out is visible; /api/whoami decides at load (the shared
|
||||
header module). Icon-only below 640px (aria-labels keep the
|
||||
accessible names). -->
|
||||
<a href="/login.html?next=/history.html" class="auth-link sign-in-link" id="sign-in-link" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
<button type="button" class="auth-link sign-out-btn" id="sign-out-btn" aria-label="Sign out" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main" class="app-main" tabindex="-1">
|
||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the tuning-notes panel (stored notes, newest
|
||||
first) — rendered + driven by assets/header.js (shared), not
|
||||
the page script. First child of <main> on the non-chat pages;
|
||||
the chat page keeps it after #kb-banner. -->
|
||||
<section class="steering-panel" id="steering-panel" role="region"
|
||||
aria-label="Tuning notes" hidden>
|
||||
<div class="steering-panel-head">
|
||||
<h2 class="steering-panel-title">Tuning notes</h2>
|
||||
<p class="steering-panel-sub">Every note below steers all future answers.</p>
|
||||
</div>
|
||||
<ul class="steering-list" id="steering-list"></ul>
|
||||
<p class="steering-empty" id="steering-empty">No tuning notes yet — press “Tune” under any answer to add one.</p>
|
||||
</section>
|
||||
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||
<div class="container history-shell">
|
||||
<div class="page-head">
|
||||
<h1>Saved chats</h1>
|
||||
<p class="page-sub">
|
||||
Every conversation is saved automatically — newest activity first. Click a title to return to that chat.
|
||||
</p>
|
||||
</div>
|
||||
<!-- Phase 50 (owner permission 2026-08-29): anonymous sign-in
|
||||
gate — the EXACT #sources-gate pattern (phase 16) and the
|
||||
same .sources-gate visual language (phase 35, git-sources):
|
||||
the saved-chat list is what the login locks. Visible for
|
||||
anonymous, hidden for the admin (history.js) — and the
|
||||
page never fetches /api/chats for an anonymous visitor
|
||||
(the router 403s them; the story E2E pins the request
|
||||
log). -->
|
||||
<section class="sources-gate" id="history-gate" aria-labelledby="history-gate-title" hidden>
|
||||
<div class="sources-gate-glyph" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg>
|
||||
</div>
|
||||
<h2 id="history-gate-title">Sign in to view your saved chats</h2>
|
||||
<p class="sources-gate-sub">
|
||||
Saved conversations are admin-only. Chat — and any document an
|
||||
answer cites — stays open to everyone.
|
||||
</p>
|
||||
<a class="sources-gate-link" href="/login.html?next=/history.html">Sign in</a>
|
||||
</section>
|
||||
|
||||
<!-- Live-region feedback for row actions (the "never stale"
|
||||
contract): history.js sets textContent here — a delete's
|
||||
outcome, its error line, nothing else. -->
|
||||
<span class="history-status" id="history-status" role="status" aria-live="polite"></span>
|
||||
|
||||
<!-- Phase 50: the full-width table (AGENTS.md rule 5 — no skinny
|
||||
list): Title (the Open link → /?chat=<id>) | Messages |
|
||||
Updated | Stale (phase 53: the READ-ONLY staleness marker —
|
||||
the rose pill when the row predates the last KB-changing
|
||||
sync; the Regenerate action lives on the chat-page banner,
|
||||
task 05) | Share (phase 51: Create link / Copy / Unshare —
|
||||
the row's share_url comes from GET /api/chats itself, no
|
||||
second fetch) | Actions (Delete, inline two-step confirm).
|
||||
history.js fills #history-tbody; #history-empty-row ships
|
||||
hidden and is revealed by a 0-row fetch. The Actions column
|
||||
header is visually-hidden — the row buttons carry their own
|
||||
aria-labels. -->
|
||||
<div class="table-wrap history-table-wrap" id="history-table-wrap" role="region" aria-label="Saved chats" tabindex="0">
|
||||
<table class="history-table">
|
||||
<caption class="visually-hidden">Saved chats — click a title to return to that conversation</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Messages</th>
|
||||
<th scope="col">Updated</th>
|
||||
<th scope="col">Stale</th>
|
||||
<th scope="col">Share</th>
|
||||
<th scope="col"><span class="visually-hidden">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="history-tbody">
|
||||
<tr class="history-empty-row" id="history-empty-row" hidden>
|
||||
<td colspan="6">No saved chats yet — start a conversation and it will be saved automatically.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<div class="container footer-inner">
|
||||
<span class="footer-text">Powered by self-hosted models</span>
|
||||
<span class="footer-version" id="app-version"></span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Phase 50: the shared header module loads through the page script's
|
||||
own `import "./header.js"` — a hoisted import that is evaluated
|
||||
before the page script body calls initSharedHeader() at boot
|
||||
(no direct header.js <script> tag — single-evaluation design).
|
||||
Phase 39: the brand layer — classic script, first on the page:
|
||||
window.BOR_BRAND at parse time, refreshed from /api/config. -->
|
||||
<script src="assets/brand.js"></script>
|
||||
<script type="module" src="/assets/history.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -82,7 +82,20 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Phase 76 (task 01): the shell's single <main> holds the navbar
|
||||
views as <section class="view"> blocks — only the active one is
|
||||
shown (the others carry hidden + inert, so focus and keyboard
|
||||
traversal never enter them). A navbar click is a client-side
|
||||
view switch (assets/router.js — pushState + show/hide), never a
|
||||
document load; the in-flight chat stream in the hidden view
|
||||
keeps streaming through any switch. Each folded page's own
|
||||
<main class="app-main"> wrapper (identical on all five pages —
|
||||
the layout CSS is class-based) is dropped with the move, and
|
||||
the per-view copies of the header-owned steering panel are
|
||||
dropped too (this shell's ONE panel — the chat one, inside
|
||||
#view-chat — is the instance header.js drives). -->
|
||||
<main id="main" class="app-main" tabindex="-1">
|
||||
<section class="view" id="view-chat" aria-label="Chat" tabindex="-1">
|
||||
<div class="container chat-shell" data-state="empty">
|
||||
<div class="kb-banner" id="kb-banner" role="status" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.6 22.2 20.4H1.8Z"/><path d="M12 9.5v4.6"/><path d="M12 17.4h.01"/></svg>
|
||||
@@ -260,6 +273,467 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Phase 76 (task 01): the Global Tuning view — the content of
|
||||
frontend/tuning.html's <main> (wrapper dropped), folded into
|
||||
the shell. /tuning.html now serves THIS document (the shell
|
||||
route in app/main.py); the router shows this section for that
|
||||
pathname. Its own header / steering-panel copies lived in the
|
||||
old page's <header>/<main> and are dropped — the shell's
|
||||
single header + chat-view panel stand in for them. The
|
||||
hidden + inert pair is the WCAG contract: a hidden view must
|
||||
not receive focus or keyboard traversal (AGENTS.md rule 5).
|
||||
mounted lazily — assets/router.js imports tuning.js on first
|
||||
show only (mount-once, hide-forever). -->
|
||||
<section class="view" id="view-tuning" hidden inert aria-label="Global Tuning" tabindex="-1">
|
||||
<div class="container tuning-shell">
|
||||
<div class="page-head">
|
||||
<h1>Global Tuning</h1>
|
||||
<p class="page-sub">
|
||||
Every note below is read into the system prompt of
|
||||
<strong>every</strong> chat turn. Add, edit, or remove them here —
|
||||
no conversation required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Phase 27: create a note without a chat. The label is
|
||||
visually-hidden (the heading + placeholder carry the visible
|
||||
context); the 1–2000-char contract mirrors the chat-page tune
|
||||
form — the server re-validates (422). -->
|
||||
<form id="tune-form">
|
||||
<label class="visually-hidden" for="tune-note">Add a global tuning note</label>
|
||||
<textarea
|
||||
id="tune-note"
|
||||
name="note"
|
||||
rows="3"
|
||||
maxlength="2000"
|
||||
placeholder="e.g. be more concise — or: assume I'm on NixOS"
|
||||
required
|
||||
></textarea>
|
||||
<button type="submit" id="tune-save">Add note</button>
|
||||
</form>
|
||||
|
||||
<!-- Live announcer for create / edit / delete — tuning.js (phase 27,
|
||||
task 03) owns the message text. -->
|
||||
<p class="visually-hidden" id="tune-announcer" role="status" aria-live="polite"></p>
|
||||
|
||||
<!-- Phase 27: the note list — the phase-15 steering panel's
|
||||
language, full column width. tuning.js fills it newest-first;
|
||||
each row is an <li class="tuning-note"> with a
|
||||
.tuning-note-text span + an Edit and a Delete button (styles:
|
||||
styles.css "Global tuning page"). The empty state toggles with
|
||||
the list. -->
|
||||
<section class="tuning-panel" aria-labelledby="tuning-panel-title">
|
||||
<h2 id="tuning-panel-title" class="tuning-panel-title">Tuning notes</h2>
|
||||
<ul id="tune-list" class="tuning-list" role="list"></ul>
|
||||
<p id="tune-empty">No tuning notes yet — add one above.</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Phase 76 (task 02): the RAG view (the Knowledge base catalog)
|
||||
— the content of frontend/sources.html's <main> (wrapper
|
||||
dropped), folded into the shell. /sources.html now serves THIS
|
||||
document (the shell route in app/main.py); the router shows
|
||||
this section for that pathname. The per-view copies of the
|
||||
header-owned steering panel + announcer are dropped (the
|
||||
shell's ONE panel — the chat one, inside #view-chat — is the
|
||||
instance header.js drives), and the old page's SECOND
|
||||
doc-modal-* skeleton copy is dropped too: the shell keeps
|
||||
EXACTLY ONE (the chat's, body level), which BOTH app.js (chat
|
||||
chips) and sources.js (RAG rows) open through
|
||||
openDocumentModal(...). The per-page footer does not move
|
||||
(body-level — the shell's single footer stands in). The
|
||||
hidden + inert pair is the WCAG contract: a hidden view must
|
||||
not receive focus or keyboard traversal (AGENTS.md rule 5).
|
||||
Mounted lazily — assets/router.js imports sources.js on first
|
||||
show only (mount-once, hide-forever). -->
|
||||
<section class="view" id="view-rag" hidden inert aria-label="RAG" tabindex="-1">
|
||||
<div class="container sources-shell">
|
||||
<div class="page-head">
|
||||
<div class="page-head-row">
|
||||
<h1>Knowledge base</h1>
|
||||
<button type="button" class="sync-btn" id="sync-btn" aria-label="Sync sources" hidden>
|
||||
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||
<span class="sync-label" id="sync-label">Sync sources</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="page-sub">
|
||||
Every file indexed from your configured sources — git repositories,
|
||||
local directories, and uploaded archives. Press <strong>Sync sources</strong>
|
||||
to pull the latest and re-import.
|
||||
</p>
|
||||
</div>
|
||||
<!-- #sync-result is the aria-live announcer: the last sync
|
||||
result ("N added · …") when a sync settles, and — phase 64 —
|
||||
the LIVE file label while either job runs ("Syncing… <file>
|
||||
(n/m)" / "Importing <file> (n/m)"), UNTRUNCATED (the button's
|
||||
label span ellipsizes; screen readers hear the full
|
||||
source/relative path, which also rides the button title).
|
||||
After an upload settles it stays empty — the upload's counts
|
||||
live on the Sources page (A3). -->
|
||||
<span class="sync-result" id="sync-result" role="status" aria-live="polite"></span>
|
||||
<!-- Sync failure banner — role="alert" so a failed sync is announced. -->
|
||||
<div class="kb-banner is-error" id="sync-error-banner" role="alert" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.6 22.2 20.4H1.8Z"/><path d="M12 9.5v4.6"/><path d="M12 17.4h.01"/></svg>
|
||||
<span id="sync-error-text"></span>
|
||||
</div>
|
||||
|
||||
<!-- Phase 16: anonymous sign-in gate. The catalog is what the
|
||||
login locks — the document viewer itself stays public (soft
|
||||
rule), so the copy says what stays open. -->
|
||||
<section class="sources-gate" id="sources-gate" aria-labelledby="sources-gate-title" hidden>
|
||||
<div class="sources-gate-glyph" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg>
|
||||
</div>
|
||||
<h2 id="sources-gate-title">Sign in to view the full catalog</h2>
|
||||
<p class="sources-gate-sub">
|
||||
The complete list of indexed documents is admin-only. Chat — and
|
||||
any document an answer cites — stays open to everyone.
|
||||
</p>
|
||||
<a class="sources-gate-link" href="/login.html?next=/sources.html">Sign in</a>
|
||||
</section>
|
||||
|
||||
<div class="stat-cards" id="stat-cards">
|
||||
<div class="stat-card" role="group" aria-label="Document statistics">
|
||||
<span class="stat-value" id="stat-docs">–</span>
|
||||
<span class="stat-label">documents</span>
|
||||
</div>
|
||||
<div class="stat-card" role="group" aria-label="Chunk statistics">
|
||||
<span class="stat-value" id="stat-chunks">–</span>
|
||||
<span class="stat-label">chunks</span>
|
||||
</div>
|
||||
<div class="stat-card" role="group" aria-label="Last indexed">
|
||||
<span class="stat-value stat-value-sm" id="stat-last">–</span>
|
||||
<span class="stat-label">last indexed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap" role="region" aria-label="Indexed documents" tabindex="0">
|
||||
<table class="docs-table" id="docs-table">
|
||||
<caption class="visually-hidden">Indexed markdown documents</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Source</th>
|
||||
<th scope="col">Path</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Chunks</th>
|
||||
<th scope="col">Indexed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="docs-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" id="sources-empty" hidden>
|
||||
<div class="empty-state-glyph" aria-hidden="true">
|
||||
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M6 12a4 4 0 0 1 4-4h10l4 5h14a4 4 0 0 1 4 4v17a4 4 0 0 1-4 4H10a4 4 0 0 1-4-4Z"/><path d="M6 20h36"/><path d="M15 28h9M15 33h14"/></svg>
|
||||
</div>
|
||||
<h2 class="empty-state-title">Nothing indexed yet</h2>
|
||||
<p class="empty-state-sub">
|
||||
Run the import to pull in the markdown docs:
|
||||
<code>uv run python -m scripts.import_docs</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Phase 76 (task 02): the Sources view (the git-sources manager
|
||||
+ archive uploads) — the content of frontend/git-sources.html's
|
||||
<main> (wrapper dropped), folded into the shell.
|
||||
/git-sources.html now serves THIS document (the shell route
|
||||
in app/main.py); the router shows this section for that
|
||||
pathname. The per-view copies of the header-owned steering
|
||||
panel + announcer are dropped (same reasoning as the RAG
|
||||
view above), and the per-page footer does not move. The
|
||||
upload-progress state machine (phase 64/65) is mounted ONCE
|
||||
(mount-once, hide-forever) and keeps running across view
|
||||
switches in this one document: its poller is a self-chaining
|
||||
setTimeout started when an upload begins — never at boot — so
|
||||
progress continues while the user is on another view, and
|
||||
nothing refetches on re-show. The hidden + inert pair is the
|
||||
WCAG contract (AGENTS.md rule 5). Mounted lazily —
|
||||
assets/router.js imports git-sources.js on first show only. -->
|
||||
<section class="view" id="view-git-sources" hidden inert aria-label="Sources" tabindex="-1">
|
||||
<div class="container git-sources-shell">
|
||||
<!-- Phase 35: anonymous sign-in gate — the EXACT #sources-gate
|
||||
pattern (phase 16) and the same .sources-gate visual
|
||||
language: the page is the same shape as Sources. Visible
|
||||
for anonymous, hidden for the admin (git-sources.js). The
|
||||
catalog of git sources is what the login locks — chat stays
|
||||
open to everyone (the soft rule). -->
|
||||
<section class="sources-gate" id="git-sources-gate" aria-labelledby="git-sources-gate-title" hidden>
|
||||
<div class="sources-gate-glyph" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg>
|
||||
</div>
|
||||
<h2 id="git-sources-gate-title">Sign in to manage the git sources</h2>
|
||||
<p class="sources-gate-sub">
|
||||
The list of repositories cloned and indexed by the sync service
|
||||
clones and indexes is admin-only. Chat — and any document an
|
||||
answer cites — stays open to everyone.
|
||||
</p>
|
||||
<a class="sources-gate-link" href="/login.html?next=/git-sources.html">Sign in</a>
|
||||
</section>
|
||||
|
||||
<!-- Phase 35: the manager — SHIPS hidden (anonymous-safe; the
|
||||
gate is what anonymous visitors see). git-sources.js
|
||||
reveals it once the cached whoami says admin, then loads
|
||||
the list. Full-width table on the 72rem frame — the
|
||||
Sources-page pattern, no skinny single-column list. -->
|
||||
<div id="git-sources-content" hidden>
|
||||
<div class="page-head">
|
||||
<h1>Git sources</h1>
|
||||
<p class="page-sub">
|
||||
The git repositories and local directories the Sync button
|
||||
imports. Add or remove them here — no <code>.env</code>, no
|
||||
restart.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Load failure (role=alert) with a retry — a GET /api/git-sources
|
||||
non-2xx or network failure must never leave a stuck page.
|
||||
git-sources.js fills #git-sources-load-error-text. -->
|
||||
<div class="git-source-load-error" id="git-sources-load-error" role="alert" hidden>
|
||||
<span id="git-sources-load-error-text"></span>
|
||||
<button type="button" id="git-sources-retry">Try again</button>
|
||||
</div>
|
||||
|
||||
<!-- Env-fallback note (phase locked decision): while the
|
||||
git_sources table is EMPTY the list above comes from
|
||||
BOR_GIT_SOURCES in .env (from_env: true) — the note says
|
||||
so, and that adding or removing here switches management
|
||||
to the database. Hidden by default; git-sources.js shows
|
||||
it off the API's from_env flag. -->
|
||||
<p class="git-source-env-note" id="git-sources-env-note" role="note" hidden>
|
||||
These sources currently come from <code>BOR_GIT_SOURCES</code> in
|
||||
<code>.env</code> — adding or removing one here switches management
|
||||
to the database.
|
||||
</p>
|
||||
|
||||
<!-- Add form: visible label + mono URL input + brand button
|
||||
(dark ink on brand 5.2:1). §7.4 never-stale: the button
|
||||
disables + relabels "Adding…" while the POST is in flight
|
||||
and re-enables on success AND failure (the input is kept
|
||||
on failure, same as the tuning forms). -->
|
||||
<form id="git-source-form">
|
||||
<label for="git-source-url">Add a git source</label>
|
||||
<input
|
||||
id="git-source-url"
|
||||
name="url"
|
||||
type="text"
|
||||
maxlength="500"
|
||||
autocomplete="off"
|
||||
placeholder="https://github.com/you/your-repo.git"
|
||||
required
|
||||
>
|
||||
<button type="submit" id="git-source-add">Add source</button>
|
||||
<p class="git-source-error" id="git-source-error" role="alert" hidden></p>
|
||||
</form>
|
||||
|
||||
<!-- Phase 49 (owner permission 2026-08-28): the archive upload
|
||||
form replaces the phase-38 local-directory form — an
|
||||
uploaded .tar/.tar.gz/.tgz/.zip is unpacked under
|
||||
BOR_UPLOAD_DIR and scanned; the same filename replaces the
|
||||
source in place (no new folder, no duplicate row). The file
|
||||
control is labeled (visible <label for=…> — WCAG
|
||||
input-label rule); the button runs the §7.4 never-stale
|
||||
lifecycle ("Uploading…" while the POST is out). Phase 64
|
||||
(task 05) reworks the rest to the 202 contract (the
|
||||
phase-49 synchronous 200 paragraph is superseded): the 202
|
||||
arrives the moment the archive is safely on disk (A1) — a
|
||||
JS-created "Successfully uploaded — <file>" toast fires
|
||||
then (A2 — the phase-55 .toast node, no markup here; safe
|
||||
to navigate away) and the button settles into the live
|
||||
"Processing… <file> (n/m)" label (A4 — the full path rides
|
||||
the button title) driven by the 2 s poll of
|
||||
GET /api/git-sources/upload/status, until the success line
|
||||
(role=status) or the sanitized error banner (role=alert)
|
||||
lands; 409 re-attaches to the in-flight run — no error
|
||||
banner; the other non-2xx still show the server detail
|
||||
inline. -->
|
||||
<form id="archive-upload-form">
|
||||
<label for="archive-upload-file">Upload a source archive (.tar, .tar.gz, .tgz, .zip)</label>
|
||||
<input id="archive-upload-file" name="file" type="file"
|
||||
accept=".tar,.tar.gz,.tgz,.zip" required>
|
||||
<button type="submit" id="archive-upload-btn">Upload & scan</button>
|
||||
<p class="git-source-error" id="archive-upload-error" role="alert" hidden></p>
|
||||
<p class="git-source-result" id="archive-upload-result" role="status"
|
||||
aria-live="polite" hidden></p>
|
||||
</form>
|
||||
|
||||
<div class="table-wrap" id="git-sources-table-wrap" role="region" aria-label="Sources" tabindex="0">
|
||||
<table class="git-sources-table" id="git-sources-table">
|
||||
<caption class="visually-hidden">Sources the Sync button imports — git repositories it clones, local directories it walks, and uploaded archives (unpacked under the upload directory)</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Source</th>
|
||||
<th scope="col">Added</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="git-sources-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Empty state — no stored rows AND no env fallback. With
|
||||
from_env, the env note above already explains where the
|
||||
active list comes from. -->
|
||||
<p class="git-sources-empty" id="git-sources-empty" hidden>No sources stored yet.</p>
|
||||
|
||||
<!-- Phase 69 (owner request 2026-09-02): removal is a TOTAL
|
||||
removal — the row, the source's indexed documents, and —
|
||||
for git clones and uploaded archives — the files on the
|
||||
server's disk, all immediately (the confirmation modal
|
||||
below spells it out; foreign local directories are never
|
||||
touched). Adding still does not clone — the Sync button
|
||||
mirrors the remaining sources (upstream file churn is
|
||||
pruned on that run); the phase-49 upload is the
|
||||
in-place exception (it unpacks and scans, and a
|
||||
same-name re-upload replaces the source in place). -->
|
||||
<p class="git-source-hint" id="git-sources-hint" role="note">
|
||||
Removing a source is a total removal, done immediately: its
|
||||
entry, its indexed documents, and — for git clones and
|
||||
uploaded archives — its files on the server's disk (the
|
||||
confirmation modal spells out exactly what will be deleted;
|
||||
files in your own local directories are never touched).
|
||||
Uploads unpack and scan immediately — re-uploading the same
|
||||
filename replaces that source in place (no new folder, no
|
||||
duplicate row). The Sync button still mirrors the remaining
|
||||
sources (files removed upstream are pruned on that run).
|
||||
</p>
|
||||
|
||||
<!-- Phase 69 (owner request 2026-09-02): the remove
|
||||
confirmation — a real in-app alertdialog (the native
|
||||
confirm() retired): a row's Remove button opens it
|
||||
(git-sources.js).
|
||||
It names the source (#remove-confirm-source — ALWAYS
|
||||
populated via textContent: URLs may embed user:pass@
|
||||
credentials, the phase-32 masking discipline) and states
|
||||
the full-removal policy. Focus lands on Cancel (the safe
|
||||
default for a destructive action); Escape, the Cancel
|
||||
button, and the dim backdrop all close as cancel (no
|
||||
request — focus returns to the row's Remove button); only
|
||||
"Remove source" sends the DELETE, in the §7.4 "Removing…"
|
||||
in-flight state. The .doc-modal overlay contract: a fixed
|
||||
full-viewport dim backdrop + a centered panel (no blur).
|
||||
Static markup so the E2E suite gets stable selectors (the
|
||||
#git-sources-hint / gate convention). -->
|
||||
<div class="remove-confirm" id="remove-confirm-dialog" role="alertdialog"
|
||||
aria-modal="true" aria-labelledby="remove-confirm-title"
|
||||
aria-describedby="remove-confirm-copy" hidden>
|
||||
<div class="remove-confirm-backdrop" aria-hidden="true"></div>
|
||||
<div class="remove-confirm-panel">
|
||||
<h2 class="remove-confirm-title" id="remove-confirm-title">Remove this source?</h2>
|
||||
<code class="remove-confirm-source" id="remove-confirm-source"></code>
|
||||
<p class="remove-confirm-copy" id="remove-confirm-copy">
|
||||
This permanently removes the source entry, all of its
|
||||
indexed documents from the knowledge base, and — for git
|
||||
clones and uploaded archives — the files on the server's
|
||||
disk. Files in your own local directories are never
|
||||
touched. This cannot be undone.
|
||||
</p>
|
||||
<p class="remove-confirm-error" id="remove-confirm-error" role="alert" hidden></p>
|
||||
<div class="remove-confirm-actions">
|
||||
<button type="button" class="remove-confirm-btn remove-confirm-cancel"
|
||||
id="remove-confirm-cancel">Cancel</button>
|
||||
<button type="button" class="remove-confirm-btn remove-confirm-remove"
|
||||
id="remove-confirm-remove">Remove source</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Polite live region: the screen-reader confirmation for list
|
||||
loads, adds, and removals (git-sources.js owns the text). -->
|
||||
<p class="visually-hidden" id="git-sources-announcer" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Phase 76 (task 03): the History view (saved chats) — the
|
||||
content of frontend/history.html's <main> (wrapper dropped),
|
||||
folded into the shell. /history.html now serves THIS document
|
||||
(the shell route in app/main.py); the router shows this
|
||||
section for that pathname. The per-view copies of the
|
||||
header-owned steering panel + announcer are dropped (the
|
||||
shell's ONE panel — the chat one, inside #view-chat — is the
|
||||
instance header.js drives), and the page's footer is dropped
|
||||
too (the history page's #app-version span would DUPLICATE the
|
||||
shell's single (chat) footer). The row actions stay REAL
|
||||
navigations: the Open link (?chat=<id>) and the copy-link
|
||||
field are plain anchor/document-load targets — opening a
|
||||
saved chat is a chat-view concern handled by app.js at boot
|
||||
via ?chat= (out of scope for the router). The hidden + inert
|
||||
pair is the WCAG contract: a hidden view must not receive
|
||||
focus or keyboard traversal (AGENTS.md rule 5). Mounted
|
||||
lazily — assets/router.js imports history.js on first show
|
||||
only (mount-once, hide-forever). -->
|
||||
<section class="view" id="view-history" hidden inert aria-label="History" tabindex="-1">
|
||||
<div class="container history-shell">
|
||||
<div class="page-head">
|
||||
<h1>Saved chats</h1>
|
||||
<p class="page-sub">
|
||||
Every conversation is saved automatically — newest activity first. Click a title to return to that chat.
|
||||
</p>
|
||||
</div>
|
||||
<!-- Phase 50 (owner permission 2026-08-29): anonymous sign-in
|
||||
gate — the EXACT #sources-gate pattern (phase 16) and the
|
||||
same .sources-gate visual language (phase 35, git-sources):
|
||||
the saved-chat list is what the login locks. Visible for
|
||||
anonymous, hidden for the admin (history.js) — and the
|
||||
view never fetches /api/chats for an anonymous visitor
|
||||
(the router 403s them; the story E2E pins the request
|
||||
log). -->
|
||||
<section class="sources-gate" id="history-gate" aria-labelledby="history-gate-title" hidden>
|
||||
<div class="sources-gate-glyph" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg>
|
||||
</div>
|
||||
<h2 id="history-gate-title">Sign in to view your saved chats</h2>
|
||||
<p class="sources-gate-sub">
|
||||
Saved conversations are admin-only. Chat — and any document an
|
||||
answer cites — stays open to everyone.
|
||||
</p>
|
||||
<a class="sources-gate-link" href="/login.html?next=/history.html">Sign in</a>
|
||||
</section>
|
||||
|
||||
<!-- Live-region feedback for row actions (the "never stale"
|
||||
contract): history.js sets textContent here — a delete's
|
||||
outcome, its error line, nothing else. -->
|
||||
<span class="history-status" id="history-status" role="status" aria-live="polite"></span>
|
||||
|
||||
<!-- Phase 50: the full-width table (AGENTS.md rule 5 — no skinny
|
||||
list): Title (the Open link → /?chat=<id>) | Messages |
|
||||
Updated | Stale (phase 53: the READ-ONLY staleness marker —
|
||||
the rose pill when the row predates the last KB-changing
|
||||
sync; the Regenerate action lives on the chat-page banner,
|
||||
task 05) | Share (phase 51: Create link / Copy / Unshare —
|
||||
the row's share_url comes from GET /api/chats itself, no
|
||||
second fetch) | Actions (Delete, inline two-step confirm).
|
||||
history.js fills #history-tbody; #history-empty-row ships
|
||||
hidden and is revealed by a 0-row fetch. The Actions column
|
||||
header is visually-hidden — the row buttons carry their own
|
||||
aria-labels. -->
|
||||
<div class="table-wrap history-table-wrap" id="history-table-wrap" role="region" aria-label="Saved chats" tabindex="0">
|
||||
<table class="history-table">
|
||||
<caption class="visually-hidden">Saved chats — click a title to return to that conversation</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Messages</th>
|
||||
<th scope="col">Updated</th>
|
||||
<th scope="col">Stale</th>
|
||||
<th scope="col">Share</th>
|
||||
<th scope="col"><span class="visually-hidden">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="history-tbody">
|
||||
<tr class="history-empty-row" id="history-empty-row" hidden>
|
||||
<td colspan="6">No saved chats yet — start a conversation and it will be saved automatically.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
@@ -279,6 +753,12 @@
|
||||
own `import "./header.js"` — a hoisted import that is evaluated
|
||||
before the page script body calls initSharedHeader() at boot. -->
|
||||
<script type="module" src="/assets/app.js"></script>
|
||||
<!-- Phase 76 (task 01): the shell router — AFTER app.js (boot order:
|
||||
brand.js classic → app.js module → router.js module). It reads
|
||||
location.pathname, shows the matching view, and lazy-imports the
|
||||
non-chat view modules on first show only (mount-once). The chat
|
||||
view needs no module import: app.js already ran at shell boot. -->
|
||||
<script type="module" src="/assets/router.js"></script>
|
||||
|
||||
<!-- Phase 26: the almost-fullscreen document modal. Source chips and
|
||||
Sources-table path links open documents here (same-page overlay,
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="description" content="Documents indexed in Brain of Reese.">
|
||||
<title>Sources · Brain of Reese</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<header class="app-header">
|
||||
<div class="container header-inner">
|
||||
<span class="brand">
|
||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||
</span>
|
||||
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||
mobile hamburger — visible ≤640px only (CSS); opens the nav as
|
||||
an animated dropdown. Behavior: assets/header.js. -->
|
||||
<button type="button" class="nav-toggle" id="nav-toggle"
|
||||
aria-expanded="false" aria-controls="app-nav" aria-label="Menu">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||
</button>
|
||||
<nav class="app-nav" id="app-nav" aria-label="Primary">
|
||||
<a href="/" class="nav-link">Chat</a>
|
||||
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Sources link is admin-only (owner
|
||||
permission 2026-08-23) — hidden by default, header.js
|
||||
reveals it once whoami says admin. The soft-gated page
|
||||
itself is unchanged. -->
|
||||
<a href="/sources.html" class="nav-link is-active" aria-current="page" id="nav-sources" hidden>RAG</a>
|
||||
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||
link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>
|
||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Global Tuning link is admin-only (owner
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
<!-- Phase 50 (owner permission 2026-08-29, `TODO.md` L5): the
|
||||
History link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Tuning link above. -->
|
||||
<a href="/history.html" class="nav-link" id="nav-history" hidden>History</a>
|
||||
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign out</span>
|
||||
</button>
|
||||
</nav>
|
||||
<!-- Phase 15: the tuning-notes panel (stored in Postgres, read
|
||||
into every system prompt) — owned by the shared header
|
||||
module (assets/header.js); the #steering-panel section
|
||||
ships in every page's <main>. The navbar toggle was
|
||||
removed at owner request (2026-08-28): note management
|
||||
lives on /tuning.html. -->
|
||||
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
||||
out is visible; /api/whoami decides at load (the shared
|
||||
header module). Icon-only below 640px (aria-labels keep the
|
||||
accessible names). -->
|
||||
<a href="/login.html?next=/sources.html" class="auth-link sign-in-link" id="sign-in-link" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
<button type="button" class="auth-link sign-out-btn" id="sign-out-btn" aria-label="Sign out" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main" class="app-main" tabindex="-1">
|
||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the tuning-notes panel (stored notes, newest
|
||||
first) — rendered + driven by assets/header.js (shared), not
|
||||
the page script. First child of <main> on the non-chat pages;
|
||||
the chat page keeps it after #kb-banner. -->
|
||||
<section class="steering-panel" id="steering-panel" role="region"
|
||||
aria-label="Tuning notes" hidden>
|
||||
<div class="steering-panel-head">
|
||||
<h2 class="steering-panel-title">Tuning notes</h2>
|
||||
<p class="steering-panel-sub">Every note below steers all future answers.</p>
|
||||
</div>
|
||||
<ul class="steering-list" id="steering-list"></ul>
|
||||
<p class="steering-empty" id="steering-empty">No tuning notes yet — press “Tune” under any answer to add one.</p>
|
||||
</section>
|
||||
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||
<div class="container sources-shell">
|
||||
<div class="page-head">
|
||||
<div class="page-head-row">
|
||||
<h1>Knowledge base</h1>
|
||||
<button type="button" class="sync-btn" id="sync-btn" aria-label="Sync sources" hidden>
|
||||
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||
<span class="sync-label" id="sync-label">Sync sources</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="page-sub">
|
||||
Every file indexed from your configured sources — git repositories,
|
||||
local directories, and uploaded archives. Press <strong>Sync sources</strong>
|
||||
to pull the latest and re-import.
|
||||
</p>
|
||||
</div>
|
||||
<!-- #sync-result is the aria-live announcer: the last sync
|
||||
result ("N added · …") when a sync settles, and — phase 64 —
|
||||
the LIVE file label while either job runs ("Syncing… <file>
|
||||
(n/m)" / "Importing <file> (n/m)"), UNTRUNCATED (the button's
|
||||
label span ellipsizes; screen readers hear the full
|
||||
source/relative path, which also rides the button title).
|
||||
After an upload settles it stays empty — the upload's counts
|
||||
live on the Sources page (A3). -->
|
||||
<span class="sync-result" id="sync-result" role="status" aria-live="polite"></span>
|
||||
<!-- Sync failure banner — role="alert" so a failed sync is announced. -->
|
||||
<div class="kb-banner is-error" id="sync-error-banner" role="alert" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.6 22.2 20.4H1.8Z"/><path d="M12 9.5v4.6"/><path d="M12 17.4h.01"/></svg>
|
||||
<span id="sync-error-text"></span>
|
||||
</div>
|
||||
|
||||
<!-- Phase 16: anonymous sign-in gate. The catalog is what the
|
||||
login locks — the document viewer itself stays public (soft
|
||||
rule), so the copy says what stays open. -->
|
||||
<section class="sources-gate" id="sources-gate" aria-labelledby="sources-gate-title" hidden>
|
||||
<div class="sources-gate-glyph" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg>
|
||||
</div>
|
||||
<h2 id="sources-gate-title">Sign in to view the full catalog</h2>
|
||||
<p class="sources-gate-sub">
|
||||
The complete list of indexed documents is admin-only. Chat — and
|
||||
any document an answer cites — stays open to everyone.
|
||||
</p>
|
||||
<a class="sources-gate-link" href="/login.html?next=/sources.html">Sign in</a>
|
||||
</section>
|
||||
|
||||
<div class="stat-cards" id="stat-cards">
|
||||
<div class="stat-card" role="group" aria-label="Document statistics">
|
||||
<span class="stat-value" id="stat-docs">–</span>
|
||||
<span class="stat-label">documents</span>
|
||||
</div>
|
||||
<div class="stat-card" role="group" aria-label="Chunk statistics">
|
||||
<span class="stat-value" id="stat-chunks">–</span>
|
||||
<span class="stat-label">chunks</span>
|
||||
</div>
|
||||
<div class="stat-card" role="group" aria-label="Last indexed">
|
||||
<span class="stat-value stat-value-sm" id="stat-last">–</span>
|
||||
<span class="stat-label">last indexed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap" role="region" aria-label="Indexed documents" tabindex="0">
|
||||
<table class="docs-table" id="docs-table">
|
||||
<caption class="visually-hidden">Indexed markdown documents</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Source</th>
|
||||
<th scope="col">Path</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Chunks</th>
|
||||
<th scope="col">Indexed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="docs-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" id="sources-empty" hidden>
|
||||
<div class="empty-state-glyph" aria-hidden="true">
|
||||
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M6 12a4 4 0 0 1 4-4h10l4 5h14a4 4 0 0 1 4 4v17a4 4 0 0 1-4 4H10a4 4 0 0 1-4-4Z"/><path d="M6 20h36"/><path d="M15 28h9M15 33h14"/></svg>
|
||||
</div>
|
||||
<h2 class="empty-state-title">Nothing indexed yet</h2>
|
||||
<p class="empty-state-sub">
|
||||
Run the import to pull in the markdown docs:
|
||||
<code>uv run python -m scripts.import_docs</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<div class="container footer-inner">
|
||||
<span class="footer-text">Powered by self-hosted models</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Phase 19: the shared header module loads through the page script's
|
||||
own `import "./header.js"` — a hoisted import that is evaluated
|
||||
before the page script body calls initSharedHeader() at boot.
|
||||
Phase 26: markdown.js (the classic global renderMarkdown) loads
|
||||
BEFORE the module script — the document modal renders md
|
||||
documents through it on this page too. -->
|
||||
<!-- Phase 39: the brand layer — classic script, first on the page:
|
||||
window.BOR_BRAND at parse time, refreshed from /api/config. -->
|
||||
<script src="assets/brand.js"></script>
|
||||
<script src="assets/markdown.js"></script>
|
||||
<script type="module" src="/assets/sources.js"></script>
|
||||
|
||||
<!-- Phase 26: the almost-fullscreen document modal — SAME skeleton as
|
||||
the chat page (index.html): Sources-table path links open documents
|
||||
here (same-page overlay, no new tab) instead of navigating to
|
||||
/document.html, which stays the no-JS / direct-link fallback,
|
||||
unchanged. The page script fetches /api/documents/content and
|
||||
renders into #doc-modal-content through the shared renderDocument
|
||||
(document.js); the hidden attribute keeps the skeleton inert until
|
||||
JS opens it. #doc-modal-open points at the same
|
||||
/document.html?source=…&path=… URL the link carries, so the
|
||||
dedicated page is always one click away. -->
|
||||
<div class="doc-modal" id="doc-modal" hidden>
|
||||
<div class="doc-modal-backdrop" id="doc-modal-backdrop" aria-hidden="true"></div>
|
||||
<div class="doc-modal-panel" id="doc-modal-panel" role="dialog" aria-modal="true" aria-labelledby="doc-modal-title" aria-describedby="doc-modal-desc">
|
||||
<header class="doc-modal-header">
|
||||
<h2 class="doc-modal-title" id="doc-modal-title">Loading…</h2>
|
||||
<div class="doc-modal-actions">
|
||||
<a class="doc-modal-open" id="doc-modal-open" target="_blank" rel="noopener" hidden aria-label="Open in full page">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/></svg>
|
||||
<span>Full page</span>
|
||||
</a>
|
||||
<button type="button" class="doc-modal-close" id="doc-modal-close" aria-label="Close document">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="doc-modal-meta" id="doc-modal-meta" aria-live="polite"></div>
|
||||
<p class="visually-hidden" id="doc-modal-desc" role="status">Document content is loading.</p>
|
||||
<main class="doc-modal-content" id="doc-modal-content" tabindex="-1">
|
||||
<p class="doc-modal-loading" role="status">Loading document…</p>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,163 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="description" content="Manage the global tuning notes that steer every Brain of Reese answer.">
|
||||
<title>Global Tuning · Brain of Reese</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<header class="app-header">
|
||||
<div class="container header-inner">
|
||||
<span class="brand">
|
||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||
</span>
|
||||
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||
mobile hamburger — visible ≤640px only (CSS); opens the nav as
|
||||
an animated dropdown. Behavior: assets/header.js. -->
|
||||
<button type="button" class="nav-toggle" id="nav-toggle"
|
||||
aria-expanded="false" aria-controls="app-nav" aria-label="Menu">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||
</button>
|
||||
<nav class="app-nav" id="app-nav" aria-label="Primary">
|
||||
<a href="/" class="nav-link">Chat</a>
|
||||
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Sources link is admin-only (owner
|
||||
permission 2026-08-23) — hidden by default, header.js
|
||||
reveals it once whoami says admin. The soft-gated page
|
||||
itself is unchanged. -->
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
|
||||
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||
link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>
|
||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Global Tuning link is admin-only (owner
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/tuning.html" class="nav-link is-active" aria-current="page" id="nav-tuning" hidden>Tuning</a>
|
||||
<!-- Phase 50 (owner permission 2026-08-29, `TODO.md` L5): the
|
||||
History link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Tuning link above. -->
|
||||
<a href="/history.html" class="nav-link" id="nav-history" hidden>History</a>
|
||||
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign out</span>
|
||||
</button>
|
||||
</nav>
|
||||
<!-- Phase 15: the tuning-notes panel (stored in Postgres, read
|
||||
into every system prompt) — owned by the shared header
|
||||
module (assets/header.js); the #steering-panel section
|
||||
ships in every page's <main>. The navbar toggle was
|
||||
removed at owner request (2026-08-28): note management
|
||||
lives on /tuning.html. -->
|
||||
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
||||
out is visible; /api/whoami decides at load (the shared
|
||||
header module). Icon-only below 640px (aria-labels keep the
|
||||
accessible names). -->
|
||||
<a href="/login.html?next=/tuning.html" class="auth-link sign-in-link" id="sign-in-link" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
<button type="button" class="auth-link sign-out-btn" id="sign-out-btn" aria-label="Sign out" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main" class="app-main" tabindex="-1">
|
||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the tuning-notes panel (stored notes, newest
|
||||
first) — rendered + driven by assets/header.js (shared), not
|
||||
the page script. First child of <main> on the non-chat pages;
|
||||
the chat page keeps it after #kb-banner. -->
|
||||
<section class="steering-panel" id="steering-panel" role="region"
|
||||
aria-label="Tuning notes" hidden>
|
||||
<div class="steering-panel-head">
|
||||
<h2 class="steering-panel-title">Tuning notes</h2>
|
||||
<p class="steering-panel-sub">Every note below steers all future answers.</p>
|
||||
</div>
|
||||
<ul class="steering-list" id="steering-list"></ul>
|
||||
<p class="steering-empty" id="steering-empty">No tuning notes yet — press “Tune” under any answer to add one.</p>
|
||||
</section>
|
||||
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||
<div class="container tuning-shell">
|
||||
<div class="page-head">
|
||||
<h1>Global Tuning</h1>
|
||||
<p class="page-sub">
|
||||
Every note below is read into the system prompt of
|
||||
<strong>every</strong> chat turn. Add, edit, or remove them here —
|
||||
no conversation required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Phase 27: create a note without a chat. The label is
|
||||
visually-hidden (the heading + placeholder carry the visible
|
||||
context); the 1–2000-char contract mirrors the chat-page tune
|
||||
form — the server re-validates (422). -->
|
||||
<form id="tune-form">
|
||||
<label class="visually-hidden" for="tune-note">Add a global tuning note</label>
|
||||
<textarea
|
||||
id="tune-note"
|
||||
name="note"
|
||||
rows="3"
|
||||
maxlength="2000"
|
||||
placeholder="e.g. be more concise — or: assume I'm on NixOS"
|
||||
required
|
||||
></textarea>
|
||||
<button type="submit" id="tune-save">Add note</button>
|
||||
</form>
|
||||
|
||||
<!-- Live announcer for create / edit / delete — tuning.js (phase 27,
|
||||
task 03) owns the message text. -->
|
||||
<p class="visually-hidden" id="tune-announcer" role="status" aria-live="polite"></p>
|
||||
|
||||
<!-- Phase 27: the note list — the phase-15 steering panel's
|
||||
language, full column width. tuning.js fills it newest-first;
|
||||
each row is an <li class="tuning-note"> with a
|
||||
.tuning-note-text span + an Edit and a Delete button (styles:
|
||||
styles.css "Global tuning page"). The empty state toggles with
|
||||
the list. -->
|
||||
<section class="tuning-panel" aria-labelledby="tuning-panel-title">
|
||||
<h2 id="tuning-panel-title" class="tuning-panel-title">Tuning notes</h2>
|
||||
<ul id="tune-list" class="tuning-list" role="list"></ul>
|
||||
<p id="tune-empty">No tuning notes yet — add one above.</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<div class="container footer-inner">
|
||||
<span class="footer-text">Powered by self-hosted models</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Phase 27: markdown.js (the classic global renderMarkdown) loads
|
||||
BEFORE the module script; the shared header module loads through
|
||||
tuning.js's own `import "./header.js"` — a hoisted import that is
|
||||
evaluated before the page script body calls initSharedHeader() at
|
||||
boot. No direct header.js <script> tag (single-evaluation design). -->
|
||||
<!-- Phase 39: the brand layer — classic script, first on the page:
|
||||
window.BOR_BRAND at parse time, refreshed from /api/config. -->
|
||||
<script src="assets/brand.js"></script>
|
||||
<script src="assets/markdown.js"></script>
|
||||
<script type="module" src="/assets/tuning.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -272,13 +272,15 @@ def test_persists_across_page_navigation(
|
||||
_ask_deflected(page, OFF_TOPIC) # mixed conversation: grounded + deflected
|
||||
|
||||
# A trip to Sources (phase 16: the catalog is admin-only — the trip
|
||||
# starts with a real form login). The New chat button is chat-page
|
||||
# only (owner rework 2026-08-28 — it left the shared bar), so the
|
||||
# sources page carries none of it (pinned in
|
||||
# starts with a real form login). Phase 76 (task 02): the shell
|
||||
# carries the chat view (with its New chat button) in the DOM on
|
||||
# EVERY view — hidden + inert — so the button EXISTS here but must
|
||||
# be HIDDEN (the view-scoped absence pattern; it left the shared
|
||||
# bar at owner request, 2026-08-28 — 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_hidden()
|
||||
|
||||
# Back to the chat: the conversation is exactly as left — both turns,
|
||||
# the source chip, and the amber deflected bubble with its chips.
|
||||
|
||||
@@ -86,7 +86,10 @@ def test_history_tab_shows_auto_save_copy(
|
||||
|
||||
# The .page-sub reads the locked (A3) page-sub string
|
||||
# (whitespace-normalized — the template wraps the line).
|
||||
page_sub = page.locator(".page-sub")
|
||||
# Phase 76 (task 03): the shell carries one .page-sub per view
|
||||
# (the hidden views' copies remain in the DOM — hidden + inert),
|
||||
# so the pin is scoped to the visible History view.
|
||||
page_sub = page.locator("#view-history .page-sub")
|
||||
expect(page_sub).to_have_count(1)
|
||||
assert re.sub(r"\s+", " ", page_sub.inner_text()).strip() == PAGE_SUB
|
||||
|
||||
@@ -98,8 +101,10 @@ def test_history_tab_shows_auto_save_copy(
|
||||
# The no-button contract: the WHOLE rendered page reads neither
|
||||
# "press(ed) Save" nor "Save button" — while the <h1> still reads
|
||||
# "Saved chats" (asserted present, so the scan cannot pass by
|
||||
# deleting the heading).
|
||||
h1 = page.locator("h1")
|
||||
# deleting the heading). Phase 76 (task 03): scoped to the
|
||||
# History view — the shell carries one <h1> per view (the hidden
|
||||
# views' headings remain in the DOM, hidden + inert).
|
||||
h1 = page.locator("#view-history h1")
|
||||
expect(h1).to_have_count(1)
|
||||
expect(h1).to_have_text("Saved chats")
|
||||
body_text = page.locator("body").inner_text()
|
||||
|
||||
@@ -127,7 +127,9 @@ def test_sources_table_layout(
|
||||
login(page, app_url) # phase 16: the catalog is admin-only
|
||||
page.locator("#docs-tbody tr").first.wait_for(state="visible")
|
||||
|
||||
wrap = page.locator(".table-wrap")
|
||||
# Phase 76 (task 02): the shell carries BOTH views' .table-wrap —
|
||||
# scope to the RAG view.
|
||||
wrap = page.locator("#view-rag .table-wrap")
|
||||
expect(wrap).to_be_visible()
|
||||
expect(wrap).to_have_attribute("role", "region")
|
||||
expect(wrap).to_have_attribute("tabindex", "0")
|
||||
@@ -146,7 +148,7 @@ def test_sources_table_layout(
|
||||
login(mobile, app_url) # phase 16: the catalog is admin-only
|
||||
mobile.locator("#docs-tbody tr").first.wait_for(state="visible")
|
||||
scroll_width, client_width = mobile.evaluate(
|
||||
"() => { const el = document.querySelector('.table-wrap');"
|
||||
"() => { const el = document.querySelector('#view-rag .table-wrap');"
|
||||
" return [el.scrollWidth, el.clientWidth]; }"
|
||||
)
|
||||
assert scroll_width > client_width
|
||||
@@ -163,6 +165,6 @@ def test_empty_state_when_no_docs(page: Page, app_url: str, mock_llm: int, db_re
|
||||
expect(page.locator("#sources-empty code")).to_have_text(
|
||||
"uv run python -m scripts.import_docs"
|
||||
)
|
||||
expect(page.locator(".table-wrap")).to_be_hidden()
|
||||
expect(page.locator("#view-rag .table-wrap")).to_be_hidden()
|
||||
expect(page.locator("#stat-docs")).to_have_text("0")
|
||||
expect(page.locator("#stat-chunks")).to_have_text("0")
|
||||
|
||||
@@ -83,7 +83,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Dialog, Page, expect
|
||||
from playwright.sync_api import Page, expect
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import SessionLocal
|
||||
@@ -498,7 +498,10 @@ def test_admin_sync_imports_fixture_prunes_after_delete_removes_row(
|
||||
f"fixture doc survived the prune: {docs}"
|
||||
)
|
||||
|
||||
# --- remove the row: accept the confirm → it disappears ------------
|
||||
# --- remove the row: the phase-69 in-app confirmation modal --------
|
||||
# (window.confirm is retired — the row's Remove button opens the
|
||||
# #remove-confirm-dialog, which names the source and states the
|
||||
# full-removal policy; "Remove source" runs the server-side DELETE.)
|
||||
# Back on the manager page (the sync clicks visited the Sources page).
|
||||
page.goto(app_url + GIT_SOURCES_URL)
|
||||
removes: list[str] = []
|
||||
@@ -508,20 +511,15 @@ def test_admin_sync_imports_fixture_prunes_after_delete_removes_row(
|
||||
if r.method == "DELETE" and "/api/git-sources/" in r.url
|
||||
else None,
|
||||
)
|
||||
|
||||
def handle_dialog(dialog: Dialog) -> None:
|
||||
# "Remove this local source…? Its documents stay indexed until
|
||||
# the next sync prunes them." — accept it.
|
||||
dialog.accept()
|
||||
|
||||
page.on("dialog", handle_dialog)
|
||||
try:
|
||||
row = page.locator("#git-sources-tbody tr", has_text=str(local_dir))
|
||||
row.locator(".git-source-remove").click()
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(0, timeout=30_000)
|
||||
expect(page.locator("#git-sources-empty")).to_be_visible()
|
||||
finally:
|
||||
page.remove_listener("dialog", handle_dialog)
|
||||
row = page.locator("#git-sources-tbody tr", has_text=str(local_dir))
|
||||
row.locator(".git-source-remove").click()
|
||||
dialog = page.locator("#remove-confirm-dialog")
|
||||
expect(dialog).to_be_visible()
|
||||
expect(dialog.locator("#remove-confirm-source")).to_contain_text(str(local_dir))
|
||||
# Confirm: the full cleanup runs server-side; the row disappears.
|
||||
dialog.locator("#remove-confirm-remove").click()
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(0, timeout=30_000)
|
||||
expect(page.locator("#git-sources-empty")).to_be_visible()
|
||||
assert len(removes) == 1, f"expected one DELETE, saw: {removes}"
|
||||
# The registry is empty again — and with no env git list, a further
|
||||
# sync would fail loudly ("no sources configured (git or local)").
|
||||
|
||||
@@ -72,6 +72,18 @@ Test → story mapping (Playwright Mapping Rule):
|
||||
4. ``test_viewer_back_link_honors_back_param``
|
||||
5. ``test_steering_surface_off_chat_on_tuning_page``
|
||||
6. ``test_sync_button_present_on_sources_page_without_triggering``
|
||||
7. ``test_viewer_nav_click_full_loads_the_shell_rag_view`` (phase 76
|
||||
task 04 — the header is shell-owned: the surviving standalone
|
||||
documents keep their header copies, and a navbar click on one is a
|
||||
REAL departure that full-loads the shell, whose router renders the
|
||||
target view from the pathname)
|
||||
|
||||
Phase 76 adaptation (tasks 01–03): chat / sources / tuning are VIEWS of
|
||||
ONE shell document (index.html) — the header under test on those URLs is
|
||||
the shell's single one (the router deep-links the view from the
|
||||
pathname on each real goto). The per-URL inventory comparison is the
|
||||
pre-phase-76 probe, kept verbatim; test 7 adds the surviving-document
|
||||
side of the ownership boundary (the viewer's own header copy).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -243,15 +255,16 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
|
||||
assert page.locator("#steering-toggle").count() == 0, (
|
||||
f"{name}: the steering toggle was removed from the navbar"
|
||||
)
|
||||
# The Sync button is a page-specific control (Sources page only
|
||||
# — owner rework 2026-08-28): visible on sources, absent from
|
||||
# the shared bar everywhere else.
|
||||
# The Sync button is a view-specific control (RAG view only —
|
||||
# owner rework 2026-08-28): visible on sources, not visible
|
||||
# anywhere else. Phase 76 (task 02): in the shell the RAG view
|
||||
# (with the button) is in the DOM on every view — hidden +
|
||||
# inert — so the pin is VISIBLE, not ABSENT (to_be_hidden()
|
||||
# also passes on standalone pages where the button is absent).
|
||||
if name == "sources":
|
||||
expect(page.locator("#sync-btn")).to_be_visible()
|
||||
else:
|
||||
assert page.locator("#sync-btn").count() == 0, (
|
||||
f"{name}: #sync-btn left the shared bar (Sources page only)"
|
||||
)
|
||||
expect(page.locator("#sync-btn")).to_be_hidden()
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible()
|
||||
expect(page.locator("#sign-in-link")).to_be_hidden()
|
||||
else:
|
||||
@@ -277,14 +290,16 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
|
||||
assert page.locator("#steering-panel").count() == 0, (
|
||||
f"{name}: the steering panel must be absent for anonymous"
|
||||
)
|
||||
# The New chat button is chat-page only (moved from the shared bar
|
||||
# to index.html's .chat-shell at owner request, 2026-08-28).
|
||||
# The New chat button is chat-view only (moved from the shared bar
|
||||
# to the shell's .chat-shell at owner request, 2026-08-28).
|
||||
# Phase 76 (task 02): in the shell the chat view (with the button)
|
||||
# is in the DOM on every view — hidden + inert — so the pin is
|
||||
# VISIBLE, not ABSENT (to_be_hidden() also passes on standalone
|
||||
# pages where the button is absent).
|
||||
if name == "chat":
|
||||
expect(page.locator("#new-chat-btn")).to_be_visible()
|
||||
else:
|
||||
assert page.locator("#new-chat-btn").count() == 0, (
|
||||
f"{name}: #new-chat-btn left the shared bar (chat page only)"
|
||||
)
|
||||
expect(page.locator("#new-chat-btn")).to_be_hidden()
|
||||
|
||||
_assert_landmarks(page, name)
|
||||
return _header_inventory(page)
|
||||
@@ -540,3 +555,61 @@ def test_sync_button_present_on_sources_page_without_triggering(
|
||||
assert btn.get_attribute("aria-busy") is None, "a fresh idle sync must not be busy"
|
||||
expect(page.locator("#sync-label")).to_have_text("Sync sources")
|
||||
# Deliberately NOT clicked.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Phase 76 task 04: the surviving documents keep their header copies —
|
||||
# a navbar click on one is a REAL departure that full-loads the shell
|
||||
# (whose router then renders the target view from the pathname)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_viewer_nav_click_full_loads_the_shell_rag_view(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
"""Phase 76 task 04: the document viewer is one of the four surviving
|
||||
standalone documents (login, shared, doc-edit, document) — it keeps
|
||||
its own header copy, and the router does NOT run there (the router
|
||||
lives in the shell). Clicking its RAG nav link is therefore a REAL,
|
||||
document-level navigation — not the shell's pushState switch: it
|
||||
full-loads the shell at /sources.html, and the shell's router renders
|
||||
the RAG view from the pathname (the Chat view ships hidden + inert
|
||||
inside that same document).
|
||||
|
||||
The window sentinel proves the departure in the phase-76 canonical
|
||||
form, used in INVERSE: it is set in the viewer document and must be
|
||||
GONE after the click (a real load wipes window globals — exactly
|
||||
what distinguishes a departure from the shell's same-document
|
||||
switches, where the sentinel survives)."""
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_seed_db(mock_llm)
|
||||
|
||||
# Admin: the viewer's RAG nav link is admin-only (revealed by the
|
||||
# whoami pass of its own header copy).
|
||||
login(page, app_url, next=CHAT_URL)
|
||||
expect(page).to_have_url(app_url + CHAT_URL, timeout=30_000)
|
||||
page.goto(app_url + VIEWER_URL)
|
||||
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
|
||||
_wait_settled(page, admin=True)
|
||||
expect(page.locator("#nav-sources")).to_be_visible()
|
||||
|
||||
# The sentinel lives in the VIEWER document only.
|
||||
page.evaluate("() => { window.__phase76_viewer = 'phase76'; }")
|
||||
page.click("#nav-sources")
|
||||
|
||||
# The arrival is the SHELL at the RAG view's URL: the document loaded
|
||||
# for real (the sentinel is gone), the RAG view is rendered (first
|
||||
# table row visible), the Chat view is hidden AND inert in the same
|
||||
# document, and the RAG link carries the router's single-writer
|
||||
# active stamp.
|
||||
expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000)
|
||||
assert page.evaluate("() => window.__phase76_viewer") is None, (
|
||||
"a surviving document's nav click must be a real departure "
|
||||
"(a fresh document load wipes window globals)"
|
||||
)
|
||||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#view-chat")).to_be_hidden()
|
||||
assert page.evaluate("() => document.getElementById('view-chat').inert") is True, (
|
||||
"the chat view ships hidden AND inert in the shell"
|
||||
)
|
||||
expect(page.locator("#nav-sources")).to_have_class(re.compile(r"\bis-active\b"))
|
||||
|
||||
@@ -112,6 +112,11 @@ CURRENT_LINK: dict[str, str | None] = {
|
||||
#: The four primary nav links, in their physical DOM order — the labels
|
||||
#: after the phase-48 swap (ids/hrefs unchanged).
|
||||
NAV_LABELS = ("Chat", "RAG", "Sources", "Tuning")
|
||||
#: The FIFTH a.nav-link in every page header (phase 53 saved-chat
|
||||
#: history — ship-hidden, revealed by header.js for admins). The DOM
|
||||
#: enumeration below therefore always sees it (pre-existing since phase
|
||||
#: 53; the list below now matches the real nav).
|
||||
NAV_TAIL = ("History",)
|
||||
|
||||
#: The login.js script — route pattern for the redirect suppression.
|
||||
LOGIN_JS_ROUTE = re.compile(r"/assets/login\.js(\?.*)?$")
|
||||
@@ -191,12 +196,14 @@ def _assert_renamed_labels(page: Page, name: str) -> None:
|
||||
expect(git).to_have_text("Sources")
|
||||
expect(git).to_have_attribute("href", "/git-sources.html")
|
||||
|
||||
# The four primary nav links (class nav-link) in physical DOM order.
|
||||
# The nav links (class nav-link) in physical DOM order — the four
|
||||
# primaries plus the phase-53 admin-only History link.
|
||||
nav_texts = page.eval_on_selector_all(
|
||||
".app-nav a.nav-link", "els => els.map(e => e.textContent.trim())"
|
||||
)
|
||||
assert nav_texts == list(NAV_LABELS), (
|
||||
f"{name}: nav link order/labels are {nav_texts}, expected {list(NAV_LABELS)}"
|
||||
assert nav_texts == [*NAV_LABELS, *NAV_TAIL], (
|
||||
f"{name}: nav link order/labels are {nav_texts}, expected "
|
||||
f"{[*NAV_LABELS, *NAV_TAIL]}"
|
||||
)
|
||||
# …and the full anchor sequence of the nav (it also carries the
|
||||
# phase-46 mobile sign-in copy) opens with the same four, in order.
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
"""Phase 76 E2E (Playwright): in-app view switches never halt a
|
||||
generating answer — the owner repro, pinned against the deterministic
|
||||
mock LLM.
|
||||
|
||||
Source: owner repro, verified in a real browser 2026-09-06 — send a
|
||||
question → click **RAG** in the navbar mid-stream → click **Chat** →
|
||||
the answer never finished (every navbar view was a separate document,
|
||||
so the navbar click was a REAL cross-document navigation: the chat
|
||||
page unloaded, the in-flight fetch was aborted, and the phase-48
|
||||
teardown cancelled the turn — no ``query_log`` row, a dangling
|
||||
question on return). Phase 76 folded the five navbar views into ONE
|
||||
HTML shell: a navbar click is a CLIENT-SIDE view switch
|
||||
(``history.pushState`` + show/hide), so the in-flight SSE reader in
|
||||
the hidden chat view keeps streaming and the answer COMPLETES when
|
||||
the user returns.
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_nav_switch_keeps_stream.py -v --no-cov
|
||||
|
||||
Timing is deterministic by construction: the mock's ``write a long
|
||||
answer`` trigger streams a ~5400-char answer at 12 chars / 0.02 s
|
||||
(~8–9 s of content), so the mid-stream switch window is wide.
|
||||
|
||||
The "same document" proof (the canonical pattern from the phase
|
||||
overview): a ``window`` sentinel set before the nav click is still
|
||||
readable after the switch — a real document load would wipe ``window``
|
||||
globals. The ``performance.getEntriesByType("navigation")`` length is
|
||||
deliberately NOT used: a real load resets that counter to 1 in the
|
||||
fresh document, so it cannot distinguish pushState from a reload.
|
||||
|
||||
Test → story mapping (Playwright Mapping Rule):
|
||||
1. ``test_rag_switch_mid_stream_completes`` — THE OWNER REPRO: send →
|
||||
RAG mid-stream → Chat; the FULL answer completes, ``bor.chat.v1``
|
||||
holds EXACTLY ONE brain turn, the server SETTLED the turn (one
|
||||
``query_log`` row — no phase-48 ``turn cancelled``), and the
|
||||
auto-saved row (admin, ``persistConversation``) carries the same
|
||||
single full turn.
|
||||
2. ``test_every_nav_view_keeps_stream`` — the same mid-stream switch
|
||||
against the other three views (Sources/git-sources, Tuning,
|
||||
History): one send, one switch, one return, full answer + settled
|
||||
``query_log`` row each time.
|
||||
3. ``test_real_departure_still_cancels`` — the phase-48 CONTROL (the
|
||||
locked contract survives the phase): a genuine cross-document
|
||||
departure (``/shared.html`` — a stable document for a signed-in
|
||||
session; ``/login.html`` is deliberately avoided because it
|
||||
auto-redirects a signed-in admin straight back into the shell)
|
||||
still aborts the fetch, leaves NO ``query_log`` row, and the
|
||||
page-20/73 leave-save lands the partial in the EXACT shape pinned
|
||||
by ``tests/e2e/test_sources_midstream_bug.py::test_partial_answer_
|
||||
survives_real_departure_midstream`` (mirrored, not re-invented).
|
||||
The overlap with that suite is on purpose — different stories:
|
||||
phase 20 pins the partial shape, this phase pins that the
|
||||
navbar-switch path no longer cancels while real departures still
|
||||
do.
|
||||
4. ``test_baseline_no_switch_still_completes`` — the long question
|
||||
with NO navigation completes identically (guards against the
|
||||
shell fold changing the ordinary path).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from playwright.sync_api import Locator, 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
|
||||
from e2e.mock_llm import LONG_ANSWER_END, LONG_ANSWER_LINES, long_answer
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||||
|
||||
#: The phase-11 on-topic long-answer phrasing (house pattern,
|
||||
#: ``test_hidden_tab_stream.py`` / ``test_stop_generation.py``): the
|
||||
#: honesty gate is HIGH and the ~900-word answer streams for ~8–9 s
|
||||
#: (12 chars / 0.02 s) — the guaranteed mid-stream window.
|
||||
LONG_QUESTION = "How is my Kubernetes cluster set up? write a long answer"
|
||||
#: The mock's byte-stable long answer — the EXACT string the stream
|
||||
#: delivers, so "the full answer" is an exact comparison, not a
|
||||
#: contains check.
|
||||
LONG_ANSWER = long_answer()
|
||||
|
||||
#: The mock's first 12-char content slice (the same cut ``_sse_stream``
|
||||
#: makes) — the real-departure partial must START with it (raw text,
|
||||
#: pre-render); the rendered first line keeps the list-item form (the
|
||||
#: markdown renderer converts the "1. " marker into a list item,
|
||||
#: pinned by test_long_answers).
|
||||
FIRST_CHUNK_RAW = re.findall(r".{1,12}", LONG_ANSWER, re.S)[0]
|
||||
FIRST_LINE_DOM = "Step 1: configure node-1"
|
||||
|
||||
STORAGE_KEY = "bor.chat.v1"
|
||||
#: The typing indicator is itself a .msg.brain — exclude its bubble.
|
||||
ANSWER = ".msg.brain .bubble:not(.typing)"
|
||||
|
||||
#: The other three navbar views (test 2): the nav link, the view's
|
||||
#: URL (pushState target), and an admin-visible content marker inside
|
||||
#: the view (proof the view actually showed — the RAG view gets the
|
||||
#: same treatment with ``#docs-tbody tr`` in test 1).
|
||||
OTHER_VIEWS: tuple[tuple[str, str, str], ...] = (
|
||||
("#nav-git-sources", "/git-sources.html", "#git-sources-content"),
|
||||
("#nav-tuning", "/tuning.html", "#tune-save"),
|
||||
("#nav-history", "/history.html", "#history-table-wrap"),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KB seeding (house pattern: TRUNCATE-then-import)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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) -> ImportSummary:
|
||||
"""House reset + the prompt-shaping tables: steering notes and the
|
||||
KB overview would otherwise append deterministic suffixes to every
|
||||
mock answer and break the exact-text assertions."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text("TRUNCATE chunks, documents, query_log, steering_notes, kb_overview")
|
||||
)
|
||||
db.commit()
|
||||
return _run_in_thread(_import_fixtures(mock_port))
|
||||
|
||||
|
||||
def _query_log_count() -> int:
|
||||
"""The settled-row count over the whole (truncated) log.
|
||||
|
||||
The query log finalizes a row ONLY when the LLM finished AND the
|
||||
persistence succeeded (phase 48); a cancelled turn — a real
|
||||
departure mid-stream, or one before the first token — leaves no
|
||||
settled row, so the count IS the settled-row signal (0 = cancelled,
|
||||
1 = settled; the house pattern from test_hidden_tab_stream.py).
|
||||
"""
|
||||
with SessionLocal() as db:
|
||||
return db.execute(text("SELECT count(*) FROM query_log")).scalar_one()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared flows
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _stored_parsed(page: Page) -> dict[str, Any]:
|
||||
raw = page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
|
||||
assert raw is not None, "the conversation key must exist in localStorage"
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def _no_error_banner(page: Page) -> None:
|
||||
"""The never-stale contract, shell-scoped: the hidden views ship
|
||||
their own role=alert surfaces (sync/upload banners, …) that are
|
||||
inert while their view is hidden — so the pin is that NO alert is
|
||||
VISIBLE, whatever the document carries hidden (the phase-20
|
||||
rewrite's shell form)."""
|
||||
expect(page.locator('[role="alert"]:visible')).to_have_count(0)
|
||||
|
||||
|
||||
def _ask_long(page: Page) -> None:
|
||||
page.fill("#message-input", LONG_QUESTION)
|
||||
page.click("#send-btn")
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(LONG_QUESTION)
|
||||
|
||||
|
||||
def _wait_streaming(page: Page, answer: Locator) -> Locator:
|
||||
"""Wait until answer text is visibly streaming (a few delta frames
|
||||
rendered — the mid-stream moment, well inside the ~8–9 s stream)."""
|
||||
answer.wait_for(state="visible", timeout=30_000)
|
||||
partial = ""
|
||||
deadline = time.monotonic() + 15
|
||||
while time.monotonic() < deadline:
|
||||
partial = answer.inner_text()
|
||||
if len(partial.split()) >= 8:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert len(partial.split()) >= 8, "no answer deltas before the view switch"
|
||||
# In flight at the switch: the button IS the enabled Stop control.
|
||||
expect(page.locator("#send-label")).to_have_text("Stop")
|
||||
expect(page.locator("#send-btn")).to_have_class(re.compile(r"is-stop"))
|
||||
return answer
|
||||
|
||||
|
||||
def _wait_done(page: Page, answer: Locator) -> str:
|
||||
"""Wait for the ``done`` settle: the Send button is back and the
|
||||
bubble carries the unique final line — no error banner on the way."""
|
||||
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
|
||||
expect(page.locator("#send-btn")).not_to_have_class(re.compile(r"is-stop"))
|
||||
expect(answer).to_contain_text(LONG_ANSWER_END, timeout=30_000)
|
||||
_no_error_banner(page)
|
||||
return answer.inner_text()
|
||||
|
||||
|
||||
def _assert_full_answer(text: str) -> None:
|
||||
"""The bubble carries the FULL mock answer — every one of the 40
|
||||
numbered steps plus the unique final line (a truncated stream
|
||||
would be missing its tail)."""
|
||||
for i in range(1, LONG_ANSWER_LINES + 1):
|
||||
assert f"Step {i}: configure node-{i}" in text, f"step {i} missing from the answer"
|
||||
assert LONG_ANSWER_END in text
|
||||
|
||||
|
||||
def _assert_one_brain_turn(page: Page) -> dict[str, Any]:
|
||||
"""``bor.chat.v1`` holds EXACTLY ONE brain turn for the question,
|
||||
and its text is the COMPLETE mock answer byte-for-byte (the
|
||||
``done`` settle's record — the settle, not a partial)."""
|
||||
stored = _stored_parsed(page)
|
||||
assert stored["v"] == 1
|
||||
msgs = stored["messages"]
|
||||
assert [m["who"] for m in msgs] == ["user", "brain"], (
|
||||
"exactly ONE brain turn for the question: "
|
||||
f"{[m['who'] for m in msgs]}"
|
||||
)
|
||||
assert msgs[0]["text"] == LONG_QUESTION
|
||||
brain = msgs[1]
|
||||
assert brain["text"] == LONG_ANSWER, "the record's text is the FULL answer"
|
||||
assert brain.get("deflected") is False, "the done metadata rides the record"
|
||||
return brain
|
||||
|
||||
|
||||
def _admin_cookies(page: Page) -> dict[str, str]:
|
||||
"""The signed session cookies the browser holds after a form login —
|
||||
used to call the admin API with plain httpx (the test's API side
|
||||
sees exactly what the signed-in browser sees)."""
|
||||
return {c["name"]: c["value"] for c in page.context.cookies() if "name" in c and "value" in c}
|
||||
|
||||
|
||||
def _delete_rows_by_title(app_url: str, cookies: dict[str, str], title: str) -> None:
|
||||
"""Best-effort cleanup of the auto-saved row (a 404 is fine)."""
|
||||
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
|
||||
if r.status_code != 200:
|
||||
return
|
||||
for c in r.json()["chats"]:
|
||||
if c["title"] == title:
|
||||
httpx.delete(f"{app_url}/api/chats/{c['id']}", timeout=10, cookies=cookies)
|
||||
|
||||
|
||||
def _wait_row_full(app_url: str, cookies: dict[str, str], title: str) -> dict[str, Any]:
|
||||
"""Poll the auto-saved row until it carries the full answer as a
|
||||
single brain turn (the ``done`` settle's fire-and-forget
|
||||
``persistConversation`` PUT is the last writer)."""
|
||||
deadline = time.monotonic() + 15
|
||||
last: list[dict[str, Any]] = []
|
||||
while time.monotonic() < deadline:
|
||||
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
|
||||
rows = (
|
||||
[c for c in r.json()["chats"] if c["title"] == title]
|
||||
if r.status_code == 200
|
||||
else []
|
||||
)
|
||||
for c in rows:
|
||||
row = httpx.get(f"{app_url}/api/chats/{c['id']}", timeout=10, cookies=cookies).json()
|
||||
brains = [m for m in row["messages"] if m["who"] == "brain"]
|
||||
if len(brains) == 1 and brains[0]["text"] == LONG_ANSWER:
|
||||
return row
|
||||
last = [row]
|
||||
time.sleep(0.2)
|
||||
raise AssertionError(
|
||||
"the auto-saved row never held the full answer as exactly one brain turn; last: "
|
||||
f"{last!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. THE OWNER REPRO: send → RAG mid-stream → Chat — the FULL answer
|
||||
# completes, one brain turn, one settled query_log row, and the
|
||||
# auto-saved row carries the same single full turn
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rag_switch_mid_stream_completes(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
summary = _reset_db(mock_llm)
|
||||
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
|
||||
page.set_default_timeout(30_000)
|
||||
# Admin login: the admin-only #nav-sources link is revealed, and
|
||||
# the auto-save row (the persistConversation path) is reachable,
|
||||
# so the saved-chat side gets pinned too.
|
||||
login(page, app_url, next="/")
|
||||
expect(page.locator("#nav-sources")).to_be_visible()
|
||||
cookies = _admin_cookies(page)
|
||||
_delete_rows_by_title(app_url, cookies, LONG_QUESTION) # stale rows from crashed runs
|
||||
|
||||
_ask_long(page)
|
||||
answer = _wait_streaming(page, page.locator(ANSWER))
|
||||
|
||||
# THE SWITCH (mid-stream): the window sentinel set BEFORE the click
|
||||
# is still readable AFTER it — the canonical same-document proof
|
||||
# (a real navigation would have wiped window globals).
|
||||
page.evaluate("() => { window.__shell_boot = 'phase76'; }")
|
||||
page.click("#nav-sources")
|
||||
expect(page).to_have_url(app_url + "/sources.html")
|
||||
assert page.evaluate("() => window.__shell_boot") == "phase76", (
|
||||
"a real navigation would have wiped the window sentinel — "
|
||||
"the switch must be same-document"
|
||||
)
|
||||
# The RAG view actually showed (the fixture docs' rows are listed)
|
||||
# and the chat view is hidden (the stream fills it in the
|
||||
# background — that persistence IS the fix).
|
||||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#view-chat")).to_be_hidden()
|
||||
|
||||
# Stay on the RAG view while the stream keeps running (the switch
|
||||
# is ~t+2 s; the full answer needs ~8–9 s).
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# Back to the chat (the header link — a router-intercepted
|
||||
# switch, still same-document).
|
||||
page.click('a.nav-link[href="/"]')
|
||||
expect(page).to_have_url(app_url + "/")
|
||||
assert page.evaluate("() => window.__shell_boot") == "phase76"
|
||||
|
||||
# The answer COMPLETED — the bubble carries the FULL mock answer
|
||||
# (every step line + the unique final line), no error banner.
|
||||
done_text = _wait_done(page, answer)
|
||||
_assert_full_answer(done_text)
|
||||
|
||||
# Storage: EXACTLY ONE brain turn — the FULL answer with done
|
||||
# metadata (the settle, not a partial).
|
||||
page.wait_for_timeout(500)
|
||||
_assert_one_brain_turn(page)
|
||||
|
||||
# Server side: the turn SETTLED — exactly one query_log row, so no
|
||||
# phase-48 "turn cancelled" teardown fired for an in-app switch.
|
||||
assert _query_log_count() == 1, "a completed turn must finalize its query_log row"
|
||||
|
||||
# Auto-save (admin): the row carries the same single full brain
|
||||
# turn (the shared record shape).
|
||||
try:
|
||||
row = _wait_row_full(app_url, cookies, LONG_QUESTION)
|
||||
brains = [m for m in row["messages"] if m["who"] == "brain"]
|
||||
assert len(brains) == 1, "the saved row holds exactly one brain turn"
|
||||
assert brains[0]["text"] == LONG_ANSWER
|
||||
finally:
|
||||
_delete_rows_by_title(app_url, cookies, LONG_QUESTION)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. The same mid-stream switch against the other three views — one
|
||||
# send, one switch, one return, full answer + settled row each time
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_every_nav_view_keeps_stream(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
summary = _reset_db(mock_llm)
|
||||
assert summary is not None and summary.added == 13
|
||||
page.set_default_timeout(30_000)
|
||||
# Admin: every navbar link (incl. the three below) is revealed by
|
||||
# the whoami gate.
|
||||
login(page, app_url, next="/")
|
||||
|
||||
for i, (nav_sel, view_path, marker) in enumerate(OTHER_VIEWS, start=1):
|
||||
expect(page.locator(nav_sel)).to_be_visible()
|
||||
|
||||
_ask_long(page)
|
||||
# The CURRENT turn's bubble (the conversation accumulates one
|
||||
# full turn per iteration — the latest pair is the pin).
|
||||
answer = _wait_streaming(page, page.locator(ANSWER).last)
|
||||
|
||||
# Mid-stream switch to this view — same-document (sentinel).
|
||||
page.evaluate("() => { window.__shell_boot = 'phase76'; }")
|
||||
page.click(nav_sel)
|
||||
expect(page).to_have_url(app_url + view_path)
|
||||
assert page.evaluate("() => window.__shell_boot") == "phase76", (
|
||||
f"a real navigation to {view_path} would have wiped the sentinel"
|
||||
)
|
||||
# The view actually showed (its admin content is up) and the
|
||||
# chat view is hidden (the stream fills it in the background).
|
||||
expect(page.locator(marker)).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#view-chat")).to_be_hidden()
|
||||
|
||||
# Let the stream run while this view is up, then return to the
|
||||
# chat (still same-document).
|
||||
page.wait_for_timeout(2000)
|
||||
page.click('a.nav-link[href="/"]')
|
||||
expect(page).to_have_url(app_url + "/")
|
||||
assert page.evaluate("() => window.__shell_boot") == "phase76"
|
||||
|
||||
# The answer COMPLETED — FULL mock answer, no error banner.
|
||||
done_text = _wait_done(page, answer)
|
||||
_assert_full_answer(done_text)
|
||||
|
||||
# Every turn SETTLED: exactly one query_log row per completed
|
||||
# turn so far (a cancelled turn would leave no row).
|
||||
page.wait_for_timeout(500)
|
||||
assert _query_log_count() == i, (
|
||||
f"turn {i} must finalize exactly one settled query_log row"
|
||||
)
|
||||
|
||||
# Storage: the latest pair is the question + ONE brain turn
|
||||
# carrying the FULL answer (each turn appended, none
|
||||
# cancelled, none truncated).
|
||||
msgs = _stored_parsed(page)["messages"]
|
||||
assert msgs[-2] == {"who": "user", "text": LONG_QUESTION}
|
||||
assert msgs[-1]["who"] == "brain"
|
||||
assert msgs[-1]["text"] == LONG_ANSWER
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. The phase-48 CONTROL: a REAL cross-document departure still
|
||||
# cancels the fetch (the locked contract survives the phase) — and
|
||||
# the page-20/73 partial persist lands in the exact phase-20 shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_real_departure_still_cancels(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
summary = _reset_db(mock_llm)
|
||||
assert summary is not None and summary.added == 13
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
_ask_long(page)
|
||||
_wait_streaming(page, page.locator(ANSWER))
|
||||
|
||||
# THE DEPARTURE: a REAL cross-document navigation (NOT a navbar
|
||||
# link — those are view switches now). The fetch is aborted by the
|
||||
# unload, which is the point (phase 48). /shared.html is a plain
|
||||
# document with a stable state for a signed-in session — unlike
|
||||
# /login.html, which auto-redirects a signed-in admin straight
|
||||
# back into the shell.
|
||||
page.goto(app_url + "/shared.html")
|
||||
expect(page).to_have_url(app_url + "/shared.html")
|
||||
expect(page.locator("#shared-title")).to_be_visible(timeout=15_000)
|
||||
|
||||
# The turn was CANCELLED — the phase-48 query_log row only lands
|
||||
# when the LLM finished AND the persistence succeeded, so a
|
||||
# cancelled mid-stream turn must leave NO settled row.
|
||||
assert _query_log_count() == 0, (
|
||||
"a cancelled mid-stream turn must not finalize a query_log row"
|
||||
)
|
||||
|
||||
# Return to the chat — the page-20/73 leave-save is intact: the
|
||||
# question AND the already-streamed partial are both rendered.
|
||||
page.goto(app_url + "/")
|
||||
expect(page.locator("#empty-state")).to_be_hidden()
|
||||
expect(page.locator(".msg.user .bubble")).to_have_count(1)
|
||||
expect(page.locator(".msg.user .bubble").first).to_contain_text(LONG_QUESTION)
|
||||
restored = page.locator(".msg.brain .bubble")
|
||||
expect(restored).to_have_count(1)
|
||||
expect(restored.first).to_contain_text(FIRST_LINE_DOM)
|
||||
_no_error_banner(page)
|
||||
|
||||
# The EXACT phase-20 partial shape (mirrored from
|
||||
# test_sources_midstream_bug.py::test_partial_answer_survives_real_
|
||||
# departure_midstream — do not invent a new shape): exactly one
|
||||
# brain turn, raw text STARTING with the first streamed chunk,
|
||||
# SHORTER than the full answer, and NO done metadata (no
|
||||
# sources/deflected/suggestions/thinking — the turn never
|
||||
# settled when it was written).
|
||||
msgs = _stored_parsed(page)["messages"]
|
||||
assert [m["who"] for m in msgs] == ["user", "brain"]
|
||||
assert msgs[0]["text"] == LONG_QUESTION
|
||||
brain = msgs[1]
|
||||
assert brain["text"].startswith(FIRST_CHUNK_RAW)
|
||||
assert len(brain["text"]) < len(LONG_ANSWER), "the stored answer must be partial"
|
||||
assert brain["text"] != LONG_ANSWER
|
||||
assert "sources" not in brain
|
||||
assert "deflected" not in brain
|
||||
assert "suggestions" not in brain
|
||||
assert "thinking" not in brain
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Baseline: the long question with NO navigation completes
|
||||
# identically (guards against the shell fold changing the ordinary
|
||||
# path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_baseline_no_switch_still_completes(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
"""The normal path, untouched by the shell: the long answer
|
||||
completes identically without any view switch (guards against an
|
||||
over-eager router/view change altering the ordinary settle)."""
|
||||
summary = _reset_db(mock_llm)
|
||||
assert summary is not None and summary.added == 13
|
||||
page.set_default_timeout(30_000)
|
||||
page.goto(app_url)
|
||||
|
||||
_ask_long(page)
|
||||
answer = _wait_streaming(page, page.locator(ANSWER))
|
||||
done_text = _wait_done(page, answer)
|
||||
_assert_full_answer(done_text)
|
||||
_assert_one_brain_turn(page)
|
||||
assert _query_log_count() == 1
|
||||
@@ -277,7 +277,9 @@ def test_sources_table_full_width(
|
||||
try:
|
||||
login(page, app_url, next="/sources.html") # phase 16: admin-only
|
||||
page.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
|
||||
wrap_box = page.locator(".table-wrap").bounding_box()
|
||||
# Phase 76 (task 02): the shell carries BOTH views' .table-wrap
|
||||
# (the git-sources one ships hidden) — scope to the RAG view.
|
||||
wrap_box = page.locator("#view-rag .table-wrap").bounding_box()
|
||||
shell_box = page.locator(".sources-shell").bounding_box()
|
||||
assert wrap_box is not None and shell_box is not None
|
||||
assert wrap_box["width"] >= 0.80 * shell_box["width"], (
|
||||
@@ -292,7 +294,7 @@ def test_sources_table_full_width(
|
||||
login(mobile, app_url, next="/sources.html") # phase 16: admin-only
|
||||
mobile.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
|
||||
scroll, client = mobile.evaluate(
|
||||
"() => { const el = document.querySelector('.table-wrap');"
|
||||
"() => { const el = document.querySelector('#view-rag .table-wrap');"
|
||||
" return [el.scrollWidth, el.clientWidth]; }"
|
||||
)
|
||||
assert scroll > client, (
|
||||
|
||||
@@ -157,14 +157,16 @@ def assert_shared_bar(page: Page, admin: bool, page_kind: str, mobile: bool = Fa
|
||||
)
|
||||
expect(page.locator("#sign-out-btn")).to_be_hidden()
|
||||
|
||||
# The New chat button is chat-page only (moved from the shared bar
|
||||
# to index.html's .chat-shell at owner request, 2026-08-28).
|
||||
# The New chat button is chat-view only (moved from the shared bar
|
||||
# to the shell's .chat-shell at owner request, 2026-08-28).
|
||||
# Phase 76 (task 02): in the shell the chat view (with the button)
|
||||
# is in the DOM on every view — hidden + inert — so the pin is
|
||||
# VISIBLE, not ABSENT (to_be_hidden() also passes on standalone
|
||||
# pages like the viewer, where the button does not exist at all).
|
||||
if page_kind == "chat":
|
||||
expect(page.locator("#new-chat-btn")).to_be_visible()
|
||||
else:
|
||||
assert page.locator("#new-chat-btn").count() == 0, (
|
||||
f"{page_kind}: the New chat button is chat-page only"
|
||||
)
|
||||
expect(page.locator("#new-chat-btn")).to_be_hidden()
|
||||
|
||||
# Phase 34: EVERY page kind — viewer included — carries the SAME
|
||||
# nav contract: the Chat link always visible; the admin-only
|
||||
@@ -311,12 +313,15 @@ def test_new_chat_button_is_chat_page_only(
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_seed_db(mock_llm)
|
||||
|
||||
# No non-chat page carries the button anymore…
|
||||
# No non-chat view shows the button (phase 76 task 02: in the shell
|
||||
# it EXISTS in the DOM on every view — hidden + inert — so the pin
|
||||
# is visibility, not existence; standalone pages carry none at
|
||||
# all, and to_be_hidden() passes for both) …
|
||||
for path in (SOURCES_URL, VIEWER_URL, "/tuning.html", "/login.html", "/git-sources.html"):
|
||||
page.goto(app_url + path)
|
||||
expect(page.locator("#new-chat-btn")).to_have_count(0)
|
||||
expect(page.locator("#new-chat-btn")).to_be_hidden()
|
||||
|
||||
# …and the chat page has exactly one (visible, inside .chat-shell).
|
||||
# …and the chat view has exactly one (visible, inside .chat-shell).
|
||||
page.goto(app_url + "/")
|
||||
expect(page.locator("#new-chat-btn")).to_have_count(1)
|
||||
expect(page.locator("#new-chat-btn")).to_be_visible()
|
||||
|
||||
@@ -1,36 +1,45 @@
|
||||
"""Phase 20 E2E (Playwright): navigating away mid-turn keeps the answer.
|
||||
"""Story: mid-stream navigation — the phase-76 SPA shell fix (phase 20
|
||||
story, re-purposed by phase 76 task 02).
|
||||
|
||||
Story: ``.agents/user_stories/sources-midstream.md``
|
||||
Bug report (TODO.md L3): *"Clicking "sources" while chat is generating
|
||||
clears chat and result will never show up."*
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_sources_midstream_bug.py -v --no-cov
|
||||
|
||||
The bug: the brain message persisted only on ``done``, so leaving the
|
||||
chat page while a turn was in flight aborted the stream and dropped
|
||||
whatever had already streamed — the user came back to their own question
|
||||
with no result, ever. The fix (phase 20, owner-confirmed A1): a single
|
||||
``pagehide`` save point in app.js persists the partial raw answer (via
|
||||
the existing ``rememberBrainTurn`` helper) when navigation hits a turn
|
||||
that is in flight and has already streamed text.
|
||||
Phase 20 (bug 24) pinned a REAL departure from the chat mid-answer: a
|
||||
full page navigation (the "Sources" navbar link → /sources.html) aborted
|
||||
the stream via the unload, and a single ``pagehide`` save point in
|
||||
app.js persisted the partial raw answer (``rememberBrainTurn``) so the
|
||||
user came back to their question WITH the partial, rendered as
|
||||
"Partial answer — navigation interrupted the stream."
|
||||
|
||||
Phase 76 (task 02) folded /sources.html (and /git-sources.html) into
|
||||
the ONE-document shell: from this phase on, a navbar click is a
|
||||
CLIENT-SIDE view switch — the document (and its in-flight SSE reader)
|
||||
survive, so the pinned behavior is "the stream survives and the answer
|
||||
COMPLETES." The phase-20 pagehide partial-persist REMAINS for REAL
|
||||
departures only (a cross-document navigation still aborts the fetch),
|
||||
and its coverage home is scenario 1 below in its renamed form.
|
||||
|
||||
Timing is deterministic by construction:
|
||||
|
||||
* scenario 1 keys off the mock's ``write a long answer`` trigger — a
|
||||
~5400-char / ~450-frame / ~9s content stream, so the navigation lands
|
||||
~5400-char / ~450-frame / ~9s content stream, so the departure lands
|
||||
mid-stream with a wide margin;
|
||||
* scenario 2 keys off the mock's ``think out loud then hesitate``
|
||||
trigger — the phase-17 thinking stream followed by a 4s silence before
|
||||
the first content frame, so the navigation lands inside pure thinking;
|
||||
* scenarios 3 and 4 settle the turn fully (send button re-enabled)
|
||||
before any navigation.
|
||||
* scenarios 2–3 key off the same long stream (mid-stream view switch)
|
||||
and the ``think out loud then hesitate`` trigger — the phase-17
|
||||
thinking stream followed by a 4s silence before the first content
|
||||
frame, so the switch lands inside pure thinking;
|
||||
* scenario 4 (the pre-token real-departure pin) uses the same
|
||||
hesitate trigger; scenarios 5 and 6 settle the turn fully (send
|
||||
button re-enabled) before any navigation.
|
||||
|
||||
Test → story mapping (Playwright Mapping Rule):
|
||||
1. ``test_partial_answer_survives_sources_nav_midstream``
|
||||
2. ``test_no_orphan_brain_message_when_navigated_before_first_token``
|
||||
3. ``test_completed_turn_unaffected``
|
||||
4. ``test_new_chat_still_clears_conversation``
|
||||
1. ``test_partial_answer_survives_real_departure_midstream``
|
||||
2. ``test_full_answer_completes_after_rag_nav_midstream``
|
||||
3. ``test_nav_switch_before_first_token_completes``
|
||||
4. ``test_no_orphan_brain_message_when_navigated_before_first_token``
|
||||
5. ``test_completed_turn_unaffected``
|
||||
6. ``test_new_chat_still_clears_conversation``
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -51,7 +60,7 @@ 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
|
||||
from e2e.mock_llm import long_answer
|
||||
from e2e.mock_llm import LONG_ANSWER_END, LONG_ANSWER_LINES, long_answer
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||||
@@ -71,7 +80,7 @@ FIRST_CHUNK_RAW = re.findall(r".{1,12}", FULL_LONG, re.S)[0]
|
||||
#: dropping the marker (pinned by test_long_answers).
|
||||
FIRST_LINE_DOM = "Step 1: configure node-1"
|
||||
|
||||
# --- scenario 2: navigation during pure thinking (no answer tokens) -----
|
||||
# --- scenario 3: navigation during pure thinking (no answer tokens) -----
|
||||
HESITATE_QUESTION = (
|
||||
"think out loud then hesitate — how is my kubernetes cluster set up?"
|
||||
)
|
||||
@@ -123,6 +132,20 @@ def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
|
||||
return _run_in_thread(_import_fixtures(mock_port))
|
||||
|
||||
|
||||
def _query_log_count() -> int:
|
||||
"""The settled-row count over the whole (truncated) log.
|
||||
|
||||
The query log finalizes a row ONLY when the LLM finished AND the
|
||||
persistence succeeded (phase 48); a cancelled turn — a real
|
||||
departure mid-stream, or one before the first token — leaves no
|
||||
settled row, so the count IS the settled-row signal (0 = cancelled,
|
||||
1 = settled; the house pattern from tests/e2e/test_hidden_tab_
|
||||
stream.py).
|
||||
"""
|
||||
with SessionLocal() as db:
|
||||
return db.execute(text("SELECT count(*) FROM query_log")).scalar_one()
|
||||
|
||||
|
||||
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}')")
|
||||
@@ -148,8 +171,12 @@ def _ask(page: Page, question: str) -> None:
|
||||
|
||||
def _no_error_banner(page: Page) -> None:
|
||||
"""The never-stale contract: a restored/partial state must never
|
||||
present an error banner (role=alert) — the turn is simply partial."""
|
||||
expect(page.locator('[role="alert"]')).to_have_count(0)
|
||||
present an error banner (role=alert) — the turn is simply partial.
|
||||
Phase 76 (task 02): the shell's hidden views carry their own
|
||||
ship-hidden role=alert surfaces (sync banner, upload banner, …),
|
||||
so the pin is VIEW-SCOPED IN EFFECT — NO alert may be VISIBLE,
|
||||
whatever the document carries hidden."""
|
||||
expect(page.locator('[role="alert"]:visible')).to_have_count(0)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -164,17 +191,17 @@ def seeded_kb(mock_llm: int, db_ready: None) -> Iterator[None]:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Mid-stream navigation via the Sources nav link: the partial answer
|
||||
# that had already streamed is persisted and restored
|
||||
# 1. REAL departure mid-stream (pagehide partial persist — the phase-20
|
||||
# contract, now exercised via a genuine cross-document navigation;
|
||||
# a navbar click is no longer a departure — that is scenario 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_partial_answer_survives_sources_nav_midstream(
|
||||
def test_partial_answer_survives_real_departure_midstream(
|
||||
page: Page, app_url: str, seeded_kb: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
# Admin (phase 16/19): only the admin sees the #nav-sources link the
|
||||
# bug report clicks.
|
||||
# Admin (phase 16/19): only the admin sees the #nav-sources link.
|
||||
login(page, app_url, next="/")
|
||||
expect(page.locator("#nav-sources")).to_be_visible()
|
||||
|
||||
@@ -193,13 +220,22 @@ def test_partial_answer_survives_sources_nav_midstream(
|
||||
expect(page.locator("#send-btn")).to_be_enabled()
|
||||
expect(page.locator("#send-label")).to_have_text("Stop")
|
||||
|
||||
# THE BUG REPORT, VERBATIM: click "Sources" while chat is generating.
|
||||
page.click("#nav-sources")
|
||||
expect(page).to_have_url(app_url + "/sources.html")
|
||||
# The navigation really landed on the admin catalog (mid-stream state
|
||||
# of the stream itself does not matter to the page — the fetch is
|
||||
# aborted by the unload, which is the point).
|
||||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
# THE DEPARTURE, in its phase-76 form: a REAL cross-document
|
||||
# navigation — page.goto to a genuine other document. /shared.html
|
||||
# is a plain document (stable for every session state) and — unlike
|
||||
# /login.html, which auto-redirects a signed-in session straight
|
||||
# back into the shell — it is a real departure, so the in-flight SSE
|
||||
# fetch is aborted by the unload (the point).
|
||||
page.goto(app_url + "/shared.html")
|
||||
expect(page).to_have_url(app_url + "/shared.html")
|
||||
expect(page.locator("#shared-title")).to_be_visible(timeout=15_000)
|
||||
|
||||
# The turn was CANCELLED — the phase-48 query_log row only lands
|
||||
# when the LLM finished AND the persistence succeeded, so a
|
||||
# cancelled mid-stream turn must leave NO settled row.
|
||||
assert _query_log_count() == 0, (
|
||||
"a cancelled mid-stream turn must not finalize a query_log row"
|
||||
)
|
||||
|
||||
# Return to the chat.
|
||||
page.goto(app_url + "/")
|
||||
@@ -238,8 +274,149 @@ def test_partial_answer_survives_sources_nav_midstream(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Navigation BEFORE the first answer token (pure thinking): nothing
|
||||
# brain-side is persisted — the question comes back alone
|
||||
# 2. Navbar click to RAG mid-stream = a client-side VIEW SWITCH (phase 76,
|
||||
# task 02): the in-flight stream keeps running while the RAG view
|
||||
# shows, and the answer COMPLETES — the phase-20 "answer cut short"
|
||||
# outcome is impossible now (the fetch was never cancelled)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_full_answer_completes_after_rag_nav_midstream(
|
||||
page: Page, app_url: str, seeded_kb: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
expect(page.locator("#nav-sources")).to_be_visible()
|
||||
|
||||
# Start the ~9s long answer and wait until visible streaming (the
|
||||
# house pattern: first line rendered + the enabled Stop control).
|
||||
page.fill("#message-input", LONG_QUESTION)
|
||||
page.click("#send-btn")
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(LONG_QUESTION)
|
||||
bubble = page.locator(".msg.brain .bubble").last
|
||||
expect(bubble).to_contain_text(FIRST_LINE_DOM, timeout=30_000)
|
||||
expect(page.locator("#send-btn")).to_be_enabled()
|
||||
expect(page.locator("#send-label")).to_have_text("Stop")
|
||||
|
||||
# Same-document proof: a window sentinel set before the click is
|
||||
# still readable after — no load happened (the navigation-entries
|
||||
# length is NOT used: it resets on a real load).
|
||||
page.evaluate("() => { window.__shell_boot = 'phase76'; }")
|
||||
page.click("#nav-sources")
|
||||
expect(page).to_have_url(app_url + "/sources.html")
|
||||
assert page.evaluate("() => window.__shell_boot") == "phase76"
|
||||
# The RAG view actually mounted (the fixture docs' rows are listed).
|
||||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
|
||||
# Stay on the RAG view while the stream keeps running in the
|
||||
# background (the switch is ~t+2s; the full answer needs ~9s).
|
||||
page.wait_for_timeout(2000)
|
||||
# Back to the chat (the header link — a router-intercepted switch).
|
||||
page.click('a.nav-link[href="/"]')
|
||||
expect(page).to_have_url(app_url + "/")
|
||||
|
||||
# The answer COMPLETED — the final sentinel line is in the bubble
|
||||
# (not a partial), no error banner.
|
||||
bubble = page.locator(".msg.brain .bubble").last
|
||||
expect(bubble).to_contain_text(LONG_ANSWER_END, timeout=30_000)
|
||||
text_now = bubble.inner_text()
|
||||
for i in range(1, LONG_ANSWER_LINES + 1):
|
||||
assert f"Step {i}: configure node-{i}" in text_now
|
||||
_no_error_banner(page)
|
||||
|
||||
# Settle, then storage: EXACTLY ONE brain turn — the FULL answer,
|
||||
# with done metadata (the settle, not a partial).
|
||||
page.wait_for_timeout(500)
|
||||
msgs = _stored_parsed(page)["messages"]
|
||||
assert [m["who"] for m in msgs] == ["user", "brain"]
|
||||
brain = msgs[1]
|
||||
assert brain["text"] == FULL_LONG
|
||||
assert brain["deflected"] is False
|
||||
assert any(s["path"] == "homelab/kubernetes.md" for s in brain["sources"])
|
||||
|
||||
# The turn SETTLED — the phase-48 query_log row exists (a
|
||||
# cancelled turn would leave no row at all).
|
||||
assert _query_log_count() == 1, "a completed turn must finalize its query_log row"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Navbar switch in the pre-first-token window (pure thinking — no
|
||||
# content frame yet): the surviving reader completes the answer, and
|
||||
# bor.chat.v1 holds exactly ONE brain turn (the no-orphan invariant
|
||||
# in its new form — a pre-token view switch neither kills the turn
|
||||
# nor persists a partial)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_nav_switch_before_first_token_completes(
|
||||
page: Page, app_url: str, seeded_kb: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
expect(page.locator("#nav-sources")).to_be_visible()
|
||||
|
||||
page.fill("#message-input", HESITATE_QUESTION)
|
||||
page.click("#send-btn")
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(HESITATE_QUESTION)
|
||||
|
||||
# The pre-first-token window, pinned the same way as scenario 4:
|
||||
# the scratchpad's tail is rendered (the thinking stream has just
|
||||
# ended) and the 4s pre-content pause (SLOW_PRETOKEN_TRIGGER) is
|
||||
# running — NO content frame has landed yet. (The .msg.brain bubble
|
||||
# element exists from turn start with its thinking block — the
|
||||
# pre-token state is "no content text", not "no bubble element".)
|
||||
thinking = page.locator(".msg.brain").last.locator("details.thinking")
|
||||
thinking.wait_for(state="attached", timeout=10_000)
|
||||
expect(thinking.locator(".thinking-text")).to_contain_text(
|
||||
THINKING_TAIL, timeout=30_000
|
||||
)
|
||||
# Still pre-token: the button is the enabled Stop control (phase 48
|
||||
# — the old disabled "Thinking…" busy state is gone).
|
||||
expect(page.locator("#send-btn")).to_be_enabled()
|
||||
expect(page.locator("#send-label")).to_have_text("Stop")
|
||||
|
||||
# Switch to the RAG view NOW — mid-pause, still before the first
|
||||
# content token.
|
||||
page.evaluate("() => { window.__shell_boot = 'phase76'; }")
|
||||
page.click("#nav-sources")
|
||||
expect(page).to_have_url(app_url + "/sources.html")
|
||||
assert page.evaluate("() => window.__shell_boot") == "phase76"
|
||||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
|
||||
# Let the 4s pre-token pause elapse WHILE the RAG view is up — the
|
||||
# first content frames land while the chat view is still hidden —
|
||||
# then return to the chat: the surviving reader completes the
|
||||
# answer.
|
||||
page.wait_for_timeout(4500)
|
||||
page.click('a.nav-link[href="/"]')
|
||||
expect(page).to_have_url(app_url + "/")
|
||||
|
||||
# The answer COMPLETED (full text — the deterministic mock answer),
|
||||
# no error banner.
|
||||
bubble = page.locator(".msg.brain .bubble").last
|
||||
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
|
||||
_no_error_banner(page)
|
||||
|
||||
# Storage: EXACTLY ONE brain turn — the completed answer with done
|
||||
# metadata (no partial, no orphan, no duplicate).
|
||||
page.wait_for_timeout(500)
|
||||
msgs = _stored_parsed(page)["messages"]
|
||||
assert [m["who"] for m in msgs] == ["user", "brain"]
|
||||
assert MOCK_ANSWER_MARKER in msgs[1]["text"]
|
||||
assert msgs[1]["deflected"] is False
|
||||
assert any(s["path"] == "homelab/kubernetes.md" for s in msgs[1]["sources"])
|
||||
|
||||
# The turn settled — one finalized row (a cancelled turn would
|
||||
# leave no row at all).
|
||||
assert _query_log_count() == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. REAL departure BEFORE the first answer token (pure thinking): nothing
|
||||
# brain-side is persisted — the pre-token no-orphan convention,
|
||||
# unchanged (a direct page.goto to /sources.html REMAINS a real
|
||||
# departure in the SPA — the shell is served, the fetch is aborted
|
||||
# by the unload)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -270,8 +447,9 @@ def test_no_orphan_brain_message_when_navigated_before_first_token(
|
||||
expect(page.locator("#send-btn")).to_be_enabled()
|
||||
expect(page.locator("#send-label")).to_have_text("Stop")
|
||||
|
||||
# Leave during the pause (no answer token has streamed — acc is empty,
|
||||
# so the pagehide save point must persist nothing brain-side).
|
||||
# Leave during the pause via a REAL cross-document departure (no
|
||||
# answer token has streamed — acc is empty, so the pagehide save
|
||||
# point must persist nothing brain-side).
|
||||
page.goto(app_url + "/sources.html")
|
||||
|
||||
# Return to the chat.
|
||||
@@ -294,8 +472,9 @@ def test_no_orphan_brain_message_when_navigated_before_first_token(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Completed turn: the done save point is byte-identical to before —
|
||||
# the new pagehide save point must not duplicate or alter it
|
||||
# 5. Completed turn: the done save point is byte-identical to before —
|
||||
# the new pagehide save point must not duplicate or alter it (the
|
||||
# direct gotos are real departures — unaffected by the fold)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -337,7 +516,7 @@ def test_completed_turn_unaffected(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. The DELIBERATE clear is untouched: New chat (chat-page only since
|
||||
# 6. The DELIBERATE clear is untouched: New chat (chat-page only since
|
||||
# the owner rework 2026-08-28) still clears the conversation
|
||||
# (phase 14 contract)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -94,7 +94,9 @@ def test_sources_page_sub_describes_the_current_source_model(
|
||||
model. The page is anonymously viewable — the catalog gate hides
|
||||
the table, not the page-head."""
|
||||
page.goto(f"{app_url}/sources.html")
|
||||
sub = page.locator(".page-sub").first
|
||||
# Phase 76 (task 02): the shell carries the hidden tuning view's
|
||||
# .page-sub earlier in the DOM — scope to the RAG view.
|
||||
sub = page.locator("#view-rag .page-sub")
|
||||
expect(sub).to_be_visible()
|
||||
text = sub.inner_text().lower()
|
||||
assert "homelab" not in text, f"retired copy in the page-sub: {text!r}"
|
||||
|
||||
@@ -46,10 +46,12 @@ QUESTION = "How is my Kubernetes cluster set up?"
|
||||
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
||||
NOTE = "STEEER-MARKER be concise"
|
||||
XSS_NOTE = "<script>window.__xss = true; alert('xss')</script>"
|
||||
#: Both index.html and tuning.html ship exactly three classic/module
|
||||
#: script tags: the phase-39 brand.js classic layer + markdown.js + the
|
||||
#: page module (app.js / tuning.js).
|
||||
BASE_SCRIPT_COUNT = 3
|
||||
#: The shell (index.html — served for BOTH / and /tuning.html since
|
||||
#: phase 76 task 01, when the Tuning view folded into it) ships exactly
|
||||
#: FOUR classic/module script tags: the phase-39 brand.js classic layer
|
||||
#: + markdown.js + the chat module (app.js) + the shell router
|
||||
#: (router.js, which lazy-imports the tuning.js view module).
|
||||
BASE_SCRIPT_COUNT = 4
|
||||
|
||||
|
||||
async def _import_fixtures(mock_port: int) -> ImportSummary:
|
||||
|
||||
@@ -150,12 +150,23 @@ def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None:
|
||||
("path", "marker"),
|
||||
[
|
||||
("/", "Brain of Reese"),
|
||||
("/sources.html", "Knowledge base"),
|
||||
# Phase 76 (task 02): /sources.html + /git-sources.html are SHELL
|
||||
# routes — the body is the shell (index.html) whose static
|
||||
# <title> is "Brain of Reese" (the per-view title is set
|
||||
# CLIENT-side by the router, invisible to httpx). The marker
|
||||
# asserts the shell body (the view section is inside it) instead
|
||||
# of the old page's title.
|
||||
("/sources.html", 'id="view-rag"'), # phase 76: shell route (was "Knowledge base")
|
||||
("/document.html", "Brain of Reese"), # phase 10: viewer page
|
||||
("/login.html", "Sign in"), # phase 16: admin sign-in page
|
||||
("/tuning.html", "Global Tuning"), # phase 27: global tuning page
|
||||
("/git-sources.html", "Git sources"), # phase 35: admin git sources page
|
||||
("/history.html", "Saved chats"), # phase 50: admin saved-chats page
|
||||
# Phase 76 (task 01): /tuning.html is a SHELL route — same
|
||||
# shell-body marker pattern as the two task-02 paths above.
|
||||
("/tuning.html", 'id="view-tuning"'), # phase 76: shell route (was "Global Tuning")
|
||||
("/git-sources.html", 'id="view-git-sources"'), # phase 76: shell route (was "Git sources")
|
||||
# Phase 76 (task 03): /history.html is a SHELL route too — the
|
||||
# shell-body marker (the History view section is inside the
|
||||
# shell; the old standalone page's title is client-side now).
|
||||
("/history.html", 'id="view-history"'), # phase 76: shell route (was "Saved chats")
|
||||
("/shared.html", "Shared conversation"), # phase 51: anonymous shared page
|
||||
],
|
||||
)
|
||||
@@ -194,7 +205,7 @@ def test_index_page_no_cache_with_versioned_asset_refs(client) -> None:
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/sources.html", "/document.html", "/login.html", "/tuning.html",
|
||||
"/git-sources.html", "/history.html", # phase 50: + the History page
|
||||
"/git-sources.html", "/history.html", # phase 50: + History (shell route, task 03)
|
||||
"/shared.html"], # phase 51: + the anonymous shared page
|
||||
)
|
||||
def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
|
||||
@@ -208,6 +219,51 @@ def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
|
||||
assert f"?v={asset_version()}" in r.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "view_id", "old_title"),
|
||||
[
|
||||
("/tuning.html", 'id="view-tuning"', "Global Tuning · Brain of Reese"),
|
||||
("/sources.html", 'id="view-rag"', "Sources · Brain of Reese"),
|
||||
("/git-sources.html", 'id="view-git-sources"', "Git sources · Brain of Reese"),
|
||||
("/history.html", 'id="view-history"', "Saved chats · Brain of Reese"), # phase 76 task 03
|
||||
],
|
||||
)
|
||||
def test_shell_routes_serve_the_shell_no_cache_versioned(
|
||||
client, path: str, view_id: str, old_title: str
|
||||
) -> None:
|
||||
"""Phase 76 (task 01: /tuning.html; task 02: /sources.html +
|
||||
/git-sources.html; task 03: /history.html — all four non-chat
|
||||
navbar views): every shell route serves the shell
|
||||
(``frontend/index.html``), so the phase-33 page contract applies to
|
||||
it exactly as to a static page: 200, text/html, ``Cache-Control:
|
||||
no-cache``, ``?v=<token>`` asset refs, no validators (the
|
||||
middleware wraps the whole app and lists the path in ``HTML_PAGES``
|
||||
— unchanged). The body IS the shell: the view section is inside it,
|
||||
the old standalone page's title is gone. The static catch-all stays
|
||||
intact: an unknown path still 404s. (The conditional-GET
|
||||
revalidation contract for these paths is pinned by
|
||||
``tests/integration/test_caching_revalidation.py``.)"""
|
||||
from app.core.caching import asset_version
|
||||
|
||||
r = client.get(path)
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].split(";", 1)[0] == "text/html"
|
||||
assert r.headers["cache-control"] == "no-cache"
|
||||
assert "etag" not in r.headers
|
||||
assert "last-modified" not in r.headers
|
||||
assert f"?v={asset_version()}" in r.text
|
||||
# The body is the SHELL: the chat view AND the route's view section
|
||||
# are inside it…
|
||||
assert 'id="view-chat"' in r.text
|
||||
assert view_id in r.text
|
||||
# …with the shell's static title (the per-view title is the
|
||||
# router's client-side job), and the old page's own <title> is gone.
|
||||
assert "<title>Brain of Reese</title>" in r.text
|
||||
assert f"{old_title}</title>" not in r.text
|
||||
# The catch-all is intact: an unknown path still 404s.
|
||||
assert client.get("/nonexistent.html").status_code == 404
|
||||
|
||||
|
||||
def test_index_html_variant_no_cache_versioned(client) -> None:
|
||||
"""/index.html is the same page as / — same caching treatment."""
|
||||
from app.core.caching import asset_version
|
||||
|
||||
@@ -68,9 +68,30 @@ def file_validators(page_file: Path) -> tuple[str, str]:
|
||||
return headers["etag"], headers["last-modified"]
|
||||
|
||||
|
||||
#: Phase 76 (task 01): the shell-served view paths — the URL is a
|
||||
#: navbar view, the file on disk is the SHELL (the shell route in
|
||||
#: app/main.py serves frontend/index.html for it). The etag
|
||||
#: computation below must use the file that actually backs the
|
||||
#: response, or the conditional-GET probe would carry a validator no
|
||||
#: browser ever saw. Tasks 02/03 extended this as the remaining views
|
||||
#: folded in (task 03 — History — completes the set: all four
|
||||
#: non-chat navbar views). The page CONTRACT itself is unchanged: the
|
||||
#: phase-33 middleware wraps the whole app and lists the path in
|
||||
#: HTML_PAGES, so the shell-route response is normalized exactly like
|
||||
#: a static page (200, no-cache, ?v=, no validators — asserted by
|
||||
#: _assert_page_contract below).
|
||||
SHELL_BACKED_PAGES = {
|
||||
"/tuning.html": "index.html", # phase 76 task 01
|
||||
"/sources.html": "index.html", # phase 76 task 02
|
||||
"/git-sources.html": "index.html", # phase 76 task 02
|
||||
"/history.html": "index.html", # phase 76 task 03
|
||||
}
|
||||
|
||||
|
||||
def _page_file(path: str) -> Path:
|
||||
"""The static file backing a page path (``/`` → ``index.html``)."""
|
||||
name = path.lstrip("/") or "index.html"
|
||||
"""The static file backing a page path (``/`` → ``index.html``;
|
||||
the shell-served view paths → the shell, ``SHELL_BACKED_PAGES``)."""
|
||||
name = SHELL_BACKED_PAGES.get(path, path.lstrip("/") or "index.html")
|
||||
file = FRONTEND / name
|
||||
assert file.is_file(), f"missing page file for {path}: {file}"
|
||||
return file
|
||||
|
||||
@@ -242,6 +242,18 @@ def test_header_module_is_imported_not_directly_loaded() -> None:
|
||||
js_path = ASSETS / name
|
||||
assert js_path.is_file(), f"page script missing: {js_path}"
|
||||
body = js_path.read_text(encoding="utf-8")
|
||||
# Phase 76: the shell's router (router.js) is NOT a view module
|
||||
# — the shell's shared header boots via the chat module (app.js,
|
||||
# which imports header.js), and the view modules the router
|
||||
# lazy-imports (tuning.js, …) import header.js themselves, so
|
||||
# the single-evaluation contract holds without the router
|
||||
# importing it (it owns the view switch, not the header).
|
||||
if name == "router.js":
|
||||
assert 'from "./header.js"' not in body, (
|
||||
"router.js must not import the header module — the chat "
|
||||
"module boots the shell's ONE header"
|
||||
)
|
||||
continue
|
||||
assert re.search(r"""from\s+["']\./header\.js["']""", body), (
|
||||
f"{name}: must import the shared header module relatively "
|
||||
f'("from \\"./header.js\\"")'
|
||||
|
||||
@@ -26,11 +26,10 @@ 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"
|
||||
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
|
||||
DOCUMENT_HTML = FRONTEND / "document.html"
|
||||
LOGIN_HTML = FRONTEND / "login.html"
|
||||
TUNING_HTML = FRONTEND / "tuning.html"
|
||||
SHARED_HTML = FRONTEND / "shared.html"
|
||||
DOC_EDIT_HTML = FRONTEND / "doc-edit.html"
|
||||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||
|
||||
|
||||
@@ -182,7 +181,16 @@ def test_new_chat_button_lives_only_on_the_chat_page() -> None:
|
||||
relocated the .chat-actions row from the top of the column to the
|
||||
bottom: the button now sits BELOW the #messages section, directly
|
||||
above the composer (the single module binding + the no-op guard are
|
||||
pinned in test_shared_header.py)."""
|
||||
pinned in test_shared_header.py).
|
||||
|
||||
Phase 76 (task 01) — the final shell form, converted in ONE step so
|
||||
the later fold tasks (02/03) leave this pin alone: the file-level
|
||||
"only on the chat page" semantics die with the shell — the button
|
||||
must sit inside the shell's CHAT VIEW section (#view-chat; the
|
||||
hidden views' elements remain in the DOM, so a per-file negative
|
||||
check is meaningless inside the shell), and it must be absent from
|
||||
the SURVIVING separate documents (the flow pages that are not
|
||||
navbar views)."""
|
||||
html = _index()
|
||||
btn = re.search(r'<button[^>]*id="new-chat-btn"[^>]*>', html)
|
||||
assert btn, "index.html must contain #new-chat-btn"
|
||||
@@ -191,6 +199,14 @@ def test_new_chat_button_lives_only_on_the_chat_page() -> None:
|
||||
assert 'aria-label="New chat"' in tag
|
||||
main_idx = html.find('main id="main"')
|
||||
assert main_idx != -1 and btn.start() > main_idx, "the button belongs inside <main>"
|
||||
# The button sits inside the shell's chat view section (phase 76):
|
||||
# after the #view-chat open, before the #view-tuning section starts
|
||||
# (the chat section closes before it — hidden views are separate).
|
||||
view_chat_idx = html.find('id="view-chat"')
|
||||
view_tuning_idx = html.find('id="view-tuning"')
|
||||
assert -1 < view_chat_idx < btn.start() < view_tuning_idx, (
|
||||
"the button belongs inside the #view-chat section"
|
||||
)
|
||||
shell_idx = html.find('class="container chat-shell"')
|
||||
assert shell_idx != -1 and shell_idx < btn.start(), "the button belongs in .chat-shell"
|
||||
messages_idx = html.find('id="messages"')
|
||||
@@ -199,10 +215,12 @@ def test_new_chat_button_lives_only_on_the_chat_page() -> None:
|
||||
assert -1 < messages_idx < messages_end < btn.start() < composer_idx, (
|
||||
"the button sits below the #messages section, above the composer"
|
||||
)
|
||||
# No other page carries the button anymore (owner request 2026-08-28).
|
||||
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML):
|
||||
# No SURVIVING separate document carries the button (owner request
|
||||
# 2026-08-28, final shell form: the folded navbar views are GONE
|
||||
# from this check — they are views of the shell now).
|
||||
for other in (DOCUMENT_HTML, LOGIN_HTML, SHARED_HTML, DOC_EDIT_HTML):
|
||||
assert 'id="new-chat-btn"' not in other.read_text(encoding="utf-8"), (
|
||||
f"{other.name}: the New chat button is chat-page only"
|
||||
f"{other.name}: the New chat button is chat-view only"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -41,8 +41,7 @@ SOURCES_JS = FRONTEND / "assets" / "sources.js"
|
||||
DOCUMENT_JS = FRONTEND / "assets" / "document.js"
|
||||
MARKDOWN_JS = FRONTEND / "assets" / "markdown.js"
|
||||
MODAL_JS = FRONTEND / "assets" / "document-modal.js" # phase 26: the modal owner
|
||||
INDEX_HTML = FRONTEND / "index.html"
|
||||
SOURCES_HTML = FRONTEND / "sources.html"
|
||||
INDEX_HTML = FRONTEND / "index.html" # the ONE-document shell (phase 76: sources.html folded in)
|
||||
|
||||
HAVE_NODE = shutil.which("node") is not None
|
||||
|
||||
@@ -294,10 +293,12 @@ def test_markdown_renderer_stays_xss_safe_and_unchanged() -> None:
|
||||
def test_render_document_exported_and_modal_imports_it() -> None:
|
||||
"""Phase 26: document.js EXPORTS renderDocument(doc, { … }) — the
|
||||
exact renderer the standalone page and the modal share (no drift).
|
||||
The modal module imports it relatively, and BOTH page scripts import
|
||||
the modal module relatively — no direct <script> tag (the header.js
|
||||
single-evaluation design: esbuild inlines it into the page bundle,
|
||||
one module instance per page)."""
|
||||
The modal module imports it relatively, and BOTH view scripts
|
||||
(app.js — the chat view at shell boot — and sources.js — the RAG
|
||||
view module) import the modal module relatively — no direct
|
||||
<script> tag (the header.js single-evaluation design: esbuild
|
||||
inlines it into the bundle, one module instance per document).
|
||||
"""
|
||||
doc_js = _read(DOCUMENT_JS)
|
||||
# Task 03 signature: the page passes its #doc-title / #doc-meta /
|
||||
# #doc-content elements under exactly these names.
|
||||
@@ -315,12 +316,11 @@ def test_render_document_exported_and_modal_imports_it() -> None:
|
||||
assert 'from "./document-modal.js"' in js, (
|
||||
f"{name}: must import the modal module relatively"
|
||||
)
|
||||
for page in (INDEX_HTML, SOURCES_HTML):
|
||||
text = _read(page)
|
||||
assert not re.search(r"<script[^>]*document-modal\.js", text), (
|
||||
f"{page.name}: no direct document-modal.js <script> tag "
|
||||
"(single-evaluation design — the page script imports it)"
|
||||
)
|
||||
text = _read(INDEX_HTML)
|
||||
assert not re.search(r"<script[^>]*document-modal\.js", text), (
|
||||
"index.html: no direct document-modal.js <script> tag "
|
||||
"(single-evaluation design — the view scripts import it)"
|
||||
)
|
||||
|
||||
|
||||
def test_document_js_page_init_is_import_safe() -> None:
|
||||
@@ -338,37 +338,43 @@ def test_document_js_page_init_is_import_safe() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_both_pages_carry_the_modal_skeleton() -> None:
|
||||
"""Phase 26: chat AND Sources ship the same modal skeleton (the
|
||||
task-01 markup) — the a11y frame included: role=dialog +
|
||||
aria-modal, a labelled close control, a focusable content target
|
||||
(tabindex=-1), and a role=status announcer. Hidden by default —
|
||||
inert until JS opens it."""
|
||||
for page in (INDEX_HTML, SOURCES_HTML):
|
||||
text = _read(page)
|
||||
assert '<div class="doc-modal" id="doc-modal" hidden>' in text, page.name
|
||||
assert 'id="doc-modal-backdrop"' in text, page.name
|
||||
assert 'id="doc-modal-panel"' in text, page.name
|
||||
assert 'role="dialog"' in text and 'aria-modal="true"' in text, page.name
|
||||
assert 'id="doc-modal-title"' in text, page.name
|
||||
assert 'id="doc-modal-meta"' in text, page.name
|
||||
assert 'id="doc-modal-desc"' in text, page.name
|
||||
assert 'id="doc-modal-open"' in text, page.name
|
||||
assert re.search(r'id="doc-modal-content"[^>]*tabindex="-1"', text), page.name
|
||||
assert re.search(
|
||||
r'id="doc-modal-close"[^>]*aria-label="Close document"', text
|
||||
), page.name
|
||||
def test_shell_carries_the_single_modal_skeleton() -> None:
|
||||
"""Phase 26 + phase 76 (task 02) dedup pin: the shell ships the
|
||||
modal skeleton EXACTLY ONCE (the chat's, body level) — the RAG
|
||||
view's second copy was dropped in the fold; BOTH view scripts
|
||||
(app.js chat chips, sources.js RAG row links) open documents
|
||||
through openDocumentModal(...) against that single instance
|
||||
(document-modal.js resolves it by document-level querySelector at
|
||||
import). The a11y frame is included: role=dialog + aria-modal, a
|
||||
labelled close control, a focusable content target (tabindex=-1),
|
||||
and a role=status announcer. Hidden by default — inert until JS
|
||||
opens it."""
|
||||
text = _read(INDEX_HTML)
|
||||
assert text.count('id="doc-modal"') == 1, (
|
||||
"the shell must carry exactly ONE modal skeleton (the fold dedup)"
|
||||
)
|
||||
assert '<div class="doc-modal" id="doc-modal" hidden>' in text
|
||||
assert 'id="doc-modal-backdrop"' in text
|
||||
assert 'id="doc-modal-panel"' in text
|
||||
assert 'role="dialog"' in text and 'aria-modal="true"' in text
|
||||
assert 'id="doc-modal-title"' in text
|
||||
assert 'id="doc-modal-meta"' in text
|
||||
assert 'id="doc-modal-desc"' in text
|
||||
assert 'id="doc-modal-open"' in text
|
||||
assert re.search(r'id="doc-modal-content"[^>]*tabindex="-1"', text)
|
||||
assert re.search(r'id="doc-modal-close"[^>]*aria-label="Close document"', text)
|
||||
|
||||
|
||||
def test_sources_page_loads_markdown_before_its_module() -> None:
|
||||
"""Phase 26: the modal renders md documents on the Sources page too —
|
||||
so sources.html loads the classic markdown.js (global renderMarkdown)
|
||||
via a relative <script src> BEFORE its module script, exactly like
|
||||
index.html does."""
|
||||
html = _read(SOURCES_HTML)
|
||||
def test_shell_loads_markdown_before_its_modules() -> None:
|
||||
"""Phase 26 + phase 76 (task 02): the modal renders md documents
|
||||
in the RAG view too — so the shell loads the classic markdown.js
|
||||
(global renderMarkdown) via a relative <script src> BEFORE its
|
||||
module scripts (app.js — the chat view — and the lazy view
|
||||
modules' modal imports), exactly as the old sources.html did."""
|
||||
html = _read(INDEX_HTML)
|
||||
assert re.search(r'<script src="assets/markdown\.js"></script>', html)
|
||||
assert html.index('src="assets/markdown.js"') < html.index('type="module"'), (
|
||||
"sources.html: markdown.js must load before the module script"
|
||||
"index.html: markdown.js must load before the module scripts"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -21,14 +21,16 @@ CONTAINERFILE = ROOT / "Containerfile"
|
||||
|
||||
#: Every page in the app ships the brand layer (phase 39: "every visible
|
||||
#: brand string on every page resolves from one place").
|
||||
#: Phase 76 (task 01): the Tuning page is folded into the shell (its
|
||||
#: file is deleted) — the shell (index.html) stands in for it here.
|
||||
#: Phase 76 (task 02): the RAG + Sources pages are folded too (both
|
||||
#: files deleted — the shell stands in for them). Phase 76 (task 03):
|
||||
#: history.html is folded too (deleted — the shell stands in for the
|
||||
#: History view; all four folded view files are gone).
|
||||
HTML_PAGES = (
|
||||
"index.html",
|
||||
"sources.html",
|
||||
"tuning.html",
|
||||
"document.html",
|
||||
"login.html",
|
||||
"git-sources.html",
|
||||
"history.html", # phase 50: the admin saved-chats page
|
||||
"shared.html", # phase 51: the anonymous shared-conversation page
|
||||
"doc-edit.html", # phase 59: the admin doc edit screen (flow page)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
"""Unit: the phase-76 shell router contract (task 01).
|
||||
|
||||
The browser behavior is E2E-gated (the phase-76 story suite —
|
||||
``test_nav_switch_keeps_stream.py`` — plus the tuning-adjacent suites);
|
||||
here we pin the router.js / index.html source-level invariants the
|
||||
"views of one document" architecture depends on, so a silent
|
||||
regression is caught without a browser (house pattern:
|
||||
``tests/unit/test_frontend_hidden_tab.py`` reads JS source and
|
||||
asserts on its mechanisms).
|
||||
|
||||
Pinned design (phase 76 overview + task 01):
|
||||
* the VIEW map — pathname → view name — is the ONLY set of paths the
|
||||
click interceptor may swallow (every other link keeps its real,
|
||||
document-level navigation);
|
||||
* a switch is ``history.pushState`` + show/hide — NEVER a document
|
||||
load (no ``location.assign`` / ``location.href`` / ``location.replace``
|
||||
/ ``location.reload`` anywhere in the module);
|
||||
* mount-once per view: the lazy module is imported on FIRST show only,
|
||||
the guard runs before the import and is set only after ``mount``
|
||||
resolves;
|
||||
* hidden views carry BOTH ``hidden`` AND ``inert`` (WCAG — a hidden
|
||||
view must not receive focus or keyboard traversal);
|
||||
* the router is the SINGLE WRITER of the ``.nav-link`` active state
|
||||
(``is-active`` + ``aria-current="page"``), of ``document.title``, and
|
||||
of the per-view ``<meta name="description">``;
|
||||
* focus lands on the target view ONLY on user-initiated switches
|
||||
(navbar click / popstate) — never on initial boot (no focus steal);
|
||||
* the shell markup: ONE main holding the view sections, only the Chat
|
||||
link statically active, boot order brand.js → app.js → router.js,
|
||||
and the chat view needs no module import (app.js ran at shell boot).
|
||||
|
||||
Phase 76 task 04 (the header is shell-owned): the shell's header is the
|
||||
canonical one — the old per-page header copies (with their static
|
||||
active stamps) are gone with the four folded view documents, so
|
||||
``is-active`` occurs EXACTLY ONCE in the whole of index.html (on the
|
||||
Chat link), and header.js carries NO ``is-active`` write: the whoami
|
||||
auth gate + the mobile hamburger are its only nav responsibilities,
|
||||
and the router is the SINGLE runtime writer of the active state.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
ASSETS = FRONTEND / "assets"
|
||||
ROUTER_JS = ASSETS / "router.js"
|
||||
INDEX_HTML = FRONTEND / "index.html"
|
||||
|
||||
|
||||
def _js() -> str:
|
||||
assert ROUTER_JS.is_file(), f"missing {ROUTER_JS}"
|
||||
return ROUTER_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _html() -> str:
|
||||
assert INDEX_HTML.is_file(), f"missing {INDEX_HTML}"
|
||||
return INDEX_HTML.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---------- the VIEW map: the only interceptable paths ----------
|
||||
|
||||
|
||||
def test_view_map_covers_the_shell_paths() -> None:
|
||||
"""The VIEW map is pathname → view name: the shell's own two URLs
|
||||
("/" and "/index.html") are the chat view, plus one entry per
|
||||
folded view (tasks 01–03: tuning, rag, git-sources, history —
|
||||
all four non-chat navbar views are in)."""
|
||||
js = _js()
|
||||
view_start = js.find("const VIEW = {")
|
||||
assert view_start != -1, "the VIEW map must exist"
|
||||
view_body = js[view_start : js.find("\n}", view_start)]
|
||||
assert '"/": "chat"' in view_body, "the app root is the chat view"
|
||||
assert '"/index.html": "chat"' in view_body, (
|
||||
"the shell's alternate URL is the chat view too (HTML_PAGES)"
|
||||
)
|
||||
assert '"/tuning.html": "tuning"' in view_body, (
|
||||
"task 01 folds the Tuning view into the shell"
|
||||
)
|
||||
assert '"/history.html": "history"' in view_body, (
|
||||
"task 03 folds the History view into the shell"
|
||||
)
|
||||
# The view names are the #view-<name> section slugs in index.html.
|
||||
for name in ("chat", "tuning", "history"):
|
||||
assert f'id="view-{name}"' in _html(), f"missing the #view-{name} section"
|
||||
|
||||
|
||||
def test_interceptor_matches_only_view_map_paths() -> None:
|
||||
"""The delegated nav click handler intercepts ONLY a link whose href
|
||||
is in VIEW — the `in VIEW` guard runs BEFORE preventDefault, and a
|
||||
non-view link (login, the viewer, the not-yet-folded views) falls
|
||||
through to its real, document-level navigation."""
|
||||
js = _js()
|
||||
fn = js.find('nav.addEventListener("click"')
|
||||
assert fn != -1, "the delegated click handler on the nav must exist"
|
||||
body = js[fn : js.find("\n });", fn)]
|
||||
guard = body.find("in VIEW")
|
||||
prevent = body.find("e.preventDefault()")
|
||||
assert 0 <= guard < prevent, (
|
||||
"the VIEW membership guard must run BEFORE the preventDefault"
|
||||
)
|
||||
assert 'closest("a.nav-link")' in body, (
|
||||
"only the .nav-link family is considered (auth links are untouched)"
|
||||
)
|
||||
|
||||
|
||||
def test_switches_use_pushstate_not_document_navigation() -> None:
|
||||
"""A view switch is history.pushState (same-document) — the module
|
||||
must contain NO document-level navigation primitive: no
|
||||
location.assign, no location.href write, no location.replace, no
|
||||
location.reload (the phase-48 abort path lives in app.js, not here)."""
|
||||
js = _js()
|
||||
assert "history.pushState" in js, "the switch must pushState"
|
||||
for banned in ("location.assign", "location.href", "location.replace", "location.reload"):
|
||||
assert banned not in js, f"{banned} is a document load — the switch is same-document"
|
||||
# The pushState is the click handler's (the popstate path only READS
|
||||
# the location — it never writes it).
|
||||
fn = js.find('nav.addEventListener("click"')
|
||||
body = js[fn : js.find("\n });", fn)]
|
||||
assert "history.pushState" in body
|
||||
|
||||
|
||||
def test_popstate_switches_views() -> None:
|
||||
"""Back / forward re-runs the switch for the pathname in history —
|
||||
user-initiated (focus + top landing included)."""
|
||||
js = _js()
|
||||
fn = js.find('window.addEventListener("popstate"')
|
||||
assert fn != -1, "the popstate listener must exist"
|
||||
body = js[fn : js.find("});", fn)]
|
||||
assert "location.pathname" in body, "popstate resolves the view from the pathname"
|
||||
assert "userInitiated: true" in body, "back/forward is a user-initiated switch"
|
||||
|
||||
|
||||
# ---------- mount-once, hide-forever ----------
|
||||
|
||||
|
||||
def test_mount_once_guard_runs_before_import_and_after_mount() -> None:
|
||||
"""A non-chat view's module is imported on FIRST show only: the
|
||||
`mounted[name]` guard is checked BEFORE the lazy import, the import
|
||||
+ `await module.mount(root)` run once, and the guard is set only
|
||||
AFTER mount resolves (a failed mount may retry on the next show)."""
|
||||
js = _js()
|
||||
fn = js.find("async function switchTo")
|
||||
assert fn != -1, "switchTo must exist"
|
||||
body = js[fn : js.find("\n}", fn)]
|
||||
guard = body.find("if (!mounted[name])")
|
||||
load = body.find("await load()")
|
||||
mount = body.find("await mod.mount(root)")
|
||||
set_guard = body.find("mounted[name] = true")
|
||||
assert 0 <= guard < load < mount < set_guard, (
|
||||
"guard → lazy import → mount → set guard (in that order)"
|
||||
)
|
||||
# The guard map starts with chat mounted (app.js ran at shell boot).
|
||||
assert re.search(r"const mounted = \{ chat: true \}", js), (
|
||||
"chat starts mounted — it needs no module import"
|
||||
)
|
||||
|
||||
|
||||
def test_only_non_chat_views_have_lazy_modules() -> None:
|
||||
"""VIEW_MODULES lazy-imports the non-chat views only — the view
|
||||
modules are STATIC specifiers (so the Containerfile's esbuild
|
||||
stage can inline them into the router bundle) and there is NO
|
||||
import of app.js (the chat view needs no module — it ran at shell
|
||||
boot)."""
|
||||
js = _js()
|
||||
mods_start = js.find("const VIEW_MODULES = {")
|
||||
assert mods_start != -1, "the lazy module map must exist"
|
||||
mods_body = js[mods_start : js.find("\n}", mods_start)]
|
||||
assert 'tuning: () => import("./tuning.js")' in mods_body, (
|
||||
"the Tuning view module is lazy-imported on first show"
|
||||
)
|
||||
assert 'history: () => import("./history.js")' in mods_body, (
|
||||
"the History view module is lazy-imported on first show"
|
||||
)
|
||||
assert '"chat"' not in mods_body, "the chat view has no lazy module"
|
||||
assert 'import("./app.js")' not in js, "app.js must never be lazy-imported"
|
||||
|
||||
|
||||
# ---------- show / hide: hidden AND inert ----------
|
||||
|
||||
|
||||
def test_hidden_views_get_both_hidden_and_inert() -> None:
|
||||
"""Show = drop hidden AND inert; hide = add BOTH — the same
|
||||
comparison drives both attributes in one loop, so a view can never
|
||||
be visible-but-inert or inert-but-visible (WCAG: a hidden view must
|
||||
not receive focus or keyboard traversal)."""
|
||||
js = _js()
|
||||
fn = js.find("async function switchTo")
|
||||
body = js[fn : js.find("\n}", fn)]
|
||||
loop = body.find("Object.entries(viewEls)")
|
||||
assert loop != -1, "the show/hide loop must walk every view"
|
||||
loop_body = body[loop : body.find("\n }", loop)]
|
||||
hidden_i = loop_body.find("el.hidden =")
|
||||
inert_i = loop_body.find("el.inert =")
|
||||
assert 0 <= hidden_i < inert_i, "both attributes are set in the same loop"
|
||||
assert loop_body.count("viewName !== name") == 2, (
|
||||
"one comparison drives hidden AND inert (they can never drift)"
|
||||
)
|
||||
|
||||
|
||||
# ---------- the router is the single writer ----------
|
||||
|
||||
|
||||
def test_router_writes_active_state_title_and_meta() -> None:
|
||||
"""The router stamps the .nav-link active state (is-active +
|
||||
aria-current, removed on the inactive links), document.title, and
|
||||
the per-view meta description — values carried over from the old
|
||||
pages' <head>s (the tuning title/description survive the fold).
|
||||
Phase 76 (task 02): the title/meta are composed through
|
||||
titleFor()/descFor() — the per-view value with the brand literal
|
||||
replaced by window.BOR_BRAND (phase 39): the lazy view import
|
||||
defers switchTo past brand.js's one-time DOM pass, so a literal
|
||||
stamp would overwrite a configured deployment's name; composing
|
||||
at write time is a no-op for the default deployment."""
|
||||
js = _js()
|
||||
fn = js.find("async function switchTo")
|
||||
body = js[fn : js.find("\n}", fn)]
|
||||
assert 'querySelectorAll("a.nav-link")' in body, "the writer walks the nav links"
|
||||
assert 'classList.toggle("is-active"' in body, "is-active is stamped + removed"
|
||||
assert 'setAttribute("aria-current", "page")' in body
|
||||
assert 'removeAttribute("aria-current")' in body
|
||||
assert "document.title = titleFor(name)" in body
|
||||
assert "metaDesc.content = descFor(name)" in body
|
||||
# The carried-over values (the old pages' <head>s — default form).
|
||||
assert 'chat: "Brain of Reese"' in js
|
||||
assert 'tuning: "Global Tuning · Brain of Reese"' in js
|
||||
assert "Manage the global tuning notes that steer every Brain of Reese answer." in js
|
||||
assert 'history: "Saved chats · Brain of Reese"' in js
|
||||
assert "Saved chats — every conversation is saved automatically, one click back." in js
|
||||
# The brand composition (phase 39's window.BOR_BRAND, read at
|
||||
# write time — never a hardcoded stamp).
|
||||
assert 'window.BOR_BRAND || "Brain of Reese"' in js
|
||||
assert 'TITLES[view].replaceAll("Brain of Reese", brandName())' in js
|
||||
assert 'DESCRIPTIONS[view].replaceAll("Brain of Reese", brandName())' in js
|
||||
|
||||
|
||||
def test_focus_only_on_user_initiated_switches() -> None:
|
||||
"""The target view is focused ONLY when the switch is
|
||||
user-initiated (navbar click / popstate) — the boot switch passes
|
||||
userInitiated:false, so a page load never steals focus. The top
|
||||
landing (scrollTo 0,0) rides the same flag."""
|
||||
js = _js()
|
||||
fn = js.find("async function switchTo")
|
||||
body = js[fn : js.find("\n}", fn)]
|
||||
flag = body.rfind("if (userInitiated)")
|
||||
focus = body.find("root.focus(")
|
||||
scroll = body.find("window.scrollTo(0, 0)")
|
||||
assert 0 <= flag < scroll < focus, "focus + top landing sit inside the flag"
|
||||
# Boot is NOT user-initiated (no focus steal on load).
|
||||
boot = js.find("switchTo(bootName")
|
||||
assert boot != -1 and "userInitiated: false" in js[boot : boot + 60]
|
||||
# The click handler IS user-initiated.
|
||||
click = js.find('nav.addEventListener("click"')
|
||||
click_body = js[click : js.find("\n });", click)]
|
||||
assert "userInitiated: true" in click_body
|
||||
|
||||
|
||||
# ---------- the shell markup + boot order ----------
|
||||
|
||||
|
||||
def test_shell_markup_has_one_main_two_views_and_chat_only_active() -> None:
|
||||
"""index.html: ONE main#main holds the view sections; the Tuning
|
||||
section ships hidden AND inert (the a11y pair); ONLY the Chat link
|
||||
carries the static active stamp (the router is the single writer —
|
||||
no view other than chat may ship statically active)."""
|
||||
html = _html()
|
||||
assert html.count('id="main"') == 1, "the shell has exactly one main"
|
||||
main = html.find('<main id="main" class="app-main" tabindex="-1">')
|
||||
assert main != -1
|
||||
view_chat = html.find('<section class="view" id="view-chat"')
|
||||
view_tuning = html.find('<section class="view" id="view-tuning"')
|
||||
main_end = html.find("</main>", main)
|
||||
assert main < view_chat < view_tuning < main_end, (
|
||||
"both view sections live inside the single main (chat first)"
|
||||
)
|
||||
tuning_tag = html[view_tuning : html.find(">", view_tuning)]
|
||||
assert "hidden" in tuning_tag and "inert" in tuning_tag, (
|
||||
"the folded view ships hidden AND inert"
|
||||
)
|
||||
assert 'tabindex="-1"' in html[view_chat : html.find(">", view_chat)]
|
||||
assert 'tabindex="-1"' in tuning_tag, "the target view is focusable"
|
||||
# Only the Chat link is statically active (exactly one stamp, on Chat).
|
||||
assert html.count('class="nav-link is-active"') == 1, (
|
||||
"only ONE nav link may ship statically active"
|
||||
)
|
||||
active = html.find('<a href="/" class="nav-link is-active" aria-current="page">Chat</a>')
|
||||
assert active != -1, "the static active stamp is the Chat link"
|
||||
# The tuning nav link ships hidden (admin-only) and UNstamped.
|
||||
tuning_match = re.search(r'<a[^>]*id="nav-tuning"[^>]*>', html)
|
||||
assert tuning_match, "the shell must carry the #nav-tuning nav link"
|
||||
tuning_link = tuning_match.group(0)
|
||||
assert "hidden" in tuning_link, "#nav-tuning ships hidden (admin-only)"
|
||||
assert "is-active" not in tuning_link, "no static active stamp on the Tuning link"
|
||||
|
||||
|
||||
# ---------- phase 76 task 04: the header is shell-owned ----------
|
||||
|
||||
|
||||
def test_shell_carries_exactly_one_static_is_active_on_chat() -> None:
|
||||
"""Phase 76 task 04: the shell's ONE header is the canonical header —
|
||||
the folded pages' header copies (which stamped is-active statically
|
||||
in their own markup) are gone, so ``is-active`` occurs EXACTLY ONCE
|
||||
in the whole of index.html, on the Chat link (the default view).
|
||||
A second stamp anywhere (a leaked page copy, a non-chat view
|
||||
shipping statically active) would break the router's single-writer
|
||||
contract the moment it disagrees with a switch."""
|
||||
html = _html()
|
||||
assert html.count("is-active") == 1, (
|
||||
"index.html must carry is-active exactly once (the Chat link's stamp)"
|
||||
)
|
||||
i = html.find("is-active")
|
||||
tag_start = html.rfind("<a ", 0, i)
|
||||
tag_end = html.find(">", tag_start)
|
||||
tag = html[tag_start:tag_end]
|
||||
assert tag.startswith('<a href="/"'), (
|
||||
"the single static active stamp must be the Chat link (href=\"/\")"
|
||||
)
|
||||
|
||||
|
||||
def test_header_js_never_writes_the_active_state() -> None:
|
||||
"""Phase 76 task 04: header.js is NOT a writer of the nav's active
|
||||
state — it never was (the old pages stamped is-active statically in
|
||||
their OWN markup; the shell's ONE header is the only header left)
|
||||
— and it must never become one: the whoami auth gate (the sign-in/
|
||||
sign-out pair + the admin-only nav links' hidden attributes) and the
|
||||
mobile hamburger are its only nav responsibilities, and neither
|
||||
touches the active state. The string is absent from the module
|
||||
entirely; the SINGLE runtime writer is the router (pinned in
|
||||
test_router_writes_active_state_title_and_meta)."""
|
||||
header_js = (ASSETS / "header.js").read_text(encoding="utf-8")
|
||||
assert "is-active" not in header_js, (
|
||||
"header.js must carry no is-active write — the router is the "
|
||||
"SINGLE WRITER of the active state (the shell markup ships the "
|
||||
"one static stamp on the Chat link)"
|
||||
)
|
||||
|
||||
|
||||
def test_boot_order_is_brand_app_router() -> None:
|
||||
"""The shell's script boot order: brand.js (classic) FIRST, then
|
||||
app.js (the chat view module — runs at shell boot exactly as
|
||||
today), then router.js (module) — the router may only see a
|
||||
fully-booted chat view."""
|
||||
html = _html()
|
||||
srcs = re.findall(r'<script[^>]*src="([^"]+)"', html)
|
||||
assert "assets/brand.js" in srcs, "brand.js (classic) still ships"
|
||||
assert srcs.index("assets/brand.js") < srcs.index("/assets/app.js") < srcs.index(
|
||||
"/assets/router.js"
|
||||
), "boot order: brand.js → app.js → router.js"
|
||||
router_tag_match = re.search(r'<script[^>]*src="/assets/router\.js"[^>]*>', html)
|
||||
assert router_tag_match, "the shell must load the router module"
|
||||
router_tag = router_tag_match.group(0)
|
||||
assert 'type="module"' in router_tag, "router.js is an ES module"
|
||||
# No CDN: every asset reference is local (AGENTS.md rule 6).
|
||||
assert 'src="http' not in html and 'href="http' not in html
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
The browser behavior is E2E-covered (tests/e2e/test_sync_upload_progress.py,
|
||||
task 06); here we pin the source-level wiring in sources.js, styles.css,
|
||||
and sources.html — the fmtSyncLabel contract (both kinds, file
|
||||
and the shell's RAG view markup (index.html — sources.html / git-
|
||||
sources.html folded in, phase 76 task 02) — the fmtSyncLabel contract
|
||||
(both kinds, file
|
||||
present/absent, counts only when total > 0), enterSyncRunningState
|
||||
writing the full untruncated path to the button title + #sync-result,
|
||||
the two-job tick decision tree (sync running > upload running > sync
|
||||
@@ -29,9 +31,11 @@ from pathlib import Path
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
SOURCES_JS = FRONTEND / "assets" / "sources.js"
|
||||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||
SOURCES_HTML = FRONTEND / "sources.html"
|
||||
# Phase 76 (task 02): both HTML pages are folded into the ONE-document
|
||||
# shell — the pinned comments now live in the RAG / Sources view
|
||||
# sections of index.html.
|
||||
SHELL_HTML = FRONTEND / "index.html"
|
||||
GIT_SOURCES_JS = FRONTEND / "assets" / "git-sources.js"
|
||||
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
|
||||
|
||||
|
||||
def _js() -> str:
|
||||
@@ -43,7 +47,7 @@ def _css() -> str:
|
||||
|
||||
|
||||
def _html() -> str:
|
||||
return SOURCES_HTML.read_text(encoding="utf-8")
|
||||
return SHELL_HTML.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _gjs() -> str:
|
||||
@@ -51,27 +55,38 @@ def _gjs() -> str:
|
||||
|
||||
|
||||
def _ghtml() -> str:
|
||||
return GIT_SOURCES_HTML.read_text(encoding="utf-8")
|
||||
return SHELL_HTML.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _gfn(js: str, name: str) -> str:
|
||||
"""The source of the first `function <name>` in git-sources.js
|
||||
(up to the first line-leading closing brace — the house pin
|
||||
pattern)."""
|
||||
(brace balanced — since phase 76 task 02 the functions live inside
|
||||
mount(root), so the closing brace is indented, not line-leading).
|
||||
"""
|
||||
fn = js.find(f"function {name}")
|
||||
assert fn != -1, f"{name} must be defined in git-sources.js"
|
||||
return js[fn : js.find("\n}", fn)]
|
||||
open_idx = js.find("{", fn)
|
||||
depth = 0
|
||||
for i in range(open_idx, len(js)):
|
||||
c = js[i]
|
||||
if c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return js[fn : i + 1]
|
||||
raise AssertionError(f"unbalanced braces in {name}")
|
||||
|
||||
|
||||
def _utick(js: str) -> str:
|
||||
"""The upload poll tick inside startUploadPolling — from `const
|
||||
tick = async () => {` to the next top-level function
|
||||
(initUploadStatus), so the whole decision tree is in the slice."""
|
||||
tick = async () => {` to the next function (initUploadStatus), so
|
||||
the whole decision tree is in the slice."""
|
||||
fn = js.find("function startUploadPolling")
|
||||
assert fn != -1, "startUploadPolling must be defined in git-sources.js"
|
||||
tick = js.find("const tick = async () => {", fn)
|
||||
assert tick != -1, "the tick must live inside startUploadPolling"
|
||||
end = js.find("\nasync function initUploadStatus", tick)
|
||||
end = js.find("async function initUploadStatus", tick)
|
||||
assert end != -1, "initUploadStatus must follow startUploadPolling"
|
||||
return js[tick:end]
|
||||
|
||||
@@ -88,23 +103,34 @@ def _usubmit(js: str) -> str:
|
||||
|
||||
|
||||
def _fn(js: str, name: str) -> str:
|
||||
"""The source of the first `function <name>` in js (up to the first
|
||||
line-leading closing brace — the house pin pattern from
|
||||
tests/unit/test_sync_button.py)."""
|
||||
"""The source of the first `function <name>` in js (brace balanced —
|
||||
since phase 76 task 02 the functions live inside mount(root), so
|
||||
the closing brace is indented, not line-leading; the house pin
|
||||
pattern from tests/unit/test_sync_button.py)."""
|
||||
fn = js.find(f"function {name}")
|
||||
assert fn != -1, f"{name} must be defined in sources.js"
|
||||
return js[fn : js.find("\n}", fn)]
|
||||
open_idx = js.find("{", fn)
|
||||
depth = 0
|
||||
for i in range(open_idx, len(js)):
|
||||
c = js[i]
|
||||
if c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return js[fn : i + 1]
|
||||
raise AssertionError(f"unbalanced braces in {name}")
|
||||
|
||||
|
||||
def _tick(js: str) -> str:
|
||||
"""The poll tick inside startSyncPolling — from `const tick = async
|
||||
() => {` to the next top-level function (startSync), so the whole
|
||||
two-job decision tree is in the slice."""
|
||||
() => {` to the next function (startSync), so the whole two-job
|
||||
decision tree is in the slice."""
|
||||
fn = js.find("function startSyncPolling")
|
||||
assert fn != -1, "startSyncPolling must be defined in sources.js"
|
||||
tick = js.find("const tick = async () => {", fn)
|
||||
assert tick != -1, "the tick must live inside startSyncPolling"
|
||||
end = js.find("\nasync function startSync", tick)
|
||||
end = js.find("async function startSync", tick)
|
||||
assert end != -1, "startSync must follow startSyncPolling"
|
||||
return js[tick:end]
|
||||
|
||||
@@ -309,8 +335,9 @@ def test_reattach_adopts_a_running_upload_only() -> None:
|
||||
assert '"upload", upload.current_file, upload.files_done, upload.files_total' in branch
|
||||
assert 'emitSyncStatus({ state: "running" })' in branch
|
||||
assert "startSyncPolling()" in branch
|
||||
# the idle settle is the fall-through (the last statement)
|
||||
assert body.rstrip().endswith("applySyncIdle(status);")
|
||||
# the idle settle is the fall-through (the last statement — the
|
||||
# brace-balanced body ends with the closing brace)
|
||||
assert body.rstrip().removesuffix("}").rstrip().endswith("applySyncIdle(status);")
|
||||
|
||||
|
||||
# ---------- the section header + the page comment ----------
|
||||
@@ -332,10 +359,11 @@ def test_section_header_documents_the_two_job_contract() -> None:
|
||||
|
||||
|
||||
def test_sources_html_comment_documents_the_live_announcer() -> None:
|
||||
"""The #sync-result comment in sources.html documents the phase-64
|
||||
dual role: the live file label (both kinds) while either job runs,
|
||||
untruncated for the aria-live announcer, and empty after an upload
|
||||
settles (A3 — the upload's counts live on the Sources page)."""
|
||||
"""The #sync-result comment in the shell's RAG view (formerly
|
||||
sources.html) documents the phase-64 dual role: the live file
|
||||
label (both kinds) while either job runs, untruncated for the
|
||||
aria-live announcer, and empty after an upload settles (A3 — the
|
||||
upload's counts live on the Sources page)."""
|
||||
html = _html()
|
||||
idx = html.find('id="sync-result"')
|
||||
assert idx != -1
|
||||
@@ -634,23 +662,27 @@ def test_boot_reattach_branches() -> None:
|
||||
assert "status.error" in fail
|
||||
assert "uploadError.hidden = false" in fail
|
||||
assert 'status.state === "idle"' not in body, "idle does nothing — no branch"
|
||||
# The boot IIFE: after the list loads, the re-attach runs (admin
|
||||
# branch only — the anonymous path returns before it).
|
||||
# The mount's tail (phase 76 task 02 — the boot IIFE is gone):
|
||||
# after the list loads, the re-attach runs (admin branch only — the
|
||||
# anonymous path returns before it), and it is the LAST statement
|
||||
# of mount(root).
|
||||
i_boot = js.rfind("await loadSources();")
|
||||
tail = js[i_boot:i_boot + 400]
|
||||
assert "await initUploadStatus();" in tail
|
||||
assert "})();" in tail
|
||||
assert "})();" not in js, "the top-level boot IIFE is gone (mount owns boot)"
|
||||
assert tail.rstrip().removesuffix("}").rstrip().endswith("await initUploadStatus();")
|
||||
|
||||
|
||||
# ---------- the page comment ----------
|
||||
|
||||
|
||||
def test_git_sources_html_comment_documents_the_202_contract() -> None:
|
||||
"""The #archive-upload-form comment in git-sources.html documents
|
||||
the phase-64 202 contract (the phase-49 synchronous paragraph
|
||||
marked superseded): the 202 = "safely on disk" + the JS-created
|
||||
toast (no markup), the live "Processing…" label via the status
|
||||
poll, and the 409 re-attach without an error banner."""
|
||||
"""The #archive-upload-form comment in the shell's Sources view
|
||||
(formerly git-sources.html) documents the phase-64 202 contract
|
||||
(the phase-49 synchronous paragraph marked superseded): the 202 =
|
||||
"safely on disk" + the JS-created toast (no markup), the live
|
||||
"Processing…" label via the status poll, and the 409 re-attach
|
||||
without an error banner."""
|
||||
html = _ghtml()
|
||||
idx = html.find('id="archive-upload-form"')
|
||||
assert idx != -1
|
||||
|
||||
@@ -45,16 +45,14 @@ STYLES_CSS = ASSETS / "styles.css"
|
||||
#: The app's pages (phase 46: the shared bar contract extends to the
|
||||
#: phase-35 git-sources page — the hamburger is part of that bar; the
|
||||
#: phase-50 History page and the phase-51 shared page carry the same
|
||||
#: bar — the full seven-page set). The two pages added after phase 46
|
||||
#: keep the identical header block, so they pin here too.
|
||||
#: bar). Phase 76 (task 02): the folded RAG + Sources files are gone —
|
||||
#: the shell (index.html) stands in for both views. Phase 76 (task
|
||||
#: 03): the History file is gone too — the shell stands in for the
|
||||
#: History view (all four folded view files deleted).
|
||||
PAGES = (
|
||||
FRONTEND / "index.html",
|
||||
FRONTEND / "sources.html",
|
||||
FRONTEND / "document.html",
|
||||
FRONTEND / "git-sources.html",
|
||||
FRONTEND / "login.html",
|
||||
FRONTEND / "tuning.html",
|
||||
FRONTEND / "history.html",
|
||||
FRONTEND / "shared.html",
|
||||
)
|
||||
|
||||
@@ -122,10 +120,10 @@ def _rule_block(css: str, selector: str) -> str:
|
||||
return m.group(1)
|
||||
|
||||
|
||||
# ---------- markup: the identical toggle + labeled nav on all six pages ----------
|
||||
# ---------- markup: the identical toggle + labeled nav on the pages ----------
|
||||
|
||||
|
||||
def test_all_six_pages_carry_the_hamburger_toggle() -> None:
|
||||
def test_all_pages_carry_the_hamburger_toggle() -> None:
|
||||
"""Every page carries the #nav-toggle button with the full aria
|
||||
contract: a real button (type=button), aria-expanded defaulting to
|
||||
"false", aria-controls pointing at the nav, the accessible name
|
||||
@@ -176,7 +174,7 @@ def test_toggle_lives_in_the_shared_bar_right_before_the_nav() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_all_six_pages_carry_the_labeled_nav_with_id() -> None:
|
||||
def test_all_pages_carry_the_labeled_nav_with_id() -> None:
|
||||
"""The nav keeps its single element + label and gains ONLY the id
|
||||
(the whoami reveal targets the same four links — no duplicated
|
||||
markup, so the phase-19/35 visibility rules apply inside the menu
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
"""Unit: phase-66 text pins — the History tab describes the auto-save
|
||||
model, not the retired Save button.
|
||||
|
||||
House pattern (``test_stale_ui_copy.py``): read ``frontend/history.html``
|
||||
as text and assert substrings — no browser. The browser-visible layer is
|
||||
House pattern (``test_stale_ui_copy.py``): read the frontend files as
|
||||
text and assert substrings — no browser. The browser-visible layer is
|
||||
gated by the dedicated story suite (``tests/e2e/test_history_copy.py``);
|
||||
these pins catch a silent regression in the template without it.
|
||||
|
||||
Phase 76 (task 03): the History view is a view of the shell —
|
||||
``frontend/history.html`` is deleted, the view copy lives in the
|
||||
shell's ``#view-history`` section, and the per-view meta description is
|
||||
the router's (``frontend/assets/router.js`` — the single writer of the
|
||||
client-side ``<meta name="description">``, value carried over from the
|
||||
old page's ``<head>``). The pins follow the copy to its new home.
|
||||
|
||||
Locked decision (owner-locked A3, 2026-09-01): every conversation saves
|
||||
itself automatically — there is NO Save button (retired phase 55, owner-
|
||||
locked A2, pinned by ``test_save_share_ux.py::test_anonymous_auto_save``),
|
||||
@@ -18,6 +25,9 @@ from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
|
||||
SHELL_HTML = FRONTEND / "index.html"
|
||||
ROUTER_JS = FRONTEND / "assets" / "router.js"
|
||||
|
||||
# --- the locked auto-save copy (owner-locked A3) -----------------------
|
||||
|
||||
META = "Saved chats — every conversation is saved automatically, one click back."
|
||||
@@ -43,8 +53,24 @@ STATE_STRINGS = (
|
||||
)
|
||||
|
||||
|
||||
def _text() -> str:
|
||||
return (FRONTEND / "history.html").read_text(encoding="utf-8")
|
||||
def _shell() -> str:
|
||||
assert SHELL_HTML.is_file(), f"missing {SHELL_HTML}"
|
||||
return SHELL_HTML.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _router() -> str:
|
||||
assert ROUTER_JS.is_file(), f"missing {ROUTER_JS}"
|
||||
return ROUTER_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _view(html: str) -> str:
|
||||
"""The shell's History view section (the view is the shell's LAST
|
||||
view section — the slice runs to the container main's close)."""
|
||||
start = html.find('<section class="view" id="view-history"')
|
||||
assert start != -1, "the #view-history section must be in the shell"
|
||||
end = html.find("</main>", start)
|
||||
assert end != -1
|
||||
return html[start:end]
|
||||
|
||||
|
||||
def _norm(text: str) -> str:
|
||||
@@ -54,26 +80,32 @@ def _norm(text: str) -> str:
|
||||
|
||||
|
||||
def test_manual_save_copy_is_gone() -> None:
|
||||
"""history.html: the three retired manual-save strings are GONE —
|
||||
the copy no longer tells the visitor to press a Save button."""
|
||||
html = _text()
|
||||
for frag in (OLD_META, OLD_PAGE_SUB, OLD_EMPTY_ROW):
|
||||
assert frag not in html, f"retired manual-save copy still present: {frag!r}"
|
||||
"""The shell (which carries the History view) and the router (which
|
||||
carries the per-view meta): the three retired manual-save strings
|
||||
are GONE — the copy no longer tells the visitor to press a Save
|
||||
button."""
|
||||
for text in (_view(_shell()), _router()):
|
||||
for frag in (OLD_META, OLD_PAGE_SUB, OLD_EMPTY_ROW):
|
||||
assert frag not in text, f"retired manual-save copy still present: {frag!r}"
|
||||
|
||||
|
||||
def test_locked_auto_save_copy_present_exactly_once() -> None:
|
||||
"""The three locked (A3) auto-save strings, each exactly once in
|
||||
history.html (a second copy could drift out of sync)."""
|
||||
html = _norm(_text())
|
||||
assert html.count(META) == 1, "the locked meta description"
|
||||
assert html.count(PAGE_SUB) == 1, "the locked page-sub"
|
||||
assert html.count(EMPTY_ROW) == 1, "the locked empty-row string"
|
||||
"""The three locked (A3) auto-save strings, each exactly once (a
|
||||
second copy could drift out of sync). Phase 76 (task 03): the meta
|
||||
description lives in the router's DESCRIPTIONS table (the router is
|
||||
the single writer of the client-side meta — the value is carried
|
||||
over from the old page's <head>), and the page-sub + empty-row
|
||||
strings live in the shell's History view."""
|
||||
assert _router().count(META) == 1, "the locked meta description (router-owned)"
|
||||
view = _norm(_view(_shell()))
|
||||
assert view.count(PAGE_SUB) == 1, "the locked page-sub"
|
||||
assert view.count(EMPTY_ROW) == 1, "the locked empty-row string"
|
||||
|
||||
|
||||
def test_state_language_survivors_are_untouched() -> None:
|
||||
"""Proof of NO over-deletion: the strings where "saved" is a state —
|
||||
the <h1>, the anonymous gate title, and the gate sub — stay exactly
|
||||
as phase 50 wrote them."""
|
||||
html = _text()
|
||||
as phase 50 wrote them (in the shell's History view)."""
|
||||
view = _view(_shell())
|
||||
for frag in STATE_STRINGS:
|
||||
assert frag in html, f"state-language survivor deleted: {frag!r}"
|
||||
assert frag in view, f"state-language survivor deleted: {frag!r}"
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
"""Unit: the phase-50 task-04 History-page contract.
|
||||
"""Unit: the phase-50 task-04 History contract.
|
||||
|
||||
The browser behavior itself is E2E-gated by the story suite (task 05);
|
||||
like the other frontend-adjacent unit files, this module pins the
|
||||
JS/CSS/HTML markers the History page depends on, so a silent
|
||||
Phase 76 (task 03): the History view is a view of the shell —
|
||||
``frontend/history.html`` is deleted, its content lives in the shell's
|
||||
``#view-history`` section, and ``history.js`` is a ``mount(root)`` view
|
||||
module the router lazy-imports. This module pins the JS/CSS/HTML
|
||||
markers the view depends on accordingly (the JS pins against
|
||||
``history.js`` — brace-balanced slices, since phase 76 task 03 the
|
||||
functions live inside ``mount(root)`` — the HTML pins against the
|
||||
shell, scoped to the view where view-scoped); the browser behavior
|
||||
itself is E2E-gated by the story suite (task 05), and a silent
|
||||
regression is caught without a browser:
|
||||
|
||||
* the anonymous no-fetch gate (the gate in, the table out, and the
|
||||
@@ -14,19 +20,21 @@ regression is caught without a browser:
|
||||
(owner-locked 2026-08-29: no native confirm dialog on this page);
|
||||
* the ``/?chat=<id>`` Open-link href shape (TODO.md L5 — "return to
|
||||
that history with a click");
|
||||
* ``#nav-history`` on ALL SEVEN pages (the phase-34 one-bar contract)
|
||||
+ ``header.js``'s reveal-for-admin block;
|
||||
* ``#nav-history`` on ALL surviving pages (the phase-34 one-bar
|
||||
contract — phase 76: the four folded navbar-view files are gone
|
||||
(tasks 01–03), so the page list is the shell + the surviving
|
||||
documents) + ``header.js``'s reveal-for-admin block;
|
||||
* the full-width table CSS (AGENTS.md rule 5) + the confirm pair +
|
||||
the empty-state row;
|
||||
* the Stale column (phase 53, task 04): the READ-ONLY marker cell in
|
||||
``makeRow`` (the rose ``.stale-pill`` from the row's ``stale`` flag
|
||||
+ the em-dash fallback, the ``<td>`` aria-label in BOTH states —
|
||||
WCAG 2.1 AA, conveyed without the visual), the ``Stale`` ``<th>``
|
||||
between Updated and Share in ``history.html``, and the ``.stale-pill``
|
||||
rose-family CSS in ``styles.css``.
|
||||
between Updated and Share in the shell's History view, and the
|
||||
``.stale-pill`` rose-family CSS in ``styles.css``.
|
||||
|
||||
The Containerfile stage-1 coverage (history.html copied, history.js
|
||||
bundled) is pinned dynamically by
|
||||
The Containerfile stage-1 coverage (the shell copied, the view modules
|
||||
bundled into the router) is pinned dynamically by
|
||||
``tests/integration/test_containerfile_assets.py`` — a page or module
|
||||
missing from stage 1 fails there.
|
||||
"""
|
||||
@@ -39,27 +47,25 @@ FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
ASSETS = FRONTEND / "assets"
|
||||
|
||||
INDEX_HTML = FRONTEND / "index.html"
|
||||
SOURCES_HTML = FRONTEND / "sources.html"
|
||||
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
|
||||
TUNING_HTML = FRONTEND / "tuning.html"
|
||||
DOCUMENT_HTML = FRONTEND / "document.html"
|
||||
LOGIN_HTML = FRONTEND / "login.html"
|
||||
HISTORY_HTML = FRONTEND / "history.html"
|
||||
SHARED_HTML = FRONTEND / "shared.html" # phase 51: the anonymous shared page
|
||||
HISTORY_JS = ASSETS / "history.js"
|
||||
HEADER_JS = ASSETS / "header.js"
|
||||
STYLES_CSS = ASSETS / "styles.css"
|
||||
|
||||
#: The phase-34 one-bar contract + the History page + the shared
|
||||
#: page: EIGHT pages.
|
||||
#: The phase-34 one-bar contract + the shared page.
|
||||
#: Phase 76 (task 01): the post-shell set — TUNING_HTML dropped (the
|
||||
#: Tuning view is folded into the shell; its file is deleted).
|
||||
#: Phase 76 (task 02): SOURCES_HTML + GIT_SOURCES_HTML dropped (both
|
||||
#: views folded into the shell; both files deleted).
|
||||
#: Phase 76 (task 03): HISTORY_HTML dropped (the History view is
|
||||
#: folded into the shell; its file is deleted) — the shell + the
|
||||
#: surviving documents.
|
||||
ALL_PAGES = (
|
||||
INDEX_HTML,
|
||||
SOURCES_HTML,
|
||||
GIT_SOURCES_HTML,
|
||||
TUNING_HTML,
|
||||
DOCUMENT_HTML,
|
||||
LOGIN_HTML,
|
||||
HISTORY_HTML,
|
||||
SHARED_HTML,
|
||||
)
|
||||
|
||||
@@ -77,11 +83,41 @@ def _css() -> str:
|
||||
return _text(STYLES_CSS)
|
||||
|
||||
|
||||
def _shell() -> str:
|
||||
return _text(INDEX_HTML)
|
||||
|
||||
|
||||
def _view(html: str) -> str:
|
||||
"""The shell's History view section — from the #view-history open
|
||||
tag to the container main's close (the view is the shell's LAST
|
||||
view section, so the slice ends at the first ``</main>`` after
|
||||
it)."""
|
||||
start = html.find('<section class="view" id="view-history"')
|
||||
assert start != -1, "the #view-history section must be in the shell"
|
||||
end = html.find("</main>", start)
|
||||
assert end != -1, "the container main must close after the view"
|
||||
return html[start:end]
|
||||
|
||||
|
||||
def _fn(js: str, name: str) -> str:
|
||||
"""The source of a top-level ``function <name>(...)`` (to its close)."""
|
||||
start = js.find(f"function {name}(")
|
||||
assert start != -1, f"{name}() must exist in history.js"
|
||||
return js[start : js.find("\n}\n", start) + 4]
|
||||
"""The source of the first ``function <name>`` in history.js (brace
|
||||
balanced — since phase 76 task 03 the functions live inside
|
||||
mount(root), so the closing brace is indented, not line-leading;
|
||||
the house pin pattern from tests/unit/test_frontend_sync_upload.py).
|
||||
"""
|
||||
fn = js.find(f"function {name}")
|
||||
assert fn != -1, f"{name} must be defined in history.js"
|
||||
open_idx = js.find("{", fn)
|
||||
depth = 0
|
||||
for i in range(open_idx, len(js)):
|
||||
c = js[i]
|
||||
if c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return js[fn : i + 1]
|
||||
raise AssertionError(f"unbalanced braces in {name}")
|
||||
|
||||
|
||||
def _nav_history_tag(html: str) -> str:
|
||||
@@ -90,14 +126,18 @@ def _nav_history_tag(html: str) -> str:
|
||||
return tag.group(0)
|
||||
|
||||
|
||||
# ---------- the #nav-history link: all seven pages ----------
|
||||
# ---------- the #nav-history link: all surviving pages ----------
|
||||
|
||||
|
||||
def test_nav_history_present_on_all_seven_pages() -> None:
|
||||
def test_nav_history_present_on_all_surviving_pages() -> None:
|
||||
"""The phase-34 one-bar contract extended by phase 50: the admin-only
|
||||
History link SHIPS hidden (revealed by header.js for admin) on every
|
||||
page, after the Tuning link, pointing at /history.html. The page's
|
||||
own link is the active one (is-active + aria-current)."""
|
||||
standalone page (the shell carries it once for its views — phase 76),
|
||||
after the Tuning link, pointing at /history.html. NO page's link is
|
||||
statically stamped active: in the shell the router is the SINGLE
|
||||
WRITER of the active state (client-side, per view — the old
|
||||
history.html page-level stamp is gone with the file), and the
|
||||
surviving documents keep their pre-shell no-stamp state."""
|
||||
for html in ALL_PAGES:
|
||||
text = _text(html)
|
||||
tag = _nav_history_tag(text)
|
||||
@@ -107,29 +147,26 @@ def test_nav_history_present_on_all_seven_pages() -> None:
|
||||
assert text.find('id="nav-tuning"') < text.find('id="nav-history"'), (
|
||||
f"{html.name}: #nav-history must follow #nav-tuning"
|
||||
)
|
||||
# The history page is the only one whose link is active.
|
||||
for html in ALL_PAGES:
|
||||
tag = _nav_history_tag(_text(html))
|
||||
if html.name == "history.html":
|
||||
assert 'class="nav-link is-active"' in tag
|
||||
assert 'aria-current="page"' in tag
|
||||
else:
|
||||
assert "is-active" not in tag, (
|
||||
f"{html.name}: no nav link is current there"
|
||||
)
|
||||
assert "is-active" not in tag, (
|
||||
f"{html.name}: no statically-current nav link (in the shell the "
|
||||
"active state is the router's single-writer job)"
|
||||
)
|
||||
assert 'aria-current="page"' not in tag
|
||||
|
||||
|
||||
def test_nav_history_count_is_exactly_eight_pages() -> None:
|
||||
def test_nav_history_count_is_exactly_four_pages() -> None:
|
||||
"""The pin counting occurrences across ``frontend/*.html`` — exactly
|
||||
one ``id="nav-history"`` per page, eight pages (phase 51: + the
|
||||
shared page), no duplicates and no extra page that forgot (or
|
||||
added twice)."""
|
||||
one ``id="nav-history"`` per page, FOUR pages (phase 51: + the shared
|
||||
page; phase 76: the four folded navbar-view files are gone — task 01
|
||||
− Tuning, task 02 − RAG + Sources, task 03 − History — the shell's
|
||||
ONE link covers all its views), no duplicates and no extra page
|
||||
that forgot (or added twice)."""
|
||||
total = 0
|
||||
for html in sorted(FRONTEND.glob("*.html")):
|
||||
count = html.read_text(encoding="utf-8").count('id="nav-history"')
|
||||
assert count in (0, 1), f"{html.name}: #nav-history appears {count} times"
|
||||
total += count
|
||||
assert total == 8, f"expected #nav-history on 8 pages, found {total}"
|
||||
assert total == 4, f"expected #nav-history on 4 pages, found {total}"
|
||||
|
||||
|
||||
def test_header_js_reveals_nav_history_for_admin() -> None:
|
||||
@@ -145,41 +182,56 @@ def test_header_js_reveals_nav_history_for_admin() -> None:
|
||||
assert "navHistory.hidden = !admin" in body
|
||||
|
||||
|
||||
# ---------- history.html: the page scaffold ----------
|
||||
# ---------- the shell's History view: the scaffold ----------
|
||||
|
||||
|
||||
def test_history_page_scaffold_and_landmarks() -> None:
|
||||
"""The standard page scaffold (AGENTS.md rule 5): skip link, the
|
||||
shared header, the steering panel + announcer (phase 34 — ships on
|
||||
every page), the page-head, the gate (ship-hidden), the
|
||||
role="status" live region, and the table inside the
|
||||
.table-wrap card. Footer with the version span (the index.html
|
||||
shape)."""
|
||||
html = _text(HISTORY_HTML)
|
||||
def test_history_view_scaffold_and_landmarks() -> None:
|
||||
"""The shell's History view (formerly history.html — phase 76 task
|
||||
03): the shell's standard landmarks (skip link, the shared header,
|
||||
the steering panel + announcer — the shell's ONE header-owned pair,
|
||||
the view's copies dropped with the move) + the view section
|
||||
(hidden AND inert + focusable — the WCAG pair, AGENTS.md rule 5),
|
||||
the page-head, the gate (ship-hidden), the role="status" live
|
||||
region, the table inside the .table-wrap card, and the shell's ONE
|
||||
footer with the version span (the history page's footer copy is
|
||||
dropped — no duplicate #app-version)."""
|
||||
html = _shell()
|
||||
assert '<a class="skip-link" href="#main">' in html
|
||||
assert 'class="app-header"' in html
|
||||
assert 'nav class="app-nav" id="app-nav" aria-label="Primary"' in html
|
||||
tag = re.search(r'<section[^>]*id="steering-panel"[^>]*>', html)
|
||||
assert tag and "hidden" in tag.group(0), "the steering panel ships hidden"
|
||||
assert re.search(r'<p[^>]*id="steering-announcer"[^>]*role="status"[^>]*>', html)
|
||||
assert "<main id=\"main\" class=\"app-main\" tabindex=\"-1\">" in html
|
||||
assert '<h1>Saved chats</h1>' in html
|
||||
assert html.count('id="steering-panel"') == 1, (
|
||||
"the shell carries its ONE steering panel (the view's copy is dropped)"
|
||||
)
|
||||
assert html.count('id="steering-announcer"') == 1
|
||||
assert html.count('<main id="main" class="app-main" tabindex="-1">') == 1
|
||||
# The view section: hidden AND inert (the WCAG pair) + focusable.
|
||||
view = re.search(r'<section[^>]*id="view-history"[^>]*>', html)
|
||||
assert view, "the #view-history section must be in the shell"
|
||||
view_tag = view.group(0)
|
||||
assert "hidden" in view_tag and "inert" in view_tag, (
|
||||
"the folded view ships hidden AND inert"
|
||||
)
|
||||
assert 'tabindex="-1"' in view_tag, "the target view is focusable"
|
||||
body = _view(html)
|
||||
assert '<h1>Saved chats</h1>' in body
|
||||
# The anonymous gate — the #sources-gate pattern, ship-hidden.
|
||||
gate = re.search(r'<section[^>]*id="history-gate"[^>]*>', html)
|
||||
gate = re.search(r'<section[^>]*id="history-gate"[^>]*>', body)
|
||||
assert gate and "hidden" in gate.group(0), "#history-gate must ship hidden"
|
||||
assert 'href="/login.html?next=/history.html"' in html, (
|
||||
"the gate's Sign in returns to the History page (no-JS fallback)"
|
||||
assert 'href="/login.html?next=/history.html"' in body, (
|
||||
"the gate's Sign in returns to the History view (no-JS fallback)"
|
||||
)
|
||||
# The action-feedback live region.
|
||||
assert re.search(r'<span[^>]*id="history-status"[^>]*role="status"[^>]*>', html)
|
||||
assert re.search(r'<span[^>]*id="history-status"[^>]*role="status"[^>]*>', body)
|
||||
# The table wrapper: the .table-wrap card (scrollable) with its
|
||||
# own id, a labeled region, focusable.
|
||||
wrap = re.search(r'<div[^>]*class="table-wrap history-table-wrap"[^>]*>', html)
|
||||
wrap = re.search(r'<div[^>]*class="table-wrap history-table-wrap"[^>]*>', body)
|
||||
assert wrap, "the table must live in the .table-wrap card"
|
||||
assert 'id="history-table-wrap"' in wrap.group(0)
|
||||
assert 'role="region"' in wrap.group(0) and 'tabindex="0"' in wrap.group(0)
|
||||
# Footer with the version span.
|
||||
# The shell keeps its ONE footer with the version span (the history
|
||||
# page's footer copy is dropped — no duplicate #app-version).
|
||||
assert 'class="footer-version" id="app-version"' in html
|
||||
assert html.count('id="app-version"') == 1
|
||||
|
||||
|
||||
def test_history_table_skeleton() -> None:
|
||||
@@ -187,8 +239,9 @@ def test_history_table_skeleton() -> None:
|
||||
Title | Messages | Updated | Stale (phase 53) | Share (phase 51) |
|
||||
Actions (the Actions header text is visually-hidden — the row
|
||||
buttons carry their own aria-labels) — and the empty-state row
|
||||
(ship-hidden, the exact copy)."""
|
||||
html = _text(HISTORY_HTML)
|
||||
(ship-hidden, the exact copy). Phase 76 (task 03): scoped to the
|
||||
shell's History view section."""
|
||||
html = _view(_shell())
|
||||
assert '<table class="history-table">' in html
|
||||
for col in ('<th scope="col">Title</th>', '<th scope="col">Messages</th>',
|
||||
'<th scope="col">Updated</th>', '<th scope="col">Stale</th>',
|
||||
@@ -224,16 +277,22 @@ def test_history_table_skeleton() -> None:
|
||||
) in html
|
||||
|
||||
|
||||
def test_history_page_scripts_and_no_cdn() -> None:
|
||||
"""Script load order (the house pattern): brand.js classic FIRST,
|
||||
the history.js module second, NO direct header.js <script> tag
|
||||
(single-evaluation design — history.js imports it relatively).
|
||||
No-CDN rule (AGENTS.md rule 6): no external script/link tags."""
|
||||
html = _text(HISTORY_HTML)
|
||||
def test_shell_scripts_and_no_cdn() -> None:
|
||||
"""Shell script load order (the house pattern): brand.js classic
|
||||
FIRST, the app.js (chat) + router.js modules, NO direct history.js
|
||||
<script> tag (single-evaluation design — the router lazy-imports
|
||||
the view module on first show; in the image the Containerfile's
|
||||
esbuild stage inlines it into the router bundle). history.js keeps
|
||||
its relative shared-header import (it uses the cached whoami
|
||||
promise). No-CDN rule (AGENTS.md rule 6): no external
|
||||
script/link tags."""
|
||||
html = _shell()
|
||||
srcs = re.findall(r'<script[^>]*src="([^"]+)"', html)
|
||||
assert srcs == ["assets/brand.js", "/assets/history.js"], (
|
||||
f"history.html must load brand.js (classic, first) + the history.js "
|
||||
f"module, got {srcs}"
|
||||
assert srcs[0] == "assets/brand.js", "brand.js (classic) must load first"
|
||||
assert "/assets/app.js" in srcs, "the shell loads the chat module"
|
||||
assert "/assets/router.js" in srcs, "the shell loads the router module"
|
||||
assert [s for s in srcs if "history.js" in s] == [], (
|
||||
"no direct history.js tag — the router lazy-imports the view module"
|
||||
)
|
||||
js = _js()
|
||||
assert 'from "./header.js"' in js, (
|
||||
@@ -249,12 +308,17 @@ def test_history_page_scripts_and_no_cdn() -> None:
|
||||
|
||||
|
||||
def test_anonymous_boot_makes_no_chats_request() -> None:
|
||||
"""The whoami gate in the boot IIFE: ``initSharedHeader()`` first
|
||||
(shared-header contract), then the anonymous branch hides the
|
||||
table, shows the gate, and RETURNS — no ``/api/chats`` request on
|
||||
the wire (the router 403s anonymous; the story E2E pins the
|
||||
request log). Only the admin path reaches ``loadChats()``. The
|
||||
single ``fetch("/api/chats")`` in the file lives in loadChats."""
|
||||
"""The whoami gate in ``mount(root)`` (phase 76 task 03 — the shell
|
||||
view module form): the view module does NOT boot the shared header
|
||||
(the shell's header boots exactly once, via the chat module
|
||||
(app.js) at shell boot — no call site, and the import carries ONLY
|
||||
the cached whoami promise), so the anonymous branch gates on
|
||||
``fetchIsAdmin()`` alone (the SAME cached /api/whoami request —
|
||||
zero extra), hides the table, shows the gate, and RETURNS — no
|
||||
``/api/chats`` request on the wire (the router 403s anonymous; the
|
||||
story E2E pins the request log). Only the admin path reaches
|
||||
``loadChats()``. The single ``fetch("/api/chats")`` in the file
|
||||
lives in loadChats."""
|
||||
js = _js()
|
||||
assert js.count('fetch("/api/chats")') == 1, (
|
||||
"exactly ONE list fetch — the anonymous path must never add one"
|
||||
@@ -262,19 +326,33 @@ def test_anonymous_boot_makes_no_chats_request() -> None:
|
||||
load = _fn(js, "loadChats")
|
||||
assert 'fetch("/api/chats")' in load, "the list fetch lives in loadChats"
|
||||
|
||||
boot = js[js.find("(async () => {"):]
|
||||
assert boot, "the boot IIFE must exist"
|
||||
assert "await initSharedHeader()" in boot
|
||||
gate_i = boot.find("if (!(await fetchIsAdmin()))")
|
||||
assert gate_i != -1, "the whoami gate must run in boot"
|
||||
# Phase 76 (task 03): the view module never boots the shell's
|
||||
# header — no CALL to the header boot (a docstring may name it;
|
||||
# a call may not) and no initSharedHeader in the import — the
|
||||
# shell's header boots via the chat module (app.js) at shell boot.
|
||||
assert "await initSharedHeader()" not in js, (
|
||||
"the view must not re-boot the shell's header"
|
||||
)
|
||||
import_lines = [line for line in js.splitlines() if line.strip().startswith("import")]
|
||||
assert all("initSharedHeader" not in line for line in import_lines), (
|
||||
"the view must not import the header boot — the shell's header boots "
|
||||
"via the chat module (app.js) at shell boot"
|
||||
)
|
||||
assert 'import { fetchIsAdmin } from "./header.js";' in js, (
|
||||
"the view imports ONLY the shared cached whoami promise"
|
||||
)
|
||||
mount_i = js.find("export async function mount(root)")
|
||||
assert mount_i != -1, "mount(root) must be the module's entry"
|
||||
gate_i = js.find("if (!(await fetchIsAdmin()))")
|
||||
assert gate_i > mount_i, "the whoami gate must run in mount"
|
||||
# The anonymous branch: gate in, table out, then a bare return —
|
||||
# and NO fetch call anywhere inside it.
|
||||
branch = boot[gate_i : boot.find("return;", gate_i)]
|
||||
branch = js[gate_i : js.find("return;", gate_i)]
|
||||
assert "fetch(" not in branch, "the anonymous branch must not fetch anything"
|
||||
assert "tableWrap.hidden = true" in branch
|
||||
assert "gateEl.hidden = false" in branch
|
||||
# The admin path: the gate hides, then the list loads.
|
||||
after = boot[boot.find("return;", gate_i):]
|
||||
after = js[js.find("return;", gate_i):]
|
||||
assert "gateEl.hidden = true" in after
|
||||
assert "loadChats();" in after
|
||||
|
||||
@@ -346,9 +424,9 @@ def test_two_step_delete_confirm_pair() -> None:
|
||||
yes_swap = fn.find("cell.replaceChildren(label, yes, no)")
|
||||
assert fn.find("yes.focus()", yes_swap) > 0, "focus moves to Yes after the swap"
|
||||
# No (and the restore helper) bring the Delete button back, focused.
|
||||
restore_start = fn.find("function restoreDelete")
|
||||
restore_end = fn.find("\n }", restore_start)
|
||||
restore = fn[restore_start:restore_end]
|
||||
# (Brace-balanced — since phase 76 task 03 the helper lives inside
|
||||
# mount(root), so a line-leading-brace slice would not find it.)
|
||||
restore = _fn(js, "restoreDelete")
|
||||
assert "cell.replaceChildren(del)" in restore
|
||||
assert "del.focus()" in restore
|
||||
assert 'no.addEventListener("click", restoreDelete)' in fn
|
||||
|
||||
@@ -32,7 +32,8 @@ source-level contract a silent regression would break:
|
||||
in-modal alert line + dialog stays open, network →
|
||||
the fixed reachable? line, re-enable in the finally);
|
||||
* the stale "prunes on the next sync" removal copy is GONE from
|
||||
``git-sources.js`` + ``git-sources.html``; the new hint copy is
|
||||
``git-sources.js`` + the shell (phase 76 task 02 folded git-
|
||||
sources.html into index.html's Sources view); the new hint copy is
|
||||
PRESENT (the README pins are task 03's);
|
||||
* styles.css — the modal classes on the house dark-tech palette
|
||||
(phase-08 tokens only, no CDN, no blur), ≥44px buttons, the
|
||||
@@ -44,7 +45,10 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
HTML = FRONTEND / "git-sources.html"
|
||||
# Phase 76 (task 02): git-sources.html is folded into the ONE-document
|
||||
# shell — the dialog markup now lives in the Sources view section of
|
||||
# index.html (moved verbatim, ids unchanged).
|
||||
SHELL_HTML = FRONTEND / "index.html"
|
||||
JS = FRONTEND / "assets" / "git-sources.js"
|
||||
CSS = FRONTEND / "assets" / "styles.css"
|
||||
|
||||
@@ -121,7 +125,7 @@ def _element_block(html: str, id_attr: str, tag: str = "div") -> str:
|
||||
a <p>)."""
|
||||
marker = f'id="{id_attr}"'
|
||||
i = html.find(marker)
|
||||
assert i != -1, f"missing id={id_attr} in git-sources.html"
|
||||
assert i != -1, f"missing id={id_attr} in the shell's Sources view"
|
||||
opens = [m.start() for m in re.finditer(rf"<{tag}\b", html[:i])]
|
||||
assert opens, f"no <{tag}> owns id={id_attr}"
|
||||
open_i = opens[-1]
|
||||
@@ -161,7 +165,7 @@ def test_dialog_markup_is_the_locked_alertdialog() -> None:
|
||||
selectors); all six child ids present; the error line is
|
||||
role="alert"; both buttons are real type="button"; the title is
|
||||
the locked h2; the modal copy is the locked paragraph verbatim."""
|
||||
html = _text(HTML)
|
||||
html = _text(SHELL_HTML)
|
||||
frag = _element_block(html, "remove-confirm-dialog")
|
||||
open_tag = frag[: frag.find(">") + 1]
|
||||
assert 'role="alertdialog"' in open_tag
|
||||
@@ -349,10 +353,10 @@ def test_confirm_runs_the_inflight_never_stale_lifecycle() -> None:
|
||||
def test_stale_next_sync_removal_copy_is_gone() -> None:
|
||||
"""The phase-35 "prunes on the next sync" removal contract is
|
||||
superseded: the retired confirm copy AND any 'prune(s/d) … next
|
||||
sync' shape are absent from git-sources.js + git-sources.html
|
||||
(code AND comments — the docstring copy moved with the flow).
|
||||
The README pins are task 03's."""
|
||||
for path in (JS, HTML):
|
||||
sync' shape are absent from git-sources.js + the shell (code AND
|
||||
comments — the docstring copy moved with the flow). The README
|
||||
pins are task 03's."""
|
||||
for path in (JS, SHELL_HTML):
|
||||
raw = _text(path)
|
||||
norm = _norm(raw)
|
||||
assert OLD_STAY_INDEXED not in raw, (
|
||||
@@ -368,7 +372,7 @@ def test_new_hint_copy_is_present_in_the_html() -> None:
|
||||
local directories are never touched), and the Sync button still
|
||||
mirrors the remaining sources (upstream churn is pruned on that
|
||||
run — not 'on the next sync')."""
|
||||
hint = _norm(_element_block(_text(HTML), "git-sources-hint", tag="p"))
|
||||
hint = _norm(_element_block(_text(SHELL_HTML), "git-sources-hint", tag="p"))
|
||||
for frag in (HINT_TOTAL_REMOVAL, HINT_MODAL_SPELLS_OUT, HINT_FOREVER_SAFE):
|
||||
assert frag in hint, f"the new hint copy is missing: {frag!r}"
|
||||
assert "pruned on that run" in hint, "the Sync-mirror clause (upstream churn)"
|
||||
|
||||
@@ -63,12 +63,11 @@ from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
APP_JS = FRONTEND / "assets" / "app.js"
|
||||
# Phase 76 (task 02): SOURCES_HTML + GIT_SOURCES_HTML dropped — both
|
||||
# views are folded into the shell (index.html); both files deleted.
|
||||
INDEX_HTML = FRONTEND / "index.html"
|
||||
SOURCES_HTML = FRONTEND / "sources.html"
|
||||
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
|
||||
DOCUMENT_HTML = FRONTEND / "document.html"
|
||||
LOGIN_HTML = FRONTEND / "login.html"
|
||||
TUNING_HTML = FRONTEND / "tuning.html"
|
||||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||
|
||||
|
||||
@@ -423,8 +422,11 @@ def test_share_button_ships_visible_beside_new_chat() -> None:
|
||||
"#messages, above the composer (phase 65)"
|
||||
)
|
||||
assert messages_end < new_idx, ("the row moved below the #messages section")
|
||||
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
|
||||
TUNING_HTML, Path(FRONTEND / "history.html")):
|
||||
# Phase 76 (task 02): the folded view files are gone (the shell's
|
||||
# chat view is the one and only carrier of the button — pinned
|
||||
# above); the standalone pages carry none (task 03 dropped the
|
||||
# last folded file, history.html).
|
||||
for other in (DOCUMENT_HTML, LOGIN_HTML):
|
||||
assert 'id="share-chat-btn"' not in other.read_text(encoding="utf-8"), (
|
||||
f"{other.name}: the Share button is chat-page only"
|
||||
)
|
||||
@@ -729,8 +731,11 @@ def test_chat_actions_wrapper_holds_both_pills_in_order() -> None:
|
||||
and "<section" not in after
|
||||
and "<form" not in after
|
||||
), ("nothing but the composer comment lands between the row and the composer")
|
||||
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
|
||||
TUNING_HTML, Path(FRONTEND / "history.html")):
|
||||
# Phase 76 (task 02): the folded view files are gone (the shell's
|
||||
# chat view is the one and only carrier of the row — pinned above);
|
||||
# the standalone pages carry none (task 03 dropped the last folded
|
||||
# file, history.html).
|
||||
for other in (DOCUMENT_HTML, LOGIN_HTML):
|
||||
assert "chat-actions" not in other.read_text(encoding="utf-8"), (
|
||||
f"{other.name}: the action row is chat-page only"
|
||||
)
|
||||
@@ -823,8 +828,11 @@ def test_stale_banner_html_after_kb_banner() -> None:
|
||||
# the kb-banner warning triangle) — aria-hidden decoration.
|
||||
lead = block[: btn.start()]
|
||||
assert 'aria-hidden="true"' in lead and 'd="M21 3v5h-5"' in lead
|
||||
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
|
||||
TUNING_HTML, Path(FRONTEND / "history.html")):
|
||||
# Phase 76 (task 02): the folded view files are gone (the shell's
|
||||
# chat view is the one and only carrier of the banner — pinned
|
||||
# above); the standalone pages carry none (task 03 dropped the last
|
||||
# folded file, history.html).
|
||||
for other in (DOCUMENT_HTML, LOGIN_HTML):
|
||||
assert 'id="stale-banner"' not in other.read_text(encoding="utf-8"), (
|
||||
f"{other.name}: the stale banner is chat-page only"
|
||||
)
|
||||
|
||||
@@ -22,14 +22,14 @@ SOURCES_JS = ASSETS / "sources.js"
|
||||
DOCUMENT_JS = ASSETS / "document.js"
|
||||
LOGIN_JS = ASSETS / "login.js"
|
||||
TUNING_JS = ASSETS / "tuning.js"
|
||||
HISTORY_JS = ASSETS / "history.js"
|
||||
STYLES_CSS = ASSETS / "styles.css"
|
||||
|
||||
# Phase 76 (task 02): SOURCES_HTML + GIT_SOURCES_HTML dropped — both
|
||||
# views are folded into the shell (index.html); both files are deleted.
|
||||
INDEX_HTML = FRONTEND / "index.html"
|
||||
SOURCES_HTML = FRONTEND / "sources.html"
|
||||
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
|
||||
DOCUMENT_HTML = FRONTEND / "document.html"
|
||||
LOGIN_HTML = FRONTEND / "login.html"
|
||||
TUNING_HTML = FRONTEND / "tuning.html"
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
@@ -126,27 +126,37 @@ def test_nav_sources_ships_hidden_on_every_nav_page() -> None:
|
||||
all five pages by phase 34 task 03 (owner confirmation 2026-08-26):
|
||||
the RAG nav link is hidden for anonymous — so it SHIPS with the
|
||||
hidden attribute (anonymous-safe default) on every page (they all
|
||||
carry the nav now, viewer included)."""
|
||||
for html in (INDEX_HTML, SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML):
|
||||
carry the nav now, viewer included).
|
||||
|
||||
Phase 76 (task 01): the page list is the post-shell set — the
|
||||
folded Tuning view's header copy is gone with tuning.html (the
|
||||
shell's ONE header is INDEX_HTML's). Phase 76 (task 02): the RAG
|
||||
+ Sources view files drop out too (the shell's ONE header covers
|
||||
all its views); task 03 drops History."""
|
||||
for html in (INDEX_HTML, DOCUMENT_HTML, LOGIN_HTML):
|
||||
text = _text(html)
|
||||
assert re.search(r'id="nav-sources"[^>]*\bhidden\b', text), (
|
||||
f"{html.name}: #nav-sources must ship hidden"
|
||||
)
|
||||
|
||||
|
||||
def test_all_six_pages_share_the_header_control_order() -> None:
|
||||
def test_shell_and_standalone_pages_share_the_header_control_order() -> None:
|
||||
"""Phase 34 task 03 (owner confirmation 2026-08-26) + phase 35/46
|
||||
(the sixth page, git-sources): every page ships the IDENTICAL
|
||||
header control inventory in the IDENTICAL order — brand, the mobile
|
||||
hamburger, nav [Chat, #nav-sources, #nav-git-sources, #nav-tuning],
|
||||
Sign in, Sign out (the #steering-toggle was removed from the navbar
|
||||
at owner request, 2026-08-28) — inside the shared .header-inner row
|
||||
(the document viewer's row 1). The #sync-btn (Sources page only)
|
||||
and the #new-chat-btn (chat page only, moved from the navbar at
|
||||
(the document viewer's row 1). The #sync-btn (RAG view only)
|
||||
and the #new-chat-btn (chat view only, moved from the navbar at
|
||||
owner request 2026-08-28) are NOT part of the shared bar anymore.
|
||||
Only the current-page is-active nav marker and the static ?next=
|
||||
fallback may differ per page (task 05's story E2E pins the
|
||||
rendered result)."""
|
||||
rendered result).
|
||||
|
||||
Phase 76 (task 02): the post-shell set — the folded views' header
|
||||
copies are gone (the shell's ONE header covers all its views); the
|
||||
RAG/Sources files are deleted, task 03 drops History."""
|
||||
markers = (
|
||||
'class="brand"',
|
||||
'<nav class="app-nav"',
|
||||
@@ -159,12 +169,9 @@ def test_all_six_pages_share_the_header_control_order() -> None:
|
||||
)
|
||||
for html in (
|
||||
INDEX_HTML,
|
||||
SOURCES_HTML,
|
||||
GIT_SOURCES_HTML,
|
||||
DOCUMENT_HTML,
|
||||
TUNING_HTML,
|
||||
LOGIN_HTML,
|
||||
):
|
||||
): # phase 76 (tasks 01/02): the folded view files are gone — the shell's ONE header stands in
|
||||
text = _text(html)
|
||||
start = text.find('<div class="container header-inner">')
|
||||
assert start != -1, f"{html.name}: missing the shared .header-inner row"
|
||||
@@ -185,8 +192,14 @@ def test_all_five_pages_carry_the_steering_panel() -> None:
|
||||
"""Phase 34 task 03: the #steering-panel section (+ the
|
||||
#steering-announcer live region) ships on every page — after
|
||||
#kb-banner in the chat shell, first child of <main> on the other
|
||||
four pages — ship hidden, driven by assets/header.js."""
|
||||
for html in (INDEX_HTML, SOURCES_HTML, DOCUMENT_HTML, TUNING_HTML, LOGIN_HTML):
|
||||
four pages — ship hidden, driven by assets/header.js.
|
||||
|
||||
Phase 76 (task 01): the folded Tuning view's panel COPY is dropped
|
||||
(the shell keeps the chat's ONE instance — duplicate ids are not
|
||||
legal). Phase 76 (task 02): the folded RAG + Sources view copies
|
||||
are dropped with their files (the shell's ONE panel stands in);
|
||||
task 03 drops History. The page list is the post-shell set."""
|
||||
for html in (INDEX_HTML, DOCUMENT_HTML, LOGIN_HTML):
|
||||
text = _text(html)
|
||||
tag = re.search(r'<section[^>]*id="steering-panel"[^>]*>', text)
|
||||
assert tag, f"{html.name}: missing the #steering-panel section"
|
||||
@@ -200,23 +213,32 @@ def test_all_five_pages_carry_the_steering_panel() -> None:
|
||||
assert text.find('id="steering-panel"') < text.find('id="steering-announcer"')
|
||||
|
||||
|
||||
def test_nav_tuning_ships_hidden_on_the_tuning_page() -> None:
|
||||
"""Phase 27: the Global Tuning page reuses the shared header — the
|
||||
"Tuning" nav link is admin-only, so it SHIPS hidden (revealed by
|
||||
initSharedHeader once whoami says admin), is the page's active link
|
||||
(is-active + aria-current), and the page loads markdown.js (classic)
|
||||
+ the tuning.js module with NO direct header.js <script> tag
|
||||
(single-evaluation design)."""
|
||||
text = _text(TUNING_HTML)
|
||||
def test_nav_tuning_ships_hidden_and_unstamped_on_the_shell() -> None:
|
||||
"""Phase 27 + phase 76 (task 01) — the shell form of this pin: the
|
||||
"Tuning" nav link is admin-only, so it SHIPS hidden in the shell's
|
||||
ONE header (revealed by initSharedHeader once whoami says admin).
|
||||
The old page-level active stamp (is-active + aria-current on
|
||||
tuning.html's own link) is GONE — the router is the SINGLE WRITER
|
||||
of the active state (client-side, per view); only the Chat link
|
||||
may carry a static stamp. The shell loads markdown.js (classic) +
|
||||
app.js + router.js (modules) with NO direct header.js <script> tag
|
||||
(single-evaluation design), and no direct tuning.js tag (the router
|
||||
lazy-imports it on first show — mount-once)."""
|
||||
text = _text(INDEX_HTML)
|
||||
tag = re.search(r'<a[^>]*id="nav-tuning"[^>]*>', text)
|
||||
assert tag, "tuning.html must carry the #nav-tuning nav link"
|
||||
assert 'class="nav-link is-active"' in tag.group(0), "the Tuning link is the active one"
|
||||
assert 'aria-current="page"' in tag.group(0)
|
||||
assert tag, "the shell must carry the #nav-tuning nav link"
|
||||
assert "hidden" in tag.group(0), "#nav-tuning must ship hidden (admin-only)"
|
||||
srcs = _script_srcs(TUNING_HTML)
|
||||
assert "is-active" not in tag.group(0), (
|
||||
"no static active stamp — the router is the single writer"
|
||||
)
|
||||
assert 'aria-current="page"' not in tag.group(0)
|
||||
srcs = _script_srcs(INDEX_HTML)
|
||||
assert [s for s in srcs if "header.js" in s] == [], "no direct header.js <script> tag"
|
||||
assert [s for s in srcs if "markdown.js" in s]
|
||||
assert [s for s in srcs if "tuning.js" in s]
|
||||
assert [s for s in srcs if "router.js" in s], "the shell loads the router module"
|
||||
assert [s for s in srcs if "tuning.js" in s] == [], (
|
||||
"no direct tuning.js tag — the router lazy-imports the view module"
|
||||
)
|
||||
|
||||
|
||||
def test_viewer_carries_the_standard_nav() -> None:
|
||||
@@ -253,27 +275,52 @@ def test_viewer_carries_the_standard_nav() -> None:
|
||||
assert back, "the back link keeps its /sources.html no-JS fallback"
|
||||
|
||||
|
||||
def test_sources_and_viewer_carry_the_shared_controls() -> None:
|
||||
"""Sources AND the document viewer carry the Sign in / Sign out pair
|
||||
(both starting hidden — initSharedHeader reveals exactly one after
|
||||
whoami), and their page scripts load header.js (phase 23: via the
|
||||
page script's relative import, not a direct script tag). The New
|
||||
chat button is NOT on non-chat pages — it moved from the navbar to
|
||||
the chat page at owner request (2026-08-28; the single binding is
|
||||
pinned in test_new_chat_binding_is_single_and_module_owned)."""
|
||||
for html in (SOURCES_HTML, DOCUMENT_HTML):
|
||||
def test_sources_view_and_viewer_carry_the_shared_controls() -> None:
|
||||
"""Sources (the shell's RAG view, phase 76 task 02) AND the document
|
||||
viewer carry the Sign in / Sign out pair (both starting hidden —
|
||||
initSharedHeader reveals exactly one after whoami), and their
|
||||
scripts load header.js (phase 23: via the relative import, not a
|
||||
direct script tag). The New chat button is chat-view only — in the
|
||||
shell its ONE instance lives in the chat view (hidden + inert on
|
||||
every other view); the viewer carries none (the single binding is
|
||||
pinned in test_new_chat_binding_is_single_and_module_owned).
|
||||
The RAG view module does NOT boot the shell's header (it boots once
|
||||
via app.js) — it keeps fetchIsAdmin() for its admin gate (zero
|
||||
extra requests)."""
|
||||
for html in (INDEX_HTML, DOCUMENT_HTML):
|
||||
text = _text(html)
|
||||
assert 'id="new-chat-btn"' not in text, (
|
||||
f"{html.name}: the New chat button is chat-page only (owner request)"
|
||||
)
|
||||
assert re.search(r'id="sign-in-link"[^>]*\bhidden\b', text)
|
||||
assert re.search(r'id="sign-out-btn"[^>]*\bhidden\b', text)
|
||||
for js_file in (SOURCES_JS, DOCUMENT_JS):
|
||||
assert 'from "./header.js"' in _text(js_file), (
|
||||
f"{js_file.name}: page script must load the shared header module"
|
||||
)
|
||||
# The New chat button: exactly ONE instance in the shell (the chat
|
||||
# view's) — none on the viewer.
|
||||
assert _text(INDEX_HTML).count('id="new-chat-btn"') == 1
|
||||
assert 'id="new-chat-btn"' not in _text(DOCUMENT_HTML), (
|
||||
"the New chat button is chat-view only (owner request)"
|
||||
)
|
||||
assert 'from "./header.js"' in _text(DOCUMENT_JS), (
|
||||
"document.js: page script must load the shared header module"
|
||||
)
|
||||
# The RAG view module (the phase-76 view-module branch):
|
||||
# relative import, NO header boot, the fetchIsAdmin gate.
|
||||
rag_js = _text(SOURCES_JS)
|
||||
assert 'from "./header.js"' in rag_js
|
||||
import_lines = [
|
||||
line for line in rag_js.splitlines() if line.strip().startswith("import")
|
||||
]
|
||||
assert all("initSharedHeader" not in line for line in import_lines), (
|
||||
"the view must not import the header boot — the shell's header boots "
|
||||
"via the chat module (app.js) at shell boot"
|
||||
)
|
||||
assert "await initSharedHeader()" not in rag_js, (
|
||||
"the view must not re-boot the shell's header"
|
||||
)
|
||||
assert "fetchIsAdmin()" in rag_js, "the admin gate keeps the shared cached promise"
|
||||
assert "new-chat-btn" not in rag_js, (
|
||||
"no #new-chat-btn binding (the module owns the single instance)"
|
||||
)
|
||||
assert 'fetch("/api/whoami")' not in rag_js
|
||||
# Each page's Sign in link returns to ITS OWN page after login.
|
||||
assert 'href="/login.html?next=/sources.html"' in _text(SOURCES_HTML)
|
||||
assert 'href="/login.html?next=/sources.html"' in _text(INDEX_HTML)
|
||||
assert 'href="/login.html?next=/document.html"' in _text(DOCUMENT_HTML)
|
||||
|
||||
|
||||
@@ -288,7 +335,6 @@ def test_header_module_loads_before_the_page_script() -> None:
|
||||
would double-bind the sign-out listener)."""
|
||||
cases = [
|
||||
(INDEX_HTML, "app.js"),
|
||||
(SOURCES_HTML, "sources.js"),
|
||||
(DOCUMENT_HTML, "document.js"),
|
||||
(LOGIN_HTML, "login.js"),
|
||||
]
|
||||
@@ -307,6 +353,20 @@ def test_header_module_loads_before_the_page_script() -> None:
|
||||
assert 'from "/assets/header.js"' not in js, (
|
||||
f"{page_script}: absolute header import would break the esbuild bundle"
|
||||
)
|
||||
# Phase 76 (task 02): the folded RAG view module is NOT directly
|
||||
# loaded by the shell — the router lazy-imports it on first show
|
||||
# (mount-once, like the Tuning view module); it still imports
|
||||
# header.js relatively (single-evaluation design).
|
||||
shell_srcs = _script_srcs(INDEX_HTML)
|
||||
assert [s for s in shell_srcs if "sources.js" in s] == [], (
|
||||
"no direct sources.js <script> tag — the router lazy-imports the view"
|
||||
)
|
||||
assert [s for s in shell_srcs if "router.js" in s], (
|
||||
"the router is the loader of the folded view modules"
|
||||
)
|
||||
js = _text(SOURCES_JS)
|
||||
assert 'from "./header.js"' in js
|
||||
assert 'from "/assets/header.js"' not in js
|
||||
|
||||
|
||||
def test_login_page_carries_the_full_header() -> None:
|
||||
@@ -403,16 +463,71 @@ def test_new_chat_binding_is_single_and_module_owned() -> None:
|
||||
assert 'window.location.href = "/"' not in js, (
|
||||
"no navigate-to-chat branch left (the button left the navbar)"
|
||||
)
|
||||
for js_file in (SOURCES_JS, DOCUMENT_JS, TUNING_JS):
|
||||
page_js = _text(js_file)
|
||||
assert 'from "./header.js"' in page_js
|
||||
assert "initSharedHeader()" in page_js
|
||||
assert "new-chat-btn" not in page_js, (
|
||||
f"{js_file.name}: no #new-chat-btn binding (the module owns it)"
|
||||
)
|
||||
assert 'fetch("/api/whoami")' not in page_js, (
|
||||
f"{js_file.name}: whoami goes through the shared cached promise"
|
||||
)
|
||||
doc_js = _text(DOCUMENT_JS)
|
||||
assert 'from "./header.js"' in doc_js
|
||||
assert "initSharedHeader()" in doc_js
|
||||
assert "new-chat-btn" not in doc_js, (
|
||||
"document.js: no #new-chat-btn binding (the module owns it)"
|
||||
)
|
||||
assert 'fetch("/api/whoami")' not in doc_js, (
|
||||
"document.js: whoami goes through the shared cached promise"
|
||||
)
|
||||
# Phase 76 (task 02): the RAG view module is the VIEW-MODULE branch
|
||||
# (like the Tuning view module below) — it does not boot the shell's
|
||||
# header, binds no #new-chat-btn, and gates on the shared cached
|
||||
# promise.
|
||||
rag_js = _text(SOURCES_JS)
|
||||
assert 'from "./header.js"' in rag_js
|
||||
assert "await initSharedHeader()" not in rag_js
|
||||
assert "fetchIsAdmin()" in rag_js, "the admin gate keeps the shared cached promise"
|
||||
assert "new-chat-btn" not in rag_js, (
|
||||
"sources.js: no #new-chat-btn binding (the module owns it)"
|
||||
)
|
||||
assert 'fetch("/api/whoami")' not in rag_js, (
|
||||
"sources.js: whoami goes through the shared cached promise"
|
||||
)
|
||||
# Phase 76 (task 01): the folded Tuning VIEW module no longer boots
|
||||
# the header — in the shell it runs exactly once, via the chat
|
||||
# module (app.js) at shell boot. The view keeps fetchIsAdmin() for
|
||||
# its admin gate (the SAME cached whoami promise — zero extra
|
||||
# requests) and imports the module relatively (single-evaluation).
|
||||
tuning_js = _text(TUNING_JS)
|
||||
assert 'from "./header.js"' in tuning_js
|
||||
# No CALL to the header boot — the import line carries no
|
||||
# initSharedHeader and no `await initSharedHeader()` call site
|
||||
# exists (a docstring may name it; a call may not).
|
||||
import_lines = [
|
||||
line for line in tuning_js.splitlines() if line.strip().startswith("import")
|
||||
]
|
||||
assert all("initSharedHeader" not in line for line in import_lines), (
|
||||
"the view must not import the header boot — the shell's header boots "
|
||||
"via the chat module (app.js) at shell boot"
|
||||
)
|
||||
assert "await initSharedHeader()" not in tuning_js, (
|
||||
"the view must not re-boot the shell's header"
|
||||
)
|
||||
assert "fetchIsAdmin()" in tuning_js, "the admin gate keeps the shared cached promise"
|
||||
assert "new-chat-btn" not in tuning_js
|
||||
assert 'fetch("/api/whoami")' not in tuning_js
|
||||
# Phase 76 (task 03): the folded History VIEW module — the same
|
||||
# view-module contract: no header boot, no #new-chat-btn binding,
|
||||
# the admin gate on the shared cached whoami promise (zero extra
|
||||
# requests), the relative import for single evaluation.
|
||||
history_js = _text(HISTORY_JS)
|
||||
assert 'from "./header.js"' in history_js
|
||||
import_lines = [
|
||||
line for line in history_js.splitlines() if line.strip().startswith("import")
|
||||
]
|
||||
assert all("initSharedHeader" not in line for line in import_lines), (
|
||||
"the view must not import the header boot — the shell's header boots "
|
||||
"via the chat module (app.js) at shell boot"
|
||||
)
|
||||
assert "await initSharedHeader()" not in history_js, (
|
||||
"the view must not re-boot the shell's header"
|
||||
)
|
||||
assert "fetchIsAdmin()" in history_js, "the admin gate keeps the shared cached promise"
|
||||
assert "new-chat-btn" not in history_js
|
||||
assert 'fetch("/api/whoami")' not in history_js
|
||||
app_js = _text(APP_JS)
|
||||
assert 'window.addEventListener("bor:new-chat", startNewChat)' in app_js
|
||||
assert "newChatBtn.addEventListener" not in app_js, (
|
||||
|
||||
@@ -19,12 +19,15 @@ CONFIG_PY = ROOT / "app" / "config.py"
|
||||
#: tuple, repeated here so this file stands alone).
|
||||
HTML_PAGES = (
|
||||
"index.html",
|
||||
"sources.html",
|
||||
"tuning.html",
|
||||
# Phase 76 (task 01): tuning.html is folded into the shell (deleted)
|
||||
# — the shell (index.html) stands in for it here.
|
||||
# Phase 76 (task 02): sources.html + git-sources.html are folded
|
||||
# too (both deleted — the shell stands in for both views).
|
||||
# Phase 76 (task 03): history.html is folded too (deleted — the
|
||||
# shell stands in for the History view; all four folded view files
|
||||
# are gone).
|
||||
"document.html",
|
||||
"login.html",
|
||||
"git-sources.html",
|
||||
"history.html",
|
||||
"shared.html",
|
||||
"doc-edit.html",
|
||||
)
|
||||
@@ -89,17 +92,21 @@ def test_chat_page_old_copy_is_gone() -> None:
|
||||
|
||||
|
||||
def test_sources_page_old_copy_is_gone() -> None:
|
||||
"""sources.html: the ~/Homelab + ~/Deployments page-sub citations and
|
||||
the old footer are retired."""
|
||||
html = _text(FRONTEND / "sources.html")
|
||||
"""The shell's RAG view (formerly sources.html, phase 76 task 02):
|
||||
the ~/Homelab + ~/Deployments page-sub citations and the old footer
|
||||
are retired (the whole shell — which carries the view — must be
|
||||
clean)."""
|
||||
html = _text(FRONTEND / "index.html")
|
||||
for frag in ("~/Homelab", "~/Deployments", OLD_FOOTER):
|
||||
assert frag not in html, f"retired sources copy still present: {frag!r}"
|
||||
|
||||
|
||||
def test_git_sources_page_old_copy_is_gone() -> None:
|
||||
"""git-sources.html: the old example repo URL (A3) and the old
|
||||
footer are retired."""
|
||||
html = _text(FRONTEND / "git-sources.html")
|
||||
"""The shell's Sources view (formerly git-sources.html, phase 76
|
||||
task 02): the old example repo URL (A3) and the old footer are
|
||||
retired (the whole shell — which carries the view — must be
|
||||
clean)."""
|
||||
html = _text(FRONTEND / "index.html")
|
||||
for frag in (OLD_GIT_EXAMPLE, OLD_FOOTER):
|
||||
assert frag not in html, f"retired git-sources copy still present: {frag!r}"
|
||||
|
||||
@@ -114,19 +121,29 @@ def test_chat_page_locked_copy_present_exactly_once() -> None:
|
||||
|
||||
|
||||
def test_sources_page_sub_is_the_locked_copy() -> None:
|
||||
"""The KB .page-sub (the string the TODO cited by name) reads the
|
||||
locked (A1) copy, including the <strong> around Sync sources —
|
||||
pinned inside the .page-sub element, not anywhere in the file."""
|
||||
html = _text(FRONTEND / "sources.html")
|
||||
m = re.search(r'<p class="page-sub">(.*?)</p>', html, re.DOTALL)
|
||||
assert m, "sources.html must keep the .page-sub"
|
||||
"""The RAG view's .page-sub (the string the TODO cited by name) reads
|
||||
the locked (A1) copy, including the <strong> around Sync sources —
|
||||
pinned inside the .page-sub element, not anywhere in the file.
|
||||
Phase 76 (task 02): scoped to the RAG view — the shell carries one
|
||||
.page-sub per view, and the earlier views' subs come first."""
|
||||
html = _text(FRONTEND / "index.html")
|
||||
i = html.find('id="view-rag"')
|
||||
j = html.find('id="view-git-sources"', i)
|
||||
assert -1 < i < j, "the RAG view section must be in the shell"
|
||||
m = re.search(r'<p class="page-sub">(.*?)</p>', html[i:j], re.DOTALL)
|
||||
assert m, "the RAG view must keep the .page-sub"
|
||||
assert _norm(m.group(1)) == PAGE_SUB
|
||||
|
||||
|
||||
def test_all_nine_footers_are_the_locked_neutral_default() -> None:
|
||||
"""Every page carries the locked (A1) footer inside exactly one
|
||||
``class="footer-text"`` span (the stable hook phase 62's
|
||||
BOR_FOOTER_TEXT env var drives)."""
|
||||
def test_all_five_footers_are_the_locked_neutral_default() -> None:
|
||||
"""Every standalone page carries the locked (A1) footer inside
|
||||
exactly one ``class="footer-text"`` span (the stable hook phase 62's
|
||||
BOR_FOOTER_TEXT env var drives). Phase 76 (task 02): the folded RAG
|
||||
+ Sources files are gone — the shell carries its footer once.
|
||||
Phase 76 (task 03): the History file is gone too — the shell's
|
||||
single (chat) footer stands in for the History view (its per-page
|
||||
footer copy, with the duplicate #app-version span, is dropped with
|
||||
the move)."""
|
||||
span = f'<span class="footer-text">{FOOTER}</span>'
|
||||
for page in HTML_PAGES:
|
||||
html = _text(FRONTEND / page)
|
||||
|
||||
@@ -6,7 +6,8 @@ the admin, removed from the DOM for anonymous. The owner asked for the
|
||||
button to go away entirely: note management now lives on the
|
||||
standalone Tuning page (``/tuning.html``, phase 27). This file pins the
|
||||
removal at source level — the toggle + count badge are ABSENT from all
|
||||
six pages, the ``#steering-panel`` section still ships hidden (kept
|
||||
standalone pages (the shell's ONE header covers its folded views —
|
||||
phase 76), the ``#steering-panel`` section still ships hidden (kept
|
||||
fresh by the chat page's per-bubble Tune form through header.js), the
|
||||
shared module owns no toggle wiring anymore, and the admin-only
|
||||
``#nav-tuning`` link — the surviving path to the notes — still ships
|
||||
@@ -22,17 +23,19 @@ ASSETS = FRONTEND / "assets"
|
||||
|
||||
HEADER_JS = ASSETS / "header.js"
|
||||
|
||||
#: All eight pages carry the shared header block (phase 34's five pages
|
||||
#: + phase 35's git-sources page + phase 50's History page +
|
||||
#: The standalone pages carry the shared header block (phase 34's five
|
||||
#: pages + phase 35's git-sources page + phase 50's History page +
|
||||
#: phase 51's shared page).
|
||||
#: Phase 76 (task 01): tuning.html is folded into the shell (deleted)
|
||||
#: — the shell (index.html) stands in for it here.
|
||||
#: Phase 76 (task 02): sources.html + git-sources.html are folded too
|
||||
#: (both deleted — the shell stands in for both views). Phase 76
|
||||
#: (task 03): history.html is folded too (deleted — the shell stands
|
||||
#: in for the History view; all four folded view files are gone).
|
||||
PAGES = (
|
||||
FRONTEND / "index.html",
|
||||
FRONTEND / "sources.html",
|
||||
FRONTEND / "document.html",
|
||||
FRONTEND / "git-sources.html",
|
||||
FRONTEND / "login.html",
|
||||
FRONTEND / "tuning.html",
|
||||
FRONTEND / "history.html",
|
||||
FRONTEND / "shared.html",
|
||||
)
|
||||
|
||||
@@ -52,7 +55,7 @@ def _init_body(js: str) -> str:
|
||||
# ---------- the removal: absent from every page's navbar ----------
|
||||
|
||||
|
||||
def test_steering_toggle_removed_from_all_six_pages() -> None:
|
||||
def test_steering_toggle_removed_from_all_pages() -> None:
|
||||
"""The #steering-toggle button (and its #steering-count badge) is
|
||||
gone from the navbar of EVERY page — absent, not hidden."""
|
||||
for html in PAGES:
|
||||
@@ -68,7 +71,7 @@ def test_steering_toggle_removed_from_all_six_pages() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_steering_panel_still_ships_hidden_on_all_six_pages() -> None:
|
||||
def test_steering_panel_still_ships_hidden_on_all_pages() -> None:
|
||||
"""The #steering-panel section survives the toggle removal (the chat
|
||||
page's per-bubble Tune form keeps it fresh through header.js) and
|
||||
still ships hidden, with its list / empty state / announcer."""
|
||||
@@ -117,7 +120,7 @@ def test_header_js_keeps_the_panel_contract() -> None:
|
||||
# ---------- the surviving path to the notes ----------
|
||||
|
||||
|
||||
def test_nav_tuning_still_ships_hidden_on_all_six_pages() -> None:
|
||||
def test_nav_tuning_still_ships_hidden_on_all_pages() -> None:
|
||||
"""The admin-only Tuning NAV LINK (#nav-tuning) — the surviving path
|
||||
to the steering notes now that the navbar toggle is gone — still
|
||||
ships hidden on every page (the phase-19/29/35 contract)."""
|
||||
|
||||
@@ -44,7 +44,9 @@ ASSETS = FRONTEND / "assets"
|
||||
HEADER_JS = ASSETS / "header.js"
|
||||
SOURCES_JS = ASSETS / "sources.js"
|
||||
STYLES_CSS = ASSETS / "styles.css"
|
||||
SOURCES_HTML = FRONTEND / "sources.html"
|
||||
# Phase 76 (task 02): sources.html is folded into the ONE-document shell
|
||||
# — the sync markup now lives in the RAG view section of index.html.
|
||||
SHELL_HTML = FRONTEND / "index.html"
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
@@ -52,23 +54,46 @@ def _text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _rag_view(html: str) -> str:
|
||||
"""The RAG view section of the shell (view-scoped page-sub scope —
|
||||
the shell carries one .page-sub per view, so a whole-file match
|
||||
would hit the earlier views first)."""
|
||||
i = html.find('<section class="view" id="view-rag"')
|
||||
assert i != -1, "the RAG view section must be in the shell"
|
||||
j = html.find('<section class="view" id="view-git-sources"', i)
|
||||
assert j != -1, "the Sources view section must follow the RAG view"
|
||||
return html[i:j]
|
||||
|
||||
|
||||
def _body(js: str, fn_name: str) -> str:
|
||||
"""The source of the first top-level `function <fn_name>` in js."""
|
||||
"""The source of the first `function <fn_name>` in js (brace
|
||||
balanced — since phase 76 task 02 the functions live inside
|
||||
mount(root), so the closing brace is indented, not column 0)."""
|
||||
fn = js.find(f"function {fn_name}")
|
||||
assert fn != -1, f"{fn_name} must be defined"
|
||||
return js[fn : js.find("\n}", fn)]
|
||||
open_idx = js.find("{", fn)
|
||||
depth = 0
|
||||
for i in range(open_idx, len(js)):
|
||||
c = js[i]
|
||||
if c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return js[fn : i + 1]
|
||||
raise AssertionError(f"unbalanced braces in {fn_name}")
|
||||
|
||||
|
||||
# ---------- sources.html: anonymous-safe ship-hidden markup ----------
|
||||
# ---------- the shell's RAG view: anonymous-safe ship-hidden markup ----------
|
||||
|
||||
|
||||
def test_sync_button_ships_hidden_and_labeled() -> None:
|
||||
"""#sync-btn SHIPS with the hidden attribute (anonymous-safe —
|
||||
sources.js reveals it for the admin at page boot), is a real
|
||||
sources.js reveals it for the admin at view boot), is a real
|
||||
<button type="button">, and carries aria-label="Sync sources" so
|
||||
the accessible name stays stable across the label states."""
|
||||
tag = re.search(r"<button[^>]*id=\"sync-btn\"[^>]*>", _text(SOURCES_HTML))
|
||||
assert tag, "sources.html must carry the #sync-btn button"
|
||||
tag = re.search(r"<button[^>]*id=\"sync-btn\"[^>]*>", _text(SHELL_HTML))
|
||||
assert tag, "the shell must carry the #sync-btn button (RAG view)"
|
||||
attrs = tag.group(0)
|
||||
assert 'class="sync-btn"' in attrs
|
||||
assert 'type="button"' in attrs
|
||||
@@ -80,7 +105,7 @@ def test_sync_button_has_icon_and_label_span() -> None:
|
||||
"""The button body is a refresh-cycle svg (aria-hidden — decorative,
|
||||
the spin is the visible running state) + the .sync-label span with
|
||||
the idle text, so the label can be swapped by sources.js."""
|
||||
text = _text(SOURCES_HTML)
|
||||
text = _text(SHELL_HTML)
|
||||
btn = text[text.find('id="sync-btn"') : text.find("</button>", text.find('id="sync-btn"'))]
|
||||
assert re.search(r'<svg[^>]*class="sync-icon"[^>]*aria-hidden="true"', btn)
|
||||
# class for the module query + id for the E2E label assertions
|
||||
@@ -95,9 +120,9 @@ def test_sync_result_is_the_aria_live_announcer() -> None:
|
||||
"""#sync-result sits right after the button and is a polite live
|
||||
region (role="status" + aria-live="polite") — the last-result /
|
||||
counts announcement for screen readers."""
|
||||
text = _text(SOURCES_HTML)
|
||||
text = _text(SHELL_HTML)
|
||||
tag = re.search(r'<span[^>]*id="sync-result"[^>]*>', text)
|
||||
assert tag, "sources.html must carry the #sync-result announcer"
|
||||
assert tag, "the shell must carry the #sync-result announcer (RAG view)"
|
||||
attrs = tag.group(0)
|
||||
assert 'role="status"' in attrs
|
||||
assert 'aria-live="polite"' in attrs
|
||||
@@ -108,9 +133,9 @@ def test_sync_error_banner_is_a_hidden_alert() -> None:
|
||||
"""The failure banner uses the chat error-banner markup style
|
||||
(kb-banner + is-error) and role="alert", shipping hidden —
|
||||
sources.js un-hides it with the error text on a failed run."""
|
||||
text = _text(SOURCES_HTML)
|
||||
text = _text(SHELL_HTML)
|
||||
tag = re.search(r'<div[^>]*id="sync-error-banner"[^>]*>', text)
|
||||
assert tag, "sources.html must carry the #sync-error-banner"
|
||||
assert tag, "the shell must carry the #sync-error-banner (RAG view)"
|
||||
attrs = tag.group(0)
|
||||
assert "kb-banner" in attrs and "is-error" in attrs
|
||||
assert 'role="alert"' in attrs
|
||||
@@ -125,9 +150,10 @@ def test_page_sub_copy_mentions_the_button() -> None:
|
||||
the latest and re-import (the import CLI docs live elsewhere).
|
||||
Phase 61: the copy describes the current source model (git repos +
|
||||
local directories + uploaded archives), not the old ~/Homelab +
|
||||
~/Deployments clone."""
|
||||
sub = re.search(r'<p class="page-sub">(.*?)</p>', _text(SOURCES_HTML), re.DOTALL)
|
||||
assert sub, "sources.html must keep the .page-sub copy"
|
||||
~/Deployments clone. Phase 76 (task 02): scoped to the RAG view —
|
||||
the shell carries one .page-sub per view."""
|
||||
sub = re.search(r'<p class="page-sub">(.*?)</p>', _rag_view(_text(SHELL_HTML)), re.DOTALL)
|
||||
assert sub, "the RAG view must keep the .page-sub copy"
|
||||
copy = re.sub(r"\s+", " ", sub.group(1)) # the markup wraps lines
|
||||
assert "Press <strong>Sync sources</strong>" in copy
|
||||
assert "pull the latest and re-import" in copy
|
||||
@@ -139,22 +165,23 @@ def test_sources_page_stays_cdn_free() -> None:
|
||||
"""No-CDN rule (PLAN §7.3, A11): the new button markup adds no
|
||||
external references — same-origin assets only (the integration
|
||||
test_index_html_served_locally re-checks this on the served page)."""
|
||||
text = _text(SOURCES_HTML)
|
||||
text = _text(SHELL_HTML)
|
||||
assert 'src="https://' not in text
|
||||
assert 'href="https://' not in text
|
||||
|
||||
|
||||
# ---------- sources.js: the admin reveal (page boot) ----------
|
||||
# ---------- sources.js: the admin reveal (view boot) ----------
|
||||
|
||||
|
||||
def test_sources_js_reveals_sync_btn_on_the_admin_branch() -> None:
|
||||
"""The page boot reveals #sync-btn for the admin on the SAME cached
|
||||
whoami initSharedHeader() used (no extra fetch — header.js keeps
|
||||
the single /api/whoami call site); anonymous users never leave the
|
||||
ship-hidden default."""
|
||||
"""The view boot (mount's tail, phase 76 task 02) reveals #sync-btn
|
||||
for the admin on the SAME cached whoami fetchIsAdmin() reads (no
|
||||
extra fetch — header.js keeps the single /api/whoami call site; the
|
||||
header itself is booted exactly once, by the chat module at shell
|
||||
boot); anonymous users never leave the ship-hidden default."""
|
||||
js = _text(SOURCES_JS)
|
||||
boot = js[js.rfind("(async () => {"):]
|
||||
assert "const admin = await initSharedHeader()" in boot
|
||||
boot = js[js.find("view boot (phase 76 task 02)"):]
|
||||
assert "const admin = await fetchIsAdmin()" in boot
|
||||
assert "syncBtn.hidden = !admin" in boot, "#sync-btn must join the admin reveal"
|
||||
# The reveal must not introduce a second whoami call site.
|
||||
header = _text(HEADER_JS)
|
||||
@@ -337,16 +364,14 @@ def test_sources_js_reattaches_on_load_admin_only() -> None:
|
||||
result, idle settles retry-ready; the click binding wires
|
||||
startSync to the button."""
|
||||
js = _text(SOURCES_JS)
|
||||
fn = js.find("function initSyncButton")
|
||||
assert fn != -1
|
||||
body = js[fn : js.find("\n}\n", fn)]
|
||||
body = _body(js, "initSyncButton")
|
||||
assert "await fetchIsAdmin()" in body, "admin-only boot (no extra fetch)"
|
||||
assert 'fetch("/api/sync/status")' in body
|
||||
assert 'status.state === "running"' in body
|
||||
assert 'status.state === "success"' in body
|
||||
assert 'status.state === "failed"' in body
|
||||
assert (
|
||||
'syncBtn.addEventListener("click", startSync);\n initSyncButton();'
|
||||
'syncBtn.addEventListener("click", startSync);\n initSyncButton();'
|
||||
in js
|
||||
), "the click binding and the boot re-attach ship together, guarded on syncBtn"
|
||||
|
||||
|
||||