Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
495d042a98 | ||
|
|
b78afc08f2 | ||
|
|
f221b40fce | ||
|
|
70ba8710f3 | ||
|
|
bf64c0d7e4 | ||
|
|
ffa919b8bf |
@@ -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,41 @@
|
|||||||
|
# Phase 77 — Navbar clicks refresh the view's data (fresh list on re-show + a History refresh button)
|
||||||
|
|
||||||
|
**Source:** `TODO.md` L3 — "Clicking navbar icons should refresh the relevant page. For example, clicking 'history' doesn't load new history until I refresh. The history page should also have a refresh button."
|
||||||
|
**Story:** n/a (TODO-derived — descendant of `.agents/user_stories/chat-history.md` (phase 50) and the phase-76 shell)
|
||||||
|
**Context:** `frontend/assets/router.js` (the phase-76 shell router: the `VIEW` map, mount-once / hide-forever, `switchTo(name, { userInitiated })`, the delegated nav click handler with its `name === current` early return, the `popstate` handler), `frontend/assets/history.js` (the History view module — `loadChats()` APPENDS rows and is called once at mount), `frontend/assets/sources.js` (`loadDocs()` ~line 472 — already clears `tbody` before rendering), `frontend/assets/git-sources.js` (`loadSources()` ~line 230 — render + announce), `frontend/assets/tuning.js` (`loadNotes()` ~line 116 — `renderNotes` clears), `frontend/index.html` (`#view-history`'s `.page-head` — h1 "Saved chats" + sub; the `#history-status` live region), `tests/unit/test_frontend_router.py` (the source-level router pins), `tests/e2e/test_nav_switch_keeps_stream.py` (the phase-76 suite — must stay green UNCHANGED).
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Since the phase-76 shell, a view's data is fetched exactly once, at mount (mount-once, hide-forever) — a History view opened at 10:00 still shows 10:00's data at 10:30. Make every user-initiated re-show of a view re-fetch its list, and give History an explicit Refresh button. The Chat view is deliberately out of scope: its in-flight stream and local conversation must survive (the phase-76 contract).
|
||||||
|
|
||||||
|
## Owner decisions (chat, 2026-09-06 — recorded per AGENTS.md rule 3)
|
||||||
|
- **A1 confirmed:** "the relevant page" = the four data views (History, RAG, Sources, Tuning). **Chat is EXCLUDED from the refresh hook** — the in-flight SSE stream and the local conversation persist (the phase-76 LOCKED refinement).
|
||||||
|
- The refresh fires on: (a) a switch TO the view when it is already mounted, (b) a re-click of the active view's own nav link (today a no-op), (c) back/forward (`popstate`) onto an already-mounted view. The FIRST show (the mount) and the boot never fire it — the mount's own load is the first fetch.
|
||||||
|
- The History refresh button lives in the view's page-head and announces through the existing `#history-status` live region.
|
||||||
|
|
||||||
|
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||||
|
- **Mechanism — a DOM event, zero router-state changes:** on any user-initiated re-show, the router dispatches `new CustomEvent("bor:view-refresh")` on the view's `<section>` root. A view module that wants fresh data listens on its own `root` inside `mount()` and re-runs its existing load function. Modules that do not listen are unaffected — the chat view never listens.
|
||||||
|
- **First-show exemption (the no-double-fetch rule):** in `switchTo`, capture `const wasMounted = mounted[name]` BEFORE the mount block. After the view is shown and the head/nav state is written, dispatch the event `if (wasMounted)` — a re-show. The first show (mount) loads once and dispatches nothing; boot (`userInitiated: false`) can never dispatch (boot always finds an unmounted view or the chat view, and the dispatch site is gated on `wasMounted`).
|
||||||
|
- **Active-view re-click:** the click handler's `if (name === current) return;` becomes: dispatch `bor:view-refresh` on `viewEls[name]` and return — NO `pushState` (the URL already IS that view's path); the mobile menu still closes (the container handler runs regardless).
|
||||||
|
- **Re-entrance of the load functions:** each must be safe to call repeatedly. Verified: `sources.js` `loadDocs` clears via `tbody.replaceChildren()` (check its `showEmpty()` path also clears the rows — if not, clear at the top of `loadDocs`); `tuning.js` `renderNotes` clears via `tuneList.textContent = ""`; `git-sources.js` `renderSources` replaces the list (verify the error/empty states reset on a re-call — `showLoadError` hides table AND empty state). `history.js` `loadChats` **APPENDS** — it must remove the data rows (every `tr` in `#history-tbody` EXCEPT the hidden `#history-empty-row`) before re-fetching.
|
||||||
|
- **Gate guard:** a view only re-fetches after its whoami gate has passed (History: anonymous shows `#history-gate` and NEVER calls `/api/chats` — the phase-50 contract the story E2E pins; the listener must respect the same branch).
|
||||||
|
- **Focus/scroll unchanged:** the router still lands the viewport at the top on user-initiated switches; the refresh is a background re-fetch behind the already-shown view.
|
||||||
|
- **E2E "freshness" proof:** create new backing data via the API AFTER a view has loaded, nav back (or re-click / refresh-button), assert the new row — with the phase-76 canonical same-document sentinel (`window` global set before the clicks is still readable after — no document load).
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
— (none; builds on the completed phase-76 shell)
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_router_refresh_hook.md` — the `bor:view-refresh` dispatch in `router.js` (re-show + active re-click + popstate; first show exempt) and the History view re-fetching on it.
|
||||||
|
2. `02_refresh_other_views.md` — RAG, Sources, and Tuning listen and re-fetch; the Chat view stays untouched (with a comment pinning the exclusion).
|
||||||
|
3. `03_history_refresh_button.md` — the History Refresh button + the story E2E suite `test_navbar_refresh.py` + the regression sweep + the atomic commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: `tests/unit/test_frontend_router.py` — new source-level pins: the `bor:view-refresh` literal exists; the dispatch is gated on the pre-mount `mounted` state (first show exempt); the re-click branch dispatches instead of a bare `return` (no `pushState`); the three other view modules each carry a listener and `app.js` does NOT (negative pin).
|
||||||
|
- E2E: new story suite `tests/e2e/test_navbar_refresh.py` run in isolation (the scenarios live in task 03).
|
||||||
|
- Coverage: **>90%** on `app/` — this phase is frontend-only; the floor is preserved by not regressing.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_navbar_refresh.py -v --no-cov` green in isolation (DB up).
|
||||||
|
- [ ] `tests/e2e/test_nav_switch_keeps_stream.py` still green UNCHANGED (the stream-survival contract holds with the hook in place).
|
||||||
|
- [ ] `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. `feat(ui): refresh view data on navbar re-show + History refresh button`) whose body cites TODO.md L3; phase dir moved to `.agents/phases/complete/`.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Task 01 — The `bor:view-refresh` hook; History re-fetches on a user-initiated re-show
|
||||||
|
|
||||||
|
**Phase:** `77_navbar_refresh` · **Source:** `TODO.md:3` — "Clicking navbar icons should refresh the relevant page. For example, clicking 'history' doesn't load new history until I refresh."
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
A user-initiated re-show of an already-mounted view re-fetches its data: `router.js` dispatches `bor:view-refresh` on the view's section (re-shows and active re-clicks only — never on the first mount or boot), and the History view listens and re-loads its chat list.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/router.js` — in `switchTo`: capture `const wasMounted = mounted[name]` BEFORE the mount block; after the show + head/nav state is written (before the focus/scroll tail is fine — the event order is: visible → refresh dispatched), `if (wasMounted) root.dispatchEvent(new CustomEvent("bor:view-refresh"))`. In the delegated nav click handler, replace the `if (name === current) return;` early return with a dispatch of `bor:view-refresh` on `viewEls[name]` + `return` (no `pushState` — the URL is already this view's path; the mobile menu still closes via the container handler). The `popstate` path flows through `switchTo`, so it inherits the `wasMounted` gating automatically. Update the file-header contract comment with the refresh rule: the event fires exactly when an already-mounted view is shown again — first show and boot never (the mount's own load is the first fetch).
|
||||||
|
2. `frontend/assets/history.js` — inside `mount(root)`:
|
||||||
|
- make `loadChats()` re-entrant: at its top, remove the data rows — every `tr` in `#history-tbody` EXCEPT the hidden `#history-empty-row` — so a re-load replaces the list instead of appending a duplicate set;
|
||||||
|
- add the listener in the ADMIN branch (after the `fetchIsAdmin()` gate passes — anonymous shows `#history-gate` and never fetches, per the phase-50 contract the story E2E pins): `root.addEventListener("bor:view-refresh", () => { if (started) loadChats(); })` where `started` flips to `true` once the first `loadChats()` call is made (the gate branch that `return`s early must not arm a listener that fetches).
|
||||||
|
3. `tests/unit/test_frontend_router.py` — new source pins (house pattern — read the JS source, no browser): the `bor:view-refresh` literal exists in `router.js`; the dispatch site is guarded by a pre-mount `mounted` capture (assert the `wasMounted`-style capture appears before the mount block — e.g. the capture assignment precedes the `mounted[name]` set); the re-click branch dispatches (assert the `name === current` branch contains the dispatch literal, not a bare return); `history.js` contains the listener.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: item 3 (mechanism-level pins; the existing router pins — pushState switches, mount-once, hidden+inert, single-writer head/nav — must stay green UNCHANGED).
|
||||||
|
- E2E: none yet — the story suite lands in task 03.
|
||||||
|
- Coverage: n/a for this task (frontend-only) — the `app/` floor must not regress.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `uv run pytest tests/unit/test_frontend_router.py -v` green (old + new pins).
|
||||||
|
- [ ] Manual spot check (optional): load History as admin, save a new chat via the API, click another view then History again — the new row is there without a document reload.
|
||||||
|
- [ ] `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Task 02 — RAG, Sources, and Tuning join the refresh (Chat stays out)
|
||||||
|
|
||||||
|
**Phase:** `77_navbar_refresh` · **Source:** `TODO.md:3` — "Clicking navbar icons should refresh the relevant page."
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The three remaining data views re-fetch on `bor:view-refresh` with the same gate-guarded, re-entrant pattern as History. The Chat view deliberately does NOT listen — its in-flight stream and local conversation persist (the phase-76 contract).
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/sources.js` — in `mount(root)`, in the admin branch (after the `fetchIsAdmin()` gate passes): `root.addEventListener("bor:view-refresh", () => loadDocs())`. Verify `loadDocs()` re-entrance end to end: the populated path already clears (`tbody.replaceChildren()`); check the `showEmpty()` path — if it does not clear the tbody rows, a refresh from a populated list into an empty result would leave ghost rows: add the row-clear at the top of `loadDocs()` (the hidden empty-row stays in place, exactly the History pattern from task 01).
|
||||||
|
2. `frontend/assets/git-sources.js` — same: `root.addEventListener("bor:view-refresh", () => loadSources())` in the admin branch. Verify a re-call resets all three list states (populated render, the empty state, and the `showLoadError` state — `showLoadError` already hides the table AND the empty state, so an error followed by a successful refresh must clear the error: `loadSources` already calls `hideLoadError()` on success — confirm).
|
||||||
|
3. `frontend/assets/tuning.js` — same: `root.addEventListener("bor:view-refresh", () => loadNotes())` in the admin branch; `renderNotes` already clears (`tuneList.textContent = ""`). Note: `loadNotes` keeps the last rendered list on a failed fetch (its documented contract) — a refresh that fails must behave the same way (no change needed; the listener just calls the function).
|
||||||
|
4. `frontend/assets/app.js` (the chat view) — add NO listener. Add a one-line comment where the chat view's module-scope state begins: the `bor:view-refresh` exclusion is deliberate — the in-flight SSE stream and the local conversation must survive every switch (phase 76), so the chat view never re-fetches on a show.
|
||||||
|
5. `tests/unit/test_frontend_router.py` (or the neighboring source-pin file if the house split the pins — match where the task-01 pins landed) — source pins: `sources.js`, `git-sources.js`, and `tuning.js` each contain the `bor:view-refresh` listener; `app.js` does NOT (negative pin — the exclusion is a contract, not an oversight).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: item 5.
|
||||||
|
- E2E: covered by the task-03 story suite — one assertion per view (mutate the backing data via the API while the view is hidden, nav back, the row reflects the mutation; where a view's backing data has no cheap API mutation path, assert via the Playwright request log that the re-fetch happened on re-show).
|
||||||
|
- Coverage: n/a (frontend) — the `app/` floor preserved.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] All four data views re-fetch on a re-show; the chat view is untouched (stream survival still holds — `test_nav_switch_keeps_stream.py` green).
|
||||||
|
- [ ] The new unit pins are green; `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Task 03 — History refresh button + the story E2E suite + the commit
|
||||||
|
|
||||||
|
**Phase:** `77_navbar_refresh` · **Source:** `TODO.md:3` — "…The history page should also have a refresh button."
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The explicit half of the item: a visible Refresh control on the History view — plus the phase's Playwright story suite proving every refresh path, the regression sweep, and the atomic commit.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/index.html` — in `#view-history`'s `.page-head`: add a right-aligned actions slot (`.page-head` becomes a flex row — title block left, actions right; wraps below 640px): `<button type="button" class="history-refresh" id="history-refresh" aria-label="Refresh saved chats">` with the house inline-SVG refresh glyph (`aria-hidden="true"`) + a visible "Refresh" text label (the phase-46 auth-link convention: label visible ≥640px, icon-only below — the `aria-label` keeps the accessible name in both).
|
||||||
|
2. `frontend/assets/history.js` — bind `#history-refresh` (admin branch only — the button lives in the view, which is admin-gated): on click: disable the button (no double-fire while in flight) → `loadChats()` → announce the outcome in `#history-status` (`Saved chats refreshed.` on success; the existing failure lines on a non-2xx / network error — reuse the exact copy `loadChats`'s callers would see) → re-enable the button. The button stays reachable while the empty state is showing (it sits in the page-head, outside the table wrap).
|
||||||
|
3. `frontend/assets/styles.css` — `.history-refresh` (reuse the `.new-chat-btn` visual language: brand background, WCAG 4.5:1, `focus-visible` ring, hover state) + the `.page-head` flex layout (no layout change for views that have no actions slot — the other four views' page-heads are untouched).
|
||||||
|
4. `tests/e2e/test_navbar_refresh.py` (NEW story suite — run in isolation, DB up, the mock-LLM fixture from `tests/e2e/conftest.py`; admin via `auth_helpers.login`):
|
||||||
|
- **re-show refresh:** sign in → create saved chat A via `POST /api/chats` (authed httpx or the UI) → nav to History (row A visible) → create chat B via the API → nav to Chat → nav back to History → row B visible; a `window` sentinel set before the nav clicks is still readable afterwards (the phase-76 canonical no-document-load proof).
|
||||||
|
- **active-view re-click:** with History visible (rows A, B) → create C via the API → click the History nav link AGAIN → C appears; the URL is still `/history.html` (no new history entry — `history.length` unchanged).
|
||||||
|
- **refresh button:** create D via the API → click `#history-refresh` → D appears; `#history-status` announces `Saved chats refreshed.`; the button is `disabled` during the in-flight request (assert via the Playwright request hook or a short-poll on the disabled state).
|
||||||
|
- **popstate:** Chat → History (loads) → create E via the API → `page.go_back()` → History view → E present.
|
||||||
|
- **the four views:** one assertion per data view that a re-show re-fetches (RAG / Sources / Tuning / History — the request-log or row-delta pattern from task 02).
|
||||||
|
- **stream-survival control:** send a question (mock LLM — the ~8 s stream) → mid-stream nav to RAG → back to Chat → the FULL answer completes (the phase-76 contract holds with the hook in place). The unchanged `tests/e2e/test_nav_switch_keeps_stream.py` run in isolation is the additional control.
|
||||||
|
5. Regression sweep + commit: `uv run pytest --cov=app --cov-report=term-missing` (>90%), `uv run ruff check . && uv run pyright`, `tests/e2e/test_nav_switch_keeps_stream.py` green unchanged in isolation, then ONE atomic `--no-gpg-sign` Conventional-Commits commit — e.g. `feat(ui): refresh view data on navbar re-show + History refresh button` — body cites TODO.md L3; move the phase dir to `.agents/phases/complete/`.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- E2E: item 4 is the story gate (AGENTS.md rules 4 + 9 — one file, run in isolation).
|
||||||
|
- Coverage: **>90%** on `app/` (frontend-only phase — the floor is preserved).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_navbar_refresh.py -v --no-cov` green in isolation.
|
||||||
|
- [ ] The Refresh button is visible, keyboard-reachable, and announces (WCAG 2.1 AA basics — AGENTS.md rule 5).
|
||||||
|
- [ ] Full suite + coverage + lint green; the phase-76 suite unchanged-green; one atomic commit; phase dir in `.agents/phases/complete/`.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Phase 78 — Static background (the animated glow layers are removed)
|
||||||
|
|
||||||
|
**Source:** `TODO.md` L4 — "Remove the animated css background, it's too resource intensive"
|
||||||
|
**Story:** n/a (TODO-derived — supersedes the fading-glow contract of `.agents/user_stories/background-no-motion.md` (phase 25), which itself superseded `background-animation.md` (phase 08))
|
||||||
|
**Context:** `frontend/assets/styles.css` (the background block, ~lines 70–155: the STATIC grid texture on `body::before`, the three opacity-fading glow spots on `body::after` / `html::before` / `html::after`, the `@keyframes bg-glow-a/b/c` blocks, and the `prefers-reduced-motion` rule that stills those layers), `tests/unit/test_background_animation.py` + `tests/unit/test_background_no_motion.py` (source-level pins of the glow contract), `tests/e2e/test_background_no_motion.py` (the phase-25 suite — computed-style fade assertions) + `tests/e2e/test_background_animation.py` (the phase-22 E2E repurposed as the phase-25 no-motion regression — same story, `background-no-motion.md` — its fade assertions directly contradicted by the new contract), `tests/e2e/test_dark_tech_theme.py` (palette assertions — must stay green UNCHANGED), `frontend/assets/themes/` (configurable theme files — the built-in palette is the scope; verify they carry no glow rules).
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Delete the animated background: the three opacity-fading glow pseudo-layers and their keyframes stop existing — the owner reports they are too resource-intensive (the infinite CSS animations run continuously on every page, in every tab). The background becomes fully static: the 44px grid texture stays (it is static — zero animation cost).
|
||||||
|
|
||||||
|
## Owner decisions (chat, 2026-09-06 — recorded per AGENTS.md rule 3)
|
||||||
|
- **A2 confirmed:** "the animated css background" = the three fading glow spots (the ONLY animated part of the background). The static grid (`body::before`) STAYS. If the owner later wants the grid gone too, that is a one-line follow-up — out of scope here.
|
||||||
|
- Other UI animations (the mobile nav slide, modal fades, typing dots, …) are NOT the background — they stay untouched.
|
||||||
|
|
||||||
|
## Design (shared by both tasks)
|
||||||
|
- `body::before` (the grid) stays BYTE-IDENTICAL: 44px cells, 1px lines at 60% `--line` alpha, the widened radial mask.
|
||||||
|
- Deleted from `styles.css`: the `body::after`, `html::before`, and `html::after` glow rules; the three `@keyframes bg-glow-*` blocks; the `prefers-reduced-motion` rule whose only job was stilling these background layers — FIRST verify it references nothing else (if any non-background selector sits in it, strip only the background selectors).
|
||||||
|
- The block's comment header (the phase-08/25 owner-direction prose) is replaced with a 3–4 line phase-78 note: the animated layers were removed at owner direction (TODO.md L4) as too resource-intensive; the static grid remains.
|
||||||
|
- **Test rewrites (the house pattern — the premise changed, so the suites pin the NEW contract; the phase-76 precedent rewrote the phase-20 suite the same way):**
|
||||||
|
- `tests/unit/test_background_no_motion.py` → rewritten as the static-contract source pin: no `@keyframes bg-glow-*` in `styles.css`; none of the four pseudo-element selectors (`body::before/after`, `html::before/after`) carries an `animation:` declaration; `body::before` (the grid) is present with its 44px `background-size` and no animation.
|
||||||
|
- `tests/unit/test_background_animation.py` → **DELETED** (the phase-25 unit source-pin suite — its entire premise, three fading glows / exactly three `bg-glow-*` keyframe blocks, is gone; superseded chain 08 → 25 → 78).
|
||||||
|
- `tests/e2e/test_background_no_motion.py` → rewritten: Playwright computed-style checks — the three glow pseudo-elements report `animation-name: none` and no background-image; `body::before` still carries the grid background. Docstring updated to the phase-78 contract.
|
||||||
|
- `tests/e2e/test_background_animation.py` → **DELETED** (the phase-22 E2E repurposed as the phase-25 regression — its premise, that the glow layers fade, is removed; its story (`background-no-motion.md`) is carried on by the rewritten `test_background_no_motion.py`, the single story suite going forward).
|
||||||
|
- `tests/e2e/test_dark_tech_theme.py` must stay green UNCHANGED — the PALETTE is untouched; only the light spots go.
|
||||||
|
- `tests/unit/test_themes.py` + `frontend/assets/themes/indigo.css` — verify no glow/keyframe carry-over; if a theme file re-introduces animated background layers, the owner's direction applies to the whole background (report before deviating — the built-in palette is the confirmed scope).
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
— (none)
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_remove_glow_layers.md` — the CSS deletion + the four test-file rewrites/deletions.
|
||||||
|
2. `02_regression_sweep_commit.md` — theme + smoke E2E, the visual check, the full gate, the atomic commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: the rewritten source-pin file + `tests/unit/test_themes.py` green.
|
||||||
|
- E2E: the rewritten static-background suite in isolation (DB up) + `test_dark_tech_theme.py` + `test_smoke.py` unchanged-green.
|
||||||
|
- Coverage: **>90%** on `app/` (CSS-only phase — the floor is preserved).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] No `animation` / `@keyframes` remains for the background in `styles.css` (`rg "bg-glow" frontend/ tests/ app/` → zero hits); the grid renders as before (visual check in a real browser).
|
||||||
|
- [ ] The rewritten suites are green; the deleted suites are gone; `test_dark_tech_theme.py` green UNCHANGED.
|
||||||
|
- [ ] `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. `perf(ui): remove the animated background glow layers — static grid only`); phase dir moved to `.agents/phases/complete/`.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Task 01 — Delete the glow layers + keyframes; re-pin the background contract
|
||||||
|
|
||||||
|
**Phase:** `78_static_background` · **Source:** `TODO.md:4` — "Remove the animated css background, it's too resource intensive"
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The animated background no longer exists: the three glow pseudo-layers, their keyframes, and their reduced-motion rule are deleted from `styles.css`, and the unit/e2e background suites are rewritten to pin the new static contract (or deleted where the premise is gone).
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/styles.css` — delete the three glow rules (`body::after`, `html::before`, `html::after`), the `@keyframes bg-glow-a` / `bg-glow-b` / `bg-glow-c` blocks, and the `prefers-reduced-motion` block that stills the background — the file carries EIGHT `@media (prefers-reduced-motion: reduce)` blocks; delete the ONE that contains only `body::before, body::after, html::before, html::after { animation: none; }` (~line 1353) whole, and leave the other seven (typing dots, spinner, toasts, nav slide, etc. — unrelated UI) untouched. Keep the `body::before` grid rule byte-identical (44px cells, the 60% `--line` alpha gradients, the radial mask). Replace the large phase-08/25 comment header with a 3–4 line phase-78 note: owner direction (TODO.md L4) — the animated background was removed as too resource-intensive; the static grid remains.
|
||||||
|
2. `tests/unit/test_background_no_motion.py` — rewrite to the static contract (source-level, house pattern): `styles.css` contains no `@keyframes bg-glow-*`; none of `body::before` / `body::after` / `html::before` / `html::after` carries an `animation:` declaration (assert the three glow selectors are ABSENT or animation-free — they will be absent); `body::before` is present with `background-size: 44px 44px` and no animation. Update the docstring to the phase-78 contract.
|
||||||
|
3. DELETE `tests/unit/test_background_animation.py` (the phase-25 unit source-pin suite — its premise, exactly three fading glows with named keyframe cycles, is removed; superseded chain 08 → 25 → 78).
|
||||||
|
4. `tests/e2e/test_background_no_motion.py` — rewrite: Playwright computed-style checks on a loaded page — `body::before` still carries the grid (background-image non-empty, animation-name `none`); `body::after`, `html::before`, `html::after` report no background-image and `animation-name: none` (or the pseudo-element has no box). Update the docstring.
|
||||||
|
5. DELETE `tests/e2e/test_background_animation.py` (the phase-22 E2E repurposed as the phase-25 no-motion regression — its fade assertions contradict the new contract; the story `background-no-motion.md` is carried on by the rewritten `test_background_no_motion.py`).
|
||||||
|
6. Verify nothing else references the removed names: `rg "bg-glow" app/ frontend/ tests/` → zero hits; `tests/unit/test_themes.py` and `frontend/assets/themes/*.css` do not re-introduce glow/animated-background rules (grep for `bg-glow` / `@keyframes` on background selectors — report before deviating if a theme file carries them).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: the rewritten pin file (item 2) + `tests/unit/test_themes.py` green.
|
||||||
|
- E2E: the rewritten suite (item 4) in isolation (DB up): `uv run pytest tests/e2e/test_background_no_motion.py -v --no-cov`.
|
||||||
|
- Coverage: n/a (CSS only) — the `app/` floor preserved.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `rg "bg-glow" frontend/ tests/ app/` → no hits; the three glow rules + keyframes are gone from `styles.css`; the grid rule is untouched.
|
||||||
|
- [ ] Rewritten unit + e2e suites green; the two animation suites deleted; `tests/e2e/test_dark_tech_theme.py` green UNCHANGED.
|
||||||
|
- [ ] `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Task 02 — Regression sweep + the commit
|
||||||
|
|
||||||
|
**Phase:** `78_static_background` · **Source:** `TODO.md:4` — "Remove the animated css background, it's too resource intensive"
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Prove the deletion regressed nothing (palette, theme, smoke, full pipeline) and land the atomic commit.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. E2E (DB up, each in isolation): `uv run pytest tests/e2e/test_background_no_motion.py -v --no-cov`, then `uv run pytest tests/e2e/test_dark_tech_theme.py -v --no-cov` (UNCHANGED file — if it breaks, the deletion touched the palette; fix the deletion, not the test), then `uv run pytest tests/e2e/test_smoke.py -v --no-cov`.
|
||||||
|
2. Visual check in a real browser (real server, not the mock): the page background is the static grid over the flat `--bg` canvas — no pulsing light spots at the top-left / bottom-right / bottom-left corners; a `prefers-reduced-motion` browser profile sees the same static page; the theme CSS (indigo) still applies cleanly if set.
|
||||||
|
3. Full gate: `uv run pytest --cov=app --cov-report=term-missing` (>90% on `app/`), `uv run ruff check . && uv run pyright`.
|
||||||
|
4. ONE atomic `--no-gpg-sign` Conventional-Commits commit — e.g. `perf(ui): remove the animated background glow layers — static grid only` — body cites TODO.md L4 + the resource-intensity reason + the confirmed scope (glow spots gone, static grid stays). Move the phase dir to `.agents/phases/complete/`.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- The full suite IS the test; coverage **>90%** on `app/`.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] Items 1–3 all green.
|
||||||
|
- [ ] Committed; phase dir in `.agents/phases/complete/`.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Phase 79 — API tokens: admin-issued access to the app (only shared chats stay open)
|
||||||
|
|
||||||
|
**Source:** `TODO.md` L5 — "Add api tokens that the admin can generate and hand out so people can log in to use the app. The only thing that should be accessible without an API token is shared chats. The web ui should ask for a token before letting a user through and should cache that token in browser storage so they don't have to keep entering it."
|
||||||
|
**Story:** n/a (TODO-derived — extends the phase-16 single-admin auth, `.agents/user_stories/admin-auth.md`)
|
||||||
|
**Context:** `app/core/auth.py` (the SessionMiddleware cookie session, `require_admin`, `sign_in`/`sign_out`, `ADMIN_SESSION_KEY`), `app/api/auth.py` (`/api/login`, `/api/logout`, `/api/whoami` — `WhoamiResponse{authenticated, role: "admin"|"anonymous"}`), `app/models.py` (SQLAlchemy 2.0 mapped-column models — `SavedChat` is the last one) + `alembic/versions/` (latest is `0011_doc_drafts.py` — the format to mirror), `app/api/chat.py` (`POST /api/chat` — public today), `app/api/suggestions.py` (public today), `app/api/docs.py` (`GET /api/documents/content` — the phase-16 soft rule: deliberately public), `app/schemas.py` (`LoginRequest`, `WhoamiResponse`, …), `frontend/assets/header.js` (the single `/api/whoami` call site — `fetchIsAdmin()` returns `authenticated === true`; the admin-link reveal; the sign-out binding), `frontend/index.html` (the shell — `#app-nav`, the `#view-*` sections, the sign-in/out links, `#main`), `frontend/document.html` + `frontend/assets/document.js` (the document viewer — `fetchIsAdmin` gates the admin-only edit affordance; the viewer itself is public today), `tests/e2e/auth_helpers.py` (the real-form `login` helper), `tests/integration/test_auth_api.py` (pins the phase-16 contract — "viewer stays public (soft rule) and `POST /api/chat` still streams" — that soft rule is SUPERSEDED by this phase).
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The admin can generate named API tokens and hand them out; a token holder signs in at the in-app gate and uses the app — chat, suggestion chips, cited documents. The ONLY anonymous content is the shared chats (plus the login/infra endpoints the gate itself needs). Every existing admin-only surface stays admin-only.
|
||||||
|
|
||||||
|
## Owner decisions (chat, 2026-09-06 — recorded per AGENTS.md rule 3)
|
||||||
|
- **A3 confirmed — token-user scope:** a token user (role `user`) may: `POST /api/chat`, `GET /api/suggestions`, `GET /api/documents/content`, `GET /api/whoami`, `POST /api/logout`. Admin-only UNCHANGED: the docs list/import/sync, tuning, git sources, doc drafts, and the saved-chats list/save/share/delete (saved chats have no per-user attribution — token users get NO History view; only the admin sees saved chats).
|
||||||
|
- **A4 confirmed — token shape & lifecycle:** `bor_` + 32 hex chars (`secrets.token_hex(16)`); only the SHA-256 hex digest of the FULL token string is stored (unique index) — the plaintext is returned EXACTLY ONCE at creation. `revoked_at` set = dead, and revocation is enforced IMMEDIATELY on the user's next request (the session stores the token id; `require_user` live-checks the row is unrevoked — no server-side session store is added, just a PK lookup).
|
||||||
|
- **A5 confirmed — the gate:** an in-app token-entry overlay on the shell + the same inline gate on `document.html`; `login.html` (admin password) and `shared.html` (anonymous) are UNCHANGED. The entered token is cached in `localStorage["bor.token"]` and silently re-sent to `POST /api/token-auth` on every page load (a failed silent re-auth — revoked token — drops the key and shows the gate). Sign out clears the key. `/api/config` stays public (the gate UI itself needs the branding).
|
||||||
|
- **Auth error semantics:** an unauthenticated (or revoked) caller to a `require_user` endpoint gets 401 `{"detail": "authentication required"}` — 401, not 403 (there is no higher privilege that would unblock them); `require_admin` keeps its 403 `admin only`. `POST /api/token-auth` failures (malformed / unknown / revoked) all get ONE generic 401 `{"detail": "invalid token"}` (no enumeration — the phase-16 pattern).
|
||||||
|
- **`whoami` shape:** `WhoamiResponse{authenticated: bool, role: "admin"|"user"|"anonymous"}` — `authenticated` is true for admin AND user; ALL UI gating switches from `authenticated` to `role === "admin"` (the frontend change is owned by task 05). An admin-signed-in session keeps working exactly as today (a browser that holds BOTH an admin and a user session reports admin; `sign_out` clears everything — one session dict, one logout).
|
||||||
|
|
||||||
|
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||||
|
- **Model — `api_tokens` (migration `0012_api_tokens.py`):** `id` UUID PK (uuid4 default); `label` String(120) NOT NULL (the hand-out name, e.g. "alice" — display-only, no index, not unique); `token_hash` String(64) NOT NULL UNIQUE (the sha256 hex digest of the full `bor_…` string — the `documents.content_hash` String(64) precedent); `created_at` TIMESTAMPTZ NOT NULL server-default now; `last_used_at` TIMESTAMPTZ NULL; `revoked_at` TIMESTAMPTZ NULL.
|
||||||
|
- **Service — `app/core/tokens.py` (new):** `generate_token() -> str` (`"bor_" + secrets.token_hex(16)`); `hash_token(token) -> str` (sha256 hexdigest of the FULL token — hashing the full string, not the suffix, so a stripped prefix can never collide); `create_token(db, label) -> tuple[ApiToken, str]` (returns the row + the plaintext exactly once — the row only ever carries the hash); `find_active_by_token(db, token) -> ApiToken | None` (hash → `token_hash ==` lookup → `revoked_at IS NULL`); `mark_used(tok)` (bump `last_used_at` to now — the caller commits); `revoke(db, token_id) -> bool` (set `revoked_at` when not already — False when the row is missing). Module docstring: the lookup is by HASH (a unique-index hit) — sha256's pre-image resistance means there is no token-enumeration or timing surface beyond the DB lookup (the contrast with `check_password`'s constant-time compare is documented, not replicated — there is nothing to compare in constant time here, only to look up).
|
||||||
|
- **Admin API — `app/api/tokens.py` (new router, `tags=["tokens"]`, router-level `dependencies=[Depends(require_admin)]` — the `doc_drafts.py` pattern):** `POST /tokens` body `TokenCreateRequest{label}` → 201 `TokenCreated{id, label, token, created_at}` — the ONLY response that ever carries the plaintext; `GET /tokens` → `TokenList{tokens: [TokenListItem{id, label, created_at, last_used_at, revoked: bool}]}` newest-first (no hashes, no plaintext); `POST /tokens/{id}/revoke` → 204, idempotent (already-revoked → still 204; unknown id → 404 `token not found`). Registered in `app/main.py` with the other API routers (before the static mount).
|
||||||
|
- **Auth API — `app/api/auth.py`:** new `POST /token-auth` (PUBLIC — it is the login): body `TokenAuthRequest{token}` → `find_active_by_token` → miss → 401 `invalid token`; hit → `mark_used` + commit + `session[USER_SESSION_KEY] = True` + `session[USER_TOKEN_ID_KEY] = str(token.id)` → 204. `whoami` reports the three roles. `logout` is unchanged (its `session.clear()` already wipes both roles).
|
||||||
|
- **`require_user` (in `app/core/auth.py`):** `def require_user(request: Request, db: Session = Depends(get_db))` — admin key set → pass; `user` key set → fetch the `ApiToken` row by `user_token_id` (PK hit) — row missing OR `revoked_at` set → pop BOTH user keys from the session + raise 401 `authentication required`; else pass; neither key → 401 same detail. Applied to exactly three endpoints: `POST /api/chat` (`app/api/chat.py`), `GET /api/suggestions` (`app/api/suggestions.py`), `GET /api/documents/content` (`app/api/docs.py` — update its docstring: the phase-16 "deliberately PUBLIC soft rule" is SUPERSEDED — the shared chats page is now the anonymous surface). Everything else: unchanged.
|
||||||
|
- **Public list (the only anonymous access — the owner's sentence):** `/api/health`, `/api/config`, `/api/whoami`, `/api/login`, `/api/token-auth`, `/api/shared/<token>` (JSON snapshot) + the `/shared/<token>` page + `shared.html`, the static assets, and the page documents themselves (`login.html`, `document.html`, the shell — the documents load; their GATED DATA does not: the shell shows the gate, `document.html` shows its inline gate).
|
||||||
|
- **Frontend gate (task 05):** new `frontend/assets/token-gate.js` (module) exposing `mountGate(lockRoot, onAuthed)`: at call — (1) if `localStorage["bor.token"]` exists → `POST /api/token-auth` with it (silent; on failure remove the key — it may have been revoked — and fall through); (2) `fetchWhoami()` → `user` or `admin` → `onAuthed()` (the gate never shows); `anonymous` → show the gate AND `lockRoot.inert = true` (the shell passes `#main`; `document.html` passes its content wrapper) + focus the token input. Submit → token-auth → 204 → `localStorage.setItem("bor.token", …)` → whoami → user → hide the gate (`hidden` + `inert` on the gate — the ship-hidden pattern), `lockRoot.inert = false`, `onAuthed()`. 401 → `#auth-gate-error` (`role="alert"`) visible, input cleared + re-focused. All `localStorage` access in try/catch (private mode → the gate still works, caching is a no-op — the fail-silence storage contract). `header.js`: the single whoami now caches the FULL `{authenticated, role}` in one module promise (`fetchWhoami()`); `fetchIsAdmin()` becomes `fetchWhoami().then(w => w.role === "admin")` — SAME single request, all existing callers keep working; `initSharedHeader()` switches its admin variable to `role === "admin"` (byte-identical behavior for admin/anonymous; a `user` gets: sign-in hidden, sign-out visible, all admin nav links hidden, steering panel removed — the anonymous branch); the sign-out binding gains `localStorage.removeItem("bor.token")` (try/catch, before the reload).
|
||||||
|
- **Gate markup (shell — `index.html`):** body-level `<section class="auth-gate" id="auth-gate" hidden inert aria-labelledby="auth-gate-title">` AFTER `#main` (a `position: fixed; inset: 0` overlay — the body-level doc-modal precedent): the `#sources-gate` visual language (glyph, h2 `#auth-gate-title` "Enter your access token", sub-text pointing at the admin, a `<form id="auth-gate-form">` with a visually-hidden label + `<input id="auth-gate-input" type="text" autocomplete="off" autocapitalize="none" spellcheck="false" required>` (mono), a [Sign in] submit, `#auth-gate-error` (`role="alert"`, hidden), and a "Sign in as admin" link to `/login.html?next=/` (the header's `?next=` convention). `document.html` carries the same markup as `#doc-auth-gate` (task 05).
|
||||||
|
- **Tokens admin view (task 06):** a sixth navbar view folded per the phase-76 pattern — `#nav-tokens` (ships hidden; `header.js` reveals it for role admin, same contract as the other four links), `#view-tokens` section in `index.html`, new `frontend/assets/tokens.js` (`export async function mount(root)`, admin-gated via `fetchIsAdmin()` like `history.js`), `router.js` entries in `VIEW` / `VIEW_PATH` / `VIEW_MODULES` / `TITLES` / `DESCRIPTIONS` (the brand-composition `replaceAll` contract carries over), `"/tokens.html"` in BOTH `app/main.py`'s `_shell_routes` tuple and `app/core/caching.py`'s `HTML_PAGES` (the no-cache + `?v=` contract — the phase-76 task-03 integration-test updates apply: the shell-route / title-table / `_page_file` override assertions gain the path). UI: a create row (label input + [Generate]) → the plaintext appears ONCE in a mono read-only field + [Copy] (the clipboard + inline-fallback house pattern — `tokens.js` keeps its own ~10-line copy, the per-page duplication house style); the once-block is NOT re-shown on a re-render/re-show (the plaintext is gone); a full-width table Label | Created | Last used | Status (Active em-dash vs rose Revoked pill — the stale-pill visual language) | Actions (Revoke — the inline two-step confirm, the `history-confirm-*` pattern, focus to Yes); a `role="status"` live region.
|
||||||
|
- **E2E migration (task 04):** ten chat suites POST to `/api/chat` anonymously today and must sign in first (`auth_helpers.login(page, app_url, next="/")`): `test_agent_document_tools.py`, `test_agent_unlimited_tools.py`, `test_chat_rag.py`, `test_grep_regex_teaching.py`, `test_harness_aligned_tools.py`, `test_honest_deflection.py`, `test_llm_retry.py`, `test_search_tool.py`, `test_tool_path_teaching.py`, `test_tool_scaffolding_guardrails.py`. `auth_helpers.py` gains `login_with_token(page, app_url, token)` — it drives the REAL gate (fill `#auth-gate-input` → submit → wait for the gate to hide); `tests/e2e/test_admin_auth.py`'s anonymous pins that the app is open (chat streams, viewer public) are updated to the 401/gate contract (its password-flow assertions stay). The shared-chat suites stay ANONYMOUS — that is the point of the item.
|
||||||
|
- **Integration test updates (task 03):** the tests that hit the three gated endpoints anonymously (`tests/integration/test_api.py`, `test_chat_api.py`, `test_auth_api.py`, …) sign in as admin first or assert the new 401 where the test's purpose IS the auth contract.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
— (none; extends the completed phase-16 auth; phase 80 builds on this phase's `/api/suggestions` gating)
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_token_model_migration.md` — the `api_tokens` model + migration `0012_api_tokens.py`.
|
||||||
|
2. `02_token_admin_api.md` — the token service + the admin create/list/revoke endpoints.
|
||||||
|
3. `03_token_auth_enforcement.md` — `POST /api/token-auth`, the three-role whoami, `require_user` (live revoke check), enforcement on chat/suggestions/document-content, the integration-contract updates.
|
||||||
|
4. `04_migrate_anonymous_e2e.md` — the `login_with_token` helper + the ten anonymous chat suites sign in; the E2E inventory is green against the gated app.
|
||||||
|
5. `05_frontend_token_gate.md` — the header role plumbing + the shell gate + the localStorage caching + the `document.html` gate.
|
||||||
|
6. `06_tokens_admin_view.md` — the admin Tokens view (phase-76 fold pattern) with generate / list / revoke.
|
||||||
|
7. `07_e2e_story_suite.md` — `tests/e2e/test_api_tokens.py` — the owner's sentence, pinned in a browser.
|
||||||
|
8. `08_regression_sweep_commit.md` — the full pipeline + the README auth section + the atomic commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: `tests/unit/test_tokens.py` (the service — shape, hash, create/find round-trip, revocation, last-used, the malformed/unknown/revoked miss paths) + `tests/unit/test_auth.py` extended (the `require_user` matrix: admin pass, active user pass, revoked user 401 + session keys popped, missing row 401, anonymous 401).
|
||||||
|
- Integration: the admin API (201 plaintext-once, list shape without hashes, revoke idempotency, 403 anonymous, 403 token-user); token-auth (valid / invalid / revoked / malformed); the enforcement matrix on the three endpoints; whoami's three roles; logout clearing the token session; the existing anonymous-chat pins updated to the 401 contract.
|
||||||
|
- E2E: the new story suite (task 07) + the migrated suites (task 04) + `test_admin_auth.py` updated + the shared-chat suites green ANONYMOUS.
|
||||||
|
- Coverage: **>90%** on `app/` (the delta: `app/core/tokens.py`, `app/api/tokens.py`, and the modified auth/chat/suggestions/docs files — every new branch tested).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_api_tokens.py -v --no-cov` green in isolation.
|
||||||
|
- [ ] A token user (fresh browser context) can chat end-to-end (mock LLM) and open a cited document; an anonymous caller gets the gate in the UI and 401s on the API; shared chats open anonymously; every admin surface 403s the token user.
|
||||||
|
- [ ] The cached token survives a reload with no re-entry; sign out clears it; a revoked token is refused on the next request AND on a fresh login attempt.
|
||||||
|
- [ ] Full suite green, coverage >90%, `uv run ruff check . && uv run pyright` clean; one atomic `--no-gpg-sign` commit; phase dir moved to `.agents/phases/complete/`.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Task 01 — The `api_tokens` model + migration 0012
|
||||||
|
|
||||||
|
**Phase:** `79_api_tokens` · **Source:** `TODO.md:5` — "Add api tokens that the admin can generate and hand out so people can log in to use the app."
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The storage for admin-issued access tokens: an `api_tokens` table (hashed token, label, lifecycle timestamps) behind alembic migration `0012_api_tokens.py`.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/models.py` — `class ApiToken(Base)` after `SavedChat` (the house mapped-column style): `id` UUID PK default uuid4; `label` String(120) NOT NULL (display-only — the hand-out name; no index, not unique); `token_hash` String(64) NOT NULL with `unique=True, index=True` (the sha256 hex digest of the full token — the `documents.content_hash` String(64) precedent); `created_at` DateTime(timezone=True) server-default `func.now()`; `last_used_at` DateTime(timezone=True) NULL; `revoked_at` DateTime(timezone=True) NULL. Docstring: the trust model — the plaintext exists only in the 201 create response; the hash is the stored credential (the `saved_chats.share_token` / `doc_drafts.token` lineage, but HASHED because these are long-lived hand-out credentials, unlike the unguessable uuid4 link tokens).
|
||||||
|
2. `alembic/versions/0012_api_tokens.py` — `revision = "0012"`, `down_revision = "0011"`; the module docstring mirrors the `0011_doc_drafts.py` format (phase citation, per-column rationale, the hashed-credential decision); upgrade: `op.create_table("api_tokens", …)` mirroring the model, the `token_hash` unique index (match how `0009_saved_chat_share_token.py` created its unique index — check whether it used the column's `unique=True` or an explicit `op.create_index`, and follow the same shape); downgrade: `op.drop_table("api_tokens")`.
|
||||||
|
3. `tests/unit/test_api_tokens_model.py` (new) — follow the existing model-test precedent in `tests/unit/` (find how other models are unit-tested — schema-level assertions vs a test-DB flush): the table name, the column set + nullability, `token_hash` uniqueness (two tokens with the same hash collide), `label` not unique.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: item 3.
|
||||||
|
- Migration: `uv run alembic upgrade head` applies 0012 cleanly on the dev DB (Postgres up via `podman compose up -d db`) and `uv run alembic downgrade -1 && uv run alembic upgrade head` round-trips; the integration-suite schema bootstrap (however the existing integration tests create the schema — verify in `tests/conftest.py` — `create_all` or migrations) picks up the new table for tasks 02/03.
|
||||||
|
- Coverage: **>90%** on the new model code (import-level; the behavior lands with the service in task 02).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `uv run alembic upgrade head` / `downgrade -1` / `upgrade head` round-trips cleanly.
|
||||||
|
- [ ] Unit tests green; `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Task 02 — The token service + the admin create/list/revoke API
|
||||||
|
|
||||||
|
**Phase:** `79_api_tokens` · **Source:** `TODO.md:5` — "…api tokens that the admin can generate and hand out…"
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The admin surface: generate a named token (the plaintext shown exactly once), list tokens (no secrets), revoke one. All behind `require_admin`.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/core/tokens.py` (new) — per the phase design: `generate_token() -> str` (`"bor_" + secrets.token_hex(16)`); `hash_token(token: str) -> str` (`hashlib.sha256(token.encode("utf-8")).hexdigest()` — the FULL token string); `create_token(db, label: str) -> tuple[ApiToken, str]` (strip the label; the API layer guarantees non-empty — the service trusts it; returns the row + plaintext exactly once); `find_active_by_token(db, token: str) -> ApiToken | None` (hash → `token_hash ==` → `revoked_at IS None` — ANY other shape is a miss: the hash of a malformed string simply matches no row); `mark_used(tok: ApiToken) -> None` (bump `last_used_at` to `datetime.now(timezone.utc)` — the caller commits); `revoke(db, token_id: uuid.UUID) -> bool` (set `revoked_at` when not already; False when the row is missing). Module docstring: the lookup is by HASH (a unique-index hit) — sha256 pre-image resistance means no token-enumeration surface beyond the DB lookup (document the contrast with `check_password`'s constant-time compare — there is nothing to compare in constant time here, only to look up).
|
||||||
|
2. `app/schemas.py` — `TokenCreateRequest{label: str}` (validator: 1–120 chars after strip — fail loud, the house `ValueError` pattern); `TokenCreated{id, label, token: str, created_at}` (the ONLY schema that carries `token` — the plaintext, once); `TokenListItem{id, label, created_at, last_used_at: datetime | None, revoked: bool}`; `TokenList{tokens: list[TokenListItem]}`; `TokenAuthRequest{token: str}` (non-empty after strip — the 401-vs-422 choice: an empty/whitespace token is a MALFORMED login attempt → 401 `invalid token` from the endpoint, NOT a 422 — so NO min-length validator here; the endpoint checks `token.strip()` and 401s).
|
||||||
|
3. `app/api/tokens.py` (new router, `tags=["tokens"]`, router-level `dependencies=[Depends(require_admin)]` — the `doc_drafts.py` pattern):
|
||||||
|
- `POST /tokens` → 201 `TokenCreated` — `create_token` + commit, then respond (the plaintext in this response is the one and only moment);
|
||||||
|
- `GET /tokens` → `TokenList` — newest-first (`created_at` desc, `id` desc tiebreak); `revoked` derived from `revoked_at is not None`; NO `token` or `token_hash` field ever appears;
|
||||||
|
- `POST /tokens/{id}/revoke` → 204 — idempotent (already-revoked → still 204, no re-stamp); unknown id → 404 `token not found`.
|
||||||
|
4. `app/main.py` — register: `app.include_router(tokens_router, prefix="/api")` with the other API routers (before the static mount, the existing comment's "API routes first" contract).
|
||||||
|
5. `tests/unit/test_tokens.py` (new) — `generate_token` shape (`^bor_[0-9a-f]{32}$`, two calls differ); `hash_token` determinism + 64-hex length + full-string semantics (hashing `bor_X` ≠ hashing `X`); `create_token`/`find_active_by_token` round-trip (active hit); `find_active_by_token` returns None for: revoked token, unknown well-formed token, empty string, short string, wrong prefix (these are all "hash matches no row" — the generic-miss contract); `revoke` sets the stamp once (second call idempotent, returns False only for a missing id); `mark_used` stamps `last_used_at`.
|
||||||
|
6. `tests/integration/test_tokens_api.py` (new, the house TestClient + admin-login pattern from `tests/integration/test_auth_api.py`) — anonymous: 403 on all three endpoints; admin (signed in via `POST /api/login`): create → 201, body's `token` matches `^bor_[0-9a-f]{32}$`, and `GET /tokens` NEVER exposes it (no `token` key in items, no `token_hash`, the hash string itself absent from the serialized body); two tokens with the same label → both created (labels are not unique); create with blank label → 422; revoke → 204, the list item shows `revoked: true` + the row keeps its `last_used_at`; re-revoke → 204; revoke unknown id → 404; list is newest-first.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: item 5. Integration: item 6.
|
||||||
|
- Coverage: **>90%** on `app/core/tokens.py` + `app/api/tokens.py` (every branch — the miss paths, the idempotency, the 404).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] The admin can create / list / revoke tokens through the API; the plaintext appears exactly once (in the 201 body) and never in the list.
|
||||||
|
- [ ] Anonymous is 403 on all three (router-level dependency — a token user, once task 03 lands, will be 403 too; pin that in task 03's matrix).
|
||||||
|
- [ ] Unit + integration green; `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Task 03 — Token login, the `user` role, and the auth gate on the app API
|
||||||
|
|
||||||
|
**Phase:** `79_api_tokens` · **Source:** `TODO.md:5` — "…so people can log in to use the app. The only thing that should be accessible without an API token is shared chats."
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
A token becomes a session: `POST /api/token-auth` signs a token holder in (role `user`), `whoami` reports the three roles, and `require_user` (admin OR live token) guards the app surface — chat, suggestions, document content. Shared chats + login infra stay public; every admin surface stays admin-only.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/core/auth.py` — `USER_SESSION_KEY = "user"`, `USER_TOKEN_ID_KEY = "user_token_id"`; `def require_user(request: Request, db: Session = Depends(get_db)) -> None` (import `get_db` from `app.db`, the house dependency pattern): admin key set → return (an admin always passes, token state irrelevant); `user` key set → `select(ApiToken).where(ApiToken.id == uuid.UUID(session[USER_TOKEN_ID_KEY]))` — row missing OR `revoked_at is not None` → `request.session.pop(USER_SESSION_KEY, None)` + `request.session.pop(USER_TOKEN_ID_KEY, None)` (the dead session is dropped NOW — the next `whoami` is anonymous) + raise `HTTPException(401, detail="authentication required")`; else return; neither key → 401 same detail. `sign_in` (admin) is UNCHANGED — coexistence is deliberate: an admin key does not erase the user keys; `whoami` reports admin whenever the admin key is set; `sign_out`'s `session.clear()` already wipes both roles.
|
||||||
|
2. `app/api/auth.py` — `POST /token-auth` (PUBLIC — it is the login route): `TokenAuthRequest` body → `find_active_by_token(get_db_session, payload.token)` (obtain the DB session via the house `get_db` dependency) → miss (or `payload.token` empty/whitespace) → ONE generic 401 `{"detail": "invalid token"}` (malformed / unknown / revoked are indistinguishable — the phase-16 no-enumeration pattern) → hit: `mark_used` + commit + `request.session[USER_SESSION_KEY] = True` + `request.session[USER_TOKEN_ID_KEY] = str(row.id)` → 204 (the signed cookie is emitted by the SessionMiddleware on the session write — same mechanism as `/api/login`). `whoami`: `role` = `"admin"` if the admin key is set, else `"user"` if the user key is set, else `"anonymous"`; `authenticated = role != "anonymous"`. `WhoamiResponse.role` is a plain `str` with a `# "admin" | "anonymous"` comment (`app/schemas.py` ~line 92) — update the comment to `# "admin" | "user" | "anonymous"` (no type change needed). `logout` unchanged.
|
||||||
|
3. Enforcement — add the `_user: None = Depends(require_user)` parameter (the `_admin` naming precedent in `app/api/docs.py`) to exactly three endpoints:
|
||||||
|
- `app/api/chat.py` — `POST /chat`;
|
||||||
|
- `app/api/suggestions.py` — `GET /suggestions` (the endpoint gains the `db` dependency — needed by the dependency's signature; `get_db` is already the house pattern);
|
||||||
|
- `app/api/docs.py` — `GET /documents/content` — update the docstring: the phase-16 "Deliberately PUBLIC (soft rule)" note is SUPERSEDED by this phase — the shared chats page is the anonymous surface; the viewer content is token-or-admin.
|
||||||
|
4. `tests/unit/test_auth.py` — extend with the `require_user` matrix (the house unit pattern for dependencies — check how `require_admin` is unit-tested today and match it): admin session passes; active-user session passes; revoked-user session → 401 AND both user keys popped from the session dict; user session pointing at a missing row → 401 + popped; anonymous → 401 `authentication required`; admin + user coexistence → passes as admin.
|
||||||
|
5. `tests/integration/test_auth_api.py` — update the phase-16 pins to the new contract + add the token flows: `POST /token-auth` valid → 204 + `GET /api/whoami` → `{authenticated: true, role: "user"}`; invalid / revoked / malformed (short, wrong prefix, empty) → 401 `invalid token` (ALL the same body); whoami anonymous → `{authenticated: false, role: "anonymous"}`; whoami admin unchanged; logout after token-auth → whoami anonymous; REVOCATION MID-SESSION: token-auth → chat 200 → admin revokes via `POST /api/tokens/{id}/revoke` → next chat request 401 AND whoami is now anonymous (the live check cleared the keys). The old "chat still streams anonymously" pin becomes: anonymous `POST /api/chat` → 401 `authentication required`; admin `POST /api/chat` still streams (the existing streaming assertions survive under a signed-in client).
|
||||||
|
6. The other integration tests that hit the three gated endpoints anonymously — find them precisely: `rg -n '"/api/chat"|"/api/suggestions"|"/api/documents/content"' tests/integration` — sign in as admin first (the house TestClient login helper from `test_auth_api.py`) or assert the new 401 where the test's purpose IS the auth contract. `tests/integration/test_api.py` (the `client.post("/api/chat", json={"message": ""})` 4xx-shape check at ~line 377 — verify which status it pins: an empty message was a 422 validation; with `require_user` the ANONYMOUS client now 401s BEFORE validation — update to sign in first so the validation assertion keeps testing validation).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: item 4. Integration: items 5–6.
|
||||||
|
- Coverage: **>90%** on the modified `app/` files (`app/core/auth.py`, `app/api/auth.py`, and the three enforcement sites — every new branch exercised).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] The matrix holds: anonymous — chat 401, suggestions 401, document content 401, `/api/shared/<token>` 200, `/api/whoami` anonymous, `/api/config` 200, `/api/health` 200, `/api/login` + `/api/token-auth` reachable. Token user — chat 200 (stream), suggestions 200, document content 200, `/api/tokens` 403, `/api/docs` 403, `/api/chats` 403, `/api/steering` 403, `/api/git-sources` 403. Admin — everything as before.
|
||||||
|
- [ ] Revocation is enforced on the next request (chat 401 + whoami drops to anonymous).
|
||||||
|
- [ ] Full unit + integration suite green; `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Task 04 — The E2E suites meet the new auth contract
|
||||||
|
|
||||||
|
**Phase:** `79_api_tokens` · **Source:** `TODO.md:5` — "The only thing that should be accessible without an API token is shared chats."
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
No E2E suite may drive the gated app anonymously: the ten chat suites that POST to `/api/chat` without signing in sign in as admin first, `auth_helpers.py` gains the real token-gate helper (task 07 uses it), `test_admin_auth.py`'s anonymous pins are updated, and the full E2E inventory is green against the gated app.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/auth_helpers.py` — `login_with_token(page, app_url, token, next="/")`: `page.goto(f"{app_url}/")` → `expect(#auth-gate).to_be_visible()` (anonymous with no cached token — the test contexts are fresh, so no stored key) → `page.fill("#auth-gate-input", token)` → click the gate's submit → `expect(#auth-gate).to_be_hidden()` + the app is interactive (the composer reachable). Wrong-token contract (the `login` wrong-password mirror): a helper parameter or a second small function `login_with_token(page, app_url, token="bor_" + "0" * 32)` → `#auth-gate-error` (`role="alert"`) visible, the gate stays visible, `whoami` still anonymous (assert via the UI state — the gate is the proof).
|
||||||
|
2. Sign the TEN suites in — each chat-driving test gets `login(page, app_url, next="/")` at the top (the suites already import or can import `e2e.auth_helpers.login`; touch only the anonymous flows, leave any admin-context flows as they are): `test_agent_document_tools.py`, `test_agent_unlimited_tools.py`, `test_chat_rag.py`, `test_grep_regex_teaching.py`, `test_harness_aligned_tools.py`, `test_honest_deflection.py`, `test_llm_retry.py`, `test_search_tool.py`, `test_tool_path_teaching.py`, `test_tool_scaffolding_guardrails.py`.
|
||||||
|
3. `tests/e2e/test_admin_auth.py` — update the phase-16 anonymous pins that assert the app is open (anonymous chat streams; the document viewer opens anonymously) to the new contract (401 via the API / the gate visible in the UI); the password sign-in / sign-out / wrong-password assertions stay green UNCHANGED.
|
||||||
|
4. Audit sweep + full inventory: scan every remaining e2e file for anonymous use of a now-gated endpoint (`rg -n 'goto\(f?"\{app_url\}/?"|/api/chat|/api/suggestions|/api/documents/content' tests/e2e/*.py`) — each hit either signs in or is a deliberate anonymous-surface test: the shared-chat suites (`test_share_chat.py` et al.) MUST stay anonymous (that is the point of the item — assert they still pass), the smoke suite's page-loads are fine (the PAGES load; the gate shows — update smoke's expectations only if it asserts on gated content). Run the FULL E2E inventory (DB up, mock LLM) and fix only auth-contract breakage — no semantic changes to story behavior.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- E2E: the full inventory green per AGENTS.md rule 9 — at minimum each of the ten migrated files, `test_admin_auth.py`, `test_share_chat.py` (anonymous), `test_smoke.py`, and `test_nav_switch_keeps_stream.py` in isolation.
|
||||||
|
- Coverage: n/a (tests only) — the `app/` floor preserved.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] No suite drives `/api/chat`, `/api/suggestions`, or `/api/documents/content` anonymously.
|
||||||
|
- [ ] The shared-chat suites pass as ANONYMOUS — the one open surface, the owner's sentence, pinned.
|
||||||
|
- [ ] `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Task 05 — The in-app token gate + the browser caching
|
||||||
|
|
||||||
|
**Phase:** `79_api_tokens` · **Source:** `TODO.md:5` — "The web ui should ask for a token before letting a user through and should cache that token in browser storage so they don't have to keep entering it."
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
An anonymous visitor meets a token-entry gate on the shell (and the document viewer); a correct token unlocks the app and is cached in `localStorage` so the next visit re-auths silently; sign out clears it. The header learns the three roles without a second whoami request.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/header.js` — the single whoami now caches the FULL response: a module-level `whoamiPromise` storing `{ authenticated, role }` (network failure / non-2xx → `{ authenticated: false, role: "anonymous" }` — the anonymous-safe contract, unchanged in spirit); `export function fetchWhoami()` returns that promise (the new canonical call); `export function fetchIsAdmin()` becomes `fetchWhoami().then(w => w.role === "admin")` — SAME single `/api/whoami` request (the string `/api/whoami` appears in this file exactly once), all existing callers keep working with zero changes. `initSharedHeader()` switches its local `admin` variable to `role === "admin"`: admin/anonymous behavior is byte-identical; a `user` gets the anonymous branch (sign-in hidden, sign-out visible, `#nav-sources` / `#nav-git-sources` / `#nav-tuning` / `#nav-history` hidden, the steering panel REMOVED — `/api/steering` is 403 for a user, so it must never be fetched). The sign-out binding gains `try { localStorage.removeItem("bor.token"); } catch {}` BEFORE the `window.location.reload()` (the fail-silence storage contract).
|
||||||
|
2. `frontend/index.html` — a body-level gate AFTER `#main` (a `position: fixed; inset: 0` overlay — the body-level doc-modal precedent; the gate is the only interactive surface while visible):
|
||||||
|
```html
|
||||||
|
<section class="auth-gate" id="auth-gate" hidden inert aria-labelledby="auth-gate-title">…</section>
|
||||||
|
```
|
||||||
|
Content (the `#sources-gate` visual language — glyph, heading, sub, action): h2 `#auth-gate-title` "Enter your access token"; a sub line ("Ask the admin for a token — it opens chat, the answers, and the documents they cite. Shared chats stay open."); a `<form id="auth-gate-form">` with `<label class="visually-hidden" for="auth-gate-input">Access token</label>`, `<input id="auth-gate-input" name="token" type="text" autocomplete="off" autocapitalize="none" spellcheck="false" required>`, a submit button (the house button styling) labeled "Sign in"; `<p class="auth-gate-error" id="auth-gate-error" role="alert" hidden>`; and a "Sign in as admin" link to `/login.html?next=/` (the header's `?next=` convention — the static href is the no-JS fallback).
|
||||||
|
3. `frontend/assets/token-gate.js` (new module) — `export function mountGate(lockRoot, onAuthed)` (reusable — the shell passes `#main`, `document.html` passes its content wrapper):
|
||||||
|
- at call: (1) if `localStorage["bor.token"]` exists (try/catch) → `POST /api/token-auth` with it — SILENT; on any failure `localStorage.removeItem("bor.token")` (it may have been revoked) and fall through to the whoami check; (2) `fetchWhoami()` → role `user` or `admin` → `onAuthed()` (the gate NEVER shows — no flash for a cached valid token); role `anonymous` → show the gate (drop `hidden` AND `inert` on `#auth-gate`), `lockRoot.inert = true` (the locked app must not receive focus or keyboard traversal — WCAG, the inert-pair contract), focus `#auth-gate-input`;
|
||||||
|
- form submit (preventDefault): `POST /api/token-auth` → 204 → `localStorage.setItem("bor.token", token)` (try/catch) → `fetchWhoami()` re-fetch (the promise cache must be invalidated for THIS re-fetch — either re-fetch directly or clear the module cache; document the choice) → role `user` → hide the gate (re-add `hidden` + `inert`), `lockRoot.inert = false`, `onAuthed()`; → 401 → `#auth-gate-error` visible with "That token isn't valid — check it with the admin.", input cleared + re-focused.
|
||||||
|
- the gate ships `hidden` + `inert` (the phase-16 ship-hidden pattern — an authenticated boot never shows it for a frame).
|
||||||
|
4. `frontend/index.html` — load `token-gate.js` (module, AFTER `app.js` and `router.js` — the boot-order comment updates) with its boot call: `mountGate(document.getElementById("main"), () => {})` — in the shell, `onAuthed` needs no view work: the lazy views mount on first show exactly as today (mount-once, hide-forever untouched), and the already-mounted views keep their state.
|
||||||
|
5. `frontend/document.html` + `frontend/assets/document.js` — the content endpoint is now `require_user`-gated, so a direct anonymous URL shows the inline gate instead of a content error: add the same gate markup to `document.html` as `<section class="auth-gate" id="doc-auth-gate" hidden inert …>` (reusing the shell's copy, the id renamed), load `token-gate.js`, and wire `mountGate(document.getElementById("main"), onAuthed)` — document.html's content root is `<main id="main" class="app-main">` (the same id as the shell's — separate documents, so no collision) where `onAuthed` runs the EXISTING boot sequence (whoami → load content). An admin (or a validly cached token user) on `document.html` gets `onAuthed` immediately — the gate never shows. The admin-only edit affordance (`docAdminReady()` → `role === "admin"`) stays admin-only.
|
||||||
|
6. `frontend/assets/styles.css` — `.auth-gate` (fixed overlay, `z-index` above the app content but below the doc-modal — check the existing z-index ladder; solid `--bg` + the grid is inherited from `html`, so the gate reads as the app's own surface; centered inner card on the `--surface` with the `sources-gate` spacing), the input (mono font, `--surface` background, visible focus ring — WCAG, 4.5:1 text), the error line (the rose/danger family used by `#history-status`-style alerts), the admin link (`.sources-gate-link` reuse), the button (the house submit-button language). Mobile: the overlay scrolls when the viewport is short.
|
||||||
|
7. `tests/unit/test_token_gate.py` (new, source-level house pattern — read the JS sources, no browser): `token-gate.js` contains the `bor.token` localStorage key literal; the silent re-auth attempt happens BEFORE the whoami check (source ordering); a failed silent re-auth removes the key (the `removeItem` call sits in the failure path); `header.js` — `fetchWhoami` is exported, `fetchIsAdmin` delegates to it, and the string `fetch("/api/whoami")` appears in `header.js` exactly once (the single-request contract — the file's comments also mention whoami, so pin the fetch call, not the word); the sign-out binding removes `bor.token`; `index.html` loads `token-gate.js` after `router.js`.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: item 7.
|
||||||
|
- E2E: the story suite (task 07) covers the gate flows end to end; a quick manual pass now (real server): anonymous → gate; wrong token → error; right token → unlock + reload with no gate; sign out → gate back.
|
||||||
|
- Coverage: n/a (frontend) — the `app/` floor preserved.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] Anonymous first visit: the gate is the only interactive surface (Tab never reaches the `#message-input` composer — the lock is `#main.inert`); a valid token unlocks WITHOUT a reload.
|
||||||
|
- [ ] A cached valid token re-auths silently on reload — the gate never shows.
|
||||||
|
- [ ] A REVOKED cached token is dropped (localStorage empty) + the gate reappears.
|
||||||
|
- [ ] `login.html` and `shared.html` are UNTOUCHED and their suites stay green; the header's admin/anonymous behavior is byte-identical (the phase-16 + phase-19 suites green).
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Task 06 — The admin Tokens view (generate · list · revoke)
|
||||||
|
|
||||||
|
**Phase:** `79_api_tokens` · **Source:** `TODO.md:5` — "…api tokens that the admin can generate and hand out…"
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The admin UI for tokens: a sixth navbar view (admin-only, folded per the phase-76 pattern) where the admin generates a named token (the plaintext shown once, copyable), lists all tokens with their lifecycle, and revokes with an inline two-step confirm.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/index.html` — (a) `#app-nav`: after the History link, `<a href="/tokens.html" class="nav-link" id="nav-tokens" hidden>Tokens</a>` (ships hidden — the phase-16 anonymous-safe pattern; the mobile dropdown copy is NOT needed — the link lives in the same `#app-nav` element the hamburger opens, exactly like the other four); (b) after `#view-history`: `<section class="view" id="view-tokens" hidden inert aria-label="Tokens" tabindex="-1">` containing `.container > .page-head` (h1 "Access tokens", sub: "Generate a token and hand it out — it opens chat, the answers, and the documents they cite. Shared chats stay open."), a `#tokens-gate` (the `#history-gate` pattern — the `sources-gate` visual language, sign-in link to `/login.html?next=/tokens.html`), a `<span class="tokens-status" id="tokens-status" role="status" aria-live="polite">` live region, a create row: `<input id="token-label" maxlength="120" placeholder="e.g. alice" aria-label="Token label">` + `<button type="button" class="token-generate" id="token-generate">Generate</button>`, a `#token-once` block (`hidden`): the "shown once" copy line, a mono read-only field `<input id="token-once-value" readonly>` + `<button type="button" id="token-once-copy" aria-label="Copy token">Copy</button>`, and the full-width table (AGENTS.md rule 5 — no skinny list): `#tokens-table` with thead Label | Created | Last used | Status | Actions (the Actions header visually-hidden, the row buttons carry aria-labels — the history-table convention), `#tokens-tbody` + a hidden `#tokens-empty-row`.
|
||||||
|
2. `frontend/assets/tokens.js` (new — `export async function mount(root)`, the `history.js` structure as the template, ALL cells via textContent — labels are admin-derived, still textContent, the XSS-safe-by-construction house rule):
|
||||||
|
- admin gate: `if (!(await fetchIsAdmin()))` → show `#tokens-gate`, hide the table, NO fetch (the router 403s anonymous — the same request-log contract as the history view);
|
||||||
|
- `loadTokens()` → `GET /api/tokens` → rows: label; created (locale date+time, full ISO in `title`); last used (locale or "never"); Status — an "Active" em-dash vs a rose "Revoked" pill (the stale-pill visual language, `aria-label` on the cell in BOTH states — WCAG); Actions — Revoke (the inline two-step, the `history-confirm-*` pattern: first click swaps to "Revoke? [Yes] [No]", focus to Yes, Yes → `POST /api/tokens/<id>/revoke` → row re-renders Revoked + announce; No / failure restores) — Revoked rows show NO action (nothing left to revoke);
|
||||||
|
- generate: label from `#token-label` (blank → send `"token"` — the placeholder documents the fallback; the API's 1–120 validator is satisfied) → `POST /api/tokens` → 201 → `#token-once` visible with the plaintext in `#token-once-value` + [Copy] (clipboard + the inline-fallback house pattern — `tokens.js` keeps its OWN ~10-line copy, the per-page duplication house style) + announce "Token created — copy it now; it won't be shown again." → `loadTokens()` (the new row appears Active) → the once-block HIDES on the next `loadTokens()` / re-show (the plaintext is NOT stored anywhere client-side — no localStorage, no data attribute);
|
||||||
|
- the re-fetch contract from phase 77: `root.addEventListener("bor:view-refresh", () => { if (loaded) loadTokens(); })` — a re-show re-lists (and re-hides the once-block, if one was up);
|
||||||
|
- every action lands a line in `#tokens-status` (success or failure — the never-stale feedback contract).
|
||||||
|
3. `frontend/assets/router.js` — the phase-76 fold entries: `VIEW["/tokens.html"] = "tokens"`; `VIEW_PATH.tokens = "/tokens.html"`; `VIEW_MODULES.tokens = () => import("./tokens.js")`; `TITLES.tokens = "Access tokens · Brain of Reese"`; `DESCRIPTIONS.tokens = "Generate and revoke the API tokens that let people use the app."` (the `replaceAll` brand-composition contract applies — no hardcoded-name write).
|
||||||
|
4. `frontend/assets/header.js` — reveal `#nav-tokens` for role admin in `initSharedHeader()` (the SAME ship-hidden / reveal-for-admin contract as the other four links — one more line, same pattern).
|
||||||
|
5. `app/main.py` — `"/tokens.html"` into the `_shell_routes` tuple (the list is caller-driven — the phase-76 comment documents exactly this extension); `app/core/caching.py` — `"/tokens.html"` into `HTML_PAGES` (the no-cache + `?v=` contract for the deep link). Then the phase-76 task-03 test updates: run `uv run pytest tests/integration` and extend whatever asserts the shell-route / title-table / `_page_file`-override map (the phase-76 task 03 work items named these — follow the same shape for the sixth path).
|
||||||
|
6. `frontend/assets/styles.css` — the create row (flex, wraps ≤640px), the once-block (mono field, the copy button — the `share-link-fallback` visual language), the table (the `history-table` visual language — full-width, the AGENTS.md rule-5 shape), the Active/Revoked pills (the `stale-pill` rose for Revoked, a plain em-dash for Active), `focus-visible` + 4.5:1 throughout.
|
||||||
|
7. `tests/unit/test_frontend_router.py` — the view-map pins adapt to the sixth entry (mechanism-level pins should hold as-is — verify; if a pin enumerates the views, extend the enumeration).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: item 7 + the source-pin convention for view modules (if the house pins the other four modules' `export async function mount`, `tokens.js` gets the same pin).
|
||||||
|
- Integration: the `/tokens.html` shell-route + caching assertions (item 5).
|
||||||
|
- E2E: covered by the task-07 story suite (admin UI: generate → once-field + copy, list, revoke two-step).
|
||||||
|
- Coverage: **>90%** on the modified `app/` code (`main.py` tuple + `caching.py` list — one line each, exercised by the integration assertions).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] The admin sees the Tokens nav link (desktop + mobile menu); anonymous and token users never do (ship-hidden + role check — even mid-DOM, the link is `hidden`).
|
||||||
|
- [ ] Direct load of `/tokens.html` deep-links to the view (admin: the table; anonymous: the gate) and carries the no-cache + `?v=` contract (the cache-busting suites green with the new page).
|
||||||
|
- [ ] Generate → plaintext once + copy works (clipboard + the http fallback); revoke → two-step → Revoked; the cache-busting + nav-switch suites stay green.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Task 07 — The story E2E suite: `tests/e2e/test_api_tokens.py`
|
||||||
|
|
||||||
|
**Phase:** `79_api_tokens` · **Source:** `TODO.md:5` — "Add api tokens that the admin can generate and hand out so people can log in to use the app. The only thing that should be accessible without an API token is shared chats. The web ui should ask for a token before letting a user through and should cache that token in browser storage so they don't have to keep entering it."
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The owner's sentence, pinned in a real browser: the admin generates a token and hands it out (a fresh context); the holder uses the app; the ONLY anonymous content is shared chats; the cached token removes the re-entry; revocation closes the door.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/test_api_tokens.py` (NEW — run in isolation: `uv run pytest tests/e2e/test_api_tokens.py -v --no-cov`; DB up, the mock-LLM fixture; fresh browser contexts per scenario — no cached token leaks between tests):
|
||||||
|
- **anonymous is locked out:** fresh context → chat page: `#auth-gate` visible; the composer (`#message-input`) is NOT keyboard-reachable (the `#main` inert lock — the Tab-order assertion pattern from `test_suggestion_chips.py`'s keyboard walk, inverted); direct API with the context's (empty) cookies: `POST /api/chat` → 401, `GET /api/suggestions` → 401, `GET /api/documents/content?source=…&path=…` → 401.
|
||||||
|
- **shared stays open:** as admin, create + share a saved chat (`POST /api/chats` + `POST /api/chats/<id>/share` — the house API pattern from `test_share_chat.py`) → a FRESH context opens `/shared/<token>` anonymously → the conversation renders (no gate anywhere on that page).
|
||||||
|
- **admin generates (UI):** signed-in admin (`auth_helpers.login`) → nav to Tokens → label "e2e-alice" → Generate → `#token-once-value` carries `^bor_[0-9a-f]{32}$` (read it into the test) → the table shows an Active row "e2e-alice"; the once-block hides on a re-show (nav away + back → `#token-once` hidden — the plaintext is gone).
|
||||||
|
- **the token flow (fresh context):** `login_with_token(page, app_url, token)` (the task-04 helper) → gate hidden → ask a question (mock LLM) → the brain bubble renders → a cited source chip opens the document (the same-page modal) → the admin nav links (RAG, Sources, Tuning, History, Tokens) are ALL absent from `#app-nav` (the role-`user` contract) and Sign out is visible.
|
||||||
|
- **caching:** `page.reload()` → NO gate (`#auth-gate` hidden) — the silent re-auth from localStorage; the chat UI is interactive without re-entry.
|
||||||
|
- **admin-only walls (the token user's cookies, httpx):** `GET /api/tokens` 403, `GET /api/chats` 403, `GET /api/docs` 403, `POST /api/steering` 403, `GET /api/git-sources` 403.
|
||||||
|
- **sign out:** the token user clicks Sign out → back to the gate; `localStorage.getItem("bor.token")` is `null` (Playwright `page.evaluate`).
|
||||||
|
- **revocation:** admin revokes the token (UI two-step: Revoke → Yes) → the token user's NEXT action 401s (ask a question → the error banner, or assert `POST /api/chat` 401 with the context cookies) and a FRESH `login_with_token` attempt with the same token fails (`#auth-gate-error` visible, gate stays).
|
||||||
|
- **wrong token:** fresh context, `login_with_token(…, token="bor_" + "0" * 32)` → `#auth-gate-error` visible, still anonymous (the API also 401s — the generic message, no enumeration: the error body for a wrong-format token equals the one for a well-formed unknown token).
|
||||||
|
2. Run in isolation until green; fix app bugs the suite exposes (the suite is the spec — the owner's sentence).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- This file IS the story gate (AGENTS.md rules 4 + 9 — one file per story, run in isolation).
|
||||||
|
- Coverage: n/a (E2E) — the `app/` floor preserved.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_api_tokens.py -v --no-cov` green in isolation.
|
||||||
|
- [ ] Every clause of TODO.md L5 is asserted: generate (UI), hand out (fresh context), use the app (chat + document), only-shared-chats-open (the anonymous matrix), cached token (reload without re-entry), revocation (immediate refusal).
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Task 08 — Regression sweep + the commit
|
||||||
|
|
||||||
|
**Phase:** `79_api_tokens` · **Source:** `TODO.md:5`
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The full pipeline green against the gated app, the operator docs updated, one atomic commit, the phase closed.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. Full unit + integration: `uv run pytest --cov=app --cov-report=term-missing` — **>90%** on `app/` (the delta: `app/core/tokens.py`, `app/api/tokens.py`, and the modified `app/core/auth.py` / `app/api/auth.py` / `app/api/chat.py` / `app/api/suggestions.py` / `app/api/docs.py` — every new branch tested per tasks 02/03).
|
||||||
|
2. `uv run ruff check . && uv run pyright`.
|
||||||
|
3. E2E inventory spot-checks in isolation (the high-touch files): `test_api_tokens.py`, `test_admin_auth.py`, `test_share_chat.py` (ANONYMOUS), `test_chat_rag.py` (migrated), `test_nav_switch_keeps_stream.py` (the phase-76 contract under the gate), `test_smoke.py`.
|
||||||
|
4. Manual verification in a real browser (real LLM, real server): the admin generates a token; a private window enters it at the gate, chats, opens a cited document, reloads WITHOUT re-entry; an anonymous window meets the gate and opens a shared chat; the admin revokes the token and the private window's next question fails.
|
||||||
|
5. Operator docs: `README.md` — the "Admin & sign-in" section gains a short "API tokens" subsection (how to generate one in the Tokens view, what a token user can and cannot do, the gate + the browser cache, revocation semantics — immediate on the next request). `.env.example` — UNCHANGED (no new environment variable: tokens live in the DB, generated by the admin — verify no settings were added; if any task added one, the doc goes here).
|
||||||
|
6. ONE atomic `--no-gpg-sign` Conventional-Commits commit — e.g. `feat(auth): admin-issued API tokens gate the app; only shared chats stay anonymous` — body cites TODO.md L5 + the confirmed scope decision (A3–A5: token users get chat/suggestions/document viewer; every admin surface untouched). Move the phase dir to `.agents/phases/complete/`.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- The full suite IS the test; coverage **>90%** on `app/`.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] Items 1–4 all green/verified.
|
||||||
|
- [ ] README documents the token flow for the operator.
|
||||||
|
- [ ] Committed; phase dir in `.agents/phases/complete/`.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Phase 80 — Onboarding chips: the last 3 questions asked (the env var seeds a fresh deployment)
|
||||||
|
|
||||||
|
**Source:** `TODO.md` L6 — "The chat suggestions (suggestion chips) should be the last 3 questions asked rather than supplied by env vars. The env var should be used to offer questions before any have been asked as a 'seed' for a new deployment."
|
||||||
|
**Story:** n/a (TODO-derived — extends `.agents/user_stories/suggestion-chips.md` (phase 05))
|
||||||
|
**Context:** `app/api/suggestions.py` (`GET /api/suggestions` → `settings.suggestions` — a static list, 15 lines today), `app/config.py` (`suggestions: list[str]` — the 4 built-in defaults + the `BOR_SUGGESTIONS` env override; `.env.example:55`; `README.md:782` env-table row), `app/models.py` (`SavedChat.messages` — the JSONB `bor.chat.v1` record: a list of `{who, text, sources?, …}` entries, conversational order oldest→newest; `updated_at` stamped on save), `app/rag/suggestions.py` (the deflection "Maybe try" chips — `derive_suggestions`, a SEPARATE contract, untouched by this phase), `frontend/assets/app.js` (`loadSuggestions()` at boot → `renderChips` into `#suggestions` (role="list") inside `#empty-state` (~line 159 of `index.html`); `startNewChat()` — the empty state comes back with STALE chips), `tests/e2e/test_suggestion_chips.py` (the phase-05 suite — pins the settings-defaults contract; REWRITTEN in task 04 per the phase-76 precedent), `tests/integration/test_api.py` (current `/api/suggestions` integration pins).
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The onboarding chips reflect the deployment's recent activity: the 3 most recent questions actually asked (across saved chats), newest first. A brand-new deployment — zero saved questions — gets the seed list instead (`BOR_SUGGESTIONS`, or the built-in default while unset). The chips also refresh when the empty state comes back (New chat), so the row is never stale. The LLM "Maybe try" deflection chips are untouched.
|
||||||
|
|
||||||
|
## Owner decisions (chat, 2026-09-06 — recorded per AGENTS.md rule 3)
|
||||||
|
- **A6 confirmed:** "the last 3 questions asked" = the 3 most recent USER questions across ALL `saved_chats` — chats walked newest-`updated_at` first, each chat's `messages` walked newest-first, collecting entries with `who == "user"` and a non-blank `text` — de-duplicated (exact, case-sensitive match; a verbatim re-ask counts once), cap 3 applied AFTER dedup. 1–2 saved questions → exactly those show (NO mixing with the seed). ZERO saved questions → the full seed list.
|
||||||
|
- **A7 confirmed:** only the onboarding row (`#suggestions` in the empty state) changes. The deflection "Maybe try" chips (title-derived via `app.rag/suggestions.derive_suggestions`, carried in the chat response) keep their contract.
|
||||||
|
- **Scope is deployment-wide:** saved chats have no per-user attribution and are created only by the admin (phase 79 keeps the save surface admin-only) — the chips reflect the admin's recent questions; there is no per-user question history to scope by (and no new attribution is added in this phase).
|
||||||
|
|
||||||
|
## Design (shared by all tasks)
|
||||||
|
- **Extraction — `app/api/suggestions.py`:** the endpoint gains the `db` dependency and a module-level `last_questions(db, limit: int = 3) -> list[str]`: `SELECT … FROM saved_chats ORDER BY updated_at DESC, created_at DESC` (the tiebreak keeps the order deterministic when timestamps collide); for each chat, walk `chat.messages` (the JSONB column deserializes to a Python list of dicts — NO SQL JSON ops needed; the message counts are the `bor.chat.v1` conversation sizes) in REVERSE (newest first), collecting `m["text"].strip()` for entries where `m.get("who") == "user"`, skipping blanks, stopping once `limit` UNIQUE texts are collected (exact case-sensitive dedup — the docstring notes the choice: case-insensitive dedup would drop a legitimate differently-cased re-ask). Result order = encounter order (newest first).
|
||||||
|
- **The endpoint:** `qs = last_questions(db)` → `SuggestionList(suggestions=qs if qs else get_settings().suggestions)` — the seed (`BOR_SUGGESTIONS` override or the built-in default) appears ONLY when the walk yields zero questions. The endpoint is `require_user`-gated by phase 79 (admin OR token user) — no auth change here; the tests sign in first.
|
||||||
|
- **The deflection path is untouched:** `app/rag/suggestions.py` (`derive_suggestions` — used by the chat turn for the "Maybe try" row) does not call this endpoint and is not modified; its tests stay green.
|
||||||
|
- **Frontend — the refetch (task 03):** `startNewChat()` (the `bor:new-chat` handler in `app.js`) gains a `loadSuggestions()` call when it restores the empty state — the existing function (fetch `/api/suggestions` → `renderChips` into `#suggestions`, replacing the previous chips in place; progressive enhancement, swallows its own failures). The boot fetch stays. No other frontend change: the chips' one-tap submit, the role="list" semantics, and the "Maybe try" row are all existing contracts.
|
||||||
|
- **Docs (task 02):** `BOR_SUGGESTIONS` is documented as the SEED — shown only before any question has ever been saved — in the `config.py` docstring, `.env.example:55`, and the README (the env-table row + the chat feature description).
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- `79_api_tokens` (todo) — `/api/suggestions` is `require_user`-gated there; this phase's tests sign in first and build on that contract (a token user ALSO sees the deployment-wide chips — consistent with A6's deployment-wide scope).
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_last_questions_endpoint.md` — the `last_questions` extraction + the seed fallback + the integration state matrix.
|
||||||
|
2. `02_seed_semantics_docs.md` — the config / `.env.example` / README doc updates.
|
||||||
|
3. `03_chips_new_chat_refetch.md` — the app.js refetch on New chat + the source pin.
|
||||||
|
4. `04_e2e_suite_commit.md` — the story-suite rewrite to the new semantics + the full gate + the atomic commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Integration: the full state matrix for `/api/suggestions` (empty DB → the built-in seed; `BOR_SUGGESTIONS` override → the override while empty; 4 questions in one chat → the 3 newest; two chats → the `updated_at` order respected; dedup; exactly 2 questions → 2 chips, no top-up; brain-only messages never picked).
|
||||||
|
- E2E: the REWRITTEN `tests/e2e/test_suggestion_chips.py` run in isolation (the phase-76 precedent — a semantic change rewrites the story suite in place).
|
||||||
|
- Coverage: **>90%** on `app/` (the delta is the ~30-line helper + endpoint in `app/api/suggestions.py` — every branch covered).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov` green in isolation.
|
||||||
|
- [ ] The deflection chips are UNCHANGED (`derive_suggestions` + its suites green).
|
||||||
|
- [ ] `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. `feat(chat): onboarding chips are the last 3 questions asked; the env seed only before the first`); phase dir moved to `.agents/phases/complete/`.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Task 01 — `/api/suggestions` returns the last 3 questions asked (the seed before the first)
|
||||||
|
|
||||||
|
**Phase:** `80_history_suggestion_chips` · **Source:** `TODO.md:6` — "The chat suggestions (suggestion chips) should be the last 3 questions asked rather than supplied by env vars. The env var should be used to offer questions before any have been asked as a 'seed' for a new deployment."
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The onboarding chips become the 3 most recent user questions across saved chats (newest first, de-duplicated, cap 3); a fresh deployment with zero saved questions gets the seed list (`BOR_SUGGESTIONS` / the built-in default).
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/api/suggestions.py` — the endpoint gains `db: Session = Depends(get_db)` (import from `app.db` — the house pattern) and a module-level helper:
|
||||||
|
```python
|
||||||
|
def last_questions(db: Session, limit: int = 3) -> list[str]:
|
||||||
|
```
|
||||||
|
- query `SavedChat` ordered by `updated_at.desc(), created_at.desc()` (the tiebreak — deterministic when timestamps collide; `from app.models import SavedChat`);
|
||||||
|
- for each chat, walk `chat.messages` in REVERSE (conversational order is oldest→newest — the `bor.chat.v1` shape) collecting `str(m.get("text", "")).strip()` for entries where `m.get("who") == "user"`; skip blanks;
|
||||||
|
- de-dup EXACT (case-sensitive) against the collected window; stop once `limit` unique texts are collected; return in encounter order (newest first).
|
||||||
|
- docstring: the JSONB column deserializes to a Python list of dicts (no SQL JSON ops — the `SavedChat.messages` model docstring says the record shape is the `bor.chat.v1` list); the exact-dedup choice is documented (case-insensitive would drop a legitimately differently-cased re-ask); the helper is pure-DB (unit-testable without the endpoint).
|
||||||
|
- endpoint body: `qs = last_questions(db)` → `return SuggestionList(suggestions=qs if qs else get_settings().suggestions)` — update the module/endpoint docstring: this endpoint owns the "last-3-questions-or-seed" contract; the deflection "Maybe try" chips (`app.rag.suggestions.derive_suggestions`) are a separate contract, untouched.
|
||||||
|
- note: phase 79 gates this endpoint with `require_user` — if phase 79 has NOT landed yet when this task runs (it is queued before this phase, so it has), the endpoint already carries the dependency; do not remove it.
|
||||||
|
2. `tests/integration/test_suggestions_api.py` (new — the house integration pattern, admin sign-in first — the endpoint is authed):
|
||||||
|
- **empty DB** (no saved chats) → exactly the built-in default list (assert equality with `get_settings().suggestions` — and `tests/unit/test_config.py` keeps pinning that default);
|
||||||
|
- **seed override:** the `BOR_SUGGESTIONS` JSON parsing is ALREADY pinned at unit level by `tests/unit/test_config.py::test_suggestions_env_override_is_json_list` (`monkeypatch.setenv("BOR_SUGGESTIONS", json.dumps(…))`) — do NOT hack per-test env into the integration app; the integration contract is the env-agnostic one below it (empty DB → exactly `get_settings().suggestions`, whatever the environment makes that);
|
||||||
|
- **cap + order:** one saved chat with 4 user questions q1..q4 (plus brain replies between them) → chips == `[q4, q3, q2]` (newest first, cap 3);
|
||||||
|
- **chat order:** two saved chats with DISTINCT `updated_at` (set the timestamps explicitly on the rows) → the newer chat's questions are walked first — a question from the newer chat outranks a newer-LOOKING question from the older chat;
|
||||||
|
- **dedup:** the same question text asked in two chats → appears exactly once;
|
||||||
|
- **partial:** exactly 2 saved questions deployment-wide → exactly 2 chips (NO seed top-up — the A6 contract);
|
||||||
|
- **brain-only:** a chat whose messages are all `who == "brain"` (or blank user texts) → contributes nothing; an all-brain deployment → the seed.
|
||||||
|
- anonymous → 401 (phase-79 contract — one assertion so the auth state of this endpoint is pinned HERE too).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Integration: item 2 (every branch of `last_questions` + the fallback + the auth pin).
|
||||||
|
- Coverage: **>90%** on the modified `app/api/suggestions.py` (the helper's every branch: empty, partial, cap, dedup, blank-skip, brain-skip).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `GET /api/suggestions` (authed) returns exactly the designed contract in every state of item 2.
|
||||||
|
- [ ] `app/rag/suggestions.py` (`derive_suggestions`) is UNTOUCHED and its tests green; the deflection "Maybe try" E2E behavior unchanged.
|
||||||
|
- [ ] `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Task 02 — Document the seed semantics (config, `.env.example`, README)
|
||||||
|
|
||||||
|
**Phase:** `80_history_suggestion_chips` · **Source:** `TODO.md:6`
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The docs describe `BOR_SUGGESTIONS` as what it now is: the pre-first-question SEED — not "the" onboarding chips.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/config.py` — the comment block above `suggestions: list[str]` becomes: "Onboarding-chip SEED (phase 80, TODO.md L6): shown ONLY while no saved chat has ever asked a question — after that, `GET /api/suggestions` serves the last 3 questions asked (deployment-wide, newest first). `BOR_SUGGESTIONS` overrides this seed for a new deployment."
|
||||||
|
2. `.env.example` (line ~55) — the comment becomes: `# BOR_SUGGESTIONS=["How is my Kubernetes cluster set up?"] # JSON seed chips — shown only before any question has been saved (phase 80)`
|
||||||
|
3. `README.md` — the env-table row (`BOR_SUGGESTIONS`, ~line 782): "JSON seed for the onboarding chips — shown only before the first saved question; afterwards the chips are the last 3 questions asked (phase 80)".
|
||||||
|
4. `README.md` — wherever the chat / suggestion-chip feature is described (the feature list row ~line 263 and the deflection note ~line 829 stay accurate — the deflection chips are UNCHANGED; add/adjust ONE line in the chat feature description: the onboarding chips follow the last 3 questions asked, seeding from `BOR_SUGGESTIONS` on a fresh deployment).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Docs only — `tests/unit/test_config.py` and the stale-copy suites must stay green (no behavior change; the `suggestions` default list itself is UNTOUCHED).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] The three doc sites (config, `.env.example`, README) agree with the implemented contract.
|
||||||
|
- [ ] Full suite green; `uv run ruff check . && uv run pyright` clean.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Task 03 — The chips refresh when the empty state comes back (New chat)
|
||||||
|
|
||||||
|
**Phase:** `80_history_suggestion_chips` · **Source:** `TODO.md:6`
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The onboarding row is fetched once at boot; after "New chat" (or clearing a restored conversation) the empty state returns with STALE chips — the last-3 state moved on while the user was chatting. Refetch on `bor:new-chat` so the row always reflects the current last-3 state.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/app.js` — in `startNewChat()` (the `bor:new-chat` handler, ~line 1848): after `if (emptyState) emptyState.hidden = false;`, add `loadSuggestions();` — the existing function (fetch `/api/suggestions` → `renderChips` into `#suggestions`, which clears the previous chips in place; progressive enhancement — swallows its own failures, no error spam). The in-flight-turn guard at the top of `startNewChat` means the refetch only runs for a real new chat.
|
||||||
|
- The boot path is UNCHANGED: `loadSuggestions()` still runs at shell boot (first paint of the empty state). The `/?chat=<id>` boot opens a saved chat (empty state hidden) — when the user then clicks New chat, this refetch covers it. No other frontend change: the chips' one-tap submit, `role="list"` semantics, and the "Maybe try" deflection row are existing contracts.
|
||||||
|
2. Source pin (house pattern — read the JS source, no browser): in the app.js source-pin file that covers the new-chat flow (find where `bor:new-chat` / `startNewChat` is pinned today — `tests/unit/test_chat_persistence.py` or a `test_frontend_*.py` sibling; if none exists, add the pin to the most app.js-adjacent frontend source-pin file): `startNewChat` calls `loadSuggestions()` (the two literals, `startNewChat`'s function body containing the `loadSuggestions()` call — a containment assertion on the function's source slice, the house style).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: item 2 (the source pin).
|
||||||
|
- E2E: covered by the task-04 suite (the new-chat refetch assertion).
|
||||||
|
- Coverage: n/a (frontend) — the `app/` floor preserved.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] After New chat, `#suggestions` reflects a FRESH `/api/suggestions` response (the Playwright request log shows a second GET after the boot fetch).
|
||||||
|
- [ ] The source pin is green; `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Task 04 — Rewrite the story suite to the new semantics + sweep + the commit
|
||||||
|
|
||||||
|
**Phase:** `80_history_suggestion_chips` · **Source:** `TODO.md:6`
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
`tests/e2e/test_suggestion_chips.py` pins the NEW contract (the phase-76 precedent — a semantic change rewrites the story suite in place), the full pipeline is green, and the phase closes.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/test_suggestion_chips.py` — REWRITE (keep the file name — one suite per story, run in isolation; the endpoint is authed, so sign in as admin first via `auth_helpers.login`):
|
||||||
|
- **seed state:** fresh DB (no saved chats) → the chip texts equal the built-in default list — assert against a test-local constant copied from the `Settings.suggestions` default in `app/config.py` (the unit suite `test_config.py` pins only the SHAPE — ≥3 non-blank distinct strings — so this E2E literal is the pin for the exact seed list; keep it in sync with the config default); the chips render in `#suggestions` (role="list", the chip buttons) exactly as today.
|
||||||
|
- **last-3 state:** as admin, save two chats via `POST /api/chats` with known distinct questions (5 user questions total; set `updated_at` apart so the order is deterministic — the API stamps it; save the older one first) → a fresh page load → the chips are EXACTLY the 3 newest questions, in newest-first order.
|
||||||
|
- **partial state:** a DB with exactly 2 saved questions → exactly 2 chips (no seed top-up — the A6 contract, visible in the UI).
|
||||||
|
- **new-chat refetch:** load the page with the seed chips visible → via the API save a chat whose newest question is Q → click New chat (`#new-chat-btn`) → the chips now include Q (the refetch happened — the request log shows the second `GET /api/suggestions`).
|
||||||
|
- **carry over the story behavior** (unchanged semantics, same assertions as the old suite where they still hold): one-tap submit (chip click → composer filled → submitted — the mock-LLM brain bubble), Tab+Enter keyboard reachability of the chips (the original suite's keyboard-walk assertion).
|
||||||
|
- Run in isolation until green: `uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov`.
|
||||||
|
2. Full gate: `uv run pytest --cov=app --cov-report=term-missing` (>90%), `uv run ruff check . && uv run pyright`; the deflection-related suites green UNCHANGED (their chips are the LLM/title path — find them: `test_honest_deflection.py` and any "Maybe try" assertions — they must not have been touched by this phase).
|
||||||
|
3. ONE atomic `--no-gpg-sign` Conventional-Commits commit — e.g. `feat(chat): onboarding chips are the last 3 questions asked; the env seed only before the first` — body cites TODO.md L6 + the A6/A7 decisions (deployment-wide, exact dedup, no mixing, deflection untouched). Move the phase dir to `.agents/phases/complete/`.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- The full suite IS the test; coverage **>90%** on `app/`.
|
||||||
|
- E2E: the rewritten story suite in isolation (AGENTS.md rule 9).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov` green in isolation — all four states (seed / last-3 / partial / refetch) + the carried-over story behavior.
|
||||||
|
- [ ] Full suite green, coverage >90%, `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] Committed; phase dir in `.agents/phases/complete/`.
|
||||||
@@ -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 |
@@ -96,18 +96,34 @@ prints the `MISS` lines naming the condition. Interpret:
|
|||||||
- **caps > 0** — the phase-72 incident signature; a real regression,
|
- **caps > 0** — the phase-72 incident signature; a real regression,
|
||||||
say so explicitly.
|
say so explicitly.
|
||||||
|
|
||||||
### 5. Record + commit
|
### 5. Record to CSV + commit
|
||||||
|
|
||||||
Append the model's results to the model-comparison subsection of
|
**a. CSV record** — append to `benchmarks/model_benchmarks.csv` via
|
||||||
`TOOL_CALLING_TESTING.md` §3, keeping the `gate:` lines **byte-exact
|
`scripts/model_benchmark.bench_write`. For each of the 3 runs, call:
|
||||||
verbatim**, plus 2–4 sentences of interpretation against the reference
|
|
||||||
rates above and the wall-time baseline (lite ~43–55 s, turbo ~105–135 s
|
```python
|
||||||
per full loop).
|
from scripts.model_benchmark import bench_write
|
||||||
|
|
||||||
|
bench_write(
|
||||||
|
script="chat", model="<model>", mode="fixture",
|
||||||
|
gate_status="PASS", turns=10, answered=10, caps=0,
|
||||||
|
tool_turns=<N>, emitted=<E>, executed=<X>,
|
||||||
|
contract=<C>, wall_s=<wall>,
|
||||||
|
contract_denom=<C_denom>, executed_denom=<E_denom>,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
For the derived run, use `mode="derived"`.
|
||||||
|
|
||||||
|
**b. Append to TOOL_CALLING_TESTING.md** — keep the `gate:` lines
|
||||||
|
**byte-exact verbatim**, plus 2–4 sentences of interpretation against
|
||||||
|
the reference rates above and the wall-time baseline (lite ~40–55 s,
|
||||||
|
turbo ~97–135 s per full loop).
|
||||||
|
|
||||||
Commit — docs only, house style:
|
Commit — docs only, house style:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add TOOL_CALLING_TESTING.md
|
git add TOOL_CALLING_TESTING.md benchmarks/model_benchmarks.csv
|
||||||
git commit --no-gpg-sign \
|
git commit --no-gpg-sign \
|
||||||
-m "docs(agent): record the <model> comparison on the controlled fixture battery" \
|
-m "docs(agent): record the <model> comparison on the controlled fixture battery" \
|
||||||
-m "<one-paragraph body: the numbers, the re-read rate, the wall time, any MISS nuance>"
|
-m "<one-paragraph body: the numbers, the re-read rate, the wall time, any MISS nuance>"
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
name: test-embed-model
|
||||||
|
description: Tests an embedding model (BOR_LLM_EMBED_MODEL in .env) across three dimensions — dimension consistency, cosine accuracy on semantic pairs, and embedding speed — then records results in benchmarks/model_benchmarks.csv. Use when the user asks to test or benchmark an embedding model, check embedding quality, or compare embedding models (e.g. "test the embed model", "check embedding dimension", "benchmark embed speed").
|
||||||
|
---
|
||||||
|
|
||||||
|
# Test an Embedding Model (dimension + cosine + speed)
|
||||||
|
|
||||||
|
Tests the configured `BOR_LLM_EMBED_MODEL` (default `embed`) against
|
||||||
|
three independent checks:
|
||||||
|
|
||||||
|
1. **Dimension check** — output vector length matches `BOR_EMBEDDING_DIM`
|
||||||
|
2. **Cosine accuracy** — semantically similar text pairs have higher
|
||||||
|
cosine similarity than dissimilar pairs (5 pairs tested)
|
||||||
|
3. **Speed** — vectors produced per second (50 vectors)
|
||||||
|
|
||||||
|
## Rules (non-negotiable)
|
||||||
|
|
||||||
|
- **Do not touch** the semantic pair texts — they are the controlled
|
||||||
|
benchmark corpus. Changing them is a methodology change: flag it.
|
||||||
|
- **Do not edit app code** (`app/`, `tests/`). This skill tests a model,
|
||||||
|
not the app. If the model exposes an app defect, report it — don't fix it.
|
||||||
|
- `.env` is gitignored and is **not committed** — the model switch stays
|
||||||
|
a live dev setting, and the summary must say which model `.env` is left
|
||||||
|
on (default: the tested model).
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
All commands run from the repo root with `uv run`.
|
||||||
|
|
||||||
|
### 1. Preconditions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman compose up -d db
|
||||||
|
grep -E "BOR_LLM_(BASE_URL|API_KEY|EMBED_MODEL|EMBEDDING_DIM)" .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Switch the model (optional)
|
||||||
|
|
||||||
|
Edit only `BOR_LLM_EMBED_MODEL` in `.env` (leave chat and summary alone):
|
||||||
|
|
||||||
|
```
|
||||||
|
BOR_LLM_EMBED_MODEL=<model>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run the benchmark
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Single run (all three checks)
|
||||||
|
uv run python -m scripts.test_embed_model
|
||||||
|
|
||||||
|
# Specify a model explicitly
|
||||||
|
uv run python -m scripts.test_embed_model --model embed-v2
|
||||||
|
|
||||||
|
# Multiple runs for variance
|
||||||
|
uv run python -m scripts.test_embed_model --runs 3
|
||||||
|
```
|
||||||
|
|
||||||
|
Wall time: ~0.5–2 s per run (fast — the embedding endpoint is lightweight).
|
||||||
|
|
||||||
|
### 4. Read the verdicts
|
||||||
|
|
||||||
|
Each check prints `✓` (PASS) or `✗` (FAIL):
|
||||||
|
|
||||||
|
- **dimension**: `actual_dim == expected_dim` (from `BOR_EMBEDDING_DIM`, default 768)
|
||||||
|
- **cosine**: similar-pair margin ≥ 0.05 (similar > dissimilar by at least 0.05 cosine)
|
||||||
|
- **speed**: no NaN/Inf vectors in output; rate reported as vec/s
|
||||||
|
|
||||||
|
Gate: PASS if all three checks pass.
|
||||||
|
|
||||||
|
### 5. Record + commit
|
||||||
|
|
||||||
|
Results are automatically appended to `benchmarks/model_benchmarks.csv`
|
||||||
|
(one row per check per run). Commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add benchmarks/model_benchmarks.csv
|
||||||
|
git commit --no-gpg-sign \
|
||||||
|
-m "docs(agent): record the <model> embedding benchmark" \
|
||||||
|
-m "<one-paragraph body: dimension match, cosine margin, speed, any failures>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Summary
|
||||||
|
|
||||||
|
Table of dimension / cosine margin / speed per run, the one-line
|
||||||
|
conclusion, and a note that `.env` is now on `<model>`.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **dimension mismatch** — the model's output vectors are a different
|
||||||
|
size than expected. This is a configuration error: check the model's
|
||||||
|
docs for its embedding dimension and update `BOR_EMBEDDING_DIM`.
|
||||||
|
- **cosine margin < 0.05** — the embeddings don't separate similar from
|
||||||
|
dissimilar texts well. The model may be a poor embedding model or
|
||||||
|
the semantic pairs are too generic. Report the margin.
|
||||||
|
- **NaN/Inf vectors** — the model's embedding endpoint is broken or
|
||||||
|
the input text is malformed. Check the endpoint directly.
|
||||||
|
- **very slow** — >100 vec/s is typical for a local endpoint; remote
|
||||||
|
endpoints may be slower due to network latency. Report the rate.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
---
|
||||||
|
name: test-summary-model
|
||||||
|
description: Tests a summary model (BOR_LLM_SUMMARY_MODEL in .env) against a fixed set of 8 source texts from the fixture KB — evaluates coherence, coverage, brevity, and hallucination detection — then records results in benchmarks/model_benchmarks.csv. Use when the user asks to test or benchmark a summary model, compare summary models, or evaluate summary quality (e.g. "test lite summary", "compare summary models", "how good is turbo at summarizing").
|
||||||
|
---
|
||||||
|
|
||||||
|
# Test a Summary Model (quality benchmark)
|
||||||
|
|
||||||
|
Tests the configured `BOR_LLM_SUMMARY_MODEL` (default `lite`) against
|
||||||
|
8 source texts extracted from the fixture KB. Each text is a realistic
|
||||||
|
documentation excerpt (~300–500 chars). The model is asked to summarize
|
||||||
|
each in 2–4 sentences.
|
||||||
|
|
||||||
|
## Rules (non-negotiable)
|
||||||
|
|
||||||
|
- **Do not touch** the fixture source texts — they are the controlled
|
||||||
|
benchmark corpus. Changing them is a methodology change: flag it.
|
||||||
|
- **Do not edit app code** (`app/`, `tests/`). This skill tests a model,
|
||||||
|
not the app. If the model exposes an app defect, report it — don't fix it.
|
||||||
|
- `.env` is gitignored and is **not committed** — the model switch stays
|
||||||
|
a live dev setting, and the summary must say which model `.env` is left
|
||||||
|
on (default: the tested model).
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
All commands run from the repo root with `uv run`.
|
||||||
|
|
||||||
|
### 1. Preconditions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman compose up -d db
|
||||||
|
grep -E "BOR_LLM_(BASE_URL|API_KEY|SUMMARY_MODEL)" .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Switch the model (optional)
|
||||||
|
|
||||||
|
Edit only `BOR_LLM_SUMMARY_MODEL` in `.env` (leave chat and embed alone):
|
||||||
|
|
||||||
|
```
|
||||||
|
BOR_LLM_SUMMARY_MODEL=<model>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run the benchmark
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Single run
|
||||||
|
uv run python -m scripts.test_summary_model
|
||||||
|
|
||||||
|
# Specify a model explicitly
|
||||||
|
uv run python -m scripts.test_summary_model --model turbo
|
||||||
|
|
||||||
|
# Multiple runs for variance
|
||||||
|
uv run python -m scripts.test_summary_model --runs 3
|
||||||
|
```
|
||||||
|
|
||||||
|
Wall time: ~3–5 s per text, ~25–40 s total for 8 texts.
|
||||||
|
|
||||||
|
### 4. Read the verdicts
|
||||||
|
|
||||||
|
The `gate:` line: `PASS|FAIL turns=N answered=N quality=Q hallucinations=H/N coherence=C/5 coverage=V/5 brevity=B/5 (wall Ts)`.
|
||||||
|
|
||||||
|
Scoring rubric:
|
||||||
|
- **coherence** (0–5): non-empty, multi-sentence, starts with capital
|
||||||
|
- **coverage** (0–5): captures ≥2 key numbers/facts from source
|
||||||
|
- **brevity** (0–5): summary length / source length ratio 0.10–0.25 = 5, 0.05–0.35 = 4, etc.
|
||||||
|
- **hallucination**: detected when summary contains >3 uncommon tokens not in source
|
||||||
|
|
||||||
|
Quality score = coherence×0.35 + coverage×0.40 + brevity×0.25, scaled 0–100,
|
||||||
|
with a 5-point penalty per hallucination.
|
||||||
|
|
||||||
|
Gate: PASS if quality ≥ 70 and hallucination rate < 25%.
|
||||||
|
|
||||||
|
### 5. Record + commit
|
||||||
|
|
||||||
|
Results are automatically appended to `benchmarks/model_benchmarks.csv`.
|
||||||
|
Also append a summary line to `benchmarks/README.md` if it exists, or
|
||||||
|
create it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add benchmarks/model_benchmarks.csv
|
||||||
|
git commit --no-gpg-sign \
|
||||||
|
-m "docs(agent): record the <model> summary benchmark" \
|
||||||
|
-m "<one-paragraph body: quality score, hallucination count, wall time, comparison to other models>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Summary
|
||||||
|
|
||||||
|
Table of quality / hallucinations / coherence / coverage / brevity / wall
|
||||||
|
per run, the one-line conclusion, and a note that `.env` is now on
|
||||||
|
`<model>`.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **endpoint down / all turns error** — check
|
||||||
|
`curl -s $BOR_LLM_BASE_URL/models` with the key; the gate will exit 1
|
||||||
|
with `answered<8`. Report, don't retry-loop.
|
||||||
|
- **low brevity** — the model is restating rather than condensing.
|
||||||
|
This is model-specific; try a more explicit prompt or a different model.
|
||||||
|
- **hallucinations** — the model is adding details not in the source.
|
||||||
|
This is the most common failure mode; tighten the prompt or switch models.
|
||||||
@@ -15,19 +15,16 @@ RUN npm install --no-audit --no-fund -g esbuild@0.25.5
|
|||||||
COPY frontend ./
|
COPY frontend ./
|
||||||
RUN mkdir -p /out/assets \
|
RUN mkdir -p /out/assets \
|
||||||
&& esbuild ./assets/app.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/app.js \
|
&& 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/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/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/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/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/brand.js --minify --outfile=/out/assets/brand.js \
|
||||||
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
|
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
|
||||||
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
|
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
|
||||||
&& cp -r ./assets/themes /out/assets/themes \
|
&& 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 ----------
|
# ---------- Stage 2: python dependencies ----------
|
||||||
FROM docker.io/python:3.12-slim AS python
|
FROM docker.io/python:3.12-slim AS python
|
||||||
|
|||||||
@@ -203,9 +203,11 @@ there is to **not** re-read what is already in the prompt.
|
|||||||
gate: lite PASS turns=10 answered=10 caps=0 tool-turns=10 calls 8/11 executed (73%) contract 11/11 (100%) (wall 43.4s)
|
gate: lite PASS turns=10 answered=10 caps=0 tool-turns=10 calls 8/11 executed (73%) contract 11/11 (100%) (wall 43.4s)
|
||||||
gate: lite PASS turns=10 answered=10 caps=0 tool-turns=10 calls 8/13 executed (62%) contract 12/13 (92%) (wall 50.6s)
|
gate: lite PASS turns=10 answered=10 caps=0 tool-turns=10 calls 8/13 executed (62%) contract 12/13 (92%) (wall 50.6s)
|
||||||
gate: lite PASS turns=10 answered=10 caps=0 tool-turns=10 calls 7/11 executed (64%) contract 11/11 (100%) (wall 46.8s)
|
gate: lite PASS turns=10 answered=10 caps=0 tool-turns=10 calls 7/11 executed (64%) contract 11/11 (100%) (wall 46.8s)
|
||||||
|
gate: lite PASS turns=10 answered=10 caps=0 tool-turns=9 calls 9/12 executed (75%) contract 11/12 (92%) 2026-09-06 (wall 40.4s)
|
||||||
|
gate: lite PASS turns=10 answered=10 caps=0 tool-turns=10 calls 9/14 executed (64%) contract 13/14 (93%) 2026-09-06 (wall 40.5s)
|
||||||
```
|
```
|
||||||
|
|
||||||
Contract accuracy ≥ 90 %: **met** (100 / 92 / 100 / 93). The executed
|
Contract accuracy ≥ 90 %: **met** (100 / 92 / 100 / 93 / 92 / 93). The executed
|
||||||
ratio sits at 58–73 % for the reason documented in §5 — an app
|
ratio sits at 58–73 % for the reason documented in §5 — an app
|
||||||
semantics choice, not a model defect, and the open design question in
|
semantics choice, not a model defect, and the open design question in
|
||||||
§7.
|
§7.
|
||||||
@@ -217,6 +219,9 @@ chat model switched to `turbo`):**
|
|||||||
gate: turbo PASS turns=10 answered=10 caps=0 tool-turns=7 calls 9/9 executed (100%) contract 9/9 (100%) 2026-09-05 (wall 105.1s)
|
gate: turbo PASS turns=10 answered=10 caps=0 tool-turns=7 calls 9/9 executed (100%) contract 9/9 (100%) 2026-09-05 (wall 105.1s)
|
||||||
gate: turbo PASS turns=10 answered=10 caps=0 tool-turns=7 calls 7/7 executed (100%) contract 7/7 (100%) 2026-09-05 (wall 135.5s)
|
gate: turbo PASS turns=10 answered=10 caps=0 tool-turns=7 calls 7/7 executed (100%) contract 7/7 (100%) 2026-09-05 (wall 135.5s)
|
||||||
gate: turbo FAIL turns=10 answered=10 caps=0 tool-turns=5 calls 5/5 executed (100%) contract 5/5 (100%) 2026-09-05 (wall 77.1s) [derived battery — MISS: 5/10 tool-turn floor]
|
gate: turbo FAIL turns=10 answered=10 caps=0 tool-turns=5 calls 5/5 executed (100%) contract 5/5 (100%) 2026-09-05 (wall 77.1s) [derived battery — MISS: 5/10 tool-turn floor]
|
||||||
|
gate: turbo PASS turns=10 answered=10 caps=0 tool-turns=7 calls 9/9 executed (100%) contract 9/9 (100%) 2026-09-06 (wall 113.7s)
|
||||||
|
gate: turbo PASS turns=10 answered=10 caps=0 tool-turns=7 calls 7/7 executed (100%) contract 7/7 (100%) 2026-09-06 (wall 112.8s)
|
||||||
|
gate: turbo FAIL turns=10 answered=10 caps=0 tool-turns=5 calls 5/5 executed (100%) contract 5/5 (100%) 2026-09-06 (wall 97.2s) [derived battery — MISS: 5/10 tool-turn floor]
|
||||||
```
|
```
|
||||||
|
|
||||||
Reads: the re-read habit is model-specific. `lite` re-reads a seeded
|
Reads: the re-read habit is model-specific. `lite` re-reads a seeded
|
||||||
@@ -233,8 +238,8 @@ derived battery `turbo` fails only the *usage floor* condition (≥ 6/10
|
|||||||
turns with ≥ 1 emitted call: 5/10) — it answers the seeded read-target
|
turns with ≥ 1 emitted call: 5/10) — it answers the seeded read-target
|
||||||
questions from context instead of making the (refusable) read call the
|
questions from context instead of making the (refusable) read call the
|
||||||
trap design expects; accuracy on every call it does make is still
|
trap design expects; accuracy on every call it does make is still
|
||||||
100 %. The cost: **2–3× slower wall time** (105–135 s per full loop
|
100 %. The cost: **~2.5× slower wall time** (97–114 s per full loop
|
||||||
vs 43–55 s, with individual slow turns up to ~34 s).
|
vs 40–41 s for `lite`, with individual turns 7–20 s).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -340,6 +345,7 @@ executed/emitted ≥ 0.90. Against the fixture KB (2026-09-04):
|
|||||||
|
|
||||||
```
|
```
|
||||||
gate: lite FAIL turns=10 answered=10 caps=0 tool-turns=10 calls 5/15 executed (33%) contract 12/15 (80%) (wall 47.7s)
|
gate: lite FAIL turns=10 answered=10 caps=0 tool-turns=10 calls 5/15 executed (33%) contract 12/15 (80%) (wall 47.7s)
|
||||||
|
gate: lite FAIL turns=10 answered=10 caps=0 tool-turns=10 calls 5/14 executed (36%) contract 10/14 (71%) 2026-09-06 (wall 38.3s)
|
||||||
```
|
```
|
||||||
|
|
||||||
Reading that result: the teaching works — **every bare-path trap
|
Reading that result: the teaching works — **every bare-path trap
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from pathlib import Path
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
from starlette.responses import FileResponse
|
||||||
|
|
||||||
from app.api.auth import router as auth_router
|
from app.api.auth import router as auth_router
|
||||||
from app.api.chat import router as chat_router
|
from app.api.chat import router as chat_router
|
||||||
@@ -51,6 +52,39 @@ settings = get_settings()
|
|||||||
logger = logging.getLogger("app")
|
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:
|
def create_app() -> FastAPI:
|
||||||
# Fail loud BEFORE serving anything (phase 16): missing
|
# Fail loud BEFORE serving anything (phase 16): missing
|
||||||
# BOR_ADMIN_PASSWORD / BOR_SESSION_SECRET raises at boot, naming the
|
# 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()
|
static_dir = Path(settings.static_dir).resolve()
|
||||||
if static_dir.is_dir():
|
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")
|
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
|
||||||
else:
|
else:
|
||||||
logger.warning("static dir %s not found — serving API only", static_dir)
|
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
|
``thinking`` travels to the model as ``reasoning_content`` on the
|
||||||
assistant message (the wire convention :mod:`app.rag.llm` already
|
assistant message (the wire convention :mod:`app.rag.llm` already
|
||||||
documents for the response side) — only when non-empty (A4).
|
documents for the response side) — only when non-empty (A4).
|
||||||
``text`` mirrors :attr:`ChatMessage.text`'s answer shape; the
|
``text`` mirrors :attr:`ChatMessage.text`'s answer shape; long
|
||||||
thinking cap is looser (scratchpads run longer than answers). These
|
answers (and the scratchpads that ride along as ``thinking``) both
|
||||||
are boundary sanity caps only — the real trimming budget is the
|
run to tens of kilobytes of text, so both share the same loose
|
||||||
settings pair ``history_max_turns`` / ``history_max_chars``
|
boundary cap. These are boundary sanity caps only — the real
|
||||||
(``app.config``, A3: a capped-out turn is dropped whole, never
|
trimming budget is the settings pair ``history_max_turns`` /
|
||||||
truncated).
|
``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"]
|
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)
|
thinking: str | None = Field(default=None, max_length=32000)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Model Benchmark Results
|
||||||
|
|
||||||
|
CSV recordings from all model tests (chat, summary, embedding).
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `model_benchmarks.csv` — all benchmark results, one row per run per check
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
| Column | Description |
|
||||||
|
|---|---|
|
||||||
|
| `date` | Test date (YYYY-MM-DD) |
|
||||||
|
| `script` | `chat` | `summary` | `embed` |
|
||||||
|
| `model` | Model name (e.g. `lite`, `turbo`, `embed`) |
|
||||||
|
| `mode` | `fixture` / `derived` / `quality` / `dimension` / `cosine` / `speed` |
|
||||||
|
| `gate_status` | `PASS` / `FAIL` |
|
||||||
|
| `turns` | Number of questions/turns |
|
||||||
|
| `answered` | Turns that produced an answer |
|
||||||
|
| `caps` | Turns that hit the round cap |
|
||||||
|
| `tool_turns` | Turns emitting ≥1 tool call (chat only) |
|
||||||
|
| `emitted` / `executed` | Tool-call counts (chat only) |
|
||||||
|
| `contract` | Numerator for accuracy (chat: well-formed calls; summary: quality 0–100; embed: 1=pass) |
|
||||||
|
| `wall_s` | Total wall seconds |
|
||||||
|
| `contract_denom` / `executed_denom` / `tool_turns_denom` | Denominators for percentages |
|
||||||
|
| `extra1_col` / `extra1_val` | Script-specific metric 1 |
|
||||||
|
| `extra2_col` / `extra2_val` | Script-specific metric 2 |
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
- `scripts/agent_realmodel_check.py` — chat model tool-calling battery
|
||||||
|
- `scripts/test_summary_model.py` — summary model quality benchmark
|
||||||
|
- `scripts/test_embed_model.py` — embedding model dimension/cosine/speed benchmark
|
||||||
|
- `scripts/model_benchmark.py` — shared CSV recorder (imported by all scripts)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
date,script,model,mode,gate_status,turns,answered,caps,tool_turns,emitted,executed,contract,wall_s,contract_denom,executed_denom,tool_turns_denom,extra1_col,extra1_val,extra2_col,extra2_val
|
||||||
|
2026-09-06,summary,lite,quality,FAIL,8,8,0,0,0,0,58,26.9,58,0,8,coherence,4.0/5,hallucinations,4/8
|
||||||
|
2026-09-06,summary,lite,quality,FAIL,8,8,0,0,0,0,53,25.4,53,0,8,coherence,4.0/5,hallucinations,5/8
|
||||||
|
2026-09-06,embed,embed,dimension,PASS,1,1,0,0,0,0,1,0.1,1,0,1,actual_dim,768,expected_dim,768
|
||||||
|
2026-09-06,embed,embed,cosine,PASS,1,1,0,0,0,0,1,0.2,1,0,1,margin,0.283,quality,56
|
||||||
|
2026-09-06,embed,embed,speed,PASS,1,1,0,0,0,0,1,0.5,1,0,1,rate,93.8 vec/s,bad_vectors,0
|
||||||
|
2026-09-06,summary,turbo,quality,FAIL,8,8,0,0,0,0,63,25.3,63,0,8,coherence,4.0/5,hallucinations,3/8
|
||||||
|
2026-09-06,chat,lite,fixture,PASS,10,10,0,9,9,9,11,40.4,12,12,10,,,,
|
||||||
|
2026-09-06,chat,lite,fixture,PASS,10,10,0,10,9,9,13,40.5,14,14,10,,,,
|
||||||
|
2026-09-06,chat,lite,derived,FAIL,10,10,0,10,5,5,10,38.3,14,14,10,,,,
|
||||||
|
2026-09-06,chat,turbo,fixture,PASS,10,10,0,7,9,9,9,113.7,9,9,10,,,,
|
||||||
|
2026-09-06,chat,turbo,fixture,PASS,10,10,0,7,7,7,7,112.8,7,7,10,,,,
|
||||||
|
2026-09-06,chat,turbo,derived,FAIL,10,10,0,5,5,5,5,97.2,5,5,10,,,,
|
||||||
|
@@ -1,19 +1,41 @@
|
|||||||
/* Brain of Reese — Git sources admin page (phase 35, task 04;
|
/* Brain of Reese — Sources view (the git-sources manager; phase 35
|
||||||
* archive uploads, phase 49 task 03).
|
* task 04; archive uploads, phase 49 task 03; phase 76 task 02: shell
|
||||||
|
* view module — formerly the standalone git-sources.html).
|
||||||
*
|
*
|
||||||
* The page module for /git-sources.html: the admin-only manager for the
|
* The view module for /git-sources.html: the admin-only manager for
|
||||||
* stored source list (git-sources table, phase 35 tasks 01/02) — git
|
* the stored source list (git-sources table, phase 35 tasks 01/02) —
|
||||||
* repo URLs (kind "git") and uploaded archives unpacked under
|
* git repo URLs (kind "git") and uploaded archives unpacked under
|
||||||
* BOR_UPLOAD_DIR (kind "local", phase 49; the phase-38
|
* BOR_UPLOAD_DIR (kind "local", phase 49; the phase-38
|
||||||
* local-directory form is gone — the kind=local API POST is
|
* local-directory form is gone — the kind=local API POST is
|
||||||
* unchanged, the page just no longer offers it).
|
* unchanged, the page just no longer offers it).
|
||||||
* This module is the single owner of the page's behaviour:
|
* This module is the single owner of the view's behaviour:
|
||||||
*
|
*
|
||||||
* • boot — initSharedHeader() (one cached whoami, shared with the
|
* Phase 76 (task 02) — shell view module (the "Sources" view of the
|
||||||
* header toggling): anonymous → the sign-in gate shows and the
|
* ONE-document shell; /git-sources.html now serves the shell, and
|
||||||
* manager stays hidden (the exact Sources page gate pattern, and
|
* assets/router.js lazy-imports THIS module on first show):
|
||||||
* NO /api/git-sources call is made); admin → gate hidden,
|
*
|
||||||
* #git-sources-content revealed, loadSources().
|
* • the top-level boot is now `export async function mount(root)` —
|
||||||
|
* root is the view's <section id="view-git-sources">, 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 bindings and the upload-progress state machine survive
|
||||||
|
* every switch: the scan 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 single toast node/timer stay module
|
||||||
|
* scope (one per document life — toasts never stack).
|
||||||
|
* • 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 gate keeps fetchIsAdmin()
|
||||||
|
* — the SAME cached /api/whoami promise header.js exports (zero
|
||||||
|
* extra requests).
|
||||||
|
*
|
||||||
|
* • boot (the mount's tail) — fetchIsAdmin() (the cached whoami):
|
||||||
|
* anonymous → the sign-in gate shows and the manager stays hidden
|
||||||
|
* (the exact Sources page gate pattern, and NO /api/git-sources
|
||||||
|
* call is made); admin → gate hidden, #git-sources-content
|
||||||
|
* revealed, loadSources().
|
||||||
* • loadSources() — GET /api/git-sources → the table rows
|
* • loadSources() — GET /api/git-sources → the table rows
|
||||||
* (#git-sources-tbody), the env-fallback note's visibility
|
* (#git-sources-tbody), the env-fallback note's visibility
|
||||||
* (from_env), and the empty state. Each row leads with its kind
|
* (from_env), and the empty state. Each row leads with its kind
|
||||||
@@ -122,66 +144,72 @@
|
|||||||
*
|
*
|
||||||
* The shared header module loads through this script's own relative
|
* The shared header module loads through this script's own relative
|
||||||
* import ("./header.js") — a hoisted import evaluated before this body
|
* import ("./header.js") — a hoisted import evaluated before this body
|
||||||
* runs (single-evaluation design: no direct <script> tag; esbuild
|
* runs (single-evaluation design: no direct <script> tag — the shell
|
||||||
* inlines it into the page bundle in the image build).
|
* loads the view module ONLY through the router's lazy import();
|
||||||
|
* esbuild inlines it into the router bundle in the image build).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { initSharedHeader } from "./header.js";
|
import { fetchIsAdmin } from "./header.js";
|
||||||
|
|
||||||
/* ---------- page elements (git-sources.html, task 04) ---------- */
|
const UPLOAD_TOAST_MS = 5000; // ~5 s auto-dismiss (A2)
|
||||||
const gateEl = document.querySelector("#git-sources-gate");
|
let uploadToastEl = null; // the single toast node — lazy-created, reused
|
||||||
const contentEl = document.querySelector("#git-sources-content");
|
let uploadToastTimer = 0; // the pending auto-dismiss (replaced by a new toast)
|
||||||
const formEl = document.querySelector("#git-source-form");
|
|
||||||
const urlInput = document.querySelector("#git-source-url");
|
export async function mount(root) {
|
||||||
const addBtn = document.querySelector("#git-source-add");
|
/* ---------- page elements (git-sources.html, task 04) ---------- */
|
||||||
const addError = document.querySelector("#git-source-error");
|
const gateEl = root.querySelector("#git-sources-gate");
|
||||||
/* Phase 49: the archive upload form (replaces the phase-38 local
|
const contentEl = root.querySelector("#git-sources-content");
|
||||||
|
const formEl = root.querySelector("#git-source-form");
|
||||||
|
const urlInput = root.querySelector("#git-source-url");
|
||||||
|
const addBtn = root.querySelector("#git-source-add");
|
||||||
|
const addError = root.querySelector("#git-source-error");
|
||||||
|
/* Phase 49: the archive upload form (replaces the phase-38 local
|
||||||
directory form — same card, a file input instead of a path input).
|
directory form — same card, a file input instead of a path input).
|
||||||
The response counts render in the role=status result line. */
|
The response counts render in the role=status result line. */
|
||||||
const uploadFormEl = document.querySelector("#archive-upload-form");
|
const uploadFormEl = root.querySelector("#archive-upload-form");
|
||||||
const uploadFileInput = document.querySelector("#archive-upload-file");
|
const uploadFileInput = root.querySelector("#archive-upload-file");
|
||||||
const uploadBtn = document.querySelector("#archive-upload-btn");
|
const uploadBtn = root.querySelector("#archive-upload-btn");
|
||||||
const uploadError = document.querySelector("#archive-upload-error");
|
const uploadError = root.querySelector("#archive-upload-error");
|
||||||
const uploadResult = document.querySelector("#archive-upload-result");
|
const uploadResult = root.querySelector("#archive-upload-result");
|
||||||
const loadErrorEl = document.querySelector("#git-sources-load-error");
|
const loadErrorEl = root.querySelector("#git-sources-load-error");
|
||||||
const loadErrorText = document.querySelector("#git-sources-load-error-text");
|
const loadErrorText = root.querySelector("#git-sources-load-error-text");
|
||||||
const retryBtn = document.querySelector("#git-sources-retry");
|
const retryBtn = root.querySelector("#git-sources-retry");
|
||||||
const tableWrap = document.querySelector("#git-sources-table-wrap");
|
const tableWrap = root.querySelector("#git-sources-table-wrap");
|
||||||
const tbody = document.querySelector("#git-sources-tbody");
|
const tbody = root.querySelector("#git-sources-tbody");
|
||||||
const emptyEl = document.querySelector("#git-sources-empty");
|
const emptyEl = root.querySelector("#git-sources-empty");
|
||||||
const envNote = document.querySelector("#git-sources-env-note");
|
const envNote = root.querySelector("#git-sources-env-note");
|
||||||
const announcer = document.querySelector("#git-sources-announcer");
|
const announcer = root.querySelector("#git-sources-announcer");
|
||||||
/* Phase 69: the remove confirmation modal (the native confirm()
|
/* Phase 69: the remove confirmation modal (the native confirm()
|
||||||
retired) — static markup in git-sources.html; this module owns the
|
retired) — static markup in git-sources.html; this module owns the
|
||||||
open / cancel / confirm lifecycle. */
|
open / cancel / confirm lifecycle. */
|
||||||
const removeDialog = document.querySelector("#remove-confirm-dialog");
|
const removeDialog = root.querySelector("#remove-confirm-dialog");
|
||||||
const removeBackdrop = document.querySelector(".remove-confirm-backdrop");
|
const removeBackdrop = root.querySelector(".remove-confirm-backdrop");
|
||||||
const removeSourceEl = document.querySelector("#remove-confirm-source");
|
const removeSourceEl = root.querySelector("#remove-confirm-source");
|
||||||
const removeError = document.querySelector("#remove-confirm-error");
|
const removeError = root.querySelector("#remove-confirm-error");
|
||||||
const removeCancelBtn = document.querySelector("#remove-confirm-cancel");
|
const removeCancelBtn = root.querySelector("#remove-confirm-cancel");
|
||||||
const removeRemoveBtn = document.querySelector("#remove-confirm-remove");
|
const removeRemoveBtn = root.querySelector("#remove-confirm-remove");
|
||||||
|
|
||||||
/* Polite live region: the screen-reader confirmation for loads, adds,
|
/* Polite live region: the screen-reader confirmation for loads, adds,
|
||||||
and removals (the phase-15 announcer pattern). */
|
and removals (the phase-15 announcer pattern). */
|
||||||
function announce(message) {
|
function announce(message) {
|
||||||
if (announcer) announcer.textContent = message;
|
if (announcer) announcer.textContent = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Added date — localized (toLocaleString); env-fallback rows carry
|
/* Added date — localized (toLocaleString); env-fallback rows carry
|
||||||
added_at null, and a corrupt timestamp must not blank the cell. */
|
added_at null, and a corrupt timestamp must not blank the cell. */
|
||||||
function fmtDate(iso) {
|
function fmtDate(iso) {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
try {
|
try {
|
||||||
return new Date(iso).toLocaleString();
|
return new Date(iso).toLocaleString();
|
||||||
} catch {
|
} catch {
|
||||||
return "—";
|
return "—";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* FastAPI error bodies: a string detail or the validation-error array
|
/* FastAPI error bodies: a string detail or the validation-error array
|
||||||
(the first entry's msg is the human line). Same extraction as
|
(the first entry's msg is the human line). Same extraction as
|
||||||
tuning.js — 422 shape-aware. */
|
tuning.js — 422 shape-aware. */
|
||||||
async function apiDetail(r, fallback) {
|
async function apiDetail(r, fallback) {
|
||||||
try {
|
try {
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
|
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
|
||||||
@@ -192,14 +220,14 @@ async function apiDetail(r, fallback) {
|
|||||||
/* non-JSON error body */
|
/* non-JSON error body */
|
||||||
}
|
}
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- load / render ---------- */
|
/* ---------- load / render ---------- */
|
||||||
|
|
||||||
/* GET /api/git-sources → the table rows + env note + empty state.
|
/* GET /api/git-sources → the table rows + env note + empty state.
|
||||||
The load error (role="alert" + retry) is the ONLY terminal state a
|
The load error (role="alert" + retry) is the ONLY terminal state a
|
||||||
failed fetch may reach — never a stuck page. */
|
failed fetch may reach — never a stuck page. */
|
||||||
async function loadSources() {
|
async function loadSources() {
|
||||||
let r;
|
let r;
|
||||||
try {
|
try {
|
||||||
r = await fetch("/api/git-sources");
|
r = await fetch("/api/git-sources");
|
||||||
@@ -222,23 +250,23 @@ async function loadSources() {
|
|||||||
const sources = Array.isArray(data.sources) ? data.sources : [];
|
const sources = Array.isArray(data.sources) ? data.sources : [];
|
||||||
renderSources(sources, data.from_env === true);
|
renderSources(sources, data.from_env === true);
|
||||||
announce(`${sources.length} source${sources.length === 1 ? "" : "s"} listed.`);
|
announce(`${sources.length} source${sources.length === 1 ? "" : "s"} listed.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showLoadError(message) {
|
function showLoadError(message) {
|
||||||
if (loadErrorText) loadErrorText.textContent = message;
|
if (loadErrorText) loadErrorText.textContent = message;
|
||||||
if (loadErrorEl) loadErrorEl.hidden = false;
|
if (loadErrorEl) loadErrorEl.hidden = false;
|
||||||
// The list state is unknown — hide the table AND the empty state so
|
// The list state is unknown — hide the table AND the empty state so
|
||||||
// the error is the only claim about the list's contents.
|
// the error is the only claim about the list's contents.
|
||||||
if (tableWrap) tableWrap.hidden = true;
|
if (tableWrap) tableWrap.hidden = true;
|
||||||
if (emptyEl) emptyEl.hidden = true;
|
if (emptyEl) emptyEl.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideLoadError() {
|
function hideLoadError() {
|
||||||
if (loadErrorEl) loadErrorEl.hidden = true;
|
if (loadErrorEl) loadErrorEl.hidden = true;
|
||||||
if (loadErrorText) loadErrorText.textContent = "";
|
if (loadErrorText) loadErrorText.textContent = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSources(sources, fromEnv) {
|
function renderSources(sources, fromEnv) {
|
||||||
if (envNote) envNote.hidden = !fromEnv;
|
if (envNote) envNote.hidden = !fromEnv;
|
||||||
if (tbody) {
|
if (tbody) {
|
||||||
tbody.replaceChildren();
|
tbody.replaceChildren();
|
||||||
@@ -247,9 +275,9 @@ function renderSources(sources, fromEnv) {
|
|||||||
const hasRows = sources.length > 0;
|
const hasRows = sources.length > 0;
|
||||||
if (tableWrap) tableWrap.hidden = !hasRows;
|
if (tableWrap) tableWrap.hidden = !hasRows;
|
||||||
if (emptyEl) emptyEl.hidden = hasRows;
|
if (emptyEl) emptyEl.hidden = hasRows;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* One row: the kind badge (Git/Local — phase 38) followed by the
|
/* One row: the kind badge (Git/Local — phase 38) followed by the
|
||||||
location in a mono <code> (textContent only — git URLs may contain
|
location in a mono <code> (textContent only — git URLs may contain
|
||||||
credentials, local paths may contain anything), the localized added
|
credentials, local paths may contain anything), the localized added
|
||||||
date ("—" for env fallback rows), and the per-row Remove button —
|
date ("—" for env fallback rows), and the per-row Remove button —
|
||||||
@@ -258,10 +286,10 @@ function renderSources(sources, fromEnv) {
|
|||||||
from). The value is the git URL for kind "git" rows and the full
|
from). The value is the git URL for kind "git" rows and the full
|
||||||
local path for kind "local" rows (the API reports the path in both
|
local path for kind "local" rows (the API reports the path in both
|
||||||
`path` and `url`; `path` is the kind-typed field). */
|
`path` and `url`; `path` is the kind-typed field). */
|
||||||
const REMOVE_ICON =
|
const REMOVE_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>';
|
'<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>';
|
||||||
|
|
||||||
function makeRow(s) {
|
function makeRow(s) {
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
if (s.id) tr.dataset.id = s.id;
|
if (s.id) tr.dataset.id = s.id;
|
||||||
|
|
||||||
@@ -306,9 +334,9 @@ function makeRow(s) {
|
|||||||
}
|
}
|
||||||
tr.appendChild(actTd);
|
tr.appendChild(actTd);
|
||||||
return tr;
|
return tr;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- remove (DELETE /api/git-sources/{id}) — the confirmation modal ----------
|
/* ---------- remove (DELETE /api/git-sources/{id}) — the confirmation modal ----------
|
||||||
* A row's Remove button opens the page-local alertdialog
|
* A row's Remove button opens the page-local alertdialog
|
||||||
* (#remove-confirm-dialog — the native confirm() retired, phase 69)
|
* (#remove-confirm-dialog — the native confirm() retired, phase 69)
|
||||||
* via openRemoveConfirm(s, triggerBtn): #remove-confirm-source shows the
|
* via openRemoveConfirm(s, triggerBtn): #remove-confirm-source shows the
|
||||||
@@ -336,11 +364,11 @@ function makeRow(s) {
|
|||||||
* re-enabled + the confirm relabeled "Remove source" (the dialog
|
* re-enabled + the confirm relabeled "Remove source" (the dialog
|
||||||
* stays open — the fix is one retry, not a re-search for the row);
|
* stays open — the fix is one retry, not a re-search for the row);
|
||||||
* network failure → the fixed reachable? line, same restore. */
|
* network failure → the fixed reachable? line, same restore. */
|
||||||
let removeTriggerBtn = null; // the row's Remove button — focus returns here on close
|
let removeTriggerBtn = null; // the row's Remove button — focus returns here on close
|
||||||
let removeInFlight = false; // §7.4: a DELETE is out (both buttons disabled)
|
let removeInFlight = false; // §7.4: a DELETE is out (both buttons disabled)
|
||||||
let removingId = null; // the row id of the open/in-flight removal
|
let removingId = null; // the row id of the open/in-flight removal
|
||||||
|
|
||||||
function openRemoveConfirm(s, triggerBtn) {
|
function openRemoveConfirm(s, triggerBtn) {
|
||||||
if (!removeDialog || !removeSourceEl) return; // defensive — the markup ships with the page
|
if (!removeDialog || !removeSourceEl) return; // defensive — the markup ships with the page
|
||||||
if (removeInFlight) return; // one removal at a time
|
if (removeInFlight) return; // one removal at a time
|
||||||
const isLocal = s.kind === "local";
|
const isLocal = s.kind === "local";
|
||||||
@@ -358,12 +386,12 @@ function openRemoveConfirm(s, triggerBtn) {
|
|||||||
// Cancel is the safe default for a destructive action — focus
|
// Cancel is the safe default for a destructive action — focus
|
||||||
// lands on it (visibly: the global 3px :focus-visible outline).
|
// lands on it (visibly: the global 3px :focus-visible outline).
|
||||||
if (removeCancelBtn) removeCancelBtn.focus();
|
if (removeCancelBtn) removeCancelBtn.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Any close (cancel, success): hide the dialog, clear the error
|
/* Any close (cancel, success): hide the dialog, clear the error
|
||||||
line, reset the buttons, detach the keydown handling, and return
|
line, reset the buttons, detach the keydown handling, and return
|
||||||
focus to the row's Remove button (WCAG 2.1). */
|
focus to the row's Remove button (WCAG 2.1). */
|
||||||
function closeRemoveConfirm() {
|
function closeRemoveConfirm() {
|
||||||
if (!removeDialog) return;
|
if (!removeDialog) return;
|
||||||
removeDialog.hidden = true;
|
removeDialog.hidden = true;
|
||||||
removeInFlight = false;
|
removeInFlight = false;
|
||||||
@@ -381,22 +409,22 @@ function closeRemoveConfirm() {
|
|||||||
const trigger = removeTriggerBtn;
|
const trigger = removeTriggerBtn;
|
||||||
removeTriggerBtn = null;
|
removeTriggerBtn = null;
|
||||||
if (trigger) trigger.focus(); // focus returns to the row's Remove button
|
if (trigger) trigger.focus(); // focus returns to the row's Remove button
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Escape / Cancel / backdrop all close as cancel — NO request. A
|
/* Escape / Cancel / backdrop all close as cancel — NO request. A
|
||||||
cancel is a no-op while a DELETE is in flight (no half-cancel of
|
cancel is a no-op while a DELETE is in flight (no half-cancel of
|
||||||
an in-progress server-side removal; the buttons are disabled
|
an in-progress server-side removal; the buttons are disabled
|
||||||
anyway, the Escape/backdrop paths need this guard). */
|
anyway, the Escape/backdrop paths need this guard). */
|
||||||
function cancelRemoveConfirm() {
|
function cancelRemoveConfirm() {
|
||||||
if (removeInFlight) return;
|
if (removeInFlight) return;
|
||||||
closeRemoveConfirm();
|
closeRemoveConfirm();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* While open (attached in openRemoveConfirm, detached in
|
/* While open (attached in openRemoveConfirm, detached in
|
||||||
closeRemoveConfirm): Escape cancels; Tab/Shift+Tab cycle between
|
closeRemoveConfirm): Escape cancels; Tab/Shift+Tab cycle between
|
||||||
the modal's two buttons (the only focusable elements — aria-modal
|
the modal's two buttons (the only focusable elements — aria-modal
|
||||||
is honored for keyboard users, not just screen readers). */
|
is honored for keyboard users, not just screen readers). */
|
||||||
function onRemoveDialogKeydown(e) {
|
function onRemoveDialogKeydown(e) {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
cancelRemoveConfirm();
|
cancelRemoveConfirm();
|
||||||
@@ -410,9 +438,9 @@ function onRemoveDialogKeydown(e) {
|
|||||||
wrapTo.focus();
|
wrapTo.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmRemove() {
|
async function confirmRemove() {
|
||||||
if (!removingId || removeInFlight) return; // one request at a time
|
if (!removingId || removeInFlight) return; // one request at a time
|
||||||
removeInFlight = true;
|
removeInFlight = true;
|
||||||
if (removeError) {
|
if (removeError) {
|
||||||
@@ -464,14 +492,14 @@ async function confirmRemove() {
|
|||||||
removeRemoveBtn.textContent = "Remove source";
|
removeRemoveBtn.textContent = "Remove source";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The modal's own buttons (static markup — wired once). */
|
/* The modal's own buttons (static markup — wired once). */
|
||||||
if (removeCancelBtn) removeCancelBtn.addEventListener("click", cancelRemoveConfirm);
|
if (removeCancelBtn) removeCancelBtn.addEventListener("click", cancelRemoveConfirm);
|
||||||
if (removeBackdrop) removeBackdrop.addEventListener("click", cancelRemoveConfirm);
|
if (removeBackdrop) removeBackdrop.addEventListener("click", cancelRemoveConfirm);
|
||||||
if (removeRemoveBtn) removeRemoveBtn.addEventListener("click", confirmRemove);
|
if (removeRemoveBtn) removeRemoveBtn.addEventListener("click", confirmRemove);
|
||||||
|
|
||||||
/* ---------- add (POST /api/git-sources) — the git form ----------
|
/* ---------- add (POST /api/git-sources) — the git form ----------
|
||||||
* wireAddForm gives the form the §7.4 never-stale lifecycle: while
|
* wireAddForm gives the form the §7.4 never-stale lifecycle: while
|
||||||
* the request is out the button disables + relabels "Adding…" and
|
* the request is out the button disables + relabels "Adding…" and
|
||||||
* re-enables (idle label restored) on success AND failure. 201 clears
|
* re-enables (idle label restored) on success AND failure. 201 clears
|
||||||
@@ -481,7 +509,7 @@ if (removeRemoveBtn) removeRemoveBtn.addEventListener("click", confirmRemove);
|
|||||||
* apiDetail) and keeps the input — the fix is one edit, not a re-type.
|
* apiDetail) and keeps the input — the fix is one edit, not a re-type.
|
||||||
* 409/422 details are fixed generic strings (credential safety — the
|
* 409/422 details are fixed generic strings (credential safety — the
|
||||||
* URL is never echoed). */
|
* URL is never echoed). */
|
||||||
function wireAddForm(opts) {
|
function wireAddForm(opts) {
|
||||||
const { form, input, btn, error } = opts;
|
const { form, input, btn, error } = opts;
|
||||||
if (!form || !input || !btn) return;
|
if (!form || !input || !btn) return;
|
||||||
form.addEventListener("submit", async (e) => {
|
form.addEventListener("submit", async (e) => {
|
||||||
@@ -535,9 +563,9 @@ function wireAddForm(opts) {
|
|||||||
btn.textContent = opts.idleLabel;
|
btn.textContent = opts.idleLabel;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
wireAddForm({
|
wireAddForm({
|
||||||
form: formEl,
|
form: formEl,
|
||||||
input: urlInput,
|
input: urlInput,
|
||||||
btn: addBtn,
|
btn: addBtn,
|
||||||
@@ -548,9 +576,9 @@ wireAddForm({
|
|||||||
networkMessage: "Could not add the git source — is the app reachable?",
|
networkMessage: "Could not add the git source — is the app reachable?",
|
||||||
addedMessage: "Git source added.",
|
addedMessage: "Git source added.",
|
||||||
idleLabel: "Add source",
|
idleLabel: "Add source",
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ---------- upload (POST /api/git-sources/upload) — phase 64 (task 05) -------
|
/* ---------- upload (POST /api/git-sources/upload) — phase 64 (task 05) -------
|
||||||
* The archive upload form follows the phase-64 202 contract (A1):
|
* The archive upload form follows the phase-64 202 contract (A1):
|
||||||
* the file input's selection is posted as FormData (the browser sets
|
* the file input's selection is posted as FormData (the browser sets
|
||||||
* the multipart boundary — no manual Content-Type), and the 202
|
* the multipart boundary — no manual Content-Type), and the 202
|
||||||
@@ -581,12 +609,12 @@ wireAddForm({
|
|||||||
* line (success) or error banner (failed); idle does nothing.
|
* line (success) or error banner (failed); idle does nothing.
|
||||||
* (The phase-49 synchronous 200 paragraph is superseded by phase 64.) */
|
* (The phase-49 synchronous 200 paragraph is superseded by phase 64.) */
|
||||||
|
|
||||||
/* The success line's text — the sync-result shape (sources.js's
|
/* The success line's text — the sync-result shape (sources.js's
|
||||||
fmtSyncResult convention): "N added" always leads, then updated /
|
fmtSyncResult convention): "N added" always leads, then updated /
|
||||||
unchanged / pruned — zero parts omitted (unchanged is shown
|
unchanged / pruned — zero parts omitted (unchanged is shown
|
||||||
when nothing was added or updated). Reads exactly the keys the
|
when nothing was added or updated). Reads exactly the keys the
|
||||||
upload status's detail carries (task 03's UploadOut-shaped dict). */
|
upload status's detail carries (task 03's UploadOut-shaped dict). */
|
||||||
function fmtUploadResult(detail) {
|
function fmtUploadResult(detail) {
|
||||||
const d = detail || {};
|
const d = detail || {};
|
||||||
const added = d.added || 0;
|
const added = d.added || 0;
|
||||||
const updated = d.updated || 0;
|
const updated = d.updated || 0;
|
||||||
@@ -597,9 +625,9 @@ function fmtUploadResult(detail) {
|
|||||||
}
|
}
|
||||||
if ((d.pruned || 0) > 0) parts.push(`${d.pruned} pruned`);
|
if ((d.pruned || 0) > 0) parts.push(`${d.pruned} pruned`);
|
||||||
return parts.join(" · ");
|
return parts.join(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Upload-success toast (phase 64 task 05, A2 — owner-locked): the
|
/* Upload-success toast (phase 64 task 05, A2 — owner-locked): the
|
||||||
* "successfully uploaded" confirmation, the phase-55 share-toast
|
* "successfully uploaded" confirmation, the phase-55 share-toast
|
||||||
* pattern (frontend/assets/app.js) made page-local. A SINGLE node —
|
* pattern (frontend/assets/app.js) made page-local. A SINGLE node —
|
||||||
* lazy-created on the first 202 and reused (toasts never stack): a
|
* lazy-created on the first 202 and reused (toasts never stack): a
|
||||||
@@ -609,11 +637,8 @@ function fmtUploadResult(detail) {
|
|||||||
* live-region line for it). SUCCESS-ONLY (A2): failures are the
|
* live-region line for it). SUCCESS-ONLY (A2): failures are the
|
||||||
* #archive-upload-error banner, never a toast. The .toast CSS ships
|
* #archive-upload-error banner, never a toast. The .toast CSS ships
|
||||||
* as-is (styles.css, phase 55). */
|
* as-is (styles.css, phase 55). */
|
||||||
const UPLOAD_TOAST_MS = 5000; // ~5 s auto-dismiss (A2)
|
|
||||||
let uploadToastEl = null; // the single toast node — lazy-created, reused
|
|
||||||
let uploadToastTimer = 0; // the pending auto-dismiss (replaced by a new toast)
|
|
||||||
|
|
||||||
function showUploadToast(message) {
|
function showUploadToast(message) {
|
||||||
if (!uploadToastEl) {
|
if (!uploadToastEl) {
|
||||||
uploadToastEl = document.createElement("div");
|
uploadToastEl = document.createElement("div");
|
||||||
uploadToastEl.className = "toast";
|
uploadToastEl.className = "toast";
|
||||||
@@ -633,9 +658,9 @@ function showUploadToast(message) {
|
|||||||
uploadToastTimer = setTimeout(() => {
|
uploadToastTimer = setTimeout(() => {
|
||||||
uploadToastEl.classList.remove("is-visible"); // auto-dismiss ~5 s
|
uploadToastEl.classList.remove("is-visible"); // auto-dismiss ~5 s
|
||||||
}, UPLOAD_TOAST_MS);
|
}, UPLOAD_TOAST_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The scan poll (phase 64 task 05): a 2 s cadence — the SYNC_POLL_MS
|
/* The scan poll (phase 64 task 05): a 2 s cadence — the SYNC_POLL_MS
|
||||||
* house value. Single timer, one loop at a time (the guard makes a
|
* house value. Single timer, one loop at a time (the guard makes a
|
||||||
* double-start a no-op, and the submit finally reads this same
|
* double-start a no-op, and the submit finally reads this same
|
||||||
* variable to know whether the poll OWNS the button). Each tick
|
* variable to know whether the poll OWNS the button). Each tick
|
||||||
@@ -650,34 +675,34 @@ function showUploadToast(message) {
|
|||||||
* one-click re-upload; idle → stop + the button restored
|
* one-click re-upload; idle → stop + the button restored
|
||||||
* (defensive — a started run never returns to idle). A network blip
|
* (defensive — a started run never returns to idle). A network blip
|
||||||
* retries next tick. */
|
* retries next tick. */
|
||||||
const UPLOAD_POLL_MS = 2000; // the SYNC_POLL_MS house value
|
const UPLOAD_POLL_MS = 2000; // the SYNC_POLL_MS house value
|
||||||
let uploadPollTimer = null; // null = no poll active (the finally's guard)
|
let uploadPollTimer = null; // null = no poll active (the finally's guard)
|
||||||
|
|
||||||
function stopUploadPolling() {
|
function stopUploadPolling() {
|
||||||
if (uploadPollTimer !== null) {
|
if (uploadPollTimer !== null) {
|
||||||
clearTimeout(uploadPollTimer);
|
clearTimeout(uploadPollTimer);
|
||||||
uploadPollTimer = null;
|
uploadPollTimer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The button's processing entry (the 202 + the 409 re-attach): from
|
/* The button's processing entry (the 202 + the 409 re-attach): from
|
||||||
* here the poll OWNS it — disabled, "Processing…", title cleared (a
|
* here the poll OWNS it — disabled, "Processing…", title cleared (a
|
||||||
* live file lands on it at the first tick). */
|
* live file lands on it at the first tick). */
|
||||||
function enterUploadProcessingState() {
|
function enterUploadProcessingState() {
|
||||||
uploadBtn.disabled = true;
|
uploadBtn.disabled = true;
|
||||||
uploadBtn.textContent = "Processing…";
|
uploadBtn.textContent = "Processing…";
|
||||||
uploadBtn.title = "";
|
uploadBtn.title = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The idle restore (the poll's terminal branches + the submit
|
/* The idle restore (the poll's terminal branches + the submit
|
||||||
* finally, which calls this ONLY when no poll is active — PLAN §7.4). */
|
* finally, which calls this ONLY when no poll is active — PLAN §7.4). */
|
||||||
function restoreUploadButton() {
|
function restoreUploadButton() {
|
||||||
uploadBtn.disabled = false; // never stale — success OR failure
|
uploadBtn.disabled = false; // never stale — success OR failure
|
||||||
uploadBtn.textContent = "Upload & scan";
|
uploadBtn.textContent = "Upload & scan";
|
||||||
uploadBtn.removeAttribute("title");
|
uploadBtn.removeAttribute("title");
|
||||||
}
|
}
|
||||||
|
|
||||||
function startUploadPolling() {
|
function startUploadPolling() {
|
||||||
if (uploadPollTimer !== null) return; // one poll loop at a time
|
if (uploadPollTimer !== null) return; // one poll loop at a time
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
let status = null;
|
let status = null;
|
||||||
@@ -734,16 +759,16 @@ function startUploadPolling() {
|
|||||||
restoreUploadButton();
|
restoreUploadButton();
|
||||||
};
|
};
|
||||||
uploadPollTimer = setTimeout(tick, UPLOAD_POLL_MS);
|
uploadPollTimer = setTimeout(tick, UPLOAD_POLL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Boot re-attach (phase 64 task 05, the admin branch only): fetch the
|
/* Boot re-attach (phase 64 task 05, the admin branch only): fetch the
|
||||||
* upload status ONCE — a running scan re-enters the processing state
|
* upload status ONCE — a running scan re-enters the processing state
|
||||||
* + the poll (a reload mid-scan re-attaches instead of dead-ending —
|
* + the poll (a reload mid-scan re-attaches instead of dead-ending —
|
||||||
* no second upload, no error); a finished run re-renders its result
|
* no second upload, no error); a finished run re-renders its result
|
||||||
* line ONLY (no announce, no toast — the toast fired at the 202, A2);
|
* line ONLY (no announce, no toast — the toast fired at the 202, A2);
|
||||||
* a failed run re-renders its error banner; idle does nothing (and a
|
* a failed run re-renders its error banner; idle does nothing (and a
|
||||||
* blip is a no-op — the page boots honest either way). */
|
* blip is a no-op — the page boots honest either way). */
|
||||||
async function initUploadStatus() {
|
async function initUploadStatus() {
|
||||||
if (!uploadBtn) return;
|
if (!uploadBtn) return;
|
||||||
let status;
|
let status;
|
||||||
try {
|
try {
|
||||||
@@ -774,9 +799,9 @@ async function initUploadStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// idle: nothing to re-attach.
|
// idle: nothing to re-attach.
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uploadFormEl && uploadFileInput && uploadBtn) {
|
if (uploadFormEl && uploadFileInput && uploadBtn) {
|
||||||
uploadFormEl.addEventListener("submit", async (e) => {
|
uploadFormEl.addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Client-side no-file check (the input is `required` too — the
|
// Client-side no-file check (the input is `required` too — the
|
||||||
@@ -849,31 +874,33 @@ if (uploadFormEl && uploadFileInput && uploadBtn) {
|
|||||||
if (uploadPollTimer === null) restoreUploadButton();
|
if (uploadPollTimer === null) restoreUploadButton();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* After a successful add, focus the new row's Remove button so the
|
/* After a successful add, focus the new row's Remove button so the
|
||||||
keyboard/screen-reader user lands where the new data is. The 201
|
keyboard/screen-reader user lands where the new data is. The 201
|
||||||
body carries the row id (tr[data-id]); without one, the first row
|
body carries the row id (tr[data-id]); without one, the first row
|
||||||
is the fallback (the list is small and ordered). */
|
is the fallback (the list is small and ordered). */
|
||||||
function focusNewRow(createdId) {
|
function focusNewRow(createdId) {
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
const row = createdId
|
const row = createdId
|
||||||
? tbody.querySelector(`tr[data-id="${CSS.escape(createdId)}"]`)
|
? tbody.querySelector(`tr[data-id="${CSS.escape(createdId)}"]`)
|
||||||
: null;
|
: null;
|
||||||
const target = (row || tbody.querySelector("tr"))?.querySelector(".git-source-remove");
|
const target = (row || tbody.querySelector("tr"))?.querySelector(".git-source-remove");
|
||||||
if (target) target.focus();
|
if (target) target.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- retry + boot ---------- */
|
/* ---------- retry + boot ---------- */
|
||||||
|
|
||||||
if (retryBtn) retryBtn.addEventListener("click", () => loadSources());
|
if (retryBtn) retryBtn.addEventListener("click", () => loadSources());
|
||||||
|
|
||||||
(async () => {
|
/* ---------- view boot (phase 76 task 02) ----------
|
||||||
// The shared header FIRST (Sign in/out + the admin-only nav links +
|
* The shared header is NOT booted here — in the shell it runs
|
||||||
// the Sync button — one cached whoami), then the gate: anonymous
|
* exactly once, via the chat module (app.js) at shell boot. The
|
||||||
// visitors get the gate and NO /api/git-sources call (the Sources
|
* gate reads fetchIsAdmin() — the SAME cached whoami promise
|
||||||
// page gate pattern); the admin gets the manager.
|
* header.js exports (zero extra requests): anonymous visitors get
|
||||||
const admin = await initSharedHeader();
|
* the gate and NO /api/git-sources call (the Sources-page gate
|
||||||
|
* pattern); the admin gets the manager. */
|
||||||
|
const admin = await fetchIsAdmin();
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
if (gateEl) gateEl.hidden = false;
|
if (gateEl) gateEl.hidden = false;
|
||||||
if (contentEl) contentEl.hidden = true; // ships hidden — stays hidden
|
if (contentEl) contentEl.hidden = true; // ships hidden — stays hidden
|
||||||
@@ -886,4 +913,4 @@ if (retryBtn) retryBtn.addEventListener("click", () => loadSources());
|
|||||||
// resumes the Processing state) or re-render a terminal run's
|
// resumes the Processing state) or re-render a terminal run's
|
||||||
// result line / error banner.
|
// result line / error banner.
|
||||||
await initUploadStatus();
|
await initUploadStatus();
|
||||||
})();
|
}
|
||||||
|
|||||||
@@ -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
|
* 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."
|
* 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`
|
* Wires the admin-only `GET /api/chats` + `POST /api/chats/<id>/share`
|
||||||
* + `POST /api/chats/<id>/unshare` + `DELETE /api/chats/<id>`
|
* + `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:
|
* full-width table:
|
||||||
*
|
*
|
||||||
* • Title — an `<a href="/?chat=<id>">`: Open IS the title link
|
* • Title — an `<a href="/?chat=<id>">`: Open IS the title link
|
||||||
@@ -55,39 +56,59 @@
|
|||||||
* • admin → the gate hides and `loadChats()` renders the rows; a
|
* • admin → the gate hides and `loadChats()` renders the rows; a
|
||||||
* 0-row fetch reveals the empty-state row.
|
* 0-row fetch reveals the empty-state row.
|
||||||
*
|
*
|
||||||
* Phase 19/34: the page joins the shared header — initSharedHeader()
|
* Phase 76 (task 03) — shell view module (the "History" view of the
|
||||||
* runs first (whoami + nav reveal + the steering panel), and the gate
|
* ONE-document shell; /history.html now serves the shell, and
|
||||||
* below reuses the SAME cached /api/whoami promise (one request per
|
* assets/router.js lazy-imports THIS module on first show):
|
||||||
* page).
|
*
|
||||||
|
* • 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");
|
export async function mount(root) {
|
||||||
const tbody = document.querySelector("#history-tbody");
|
/* ---------- view elements (the view's section, scoped to root) ---------- */
|
||||||
const emptyRow = document.querySelector("#history-empty-row");
|
const tableWrap = root.querySelector("#history-table-wrap");
|
||||||
const gateEl = document.querySelector("#history-gate");
|
const tbody = root.querySelector("#history-tbody");
|
||||||
const statusEl = document.querySelector("#history-status");
|
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
|
/* Action feedback — the role="status" live region above the table
|
||||||
(the "never stale" contract: every row action lands a line here,
|
(the "never stale" contract: every row action lands a line here,
|
||||||
success or failure alike). */
|
success or failure alike). */
|
||||||
function announce(message) {
|
function announce(message) {
|
||||||
if (statusEl) statusEl.textContent = message;
|
if (statusEl) statusEl.textContent = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtDate(iso) {
|
function fmtDate(iso) {
|
||||||
try {
|
try {
|
||||||
return new Date(iso).toLocaleString();
|
return new Date(iso).toLocaleString();
|
||||||
} catch {
|
} catch {
|
||||||
return iso;
|
return iso;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* One row. The Title cell carries the Open link (/?chat=<id> — the
|
/* One row. The Title cell carries the Open link (/?chat=<id> — the
|
||||||
"return to that history with a click" requirement); the Updated
|
"return to that history with a click" requirement); the Updated
|
||||||
cell renders the locale date+time with the full ISO on hover. */
|
cell renders the locale date+time with the full ISO on hover. */
|
||||||
function makeRow(chat) {
|
function makeRow(chat) {
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
|
|
||||||
const titleTd = document.createElement("td");
|
const titleTd = document.createElement("td");
|
||||||
@@ -145,15 +166,15 @@ function makeRow(chat) {
|
|||||||
actionsTd.appendChild(makeDeleteControl(chat, tr));
|
actionsTd.appendChild(makeDeleteControl(chat, tr));
|
||||||
tr.appendChild(actionsTd);
|
tr.appendChild(actionsTd);
|
||||||
return tr;
|
return tr;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The inline two-step Delete (owner-locked 2026-08-29 — NO native
|
/* The inline two-step Delete (owner-locked 2026-08-29 — NO native
|
||||||
confirm dialog anywhere on this page). The Delete button is
|
confirm dialog anywhere on this page). The Delete button is
|
||||||
replaced, in place, by the "Delete? [Yes] [No]" pair; focus moves
|
replaced, in place, by the "Delete? [Yes] [No]" pair; focus moves
|
||||||
to Yes (keyboard-reachable confirm). Yes → DELETE /api/chats/<id>
|
to Yes (keyboard-reachable confirm). Yes → DELETE /api/chats/<id>
|
||||||
→ the row is removed + the live region line; No or a failed
|
→ the row is removed + the live region line; No or a failed
|
||||||
request keeps the row (+ the error line on failure). */
|
request keeps the row (+ the error line on failure). */
|
||||||
function makeDeleteControl(chat, row) {
|
function makeDeleteControl(chat, row) {
|
||||||
const cell = document.createElement("span");
|
const cell = document.createElement("span");
|
||||||
cell.className = "history-actions";
|
cell.className = "history-actions";
|
||||||
|
|
||||||
@@ -189,15 +210,15 @@ function makeDeleteControl(chat, row) {
|
|||||||
|
|
||||||
cell.appendChild(del); // the shipped state IS the Delete button
|
cell.appendChild(del); // the shipped state IS the Delete button
|
||||||
return cell;
|
return cell;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The confirmed delete: DELETE /api/chats/<id> → the row is removed
|
/* The confirmed delete: DELETE /api/chats/<id> → the row is removed
|
||||||
(+ the empty-state row reappears when it was the last one) and the
|
(+ 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
|
live region gets `Deleted "<title>".` A 404 means the row is gone
|
||||||
(deleted elsewhere) — drop the stale row and say so. Any other
|
(deleted elsewhere) — drop the stale row and say so. Any other
|
||||||
failure or a network error keeps the row, restores the Delete
|
failure or a network error keeps the row, restores the Delete
|
||||||
button (retryable), and lands the error line. */
|
button (retryable), and lands the error line. */
|
||||||
async function confirmDelete(chat, row, yesBtn, restoreDelete) {
|
async function confirmDelete(chat, row, yesBtn, restoreDelete) {
|
||||||
yesBtn.disabled = true; // no double-fire while the request is in flight
|
yesBtn.disabled = true; // no double-fire while the request is in flight
|
||||||
let r;
|
let r;
|
||||||
try {
|
try {
|
||||||
@@ -221,14 +242,14 @@ async function confirmDelete(chat, row, yesBtn, restoreDelete) {
|
|||||||
row.remove();
|
row.remove();
|
||||||
showEmptyIfLast();
|
showEmptyIfLast();
|
||||||
announce(`Deleted "${chat.title}".`);
|
announce(`Deleted "${chat.title}".`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- share column (phase 51, owner-locked 2026-08-29) ---------- */
|
/* ---------- share column (phase 51, owner-locked 2026-08-29) ---------- */
|
||||||
|
|
||||||
/* Select every text node in the link field (an <a> has no .select();
|
/* 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
|
a range does the job) — best-effort: a selection failure only means
|
||||||
the user copies by hand. */
|
the user copies by hand. */
|
||||||
function selectAllInField(el) {
|
function selectAllInField(el) {
|
||||||
try {
|
try {
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
range.selectNodeContents(el);
|
range.selectNodeContents(el);
|
||||||
@@ -238,9 +259,9 @@ function selectAllInField(el) {
|
|||||||
} catch {
|
} catch {
|
||||||
/* selection is best-effort — the field still shows the full URL */
|
/* selection is best-effort — the field still shows the full URL */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Clipboard + the owner-locked inline-link fallback: a non-secure
|
/* Clipboard + the owner-locked inline-link fallback: a non-secure
|
||||||
(http) homelab origin rejects navigator.clipboard, so the failure
|
(http) homelab origin rejects navigator.clipboard, so the failure
|
||||||
path renders a TRANSIENT <a> link field in the row's share cell —
|
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
|
input-like, it selects its full URL on focus (click or Tab, then
|
||||||
@@ -248,7 +269,7 @@ function selectAllInField(el) {
|
|||||||
Returns true when the clipboard took it. (The per-page duplication
|
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;
|
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.) */
|
app.js keeps the chat page's, no new shared module.) */
|
||||||
async function copyShareLink(cell, absoluteUrl) {
|
async function copyShareLink(cell, absoluteUrl) {
|
||||||
cell.querySelectorAll(".share-link-fallback").forEach((el) => el.remove());
|
cell.querySelectorAll(".share-link-fallback").forEach((el) => el.remove());
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(absoluteUrl);
|
await navigator.clipboard.writeText(absoluteUrl);
|
||||||
@@ -264,11 +285,11 @@ async function copyShareLink(cell, absoluteUrl) {
|
|||||||
field.focus({ preventScroll: true }); // selects the URL — ready to copy
|
field.focus({ preventScroll: true }); // selects the URL — ready to copy
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The unshared state: the [Create link] button (a failed share keeps
|
/* The unshared state: the [Create link] button (a failed share keeps
|
||||||
the cell here — Create link is retryable). */
|
the cell here — Create link is retryable). */
|
||||||
function renderShareUnshared(chat, cell) {
|
function renderShareUnshared(chat, cell) {
|
||||||
const create = document.createElement("button");
|
const create = document.createElement("button");
|
||||||
create.type = "button";
|
create.type = "button";
|
||||||
create.className = "history-share-create";
|
create.className = "history-share-create";
|
||||||
@@ -276,13 +297,13 @@ function renderShareUnshared(chat, cell) {
|
|||||||
create.textContent = "Create link";
|
create.textContent = "Create link";
|
||||||
create.addEventListener("click", () => void createShareLink(chat, cell, create));
|
create.addEventListener("click", () => void createShareLink(chat, cell, create));
|
||||||
cell.replaceChildren(create);
|
cell.replaceChildren(create);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The shared state: [Copy] [Unshare]. Unshare is the inline two-step
|
/* The shared state: [Copy] [Unshare]. Unshare is the inline two-step
|
||||||
(the phase-50 Delete-confirm pattern — same .history-confirm-* CSS,
|
(the phase-50 Delete-confirm pattern — same .history-confirm-* CSS,
|
||||||
focus moves to Yes so the confirm is keyboard-reachable); No or a
|
focus moves to Yes so the confirm is keyboard-reachable); No or a
|
||||||
failed request restores this state (retryable). */
|
failed request restores this state (retryable). */
|
||||||
function renderShareShared(chat, cell) {
|
function renderShareShared(chat, cell) {
|
||||||
const copy = document.createElement("button");
|
const copy = document.createElement("button");
|
||||||
copy.type = "button";
|
copy.type = "button";
|
||||||
copy.className = "history-share-copy";
|
copy.className = "history-share-copy";
|
||||||
@@ -316,14 +337,14 @@ function renderShareShared(chat, cell) {
|
|||||||
yes.focus({ preventScroll: true }); // the confirm pair takes over the focus
|
yes.focus({ preventScroll: true }); // the confirm pair takes over the focus
|
||||||
});
|
});
|
||||||
cell.replaceChildren(copy, unshare);
|
cell.replaceChildren(copy, unshare);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The Share cell (phase 51): the span the row's Share <td> carries.
|
/* 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
|
The shipped state comes from the row's share_url (the list endpoint
|
||||||
populates it — no second fetch): shared → Copy + Unshare, unshared →
|
populates it — no second fetch): shared → Copy + Unshare, unshared →
|
||||||
Create link. No share action ever removes the ROW (only Delete
|
Create link. No share action ever removes the ROW (only Delete
|
||||||
does) — the cell just re-renders between its states. */
|
does) — the cell just re-renders between its states. */
|
||||||
function makeShareControl(chat) {
|
function makeShareControl(chat) {
|
||||||
const cell = document.createElement("span");
|
const cell = document.createElement("span");
|
||||||
cell.className = "history-share";
|
cell.className = "history-share";
|
||||||
if (chat.share_url) {
|
if (chat.share_url) {
|
||||||
@@ -332,9 +353,9 @@ function makeShareControl(chat) {
|
|||||||
renderShareUnshared(chat, cell);
|
renderShareUnshared(chat, cell);
|
||||||
}
|
}
|
||||||
return cell;
|
return cell;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Create the link: POST /api/chats/<id>/share → the response's
|
/* Create the link: POST /api/chats/<id>/share → the response's
|
||||||
share_url becomes the row's data (chat.share_url — the later Copy
|
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
|
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
|
link (the row's own origin supplies the scheme/host) is offered for
|
||||||
@@ -342,7 +363,7 @@ function makeShareControl(chat) {
|
|||||||
non-2xx (a 404 — the row was deleted behind our back — or 5xx) or 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,
|
network error keeps the unshared state (Create link re-enabled,
|
||||||
retryable) and lands the error line. */
|
retryable) and lands the error line. */
|
||||||
async function createShareLink(chat, cell, createBtn) {
|
async function createShareLink(chat, cell, createBtn) {
|
||||||
createBtn.disabled = true; // no double-fire while the request is in flight
|
createBtn.disabled = true; // no double-fire while the request is in flight
|
||||||
let r;
|
let r;
|
||||||
try {
|
try {
|
||||||
@@ -365,25 +386,25 @@ async function createShareLink(chat, cell, createBtn) {
|
|||||||
new URL(share_url, window.location.origin).toString(),
|
new URL(share_url, window.location.origin).toString(),
|
||||||
);
|
);
|
||||||
announce(copied ? "Share link copied." : "Share link ready — copy it from the field.");
|
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
|
/* Copy (shared state): re-copy the row's share_url — the per-page
|
||||||
clipboard + fallback helper; the live region lands the outcome. */
|
clipboard + fallback helper; the live region lands the outcome. */
|
||||||
async function copyRowShareLink(chat, cell) {
|
async function copyRowShareLink(chat, cell) {
|
||||||
const copied = await copyShareLink(
|
const copied = await copyShareLink(
|
||||||
cell,
|
cell,
|
||||||
new URL(chat.share_url, window.location.origin).toString(),
|
new URL(chat.share_url, window.location.origin).toString(),
|
||||||
);
|
);
|
||||||
announce(copied ? "Share link copied." : "Share link ready — copy it from the field.");
|
announce(copied ? "Share link copied." : "Share link ready — copy it from the field.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The confirmed unshare: POST /api/chats/<id>/unshare → the token is
|
/* The confirmed unshare: POST /api/chats/<id>/unshare → the token is
|
||||||
NULL (revoked — the public link 404s from now on), the cell
|
NULL (revoked — the public link 404s from now on), the cell
|
||||||
re-renders to the unshared state (Create link) and the live region
|
re-renders to the unshared state (Create link) and the live region
|
||||||
gets `Unshared "<title>".` A non-2xx / a network error keeps the
|
gets `Unshared "<title>".` A non-2xx / a network error keeps the
|
||||||
shared state (restoreShared — Copy + Unshare, retryable) and lands
|
shared state (restoreShared — Copy + Unshare, retryable) and lands
|
||||||
the error line. */
|
the error line. */
|
||||||
async function confirmUnshare(chat, cell, yesBtn, restoreShared) {
|
async function confirmUnshare(chat, cell, yesBtn, restoreShared) {
|
||||||
yesBtn.disabled = true; // no double-fire while the request is in flight
|
yesBtn.disabled = true; // no double-fire while the request is in flight
|
||||||
let r;
|
let r;
|
||||||
try {
|
try {
|
||||||
@@ -401,27 +422,27 @@ async function confirmUnshare(chat, cell, yesBtn, restoreShared) {
|
|||||||
chat.share_url = null; // revoked: the row is unshared again
|
chat.share_url = null; // revoked: the row is unshared again
|
||||||
renderShareUnshared(chat, cell);
|
renderShareUnshared(chat, cell);
|
||||||
announce(`Unshared "${chat.title}".`);
|
announce(`Unshared "${chat.title}".`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The empty-state row reappears exactly when the last data row was
|
/* The empty-state row reappears exactly when the last data row was
|
||||||
removed (the empty row itself ships in the tbody, hidden). */
|
removed (the empty row itself ships in the tbody, hidden). */
|
||||||
function showEmptyIfLast() {
|
function showEmptyIfLast() {
|
||||||
if (!emptyRow || !tbody) return;
|
if (!emptyRow || !tbody) return;
|
||||||
emptyRow.hidden = tbody.querySelectorAll("tr").length > 1;
|
emptyRow.hidden = tbody.querySelectorAll("tr").length > 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 0-row fetches, non-2xx, and network failures all land on the
|
/* 0-row fetches, non-2xx, and network failures all land on the
|
||||||
empty-state row (the sources.js house fallback — the safe state
|
empty-state row (the sources.js house fallback — the safe state
|
||||||
in every case). */
|
in every case). */
|
||||||
function showEmptyState() {
|
function showEmptyState() {
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
tbody.replaceChildren(emptyRow);
|
tbody.replaceChildren(emptyRow);
|
||||||
if (emptyRow) emptyRow.hidden = false;
|
if (emptyRow) emptyRow.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* GET /api/chats → render the rows (latest activity first — the
|
/* GET /api/chats → render the rows (latest activity first — the
|
||||||
server's order). A 0-row fetch shows the empty-state row. */
|
server's order). A 0-row fetch shows the empty-state row. */
|
||||||
async function loadChats() {
|
async function loadChats() {
|
||||||
if (emptyRow) emptyRow.hidden = true;
|
if (emptyRow) emptyRow.hidden = true;
|
||||||
let r;
|
let r;
|
||||||
try {
|
try {
|
||||||
@@ -442,21 +463,21 @@ async function loadChats() {
|
|||||||
for (const chat of chats) {
|
for (const chat of chats) {
|
||||||
tbody.appendChild(makeRow(chat));
|
tbody.appendChild(makeRow(chat));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
/* ---------- view boot (phase 76 task 03) ----------
|
||||||
// Phase 19/34: the shared header first (whoami + nav reveal + the
|
* The shared header is NOT booted here — in the shell it runs
|
||||||
// steering panel) — the whoami promise is cached, so the gate below
|
* exactly once, via the chat module (app.js) at shell boot. The
|
||||||
// reuses the SAME single /api/whoami request.
|
* whoami gate reads fetchIsAdmin() — the SAME cached whoami promise
|
||||||
await initSharedHeader();
|
* 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())) {
|
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 (tableWrap) tableWrap.hidden = true;
|
||||||
if (gateEl) gateEl.hidden = false;
|
if (gateEl) gateEl.hidden = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (gateEl) gateEl.hidden = true;
|
if (gateEl) gateEl.hidden = true;
|
||||||
loadChats();
|
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 });
|
||||||
@@ -1,45 +1,60 @@
|
|||||||
/* Brain of Reese — Sources page (knowledge base index view).
|
/* Brain of Reese — RAG view (the knowledge base catalog; phase 76
|
||||||
|
* task 02: shell view module — formerly the standalone sources.html).
|
||||||
*
|
*
|
||||||
* Wires the real `GET /api/docs` endpoint (import phase): stat cards +
|
* Wires the real `GET /api/docs` endpoint (import phase): stat cards +
|
||||||
* full-width document table, or the designed empty state when nothing is
|
* full-width document table, or the designed empty state when nothing
|
||||||
* indexed yet. Cells are built with DOM APIs (textContent) — never
|
* is indexed yet. Cells are built with DOM APIs (textContent) — never
|
||||||
* innerHTML with document-derived data (XSS-safe by construction).
|
* innerHTML with document-derived data (XSS-safe by construction).
|
||||||
*
|
*
|
||||||
* Phase 19: the page joins the shared header (assets/header.js) — the
|
* Phase 76 (task 02) — shell view module (the "RAG" view of the
|
||||||
* whoami gate below runs on the module's cached promise (one request per
|
* ONE-document shell; /sources.html now serves the shell, and
|
||||||
* page, shared with the header toggling).
|
* assets/router.js lazy-imports THIS module on first show):
|
||||||
*
|
*
|
||||||
* Phase 34 task 02: the header's functional controls are module-owned
|
* • the top-level boot is now `export async function mount(root)` —
|
||||||
* (assets/header.js), including the New chat binding (on a non-chat
|
* root is the view's <section id="view-rag">, and every DOM lookup
|
||||||
* page "new chat" means going to the chat, fresh — the module clears
|
* scopes to root (the view ids stay unique across the shell —
|
||||||
* the phase-14 conversation key and navigates to "/").
|
* scoped lookups keep the module honest and testable). The router
|
||||||
|
* mounts a view ONCE (mount-once, hide-forever), so the bindings,
|
||||||
|
* the sync state machine, and the in-flight poll 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 (sync button
|
||||||
|
* reveal + the anonymous catalog gate) keeps fetchIsAdmin() — the
|
||||||
|
* SAME cached /api/whoami promise header.js exports (zero extra
|
||||||
|
* requests).
|
||||||
|
* • the document modal needs NO wiring change: the shell keeps
|
||||||
|
* EXACTLY ONE #doc-modal-* skeleton (the chat's, body level), and
|
||||||
|
* both app.js (chat chips) and this module (RAG row links) open
|
||||||
|
* documents through the shared openDocumentModal(...) against that
|
||||||
|
* single instance (assets/document-modal.js, resolved by
|
||||||
|
* document-level querySelector at import).
|
||||||
*
|
*
|
||||||
* Phase 64 (task 04): the sync button is also the live progress face
|
* The sync button (phase 32) + the live two-job progress contract
|
||||||
* of the BACKGROUND archive scan (task 03) — while an upload import
|
* (phase 64 task 04) and the table rows (phase 26: same-page modal
|
||||||
* is in flight the button animates with the upload's current file
|
* links) are unchanged in content — only the boot shape moved.
|
||||||
* ("Importing <file>"), settles + refreshes the catalog when it
|
|
||||||
* finishes, and re-attaches to it on page load. See the sync-button
|
|
||||||
* block below for the full two-job contract.
|
|
||||||
*
|
|
||||||
* Phase 26: the table's path links open the document in the
|
|
||||||
* almost-fullscreen modal overlay (assets/document-modal.js) on the
|
|
||||||
* SAME page — no new tab, no navigation. The link keeps its
|
|
||||||
* /document.html href as the no-JS / context-menu escape hatch;
|
|
||||||
* left-clicks are intercepted (preventDefault) and routed to
|
|
||||||
* openDocumentModal. The module is loaded through the relative import
|
|
||||||
* below — the header.js single-evaluation design (no direct <script>
|
|
||||||
* tag; esbuild inlines it into the page bundle).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchIsAdmin, initSharedHeader } from "./header.js";
|
import { fetchIsAdmin } from "./header.js";
|
||||||
|
import { openDocumentModal } from "./document-modal.js"; // phase 26: row links open the same-page modal
|
||||||
|
|
||||||
/* ---------- Sync sources button (Sources page only) ----------
|
/* Viewer link (phase 10) — same encoded URL the chat chips use; both
|
||||||
|
* query values are percent-encoded (paths contain slashes, sometimes
|
||||||
|
* spaces). Phase 26: this is the href the .doc-link CARRIES (no-JS /
|
||||||
|
* context-menu escape hatch) — the left-click opens the same-page modal
|
||||||
|
* instead. */
|
||||||
|
export function documentUrl(source, path) {
|
||||||
|
return "/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mount(root) {
|
||||||
|
/* ---------- Sync sources button (Sources page only) ----------
|
||||||
*
|
*
|
||||||
* The §7.4 never-stale lifecycle: idle → click → POST /api/sync
|
* The §7.4 never-stale lifecycle: idle → click → POST /api/sync
|
||||||
* → running (2 s poll of GET /api/sync/status) → success | failed.
|
* → running (2 s poll of GET /api/sync/status) → success | failed.
|
||||||
* Admin-only: the button SHIPS hidden and the page boot below reveals it
|
* Admin-only: the button SHIPS hidden and the view boot below reveals
|
||||||
* for the admin on the SAME cached whoami initSharedHeader() used (no
|
* it for the admin on the SAME cached whoami fetchIsAdmin() reads
|
||||||
* extra fetch); the 403 branches stay as defense in depth. A failed run
|
* (no extra fetch); the 403 branches stay as defense in depth. A failed run
|
||||||
* opens an error modal (same as the former header.js module — recreated
|
* opens an error modal (same as the former header.js module — recreated
|
||||||
* here since the navbar button is gone).
|
* here since the navbar button is gone).
|
||||||
*
|
*
|
||||||
@@ -74,37 +89,37 @@ import { fetchIsAdmin, initSharedHeader } from "./header.js";
|
|||||||
* #sync-icon (the spinner icon), #sync-result (aria-live result
|
* #sync-icon (the spinner icon), #sync-result (aria-live result
|
||||||
* line), #sync-error-banner / #sync-error-text (error banner).
|
* line), #sync-error-banner / #sync-error-text (error banner).
|
||||||
*/
|
*/
|
||||||
const syncBtn = document.querySelector("#sync-btn");
|
const syncBtn = root.querySelector("#sync-btn");
|
||||||
const syncLabel = syncBtn ? syncBtn.querySelector(".sync-label") : null;
|
const syncLabel = syncBtn ? syncBtn.querySelector(".sync-label") : null;
|
||||||
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
|
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
|
||||||
const syncResult = document.querySelector("#sync-result");
|
const syncResult = root.querySelector("#sync-result");
|
||||||
const syncErrorBanner = document.querySelector("#sync-error-banner");
|
const syncErrorBanner = root.querySelector("#sync-error-banner");
|
||||||
const syncErrorText = document.querySelector("#sync-error-text");
|
const syncErrorText = root.querySelector("#sync-error-text");
|
||||||
|
|
||||||
const SYNC_POLL_MS = 2000;
|
const SYNC_POLL_MS = 2000;
|
||||||
let syncPollTimer = null;
|
let syncPollTimer = null;
|
||||||
let lastSyncState = null;
|
let lastSyncState = null;
|
||||||
|
|
||||||
function emitSyncStatus(status) {
|
function emitSyncStatus(status) {
|
||||||
lastSyncState = status.state;
|
lastSyncState = status.state;
|
||||||
window.dispatchEvent(new CustomEvent("bor:sync-status", { detail: status }));
|
window.dispatchEvent(new CustomEvent("bor:sync-status", { detail: status }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopSyncPolling() {
|
function stopSyncPolling() {
|
||||||
if (syncPollTimer !== null) {
|
if (syncPollTimer !== null) {
|
||||||
clearTimeout(syncPollTimer);
|
clearTimeout(syncPollTimer);
|
||||||
syncPollTimer = null;
|
syncPollTimer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtSyncTime(iso) {
|
function fmtSyncTime(iso) {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
if (Number.isNaN(d.getTime())) return "";
|
if (Number.isNaN(d.getTime())) return "";
|
||||||
const pad = (n) => String(n).padStart(2, "0");
|
const pad = (n) => String(n).padStart(2, "0");
|
||||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Phase 64 (task 04): the live-file label. `kind` picks the prefix —
|
/* Phase 64 (task 04): the live-file label. `kind` picks the prefix —
|
||||||
* "sync" → "Syncing…", "upload" → "Importing" (the background scan's
|
* "sync" → "Syncing…", "upload" → "Importing" (the background scan's
|
||||||
* word, A3). The current file — the status endpoint's full
|
* word, A3). The current file — the status endpoint's full
|
||||||
* source/relative/path (A4) — is appended while one is being processed;
|
* source/relative/path (A4) — is appended while one is being processed;
|
||||||
@@ -113,14 +128,14 @@ function fmtSyncTime(iso) {
|
|||||||
* import has started (total > 0). CSS ellipsizes the button label; the
|
* import has started (total > 0). CSS ellipsizes the button label; the
|
||||||
* same untruncated text goes to the button title + #sync-result (the
|
* same untruncated text goes to the button title + #sync-result (the
|
||||||
* aria-live announcer). */
|
* aria-live announcer). */
|
||||||
function fmtSyncLabel(kind, currentFile, done, total) {
|
function fmtSyncLabel(kind, currentFile, done, total) {
|
||||||
const prefix = kind === "upload" ? "Importing" : "Syncing…";
|
const prefix = kind === "upload" ? "Importing" : "Syncing…";
|
||||||
let label = currentFile ? `${prefix} ${currentFile}` : prefix;
|
let label = currentFile ? `${prefix} ${currentFile}` : prefix;
|
||||||
if (total > 0) label += ` (${done}/${total})`;
|
if (total > 0) label += ` (${done}/${total})`;
|
||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtSyncResult(detail) {
|
function fmtSyncResult(detail) {
|
||||||
const d = detail || {};
|
const d = detail || {};
|
||||||
const added = d.added || 0;
|
const added = d.added || 0;
|
||||||
const updated = d.updated || 0;
|
const updated = d.updated || 0;
|
||||||
@@ -131,21 +146,21 @@ function fmtSyncResult(detail) {
|
|||||||
}
|
}
|
||||||
if ((d.pruned || 0) > 0) parts.push(`${d.pruned} pruned`);
|
if ((d.pruned || 0) > 0) parts.push(`${d.pruned} pruned`);
|
||||||
return parts.join(" · ");
|
return parts.join(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeSyncError(message) {
|
function sanitizeSyncError(message) {
|
||||||
const text = String(message || "The sync failed.").replace(/\s+/g, " ").trim();
|
const text = String(message || "The sync failed.").replace(/\s+/g, " ").trim();
|
||||||
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
|
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The single running-state entry point (phase 64 task 04: the label
|
/* The single running-state entry point (phase 64 task 04: the label
|
||||||
* carries the live file — `kind` "sync" | "upload", the status
|
* carries the live file — `kind` "sync" | "upload", the status
|
||||||
* endpoint's current_file + done/total). Same mechanics as before
|
* endpoint's current_file + done/total). Same mechanics as before
|
||||||
* (disabled, aria-busy, spinning icon, no is-error) plus: the full
|
* (disabled, aria-busy, spinning icon, no is-error) plus: the full
|
||||||
* untruncated path on the button title (removed when null — no file
|
* untruncated path on the button title (removed when null — no file
|
||||||
* yet) and in #sync-result (the aria-live announcer reads the full
|
* yet) and in #sync-result (the aria-live announcer reads the full
|
||||||
* live path; CSS ellipsizes the button's label span only). */
|
* live path; CSS ellipsizes the button's label span only). */
|
||||||
function enterSyncRunningState(kind, currentFile, done, total) {
|
function enterSyncRunningState(kind, currentFile, done, total) {
|
||||||
if (!syncBtn) return;
|
if (!syncBtn) return;
|
||||||
syncBtn.disabled = true;
|
syncBtn.disabled = true;
|
||||||
syncBtn.setAttribute("aria-busy", "true");
|
syncBtn.setAttribute("aria-busy", "true");
|
||||||
@@ -157,9 +172,9 @@ function enterSyncRunningState(kind, currentFile, done, total) {
|
|||||||
const label = fmtSyncLabel(kind, currentFile, done, total);
|
const label = fmtSyncLabel(kind, currentFile, done, total);
|
||||||
if (syncLabel) syncLabel.textContent = label;
|
if (syncLabel) syncLabel.textContent = label;
|
||||||
if (syncResult) syncResult.textContent = label;
|
if (syncResult) syncResult.textContent = label;
|
||||||
}
|
}
|
||||||
|
|
||||||
function settleSyncButton(label) {
|
function settleSyncButton(label) {
|
||||||
if (!syncBtn) return;
|
if (!syncBtn) return;
|
||||||
syncBtn.disabled = false;
|
syncBtn.disabled = false;
|
||||||
syncBtn.removeAttribute("aria-busy");
|
syncBtn.removeAttribute("aria-busy");
|
||||||
@@ -168,23 +183,23 @@ function settleSyncButton(label) {
|
|||||||
syncBtn.classList.remove("is-error");
|
syncBtn.classList.remove("is-error");
|
||||||
if (syncIcon) syncIcon.classList.remove("is-spinning");
|
if (syncIcon) syncIcon.classList.remove("is-spinning");
|
||||||
if (syncLabel) syncLabel.textContent = label;
|
if (syncLabel) syncLabel.textContent = label;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showSyncError(detail) {
|
function showSyncError(detail) {
|
||||||
if (syncErrorText) syncErrorText.textContent = detail || "The sync failed.";
|
if (syncErrorText) syncErrorText.textContent = detail || "The sync failed.";
|
||||||
if (syncErrorBanner) syncErrorBanner.hidden = false;
|
if (syncErrorBanner) syncErrorBanner.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideSyncError() {
|
function hideSyncError() {
|
||||||
if (syncErrorText) syncErrorText.textContent = "";
|
if (syncErrorText) syncErrorText.textContent = "";
|
||||||
if (syncErrorBanner) syncErrorBanner.hidden = true;
|
if (syncErrorBanner) syncErrorBanner.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- sync failure modal (recreated here since the navbar button is gone) ---------- */
|
/* ---------- sync failure modal (recreated here since the navbar button is gone) ---------- */
|
||||||
let syncModal = null;
|
let syncModal = null;
|
||||||
let syncModalReturnFocus = null;
|
let syncModalReturnFocus = null;
|
||||||
|
|
||||||
function createSyncModal() {
|
function createSyncModal() {
|
||||||
const backdrop = document.createElement("div");
|
const backdrop = document.createElement("div");
|
||||||
backdrop.className = "sync-modal-backdrop";
|
backdrop.className = "sync-modal-backdrop";
|
||||||
backdrop.innerHTML =
|
backdrop.innerHTML =
|
||||||
@@ -203,9 +218,9 @@ function createSyncModal() {
|
|||||||
if (e.key === "Escape" && backdrop.classList.contains("is-open")) closeSyncModal();
|
if (e.key === "Escape" && backdrop.classList.contains("is-open")) closeSyncModal();
|
||||||
});
|
});
|
||||||
return backdrop;
|
return backdrop;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showSyncModal(error) {
|
function showSyncModal(error) {
|
||||||
if (!syncBtn || !document.body) return;
|
if (!syncBtn || !document.body) return;
|
||||||
if (!syncModal) syncModal = createSyncModal();
|
if (!syncModal) syncModal = createSyncModal();
|
||||||
syncModal.querySelector("#sync-modal-error").textContent = error;
|
syncModal.querySelector("#sync-modal-error").textContent = error;
|
||||||
@@ -214,17 +229,17 @@ function showSyncModal(error) {
|
|||||||
syncModalReturnFocus = active && active !== document.body ? active : syncBtn;
|
syncModalReturnFocus = active && active !== document.body ? active : syncBtn;
|
||||||
syncModal.classList.add("is-open");
|
syncModal.classList.add("is-open");
|
||||||
syncModal.querySelector(".sync-modal-close").focus();
|
syncModal.querySelector(".sync-modal-close").focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeSyncModal() {
|
function closeSyncModal() {
|
||||||
if (!syncModal || !syncModal.classList.contains("is-open")) return;
|
if (!syncModal || !syncModal.classList.contains("is-open")) return;
|
||||||
syncModal.classList.remove("is-open");
|
syncModal.classList.remove("is-open");
|
||||||
const target = syncModalReturnFocus;
|
const target = syncModalReturnFocus;
|
||||||
syncModalReturnFocus = null;
|
syncModalReturnFocus = null;
|
||||||
if (target && document.contains(target)) target.focus();
|
if (target && document.contains(target)) target.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySyncSuccess(status) {
|
function applySyncSuccess(status) {
|
||||||
const time = fmtSyncTime(status.finished_at);
|
const time = fmtSyncTime(status.finished_at);
|
||||||
settleSyncButton(time ? `Synced ${time}` : "Synced");
|
settleSyncButton(time ? `Synced ${time}` : "Synced");
|
||||||
if (syncResult) syncResult.textContent = fmtSyncResult(status.detail);
|
if (syncResult) syncResult.textContent = fmtSyncResult(status.detail);
|
||||||
@@ -232,9 +247,9 @@ function applySyncSuccess(status) {
|
|||||||
emitSyncStatus(status);
|
emitSyncStatus(status);
|
||||||
// Refresh the catalog live — the KB just changed.
|
// Refresh the catalog live — the KB just changed.
|
||||||
loadDocs();
|
loadDocs();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySyncFailure(status) {
|
function applySyncFailure(status) {
|
||||||
const error = sanitizeSyncError(status.error);
|
const error = sanitizeSyncError(status.error);
|
||||||
settleSyncButton("Sync sources");
|
settleSyncButton("Sync sources");
|
||||||
if (syncBtn) {
|
if (syncBtn) {
|
||||||
@@ -246,20 +261,20 @@ function applySyncFailure(status) {
|
|||||||
showSyncError(error);
|
showSyncError(error);
|
||||||
emitSyncStatus(status);
|
emitSyncStatus(status);
|
||||||
showSyncModal(error);
|
showSyncModal(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySyncIdle(status) {
|
function applySyncIdle(status) {
|
||||||
settleSyncButton("Sync sources");
|
settleSyncButton("Sync sources");
|
||||||
emitSyncStatus(status || { state: "idle" });
|
emitSyncStatus(status || { state: "idle" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The 2 s poll (phase 64 task 04): each tick fetches BOTH jobs — the
|
/* The 2 s poll (phase 64 task 04): each tick fetches BOTH jobs — the
|
||||||
* sync AND the background upload scan — and applies the two-job
|
* sync AND the background upload scan — and applies the two-job
|
||||||
* decision tree in order (see the section header). The 403 on the SYNC
|
* decision tree in order (see the section header). The 403 on the SYNC
|
||||||
* fetch hides the button (the whoami backstop); a 403 on the UPLOAD
|
* fetch hides the button (the whoami backstop); a 403 on the UPLOAD
|
||||||
* fetch is simply "no upload" (never a hide), and a network blip on
|
* fetch is simply "no upload" (never a hide), and a network blip on
|
||||||
* either fetch retries next tick. */
|
* either fetch retries next tick. */
|
||||||
function startSyncPolling() {
|
function startSyncPolling() {
|
||||||
if (syncPollTimer !== null) return;
|
if (syncPollTimer !== null) return;
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
let syncStatus = null;
|
let syncStatus = null;
|
||||||
@@ -338,9 +353,9 @@ function startSyncPolling() {
|
|||||||
applySyncIdle(syncStatus);
|
applySyncIdle(syncStatus);
|
||||||
};
|
};
|
||||||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startSync() {
|
async function startSync() {
|
||||||
let r;
|
let r;
|
||||||
try {
|
try {
|
||||||
r = await fetch("/api/sync", { method: "POST" });
|
r = await fetch("/api/sync", { method: "POST" });
|
||||||
@@ -373,15 +388,15 @@ async function startSync() {
|
|||||||
state: "failed",
|
state: "failed",
|
||||||
error: detail || `The server refused to start the sync (${r.status}).`,
|
error: detail || `The server refused to start the sync (${r.status}).`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Load-time re-attach (ADMIN ONLY): a running run re-enters running
|
/* Load-time re-attach (ADMIN ONLY): a running run re-enters running
|
||||||
* state, a terminal run renders its last result. Phase 64 (A3): with
|
* state, a terminal run renders its last result. Phase 64 (A3): with
|
||||||
* the sync IDLE, an in-flight background upload scan adopts the button
|
* the sync IDLE, an in-flight background upload scan adopts the button
|
||||||
* the same way — the "user clicked upload, then opened sources" case;
|
* the same way — the "user clicked upload, then opened sources" case;
|
||||||
* a terminal upload is a no-op (the boot-time loadDocs() already shows
|
* a terminal upload is a no-op (the boot-time loadDocs() already shows
|
||||||
* the current catalog). */
|
* the current catalog). */
|
||||||
async function initSyncButton() {
|
async function initSyncButton() {
|
||||||
if (!syncBtn) return;
|
if (!syncBtn) return;
|
||||||
if (!(await fetchIsAdmin())) return;
|
if (!(await fetchIsAdmin())) return;
|
||||||
let status;
|
let status;
|
||||||
@@ -422,52 +437,39 @@ async function initSyncButton() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
applySyncIdle(status);
|
applySyncIdle(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (syncBtn) {
|
if (syncBtn) {
|
||||||
syncBtn.addEventListener("click", startSync);
|
syncBtn.addEventListener("click", startSync);
|
||||||
initSyncButton();
|
initSyncButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
import { openDocumentModal } from "./document-modal.js"; // phase 26: row links open the same-page modal
|
const tbody = root.querySelector("#docs-tbody");
|
||||||
|
const emptyEl = root.querySelector("#sources-empty");
|
||||||
|
const tableWrap = root.querySelector(".table-wrap");
|
||||||
|
const statCards = root.querySelector("#stat-cards");
|
||||||
|
const gateEl = root.querySelector("#sources-gate");
|
||||||
|
const statDocs = root.querySelector("#stat-docs");
|
||||||
|
const statChunks = root.querySelector("#stat-chunks");
|
||||||
|
const statLast = root.querySelector("#stat-last");
|
||||||
|
|
||||||
const tbody = document.querySelector("#docs-tbody");
|
/* Phase 16: whoami BEFORE the docs fetch. Anonymous visitors get the
|
||||||
const emptyEl = document.querySelector("#sources-empty");
|
|
||||||
const tableWrap = document.querySelector(".table-wrap");
|
|
||||||
const statCards = document.querySelector("#stat-cards");
|
|
||||||
const gateEl = document.querySelector("#sources-gate");
|
|
||||||
const statDocs = document.querySelector("#stat-docs");
|
|
||||||
const statChunks = document.querySelector("#stat-chunks");
|
|
||||||
const statLast = document.querySelector("#stat-last");
|
|
||||||
|
|
||||||
/* Phase 16: whoami BEFORE the docs fetch. Anonymous visitors get the
|
|
||||||
* sign-in gate (stat cards + table hidden) and NO /api/docs call — the
|
* sign-in gate (stat cards + table hidden) and NO /api/docs call — the
|
||||||
* catalog is admin-only. The document viewer itself stays public (the
|
* catalog is admin-only. The document viewer itself stays public (the
|
||||||
* soft rule), so the gate copy points at what keeps working.
|
* soft rule), so the gate copy points at what keeps working.
|
||||||
* Phase 19: the whoami request is the shared header module's cached
|
* Phase 76 (task 02): the whoami request is the shared header module's
|
||||||
* promise — the same single request initSharedHeader() awaited. */
|
* cached promise — fetchIsAdmin(), the same single request the shell's
|
||||||
function isAdmin() {
|
* header boots (zero extra requests). */
|
||||||
return fetchIsAdmin();
|
function fmtDate(iso) {
|
||||||
}
|
|
||||||
|
|
||||||
function fmtDate(iso) {
|
|
||||||
try {
|
try {
|
||||||
return new Date(iso).toLocaleString();
|
return new Date(iso).toLocaleString();
|
||||||
} catch {
|
} catch {
|
||||||
return iso;
|
return iso;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Viewer link (phase 10) — same encoded URL the chat chips use; both
|
|
||||||
* query values are percent-encoded (paths contain slashes, sometimes
|
|
||||||
* spaces). Phase 26: this is the href the .doc-link CARRIES (no-JS /
|
|
||||||
* context-menu escape hatch) — the left-click opens the same-page modal
|
|
||||||
* instead. */
|
|
||||||
export function documentUrl(source, path) {
|
|
||||||
return "/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDocs() {
|
async function loadDocs() {
|
||||||
let r;
|
let r;
|
||||||
try {
|
try {
|
||||||
r = await fetch("/api/docs");
|
r = await fetch("/api/docs");
|
||||||
@@ -498,9 +500,9 @@ async function loadDocs() {
|
|||||||
statLast.textContent = last ? fmtDate(last) : "–";
|
statLast.textContent = last ? fmtDate(last) : "–";
|
||||||
emptyEl.hidden = true;
|
emptyEl.hidden = true;
|
||||||
tableWrap.hidden = false;
|
tableWrap.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeRow(d) {
|
function makeRow(d) {
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
|
|
||||||
const sourceTd = document.createElement("td");
|
const sourceTd = document.createElement("td");
|
||||||
@@ -532,21 +534,28 @@ function makeRow(d) {
|
|||||||
tr.appendChild(td);
|
tr.appendChild(td);
|
||||||
}
|
}
|
||||||
return tr;
|
return tr;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showEmpty() {
|
function showEmpty() {
|
||||||
statDocs.textContent = "0";
|
statDocs.textContent = "0";
|
||||||
statChunks.textContent = "0";
|
statChunks.textContent = "0";
|
||||||
statLast.textContent = "–";
|
statLast.textContent = "–";
|
||||||
emptyEl.hidden = false;
|
emptyEl.hidden = false;
|
||||||
if (tableWrap) tableWrap.hidden = true;
|
if (tableWrap) tableWrap.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
(async () => {
|
/* ---------- view boot (phase 76 task 02) ----------
|
||||||
const admin = await initSharedHeader(); // phase 19: shared bar (cached whoami)
|
* The shared header is NOT booted here — in the shell it runs
|
||||||
|
* exactly once, via the chat module (app.js) at shell boot. The
|
||||||
|
* admin gate reads fetchIsAdmin() — the SAME cached whoami promise
|
||||||
|
* header.js exports (zero extra requests): the sync button joins
|
||||||
|
* the admin reveal on that one whoami (no extra fetch), and the
|
||||||
|
* anonymous branch gates the catalog in / out with NO /api/docs
|
||||||
|
* request at all (the Sources-page soft rule, unchanged). */
|
||||||
|
const admin = await fetchIsAdmin();
|
||||||
if (syncBtn) syncBtn.hidden = !admin; // admin-only: ship-hidden, revealed on the same whoami
|
if (syncBtn) syncBtn.hidden = !admin; // admin-only: ship-hidden, revealed on the same whoami
|
||||||
if (!(await isAdmin())) {
|
if (!admin) {
|
||||||
// Anonymous: gate in, catalog out, and no /api/docs request at all.
|
// Anonymous: gate in, catalog out, and no /api/docs request at all.
|
||||||
if (statCards) statCards.hidden = true;
|
if (statCards) statCards.hidden = true;
|
||||||
if (tableWrap) tableWrap.hidden = true;
|
if (tableWrap) tableWrap.hidden = true;
|
||||||
@@ -556,4 +565,4 @@ function showEmpty() {
|
|||||||
}
|
}
|
||||||
if (gateEl) gateEl.hidden = true;
|
if (gateEl) gateEl.hidden = true;
|
||||||
loadDocs();
|
loadDocs();
|
||||||
})();
|
}
|
||||||
|
|||||||
@@ -404,6 +404,21 @@ html::after {
|
|||||||
padding-block: 1.25rem;
|
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
|
/* Chat is a vertical conversation: a centered, capped column is the
|
||||||
correct layout here (PLAN §UI/UX). The surrounding frame keeps it
|
correct layout here (PLAN §UI/UX). The surrounding frame keeps it
|
||||||
from collapsing into a hairline on wide screens. The cap is the
|
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 /
|
* The standalone manager for steering notes: create / list / edit /
|
||||||
* delete WITHOUT a chat conversation. This module is the single owner
|
* 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
|
* • loadNotes() — GET /api/steering → the newest-first note list
|
||||||
* (#tune-list) + the empty state. A failed fetch (API down, or the
|
* (#tune-list) + the empty state. A failed fetch (API down, or the
|
||||||
@@ -25,64 +26,76 @@
|
|||||||
* announce a retry. The empty state is re-checked on every removal.
|
* announce a retry. The empty state is re-checked on every removal.
|
||||||
* • announce(msg) — #tune-announcer (role=status, aria-live=polite),
|
* • announce(msg) — #tune-announcer (role=status, aria-live=polite),
|
||||||
* the screen-reader confirmation for create / edit / delete.
|
* 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
|
* Phase 76 (task 01) — shell view module (the "Global Tuning" view of
|
||||||
* link for anonymous visitors; a DIRECT anonymous URL still gets a safe
|
* the ONE-document shell; /tuning.html now serves the shell, and
|
||||||
* page — loadNotes() only runs when the cached whoami says admin (the
|
* assets/router.js lazy-imports THIS module on first show):
|
||||||
* Sources page gate pattern), the list stays on its empty state, and
|
*
|
||||||
* the create form 403s gracefully on submit (the inline error carries
|
* • the top-level boot is now `export async function mount(root)` —
|
||||||
* the API detail). Note text is always rendered with textContent —
|
* root is the view's <section id="view-tuning">, and every DOM
|
||||||
* never innerHTML (XSS-safe, like app.js's steering panel).
|
* 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
|
* The shared header module loads through this script's own relative
|
||||||
* import ("./header.js") — a hoisted import evaluated before this body
|
* import ("./header.js") — a hoisted import evaluated before this body
|
||||||
* runs (single-evaluation design: no direct <script> tag; esbuild
|
* runs (single-evaluation design: no direct <script> tag; in the
|
||||||
* inlines it into the page bundle in the image build).
|
* 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) ---------- */
|
export async function mount(root) {
|
||||||
const tuneForm = document.querySelector("#tune-form");
|
/* ---------- page elements (the view's section, scoped to root) ---------- */
|
||||||
const tuneNote = document.querySelector("#tune-note");
|
const tuneForm = root.querySelector("#tune-form");
|
||||||
const tuneSave = document.querySelector("#tune-save");
|
const tuneNote = root.querySelector("#tune-note");
|
||||||
const tuneList = document.querySelector("#tune-list");
|
const tuneSave = root.querySelector("#tune-save");
|
||||||
const tuneEmpty = document.querySelector("#tune-empty");
|
const tuneList = root.querySelector("#tune-list");
|
||||||
const tuneAnnouncer = document.querySelector("#tune-announcer");
|
const tuneEmpty = root.querySelector("#tune-empty");
|
||||||
|
const tuneAnnouncer = root.querySelector("#tune-announcer");
|
||||||
|
|
||||||
/* The create form's inline error (role=alert) — created once, hidden
|
/* The create form's inline error (role=alert) — created once, hidden
|
||||||
by default, and kept between attempts: a failed POST keeps the form
|
by default, and kept between attempts: a failed POST keeps the
|
||||||
AND its message until the next submit. */
|
form AND its message until the next submit. */
|
||||||
const createError = document.createElement("p");
|
const createError = document.createElement("p");
|
||||||
createError.className = "tuning-error";
|
createError.className = "tuning-error";
|
||||||
createError.setAttribute("role", "alert");
|
createError.setAttribute("role", "alert");
|
||||||
createError.hidden = true;
|
createError.hidden = true;
|
||||||
if (tuneForm) tuneForm.appendChild(createError);
|
if (tuneForm) tuneForm.appendChild(createError);
|
||||||
|
|
||||||
/* Polite live region: the screen-reader confirmation for create /
|
/* Polite live region: the screen-reader confirmation for create /
|
||||||
edit / delete (task 03). */
|
edit / delete (task 03). */
|
||||||
function announce(message) {
|
function announce(message) {
|
||||||
if (tuneAnnouncer) tuneAnnouncer.textContent = message;
|
if (tuneAnnouncer) tuneAnnouncer.textContent = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Row-action icons — inline SVG constants (aria-hidden; the buttons
|
/* Row-action icons — inline SVG constants (aria-hidden; the buttons
|
||||||
carry their own labels), the same marks as app.js's steering panel. */
|
carry their own labels), the same marks as app.js's steering panel. */
|
||||||
const EDIT_ICON =
|
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>';
|
'<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 =
|
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>';
|
'<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
|
/* 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. */
|
(the first entry's msg is the human line). Same extraction as app.js. */
|
||||||
async function apiDetail(r, fallback) {
|
async function apiDetail(r, fallback) {
|
||||||
try {
|
try {
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
|
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
|
||||||
@@ -93,14 +106,14 @@ async function apiDetail(r, fallback) {
|
|||||||
/* non-JSON error body */
|
/* non-JSON error body */
|
||||||
}
|
}
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- load / render (newest first — the API's list order) ---------- */
|
/* ---------- load / render (newest first — the API's list order) ---------- */
|
||||||
|
|
||||||
/* GET /api/steering → render. A failed fetch (API down, or the 403 on
|
/* GET /api/steering → render. A failed fetch (API down, or the 403 on
|
||||||
an anonymous direct-URL visit) keeps the last rendered list —
|
an anonymous direct-URL visit) keeps the last rendered list —
|
||||||
progressive enhancement, never a blanked panel. */
|
progressive enhancement, never a blanked panel. */
|
||||||
async function loadNotes() {
|
async function loadNotes() {
|
||||||
let r;
|
let r;
|
||||||
try {
|
try {
|
||||||
r = await fetch("/api/steering");
|
r = await fetch("/api/steering");
|
||||||
@@ -115,26 +128,26 @@ async function loadNotes() {
|
|||||||
return; // corrupt body: keep the last rendered list
|
return; // corrupt body: keep the last rendered list
|
||||||
}
|
}
|
||||||
renderNotes(notes);
|
renderNotes(notes);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderNotes(notes) {
|
function renderNotes(notes) {
|
||||||
if (!tuneList) return;
|
if (!tuneList) return;
|
||||||
tuneList.textContent = "";
|
tuneList.textContent = "";
|
||||||
for (const n of notes) tuneList.appendChild(makeNoteRow(n));
|
for (const n of notes) tuneList.appendChild(makeNoteRow(n));
|
||||||
syncEmptyState(notes.length);
|
syncEmptyState(notes.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The empty state tracks the list's rendered rows (the HTML ships on
|
/* 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). */
|
the "No tuning notes yet" text; it hides as soon as one row shows). */
|
||||||
function syncEmptyState(count) {
|
function syncEmptyState(count) {
|
||||||
if (!tuneEmpty || !tuneList) return;
|
if (!tuneEmpty || !tuneList) return;
|
||||||
const rows = typeof count === "number" ? count : tuneList.children.length;
|
const rows = typeof count === "number" ? count : tuneList.children.length;
|
||||||
tuneEmpty.hidden = rows > 0;
|
tuneEmpty.hidden = rows > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* One list row: the note text (textContent — XSS-safe, never
|
/* One list row: the note text (textContent — XSS-safe, never
|
||||||
innerHTML) + the Edit and Delete buttons. */
|
innerHTML) + the Edit and Delete buttons. */
|
||||||
function makeNoteRow(n) {
|
function makeNoteRow(n) {
|
||||||
const li = document.createElement("li");
|
const li = document.createElement("li");
|
||||||
li.className = "tuning-note";
|
li.className = "tuning-note";
|
||||||
|
|
||||||
@@ -158,11 +171,11 @@ function makeNoteRow(n) {
|
|||||||
|
|
||||||
li.append(editBtn, delBtn);
|
li.append(editBtn, delBtn);
|
||||||
return li;
|
return li;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- create (POST /api/steering) ---------- */
|
/* ---------- create (POST /api/steering) ---------- */
|
||||||
|
|
||||||
if (tuneForm) {
|
if (tuneForm) {
|
||||||
tuneForm.addEventListener("submit", async (e) => {
|
tuneForm.addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (tuneSave) tuneSave.disabled = true; // one note per click
|
if (tuneSave) tuneSave.disabled = true; // one note per click
|
||||||
@@ -188,20 +201,20 @@ if (tuneForm) {
|
|||||||
if (tuneSave) tuneSave.disabled = false;
|
if (tuneSave) tuneSave.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- edit (inline form → PUT /api/steering/{id}) ----------
|
/* ---------- edit (inline form → PUT /api/steering/{id}) ----------
|
||||||
* The row swaps to the inline form — the is-editing class does the
|
* 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
|
* visual swap (styles.css hides the text + the row buttons). One open
|
||||||
* form page-wide: opening a new one reverts the others. Cancel reverts
|
* 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.
|
* to the text span; a failed save keeps the form + the inline error.
|
||||||
*/
|
*/
|
||||||
let editSeq = 0; // unique ids for the edit forms' labeled textareas
|
let editSeq = 0; // unique ids for the edit forms' labeled textareas
|
||||||
|
|
||||||
function openEditForm(li, n) {
|
function openEditForm(li, n) {
|
||||||
if (li.classList.contains("is-editing")) return; // one per row
|
if (li.classList.contains("is-editing")) return; // one per row
|
||||||
// One open form page-wide: close any other row's first.
|
// One open form view-wide: close any other row's first.
|
||||||
document.querySelectorAll(".tuning-note.is-editing").forEach((other) => {
|
root.querySelectorAll(".tuning-note.is-editing").forEach((other) => {
|
||||||
other.classList.remove("is-editing");
|
other.classList.remove("is-editing");
|
||||||
other.querySelector(".tuning-edit-form")?.remove();
|
other.querySelector(".tuning-edit-form")?.remove();
|
||||||
});
|
});
|
||||||
@@ -254,9 +267,9 @@ function openEditForm(li, n) {
|
|||||||
|
|
||||||
li.insertBefore(form, li.querySelector(".tuning-edit"));
|
li.insertBefore(form, li.querySelector(".tuning-edit"));
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEditSave(e, li, form, textarea, saveBtn, error) {
|
async function handleEditSave(e, li, form, textarea, saveBtn, error) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
saveBtn.disabled = true;
|
saveBtn.disabled = true;
|
||||||
error.hidden = true;
|
error.hidden = true;
|
||||||
@@ -293,13 +306,13 @@ async function handleEditSave(e, li, form, textarea, saveBtn, error) {
|
|||||||
error.hidden = false;
|
error.hidden = false;
|
||||||
saveBtn.disabled = false;
|
saveBtn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- delete (DELETE /api/steering/{id}, optimistic) ----------
|
/* ---------- delete (DELETE /api/steering/{id}, optimistic) ----------
|
||||||
* The row leaves the DOM the moment the server agrees (204); a 404
|
* 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
|
* (already gone) also drops the row and reloads to resync; any other
|
||||||
* failure re-enables the button and says to retry. */
|
* failure re-enables the button and says to retry. */
|
||||||
async function deleteNote(id, btn, li) {
|
async function deleteNote(id, btn, li) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" });
|
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||||
@@ -322,20 +335,18 @@ async function deleteNote(id, btn, li) {
|
|||||||
announce("Could not delete the note — is the app reachable?");
|
announce("Could not delete the note — is the app reachable?");
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- header boot (task 02) ---------- */
|
/* ---------- view boot (phase 76 task 01) ----------
|
||||||
|
* The New chat binding is module-owned (assets/header.js, phase 34
|
||||||
/* The New chat binding is module-owned (assets/header.js, phase 34
|
* task 02 — the SINGLE binding) and lives in the chat view only.
|
||||||
* 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
|
* Boot: the shared header is NOT booted here — in the shell it runs
|
||||||
* links — one cached whoami), then the note list — admin data only
|
* exactly once, via the chat module (app.js) at shell boot. The note
|
||||||
(the Sources page gate pattern): an anonymous visitor gets the page
|
* list is admin data (the Sources page gate pattern): the gate reads
|
||||||
frame with the empty state, and the create form 403s gracefully on
|
* fetchIsAdmin() — the SAME cached whoami promise the header uses
|
||||||
submit if one tries. */
|
* (zero extra requests). An anonymous visitor gets the view frame
|
||||||
(async () => {
|
* with the empty state, and the create form 403s gracefully on
|
||||||
await initSharedHeader(); // phase 19: whoami + Sign in/out + nav links
|
* submit if one tries. */
|
||||||
if (await fetchIsAdmin()) loadNotes(); // phase 27: the list is admin-only
|
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>
|
</div>
|
||||||
</header>
|
</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">
|
<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="container chat-shell" data-state="empty">
|
||||||
<div class="kb-banner" id="kb-banner" role="status" hidden>
|
<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>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</main>
|
||||||
|
|
||||||
<footer class="app-footer">
|
<footer class="app-footer">
|
||||||
@@ -279,6 +753,12 @@
|
|||||||
own `import "./header.js"` — a hoisted import that is evaluated
|
own `import "./header.js"` — a hoisted import that is evaluated
|
||||||
before the page script body calls initSharedHeader() at boot. -->
|
before the page script body calls initSharedHeader() at boot. -->
|
||||||
<script type="module" src="/assets/app.js"></script>
|
<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
|
<!-- Phase 26: the almost-fullscreen document modal. Source chips and
|
||||||
Sources-table path links open documents here (same-page overlay,
|
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>
|
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""Shared CSV benchmark recorder for model testing scripts.
|
||||||
|
|
||||||
|
All model-test scripts (chat, summary, embedding) write their results
|
||||||
|
to ``benchmarks/model_benchmarks.csv`` so results accumulate across
|
||||||
|
runs and can be compared with a spreadsheet or a simple query.
|
||||||
|
|
||||||
|
CSV columns (one row per *run*, not per turn):
|
||||||
|
|
||||||
|
date,script,model,mode,gate_status,turns,answered,caps,
|
||||||
|
tool_turns,emitted,executed,contract,wall_s,
|
||||||
|
extra_col1,extra_val1,extra_col2,extra_val2
|
||||||
|
|
||||||
|
* ``script`` — ``chat`` | ``summary`` | ``embed``
|
||||||
|
* ``model`` — the model name (e.g. ``lite``, ``turbo``)
|
||||||
|
* ``mode`` — ``fixture`` | ``derived`` | ``quality`` | ``dimension`` |
|
||||||
|
``cosine`` | ``speed``
|
||||||
|
* ``gate_status`` — ``PASS`` | ``FAIL``
|
||||||
|
* ``turns`` — number of turns/questions
|
||||||
|
* ``answered`` — turns that produced an answer (no LLMError)
|
||||||
|
* ``caps`` — turns that hit the round cap
|
||||||
|
* ``tool_turns`` — turns that emitted at least one tool call (chat only)
|
||||||
|
* ``emitted`` / ``executed`` — tool-call counts (chat only)
|
||||||
|
* ``contract`` — well-formed / total calls (chat) or quality score 0-100
|
||||||
|
(summary) or cosine accuracy 0-100 (embed)
|
||||||
|
* ``wall_s`` — total wall seconds for the run
|
||||||
|
* ``extra_*`` — script-specific secondary metrics (e.g. summary
|
||||||
|
coherence, embed dimension, embed speed per vector)
|
||||||
|
|
||||||
|
Usage from any test script::
|
||||||
|
|
||||||
|
from scripts.model_benchmark import bench_write
|
||||||
|
|
||||||
|
bench_write(
|
||||||
|
script="chat", # or "summary" / "embed"
|
||||||
|
model="lite",
|
||||||
|
mode="fixture",
|
||||||
|
gate_status="PASS",
|
||||||
|
turns=10,
|
||||||
|
answered=10,
|
||||||
|
caps=0,
|
||||||
|
tool_turns=9,
|
||||||
|
emitted=9,
|
||||||
|
executed=9,
|
||||||
|
contract=11, # numerator (denominator = emitted)
|
||||||
|
wall_s=40.5,
|
||||||
|
# optional extras:
|
||||||
|
contract_denom=12,
|
||||||
|
executed_denom=12,
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import os
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CSV_PATH = Path(__file__).resolve().parent.parent / "benchmarks" / "model_benchmarks.csv"
|
||||||
|
_HEADER = [
|
||||||
|
"date", "script", "model", "mode", "gate_status",
|
||||||
|
"turns", "answered", "caps",
|
||||||
|
"tool_turns", "emitted", "executed",
|
||||||
|
"contract", "wall_s",
|
||||||
|
"contract_denom", "executed_denom",
|
||||||
|
"tool_turns_denom", "extra1_col", "extra1_val",
|
||||||
|
"extra2_col", "extra2_val",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def bench_write(
|
||||||
|
*,
|
||||||
|
script: str,
|
||||||
|
model: str,
|
||||||
|
mode: str,
|
||||||
|
gate_status: str,
|
||||||
|
turns: int,
|
||||||
|
answered: int,
|
||||||
|
caps: int,
|
||||||
|
wall_s: float,
|
||||||
|
# chat-specific
|
||||||
|
tool_turns: int = 0,
|
||||||
|
emitted: int = 0,
|
||||||
|
executed: int = 0,
|
||||||
|
contract: int = 0,
|
||||||
|
# denominators (for percentages)
|
||||||
|
contract_denom: int | None = None,
|
||||||
|
executed_denom: int | None = None,
|
||||||
|
tool_turns_denom: int | None = None,
|
||||||
|
# extras (any script)
|
||||||
|
extra1_col: str = "",
|
||||||
|
extra1_val: str = "",
|
||||||
|
extra2_col: str = "",
|
||||||
|
extra2_val: str = "",
|
||||||
|
) -> None:
|
||||||
|
"""Append one row to the benchmark CSV."""
|
||||||
|
CSV_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Defaults: if denom not given, use the numerator
|
||||||
|
c_denom = contract_denom if contract_denom is not None else (contract if contract > 0 else 0)
|
||||||
|
e_denom = executed_denom if executed_denom is not None else (emitted if emitted > 0 else 0)
|
||||||
|
t_denom = tool_turns_denom if tool_turns_denom is not None else turns
|
||||||
|
|
||||||
|
row = {
|
||||||
|
"date": str(date.today()),
|
||||||
|
"script": script,
|
||||||
|
"model": model,
|
||||||
|
"mode": mode,
|
||||||
|
"gate_status": gate_status,
|
||||||
|
"turns": turns,
|
||||||
|
"answered": answered,
|
||||||
|
"caps": caps,
|
||||||
|
"tool_turns": tool_turns,
|
||||||
|
"tool_turns_denom": t_denom,
|
||||||
|
"emitted": emitted,
|
||||||
|
"executed": executed,
|
||||||
|
"executed_denom": e_denom,
|
||||||
|
"contract": contract,
|
||||||
|
"contract_denom": c_denom,
|
||||||
|
"wall_s": f"{wall_s:.1f}",
|
||||||
|
"extra1_col": extra1_col,
|
||||||
|
"extra1_val": extra1_val,
|
||||||
|
"extra2_col": extra2_col,
|
||||||
|
"extra2_val": extra2_val,
|
||||||
|
}
|
||||||
|
|
||||||
|
file_exists = CSV_PATH.exists() and CSV_PATH.stat().st_size > 0
|
||||||
|
|
||||||
|
with open(CSV_PATH, "a", newline="") as f:
|
||||||
|
writer = csv.DictWriter(f, fieldnames=_HEADER)
|
||||||
|
if not file_exists:
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerow(row)
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
"""Embedding-model quality and speed benchmark.
|
||||||
|
|
||||||
|
Tests the configured ``BOR_LLM_EMBED_MODEL`` (default ``embed``) across
|
||||||
|
three dimensions:
|
||||||
|
|
||||||
|
1. **Dimension check** — output vector length matches ``BOR_EMBEDDING_DIM``
|
||||||
|
2. **Cosine accuracy** — semantically similar text pairs have higher
|
||||||
|
cosine similarity than dissimilar pairs
|
||||||
|
3. **Speed** — vectors produced per second
|
||||||
|
4. **Vector quality** — no NaN / Inf in any output vector
|
||||||
|
|
||||||
|
Each run produces one CSV row via ``scripts/model_benchmark.bench_write``
|
||||||
|
and prints a ``gate:`` verdict line.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
uv run python -m scripts.test_embed_model # default model
|
||||||
|
uv run python -m scripts.test_embed_model --model embed
|
||||||
|
uv run python -m scripts.test_embed_model --runs 3
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from scripts.model_benchmark import CSV_PATH, bench_write
|
||||||
|
|
||||||
|
# ── semantic pairs (similar / dissimilar) ──────────────────────────────
|
||||||
|
|
||||||
|
_SEMANTIC_PAIRS = [
|
||||||
|
# (similar_pair_text_a, similar_pair_text_b, dissimilar_pair_text_a, dissimilar_pair_text_b)
|
||||||
|
(
|
||||||
|
"The server runs Ubuntu 24.04 with nginx as a reverse proxy",
|
||||||
|
"Ubuntu 24.04 server with nginx reverse proxy configuration",
|
||||||
|
"The backup uses restic with daily scheduling at 2 AM",
|
||||||
|
"Qwen 3.8 model inference on llama.cpp with GPU acceleration",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"PostgreSQL 17 with pgvector extension for semantic search",
|
||||||
|
"Postgres 17 database with vector embeddings for similarity",
|
||||||
|
"Docker containers deployed via Quadlet on Proxmox VE",
|
||||||
|
"The network bridge connects VLAN 130 to the physical port",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"GitLab CI runner with autoscaling and Docker executor",
|
||||||
|
"CI/CD pipeline runner that scales containers automatically",
|
||||||
|
"Restic backup with 7-day retention and S3 repository",
|
||||||
|
"Uptime Kuma monitoring dashboard with HTTP health checks",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Valkey cache running on localhost port 6379 for Mimir",
|
||||||
|
"Redis-compatible Valkey instance for caching services",
|
||||||
|
"Ansible inventory with three Proxmox nodes and custom roles",
|
||||||
|
"The Qwen model file is 16 GB loaded into GPU VRAM",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Proxmox VE 8.3.4 cluster with three nodes and 64 GB RAM",
|
||||||
|
"PVE cluster of three servers each with 64 gigabytes memory",
|
||||||
|
"Obsidian vault with markdown documents and semantic search",
|
||||||
|
"The ntfy topic reese-uptime-7 receives monitoring alerts",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── results ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EmbedResult:
|
||||||
|
mode: str # "dimension" | "cosine" | "speed"
|
||||||
|
gate_status: str
|
||||||
|
wall_s: float
|
||||||
|
extra1_col: str
|
||||||
|
extra1_val: str
|
||||||
|
extra2_col: str
|
||||||
|
extra2_val: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _cosine(a: list[float], b: list[float]) -> float:
|
||||||
|
dot = sum(x * y for x, y in zip(a, b))
|
||||||
|
na = math.sqrt(sum(x * x for x in a))
|
||||||
|
nb = math.sqrt(sum(x * x for x in b))
|
||||||
|
if na == 0 or nb == 0:
|
||||||
|
return 0.0
|
||||||
|
return dot / (na * nb)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_nan_or_inf(vec: list[float]) -> bool:
|
||||||
|
for v in vec:
|
||||||
|
if math.isnan(v) or math.isinf(v):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _embed(client: httpx.AsyncClient, model: str, texts: list[str]) -> list[list[float]]:
|
||||||
|
"""POST /embeddings and return list of vectors."""
|
||||||
|
base_url = os.environ.get("BOR_LLM_BASE_URL", "https://aipi.reeseapps.com/v1")
|
||||||
|
api_key = os.environ.get("BOR_LLM_API_KEY", "")
|
||||||
|
embed_model = os.environ.get("BOR_LLM_EMBED_MODEL", model)
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"{base_url}/embeddings",
|
||||||
|
json={"model": embed_model, "input": texts},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"embeddings endpoint HTTP {resp.status_code}: {resp.text[:300]}"
|
||||||
|
)
|
||||||
|
body = resp.json()
|
||||||
|
return [d["embedding"] for d in body["data"]]
|
||||||
|
|
||||||
|
|
||||||
|
# ── tests ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _test_dimension(model: str) -> EmbedResult:
|
||||||
|
"""Check that output vector length matches BOR_EMBEDDING_DIM."""
|
||||||
|
expected_dim = int(os.environ.get("BOR_EMBEDDING_DIM", "768"))
|
||||||
|
probe_text = "brain of reese dimension probe"
|
||||||
|
|
||||||
|
start = time.monotonic()
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
vectors = await _embed(client, model, [probe_text])
|
||||||
|
wall = time.monotonic() - start
|
||||||
|
|
||||||
|
actual_dim = len(vectors[0])
|
||||||
|
ok = actual_dim == expected_dim
|
||||||
|
gate = "PASS" if ok else "FAIL"
|
||||||
|
|
||||||
|
print(f" dimension: expected={expected_dim} actual={actual_dim} {'✓' if ok else '✗'}")
|
||||||
|
|
||||||
|
return EmbedResult(
|
||||||
|
mode="dimension",
|
||||||
|
gate_status=gate,
|
||||||
|
wall_s=wall,
|
||||||
|
extra1_col="actual_dim",
|
||||||
|
extra1_val=str(actual_dim),
|
||||||
|
extra2_col="expected_dim",
|
||||||
|
extra2_val=str(expected_dim),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _test_cosine(model: str) -> EmbedResult:
|
||||||
|
"""Similar pairs should have higher cosine similarity than dissimilar pairs."""
|
||||||
|
start = time.monotonic()
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
all_texts = []
|
||||||
|
for a, b, c, d in _SEMANTIC_PAIRS:
|
||||||
|
all_texts.extend([a, b, c, d])
|
||||||
|
|
||||||
|
vectors = await _embed(client, model, all_texts)
|
||||||
|
|
||||||
|
wall = time.monotonic() - start
|
||||||
|
|
||||||
|
# Compute similarities
|
||||||
|
similar_scores = []
|
||||||
|
dissimilar_scores = []
|
||||||
|
for i in range(0, len(_SEMANTIC_PAIRS) * 4, 4):
|
||||||
|
# a[0] vs a[1] (similar)
|
||||||
|
sim = _cosine(vectors[i], vectors[i + 1])
|
||||||
|
similar_scores.append(sim)
|
||||||
|
# a[2] vs a[3] (dissimilar)
|
||||||
|
dissim = _cosine(vectors[i + 2], vectors[i + 3])
|
||||||
|
dissimilar_scores.append(dissim)
|
||||||
|
# Cross: a[0] vs a[2] (should be lower than similar)
|
||||||
|
cross = _cosine(vectors[i], vectors[i + 2])
|
||||||
|
|
||||||
|
avg_similar = sum(similar_scores) / len(similar_scores)
|
||||||
|
avg_dissimilar = sum(dissimilar_scores) / len(dissimilar_scores)
|
||||||
|
margin = avg_similar - avg_dissimilar
|
||||||
|
|
||||||
|
# Gate: similar > dissimilar (margin > 0) and margin >= 0.10
|
||||||
|
ok = margin >= 0.05 # relaxed threshold
|
||||||
|
gate = "PASS" if ok else "FAIL"
|
||||||
|
quality = min(100, max(0, int(margin * 200))) # map margin 0-0.5 → 0-100
|
||||||
|
|
||||||
|
print(f" cosine: similar={avg_similar:.3f} dissimilar={avg_dissimilar:.3f} "
|
||||||
|
f"margin={margin:.3f} {'✓' if ok else '✗'}")
|
||||||
|
|
||||||
|
return EmbedResult(
|
||||||
|
mode="cosine",
|
||||||
|
gate_status=gate,
|
||||||
|
wall_s=wall,
|
||||||
|
extra1_col="margin",
|
||||||
|
extra1_val=f"{margin:.3f}",
|
||||||
|
extra2_col="quality",
|
||||||
|
extra2_val=str(quality),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _test_speed(model: str) -> EmbedResult:
|
||||||
|
"""Measure embeddings per second."""
|
||||||
|
# 50 varied texts
|
||||||
|
texts = [f"Test embedding number {i} with some content to make it realistic "
|
||||||
|
f"and meaningful for benchmarking purposes in the brain of reese system."
|
||||||
|
for i in range(50)]
|
||||||
|
|
||||||
|
start = time.monotonic()
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
vectors = await _embed(client, model, texts)
|
||||||
|
wall = time.monotonic() - start
|
||||||
|
|
||||||
|
# Check for NaN/Inf
|
||||||
|
bad = sum(1 for v in vectors if _has_nan_or_inf(v))
|
||||||
|
quality = "PASS" if bad == 0 else "FAIL"
|
||||||
|
|
||||||
|
rate = len(vectors) / wall if wall > 0 else 0
|
||||||
|
|
||||||
|
print(f" speed: {len(vectors)} vectors in {wall:.1f}s = {rate:.1f} vec/s "
|
||||||
|
f"{'✓' if bad == 0 else f'✗ {bad} bad vectors'}")
|
||||||
|
|
||||||
|
return EmbedResult(
|
||||||
|
mode="speed",
|
||||||
|
gate_status=quality,
|
||||||
|
wall_s=wall,
|
||||||
|
extra1_col="rate",
|
||||||
|
extra1_val=f"{rate:.1f} vec/s",
|
||||||
|
extra2_col="bad_vectors",
|
||||||
|
extra2_val=str(bad),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── main ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def run_benchmark(model: str, runs: int = 1) -> list[EmbedResult]:
|
||||||
|
"""Run all embedding benchmark tests."""
|
||||||
|
all_results: list[EmbedResult] = []
|
||||||
|
|
||||||
|
for run_idx in range(runs):
|
||||||
|
prefix = f"run {run_idx + 1}: " if runs > 1 else ""
|
||||||
|
|
||||||
|
print(f"\n{prefix}dimension check...")
|
||||||
|
r_dim = await _test_dimension(model)
|
||||||
|
all_results.append(r_dim)
|
||||||
|
|
||||||
|
print(f"\n{prefix}cosine accuracy...")
|
||||||
|
r_cos = await _test_cosine(model)
|
||||||
|
all_results.append(r_cos)
|
||||||
|
|
||||||
|
print(f"\n{prefix}speed test...")
|
||||||
|
r_spd = await _test_speed(model)
|
||||||
|
all_results.append(r_spd)
|
||||||
|
|
||||||
|
return all_results
|
||||||
|
|
||||||
|
|
||||||
|
def _print_summary(results: list[EmbedResult]) -> str:
|
||||||
|
"""Print summary and return overall gate status."""
|
||||||
|
# Group by mode
|
||||||
|
by_mode: dict[str, list[EmbedResult]] = {}
|
||||||
|
for r in results:
|
||||||
|
by_mode.setdefault(r.mode, []).append(r)
|
||||||
|
|
||||||
|
overall = "PASS"
|
||||||
|
for mode in ("dimension", "cosine", "speed"):
|
||||||
|
runs = by_mode.get(mode, [])
|
||||||
|
statuses = [r.gate_status for r in runs]
|
||||||
|
all_pass = all(s == "PASS" for s in statuses)
|
||||||
|
if not all_pass:
|
||||||
|
overall = "FAIL"
|
||||||
|
label = f" {mode:12s}"
|
||||||
|
if len(runs) == 1:
|
||||||
|
r = runs[0]
|
||||||
|
label += f" {r.gate_status}"
|
||||||
|
if r.extra1_col:
|
||||||
|
label += f" ({r.extra1_col}={r.extra1_val})"
|
||||||
|
else:
|
||||||
|
label += f" {'/'.join(r.gate_status for r in runs)}"
|
||||||
|
print(label)
|
||||||
|
|
||||||
|
return overall
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Embedding model benchmark")
|
||||||
|
parser.add_argument("--model", default=None,
|
||||||
|
help="Model name (overrides BOR_LLM_EMBED_MODEL)")
|
||||||
|
parser.add_argument("--runs", type=int, default=1,
|
||||||
|
help="Number of full test passes")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
model = args.model or os.environ.get("BOR_LLM_EMBED_MODEL", "embed")
|
||||||
|
print(f"Benchmarking embedding model: {model} ({args.runs} run(s))")
|
||||||
|
|
||||||
|
results = asyncio.run(run_benchmark(model, runs=args.runs))
|
||||||
|
overall = _print_summary(results)
|
||||||
|
|
||||||
|
# Write to CSV — one row per mode per run
|
||||||
|
for r in results:
|
||||||
|
bench_write(
|
||||||
|
script="embed",
|
||||||
|
model=model,
|
||||||
|
mode=r.mode,
|
||||||
|
gate_status=r.gate_status,
|
||||||
|
turns=1,
|
||||||
|
answered=1,
|
||||||
|
caps=0,
|
||||||
|
wall_s=r.wall_s,
|
||||||
|
contract=1, # dimension test: 1=pass
|
||||||
|
extra1_col=r.extra1_col,
|
||||||
|
extra1_val=r.extra1_val,
|
||||||
|
extra2_col=r.extra2_col,
|
||||||
|
extra2_val=r.extra2_val,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\nBenchmarks recorded to {CSV_PATH}")
|
||||||
|
sys.exit(0 if overall == "PASS" else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
"""Summary-model quality and speed benchmark.
|
||||||
|
|
||||||
|
Tests the configured ``BOR_LLM_SUMMARY_MODEL`` (default ``lite``) against
|
||||||
|
a fixed set of source texts — extracted from the fixture KB documents —
|
||||||
|
and evaluates:
|
||||||
|
|
||||||
|
1. **Coherence** — is the summary non-empty, grammatical, and on-topic?
|
||||||
|
2. **Coverage** — does it capture the key facts from the source?
|
||||||
|
3. **Brevity** — is it under a reasonable length (≤ 200 chars per 1000
|
||||||
|
source chars)?
|
||||||
|
4. **Hallucination** — does it introduce facts not present in the source?
|
||||||
|
5. **Speed** — wall seconds per summary.
|
||||||
|
|
||||||
|
Each run produces one CSV row via ``scripts/model_benchmark.bench_write``
|
||||||
|
and prints a ``gate:`` verdict line.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
uv run python -m scripts.test_summary_model # default model
|
||||||
|
uv run python -m scripts.test_summary_model --model turbo
|
||||||
|
uv run python -m scripts.test_summary_model --model lite --runs 3
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# ── fixture texts (from tests/fixtures/agent_kb/) ──────────────────────
|
||||||
|
|
||||||
|
_FIXTURE_TEXTS = [
|
||||||
|
# (short label, source text)
|
||||||
|
("vela-bridges",
|
||||||
|
"VLAN bridges on Proxmox VE 8.3.4-1-lab1: two Linux bridges "
|
||||||
|
"br-lab (10.77.42.0/24, VLAN 130) and br-mgmt (10.77.43.0/24, "
|
||||||
|
"VLAN 131) connect VMs to the physical network via ports "
|
||||||
|
"eno1 and eno2. The rack7 cluster has three nodes (rack7-01, "
|
||||||
|
"rack7-02, rack7-03) with 64 GB RAM each, running PVE 8.3.4. "
|
||||||
|
"Uptime Kuma listens on 127.0.0.1:18443 with ntfy topic "
|
||||||
|
"reese-uptime-7 for alerts."),
|
||||||
|
|
||||||
|
("mimir-service",
|
||||||
|
"Mimir service deployed as a Quadlet container on rack7-01: "
|
||||||
|
"image ghcr.io/reese/obsidian-bor:2026.7.14, mapped to "
|
||||||
|
"127.0.0.1:18765. Configuration is stored in "
|
||||||
|
"/etc/mimir/mimir.yml with a 7-day retention policy. The "
|
||||||
|
"service depends on Valkey (redis-compatible) running on "
|
||||||
|
"localhost:6379 for caching. Health check hits /health every "
|
||||||
|
"30 seconds. Restart policy is on-failure with a 10-second "
|
||||||
|
"backoff."),
|
||||||
|
|
||||||
|
("restic-rack7",
|
||||||
|
"Restic backup for the rack7 cluster: machine ID rbm-8842, "
|
||||||
|
"schedule 17 2 * * * (daily at 2:17 AM), target repository "
|
||||||
|
"at s3:https://backup.reeseapps.com/rack7. Includes /etc, "
|
||||||
|
"/var/lib/docker, and the Postgres 17 data directory. Retention "
|
||||||
|
"keeps the last 7 daily, 4 weekly, and 12 monthly snapshots. "
|
||||||
|
"Encryption key is stored in /etc/restic/key. The backup takes "
|
||||||
|
"approximately 45 minutes and uses ~200 MB/s network throughput."),
|
||||||
|
|
||||||
|
("qwen38-llamacpp",
|
||||||
|
"Qwen 3.8 llama.cpp container on rack7-02: launch command "
|
||||||
|
"./server -m /models/qwen3.8b.Q4_K_M.gguf --host 0.0.0.0 "
|
||||||
|
"--port 8080 --ctx-size 8192 --n-gpu-layers 35 -ngl 35 "
|
||||||
|
"--batch-size 512 --threads 6 --parallel 2. The model file is "
|
||||||
|
"16 GB, loaded into GPU VRAM (12 GB) with context overflow to "
|
||||||
|
"system RAM. Inference speed is ~18 tokens/sec on the A1000 "
|
||||||
|
"laptop GPU. Temperature is set to 0.7 for creative tasks."),
|
||||||
|
|
||||||
|
("lab-inventory",
|
||||||
|
"Lab Ansible Inventory (ansible-core 2.19.4): three PVE nodes "
|
||||||
|
"(rack7-01 through rack7-03), one GitLab Runner (lab-ci), one "
|
||||||
|
"Mimir service node, and the development workstation (dev-ws). "
|
||||||
|
"All nodes share the same NTP server (time.reeseapps.com), "
|
||||||
|
"DNS (10.77.42.1), and backup repository. The inventory includes "
|
||||||
|
"custom roles for Proxmox configuration, container management, "
|
||||||
|
"and monitoring stack deployment. Playbooks are tested in a "
|
||||||
|
"staging environment before production runs."),
|
||||||
|
|
||||||
|
("gitlab-runner",
|
||||||
|
"GitLab Runner (lab-ci) registered to https://git.reeseapps.com "
|
||||||
|
"with runner token glrt-XYZ123. Executor is docker+machine with "
|
||||||
|
"autoscaling: min 1, max 3 machines. Docker image is "
|
||||||
|
"gitlab/gitlab-runner:v17.0. Each job gets a fresh machine with "
|
||||||
|
"16 GB RAM and 4 vCPUs. Cache is shared via a local MinIO "
|
||||||
|
"instance at 10.77.43.10:9000. Pipeline timeout is 30 minutes. "
|
||||||
|
"Artifacts are stored for 7 days."),
|
||||||
|
|
||||||
|
("uptime-kuma",
|
||||||
|
"Uptime Kuma monitoring on rack7-01: container image "
|
||||||
|
"louislam/uptime-kuma:1, exposed on 127.0.0.1:18443. Monitors "
|
||||||
|
"all six services (PVE nodes, Mimir, GitLab, Qwen, Restic) "
|
||||||
|
"with HTTP, TCP, and ping probes. Alerts route to ntfy topic "
|
||||||
|
"reese-uptime-7 and email (admin@reeseapps.com). Dashboard "
|
||||||
|
"requires basic auth. Data is stored in /app/data/uptime-kuma.db "
|
||||||
|
"with a daily backup to the Restic repository."),
|
||||||
|
|
||||||
|
("meridian-notes",
|
||||||
|
"Meridian project notes: a personal knowledge management system "
|
||||||
|
"using Obsidian with the Obsidian-BOR plugin (ghcr.io/reese/"
|
||||||
|
"obsidian-bor:2026.7.14). The vault contains 8 markdown documents "
|
||||||
|
"across two sources (homelab, deployments). The BOR plugin "
|
||||||
|
"provides semantic search via pgvector embeddings, tool calling "
|
||||||
|
"for file operations, and a RAG pipeline for answering questions "
|
||||||
|
"from the vault. The system runs on a single rack7 node with "
|
||||||
|
"PostgreSQL 17 + pgvector for storage."),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── quality rubric ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SummaryResult:
|
||||||
|
label: str
|
||||||
|
source_len: int
|
||||||
|
summary_len: int
|
||||||
|
summary: str
|
||||||
|
coherence: int # 1-5
|
||||||
|
coverage: int # 1-5
|
||||||
|
brevity: int # 1-5
|
||||||
|
hallucination: bool # True if we detect a hallucination
|
||||||
|
wall_s: float
|
||||||
|
|
||||||
|
|
||||||
|
def _briefness_ratio(summary_len: int, source_len: int) -> float:
|
||||||
|
"""Ratio of summary chars to source chars. Ideal: 0.10–0.25."""
|
||||||
|
if source_len == 0:
|
||||||
|
return 0.0
|
||||||
|
return summary_len / source_len
|
||||||
|
|
||||||
|
|
||||||
|
def _score_brevity(ratio: float) -> int:
|
||||||
|
if 0.10 <= ratio <= 0.25:
|
||||||
|
return 5
|
||||||
|
elif 0.05 <= ratio <= 0.35:
|
||||||
|
return 4
|
||||||
|
elif 0.02 <= ratio <= 0.50:
|
||||||
|
return 3
|
||||||
|
elif ratio > 0:
|
||||||
|
return 2
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── prompt ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_SUMMARY_PROMPT = (
|
||||||
|
"Summarize the following text in 2-4 sentences. Capture the key "
|
||||||
|
"facts and numbers. Do not add information that is not present in "
|
||||||
|
"the text.\n\n{text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── LLM client ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _summarize(client, model: str, text: str) -> tuple[str, float]:
|
||||||
|
"""Call the summary endpoint and return (summary_text, wall_s)."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
base_url = os.environ.get("BOR_LLM_BASE_URL", "https://aipi.reeseapps.com/v1")
|
||||||
|
api_key = os.environ.get("BOR_LLM_API_KEY", "")
|
||||||
|
summary_model = os.environ.get("BOR_LLM_SUMMARY_MODEL", model)
|
||||||
|
|
||||||
|
start = time.monotonic()
|
||||||
|
async with httpx.AsyncClient(timeout=120) as http:
|
||||||
|
resp = await http.post(
|
||||||
|
f"{base_url}/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": summary_model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": _SUMMARY_PROMPT.format(text=text)}
|
||||||
|
],
|
||||||
|
"temperature": 0.2,
|
||||||
|
"max_tokens": 2048,
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
wall = time.monotonic() - start
|
||||||
|
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"summary endpoint HTTP {resp.status_code}: {resp.text[:300]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
body = resp.json()
|
||||||
|
content = body.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||||
|
if content is None:
|
||||||
|
content = ""
|
||||||
|
return content.strip(), wall
|
||||||
|
|
||||||
|
|
||||||
|
# ── scoring (rule-based heuristics — no LLM judge) ─────────────────────
|
||||||
|
|
||||||
|
def _score_coherence(summary: str) -> int:
|
||||||
|
if not summary:
|
||||||
|
return 1
|
||||||
|
sentences = [s.strip() for s in summary.replace("\n", " ").split(".") if s.strip()]
|
||||||
|
if len(sentences) < 2:
|
||||||
|
return 2
|
||||||
|
if len(sentences) >= 2 and summary[0].isupper():
|
||||||
|
return 4
|
||||||
|
return 3
|
||||||
|
|
||||||
|
|
||||||
|
def _score_coverage(summary: str, source: str) -> int:
|
||||||
|
"""Heuristic: does the summary contain at least one key number from source?"""
|
||||||
|
# Extract numbers from source
|
||||||
|
import re
|
||||||
|
source_nums = set(re.findall(r"\b\d{2,}\b", source))
|
||||||
|
if not source_nums:
|
||||||
|
return 3 # no numbers to check
|
||||||
|
summary_nums = set(re.findall(r"\b\d{2,}\b", summary))
|
||||||
|
hit = source_nums & summary_nums
|
||||||
|
if len(hit) >= 2:
|
||||||
|
return 5
|
||||||
|
elif len(hit) == 1:
|
||||||
|
return 4
|
||||||
|
elif len(hit) == 0:
|
||||||
|
return 2
|
||||||
|
return 3
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_hallucination(summary: str, source: str) -> bool:
|
||||||
|
"""Check if summary contains specific identifiers not in source."""
|
||||||
|
import re
|
||||||
|
# Extract all alphanumeric tokens >= 4 chars from source
|
||||||
|
source_tokens = set(re.findall(r"\b[a-zA-Z_]\w{3,}\b", source.lower()))
|
||||||
|
# Check summary tokens
|
||||||
|
summary_tokens = set(re.findall(r"\b[a-zA-Z_]\w{3,}\b", summary.lower()))
|
||||||
|
# If summary has a long token not in source, flag it
|
||||||
|
# (short common words are fine)
|
||||||
|
uncommon = summary_tokens - source_tokens
|
||||||
|
# Filter out very common English words
|
||||||
|
common = {"system", "service", "network", "server", "data", "file",
|
||||||
|
"host", "port", "port", "running", "config", "value",
|
||||||
|
"model", "image", "container", "running", "local", "local"}
|
||||||
|
flagged = uncommon - common
|
||||||
|
return len(flagged) > 3 # more than 3 uncommon new tokens
|
||||||
|
|
||||||
|
|
||||||
|
# ── main ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def run_benchmark(
|
||||||
|
model: str,
|
||||||
|
runs: int = 1,
|
||||||
|
) -> list[SummaryResult]:
|
||||||
|
"""Run the summary benchmark and return results."""
|
||||||
|
all_results: list[SummaryResult] = []
|
||||||
|
|
||||||
|
for run_idx in range(runs):
|
||||||
|
for label, text in _FIXTURE_TEXTS:
|
||||||
|
summary, wall = await _summarize(None, model, text)
|
||||||
|
src_len = len(text)
|
||||||
|
sum_len = len(summary)
|
||||||
|
brevity_ratio = _briefness_ratio(sum_len, src_len)
|
||||||
|
|
||||||
|
result = SummaryResult(
|
||||||
|
label=label,
|
||||||
|
source_len=src_len,
|
||||||
|
summary_len=sum_len,
|
||||||
|
summary=summary,
|
||||||
|
coherence=_score_coherence(summary),
|
||||||
|
coverage=_score_coverage(summary, text),
|
||||||
|
brevity=_score_brevity(brevity_ratio),
|
||||||
|
hallucination=_detect_hallucination(summary, text),
|
||||||
|
wall_s=wall,
|
||||||
|
)
|
||||||
|
all_results.append(result)
|
||||||
|
|
||||||
|
return all_results
|
||||||
|
|
||||||
|
|
||||||
|
def _print_report(results: list[SummaryResult]) -> tuple[str, int, float]:
|
||||||
|
"""Print a human-readable report. Returns (gate_status, score, wall)."""
|
||||||
|
n = len(results)
|
||||||
|
answered = sum(1 for r in results if r.summary)
|
||||||
|
caps = 0 # N/A for summary
|
||||||
|
coherence_avg = sum(r.coherence for r in results) / n if n else 0
|
||||||
|
coverage_avg = sum(r.coverage for r in results) / n if n else 0
|
||||||
|
brevity_avg = sum(r.brevity for r in results) / n if n else 0
|
||||||
|
hallucination_count = sum(1 for r in results if r.hallucination)
|
||||||
|
total_wall = sum(r.wall_s for r in results)
|
||||||
|
|
||||||
|
# Quality score: weighted average of coherence, coverage, brevity
|
||||||
|
quality_score = (coherence_avg * 0.35 + coverage_avg * 0.40 + brevity_avg * 0.25) / 5.0 * 100
|
||||||
|
|
||||||
|
# Hallucination penalty
|
||||||
|
if hallucination_count > 0:
|
||||||
|
quality_score -= hallucination_count * 5
|
||||||
|
|
||||||
|
quality_score = max(0, min(100, quality_score))
|
||||||
|
|
||||||
|
# Gate: PASS if quality >= 70 and hallucination rate < 25%
|
||||||
|
hallucination_rate = hallucination_count / n if n else 0
|
||||||
|
gate = "PASS" if quality_score >= 70 and hallucination_rate < 0.25 else "FAIL"
|
||||||
|
|
||||||
|
print(f"\ngate: {results[0].summary.split()[0] if results else 'N/A'}" if False else "")
|
||||||
|
print(f"gate: {gate} turns={n} answered={answered} caps={caps} "
|
||||||
|
f"quality={quality_score:.0f} hallucinations={hallucination_count}/{n} "
|
||||||
|
f"coherence={coherence_avg:.1f}/5 coverage={coverage_avg:.1f}/5 "
|
||||||
|
f"brevity={brevity_avg:.1f}/5 (wall {total_wall:.1f}s)")
|
||||||
|
|
||||||
|
# Per-turn detail
|
||||||
|
for r in results:
|
||||||
|
h = "HALL" if r.hallucination else " "
|
||||||
|
print(f" {h} {r.label:20s} coh={r.coherence} cov={r.coverage} "
|
||||||
|
f"brev={r.brevity} len={r.summary_len:4d} wall={r.wall_s:.1f}s")
|
||||||
|
|
||||||
|
return gate, int(quality_score), total_wall
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Summary model benchmark")
|
||||||
|
parser.add_argument("--model", default=None,
|
||||||
|
help="Model name (overrides BOR_LLM_SUMMARY_MODEL)")
|
||||||
|
parser.add_argument("--runs", type=int, default=1,
|
||||||
|
help="Number of full passes")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
model = args.model or os.environ.get("BOR_LLM_SUMMARY_MODEL", "lite")
|
||||||
|
print(f"Benchmarking summary model: {model} ({args.runs} run(s))")
|
||||||
|
|
||||||
|
results = asyncio.run(run_benchmark(model, runs=args.runs))
|
||||||
|
gate, quality, wall = _print_report(results)
|
||||||
|
|
||||||
|
# Write to CSV
|
||||||
|
from scripts.model_benchmark import bench_write
|
||||||
|
bench_write(
|
||||||
|
script="summary",
|
||||||
|
model=model,
|
||||||
|
mode="quality",
|
||||||
|
gate_status=gate,
|
||||||
|
turns=len(results),
|
||||||
|
answered=sum(1 for r in results if r.summary),
|
||||||
|
caps=0,
|
||||||
|
wall_s=wall,
|
||||||
|
contract=quality,
|
||||||
|
extra1_col="coherence",
|
||||||
|
extra1_val=f"{sum(r.coherence for r in results) / len(results):.1f}/5",
|
||||||
|
extra2_col="hallucinations",
|
||||||
|
extra2_val=f"{sum(1 for r in results if r.hallucination)}/{len(results)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
from scripts.model_benchmark import CSV_PATH as _CSV_PATH
|
||||||
|
print(f"\nBenchmark recorded to {_CSV_PATH}")
|
||||||
|
sys.exit(0 if gate == "PASS" else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -272,13 +272,15 @@ def test_persists_across_page_navigation(
|
|||||||
_ask_deflected(page, OFF_TOPIC) # mixed conversation: grounded + deflected
|
_ask_deflected(page, OFF_TOPIC) # mixed conversation: grounded + deflected
|
||||||
|
|
||||||
# A trip to Sources (phase 16: the catalog is admin-only — the trip
|
# 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
|
# starts with a real form login). Phase 76 (task 02): the shell
|
||||||
# only (owner rework 2026-08-28 — it left the shared bar), so the
|
# carries the chat view (with its New chat button) in the DOM on
|
||||||
# sources page carries none of it (pinned in
|
# 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).
|
# tests/e2e/test_shared_header.py).
|
||||||
login(page, app_url, next="/sources.html")
|
login(page, app_url, next="/sources.html")
|
||||||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
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,
|
# Back to the chat: the conversation is exactly as left — both turns,
|
||||||
# the source chip, and the amber deflected bubble with its chips.
|
# 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
|
# The .page-sub reads the locked (A3) page-sub string
|
||||||
# (whitespace-normalized — the template wraps the line).
|
# (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)
|
expect(page_sub).to_have_count(1)
|
||||||
assert re.sub(r"\s+", " ", page_sub.inner_text()).strip() == PAGE_SUB
|
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
|
# The no-button contract: the WHOLE rendered page reads neither
|
||||||
# "press(ed) Save" nor "Save button" — while the <h1> still reads
|
# "press(ed) Save" nor "Save button" — while the <h1> still reads
|
||||||
# "Saved chats" (asserted present, so the scan cannot pass by
|
# "Saved chats" (asserted present, so the scan cannot pass by
|
||||||
# deleting the heading).
|
# deleting the heading). Phase 76 (task 03): scoped to the
|
||||||
h1 = page.locator("h1")
|
# 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_count(1)
|
||||||
expect(h1).to_have_text("Saved chats")
|
expect(h1).to_have_text("Saved chats")
|
||||||
body_text = page.locator("body").inner_text()
|
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
|
login(page, app_url) # phase 16: the catalog is admin-only
|
||||||
page.locator("#docs-tbody tr").first.wait_for(state="visible")
|
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_be_visible()
|
||||||
expect(wrap).to_have_attribute("role", "region")
|
expect(wrap).to_have_attribute("role", "region")
|
||||||
expect(wrap).to_have_attribute("tabindex", "0")
|
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
|
login(mobile, app_url) # phase 16: the catalog is admin-only
|
||||||
mobile.locator("#docs-tbody tr").first.wait_for(state="visible")
|
mobile.locator("#docs-tbody tr").first.wait_for(state="visible")
|
||||||
scroll_width, client_width = mobile.evaluate(
|
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]; }"
|
" return [el.scrollWidth, el.clientWidth]; }"
|
||||||
)
|
)
|
||||||
assert scroll_width > client_width
|
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(
|
expect(page.locator("#sources-empty code")).to_have_text(
|
||||||
"uv run python -m scripts.import_docs"
|
"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-docs")).to_have_text("0")
|
||||||
expect(page.locator("#stat-chunks")).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
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from playwright.sync_api import Dialog, Page, expect
|
from playwright.sync_api import Page, expect
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app.db import SessionLocal
|
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}"
|
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).
|
# Back on the manager page (the sync clicks visited the Sources page).
|
||||||
page.goto(app_url + GIT_SOURCES_URL)
|
page.goto(app_url + GIT_SOURCES_URL)
|
||||||
removes: list[str] = []
|
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
|
if r.method == "DELETE" and "/api/git-sources/" in r.url
|
||||||
else None,
|
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 = page.locator("#git-sources-tbody tr", has_text=str(local_dir))
|
||||||
row.locator(".git-source-remove").click()
|
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-tbody tr")).to_have_count(0, timeout=30_000)
|
||||||
expect(page.locator("#git-sources-empty")).to_be_visible()
|
expect(page.locator("#git-sources-empty")).to_be_visible()
|
||||||
finally:
|
|
||||||
page.remove_listener("dialog", handle_dialog)
|
|
||||||
assert len(removes) == 1, f"expected one DELETE, saw: {removes}"
|
assert len(removes) == 1, f"expected one DELETE, saw: {removes}"
|
||||||
# The registry is empty again — and with no env git list, a further
|
# The registry is empty again — and with no env git list, a further
|
||||||
# sync would fail loudly ("no sources configured (git or local)").
|
# 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``
|
4. ``test_viewer_back_link_honors_back_param``
|
||||||
5. ``test_steering_surface_off_chat_on_tuning_page``
|
5. ``test_steering_surface_off_chat_on_tuning_page``
|
||||||
6. ``test_sync_button_present_on_sources_page_without_triggering``
|
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
|
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, (
|
assert page.locator("#steering-toggle").count() == 0, (
|
||||||
f"{name}: the steering toggle was removed from the navbar"
|
f"{name}: the steering toggle was removed from the navbar"
|
||||||
)
|
)
|
||||||
# The Sync button is a page-specific control (Sources page only
|
# The Sync button is a view-specific control (RAG view only —
|
||||||
# — owner rework 2026-08-28): visible on sources, absent from
|
# owner rework 2026-08-28): visible on sources, not visible
|
||||||
# the shared bar everywhere else.
|
# 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":
|
if name == "sources":
|
||||||
expect(page.locator("#sync-btn")).to_be_visible()
|
expect(page.locator("#sync-btn")).to_be_visible()
|
||||||
else:
|
else:
|
||||||
assert page.locator("#sync-btn").count() == 0, (
|
expect(page.locator("#sync-btn")).to_be_hidden()
|
||||||
f"{name}: #sync-btn left the shared bar (Sources page only)"
|
|
||||||
)
|
|
||||||
expect(page.locator("#sign-out-btn")).to_be_visible()
|
expect(page.locator("#sign-out-btn")).to_be_visible()
|
||||||
expect(page.locator("#sign-in-link")).to_be_hidden()
|
expect(page.locator("#sign-in-link")).to_be_hidden()
|
||||||
else:
|
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, (
|
assert page.locator("#steering-panel").count() == 0, (
|
||||||
f"{name}: the steering panel must be absent for anonymous"
|
f"{name}: the steering panel must be absent for anonymous"
|
||||||
)
|
)
|
||||||
# The New chat button is chat-page only (moved from the shared bar
|
# The New chat button is chat-view only (moved from the shared bar
|
||||||
# to index.html's .chat-shell at owner request, 2026-08-28).
|
# 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":
|
if name == "chat":
|
||||||
expect(page.locator("#new-chat-btn")).to_be_visible()
|
expect(page.locator("#new-chat-btn")).to_be_visible()
|
||||||
else:
|
else:
|
||||||
assert page.locator("#new-chat-btn").count() == 0, (
|
expect(page.locator("#new-chat-btn")).to_be_hidden()
|
||||||
f"{name}: #new-chat-btn left the shared bar (chat page only)"
|
|
||||||
)
|
|
||||||
|
|
||||||
_assert_landmarks(page, name)
|
_assert_landmarks(page, name)
|
||||||
return _header_inventory(page)
|
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"
|
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")
|
expect(page.locator("#sync-label")).to_have_text("Sync sources")
|
||||||
# Deliberately NOT clicked.
|
# 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
|
#: The four primary nav links, in their physical DOM order — the labels
|
||||||
#: after the phase-48 swap (ids/hrefs unchanged).
|
#: after the phase-48 swap (ids/hrefs unchanged).
|
||||||
NAV_LABELS = ("Chat", "RAG", "Sources", "Tuning")
|
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.
|
#: The login.js script — route pattern for the redirect suppression.
|
||||||
LOGIN_JS_ROUTE = re.compile(r"/assets/login\.js(\?.*)?$")
|
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_text("Sources")
|
||||||
expect(git).to_have_attribute("href", "/git-sources.html")
|
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(
|
nav_texts = page.eval_on_selector_all(
|
||||||
".app-nav a.nav-link", "els => els.map(e => e.textContent.trim())"
|
".app-nav a.nav-link", "els => els.map(e => e.textContent.trim())"
|
||||||
)
|
)
|
||||||
assert nav_texts == list(NAV_LABELS), (
|
assert nav_texts == [*NAV_LABELS, *NAV_TAIL], (
|
||||||
f"{name}: nav link order/labels are {nav_texts}, expected {list(NAV_LABELS)}"
|
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
|
# …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.
|
# 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:
|
try:
|
||||||
login(page, app_url, next="/sources.html") # phase 16: admin-only
|
login(page, app_url, next="/sources.html") # phase 16: admin-only
|
||||||
page.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
|
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()
|
shell_box = page.locator(".sources-shell").bounding_box()
|
||||||
assert wrap_box is not None and shell_box is not None
|
assert wrap_box is not None and shell_box is not None
|
||||||
assert wrap_box["width"] >= 0.80 * shell_box["width"], (
|
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
|
login(mobile, app_url, next="/sources.html") # phase 16: admin-only
|
||||||
mobile.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
|
mobile.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
|
||||||
scroll, client = mobile.evaluate(
|
scroll, client = mobile.evaluate(
|
||||||
"() => { const el = document.querySelector('.table-wrap');"
|
"() => { const el = document.querySelector('#view-rag .table-wrap');"
|
||||||
" return [el.scrollWidth, el.clientWidth]; }"
|
" return [el.scrollWidth, el.clientWidth]; }"
|
||||||
)
|
)
|
||||||
assert scroll > client, (
|
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()
|
expect(page.locator("#sign-out-btn")).to_be_hidden()
|
||||||
|
|
||||||
# The New chat button is chat-page only (moved from the shared bar
|
# The New chat button is chat-view only (moved from the shared bar
|
||||||
# to index.html's .chat-shell at owner request, 2026-08-28).
|
# 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":
|
if page_kind == "chat":
|
||||||
expect(page.locator("#new-chat-btn")).to_be_visible()
|
expect(page.locator("#new-chat-btn")).to_be_visible()
|
||||||
else:
|
else:
|
||||||
assert page.locator("#new-chat-btn").count() == 0, (
|
expect(page.locator("#new-chat-btn")).to_be_hidden()
|
||||||
f"{page_kind}: the New chat button is chat-page only"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Phase 34: EVERY page kind — viewer included — carries the SAME
|
# Phase 34: EVERY page kind — viewer included — carries the SAME
|
||||||
# nav contract: the Chat link always visible; the admin-only
|
# 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})
|
page.set_viewport_size({"width": 1280, "height": 800})
|
||||||
_seed_db(mock_llm)
|
_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"):
|
for path in (SOURCES_URL, VIEWER_URL, "/tuning.html", "/login.html", "/git-sources.html"):
|
||||||
page.goto(app_url + path)
|
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 + "/")
|
page.goto(app_url + "/")
|
||||||
expect(page.locator("#new-chat-btn")).to_have_count(1)
|
expect(page.locator("#new-chat-btn")).to_have_count(1)
|
||||||
expect(page.locator("#new-chat-btn")).to_be_visible()
|
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``):
|
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
|
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
|
Phase 20 (bug 24) pinned a REAL departure from the chat mid-answer: a
|
||||||
chat page while a turn was in flight aborted the stream and dropped
|
full page navigation (the "Sources" navbar link → /sources.html) aborted
|
||||||
whatever had already streamed — the user came back to their own question
|
the stream via the unload, and a single ``pagehide`` save point in
|
||||||
with no result, ever. The fix (phase 20, owner-confirmed A1): a single
|
app.js persisted the partial raw answer (``rememberBrainTurn``) so the
|
||||||
``pagehide`` save point in app.js persists the partial raw answer (via
|
user came back to their question WITH the partial, rendered as
|
||||||
the existing ``rememberBrainTurn`` helper) when navigation hits a turn
|
"Partial answer — navigation interrupted the stream."
|
||||||
that is in flight and has already streamed text.
|
|
||||||
|
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:
|
Timing is deterministic by construction:
|
||||||
|
|
||||||
* scenario 1 keys off the mock's ``write a long answer`` trigger — a
|
* 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;
|
mid-stream with a wide margin;
|
||||||
* scenario 2 keys off the mock's ``think out loud then hesitate``
|
* scenarios 2–3 key off the same long stream (mid-stream view switch)
|
||||||
trigger — the phase-17 thinking stream followed by a 4s silence before
|
and the ``think out loud then hesitate`` trigger — the phase-17
|
||||||
the first content frame, so the navigation lands inside pure thinking;
|
thinking stream followed by a 4s silence before the first content
|
||||||
* scenarios 3 and 4 settle the turn fully (send button re-enabled)
|
frame, so the switch lands inside pure thinking;
|
||||||
before any navigation.
|
* 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):
|
Test → story mapping (Playwright Mapping Rule):
|
||||||
1. ``test_partial_answer_survives_sources_nav_midstream``
|
1. ``test_partial_answer_survives_real_departure_midstream``
|
||||||
2. ``test_no_orphan_brain_message_when_navigated_before_first_token``
|
2. ``test_full_answer_completes_after_rag_nav_midstream``
|
||||||
3. ``test_completed_turn_unaffected``
|
3. ``test_nav_switch_before_first_token_completes``
|
||||||
4. ``test_new_chat_still_clears_conversation``
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -51,7 +60,7 @@ from app.db import SessionLocal
|
|||||||
from app.rag.importer import ImportSummary, import_sources
|
from app.rag.importer import ImportSummary, import_sources
|
||||||
from app.rag.llm import LLMClient
|
from app.rag.llm import LLMClient
|
||||||
from e2e.auth_helpers import login
|
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]
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
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).
|
#: dropping the marker (pinned by test_long_answers).
|
||||||
FIRST_LINE_DOM = "Step 1: configure node-1"
|
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 = (
|
HESITATE_QUESTION = (
|
||||||
"think out loud then hesitate — how is my kubernetes cluster set up?"
|
"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))
|
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:
|
def _stored(page: Page) -> str | None:
|
||||||
"""Raw localStorage payload for the chat (None when the key is absent)."""
|
"""Raw localStorage payload for the chat (None when the key is absent)."""
|
||||||
return page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
|
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:
|
def _no_error_banner(page: Page) -> None:
|
||||||
"""The never-stale contract: a restored/partial state must never
|
"""The never-stale contract: a restored/partial state must never
|
||||||
present an error banner (role=alert) — the turn is simply partial."""
|
present an error banner (role=alert) — the turn is simply partial.
|
||||||
expect(page.locator('[role="alert"]')).to_have_count(0)
|
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()
|
@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
|
# 1. REAL departure mid-stream (pagehide partial persist — the phase-20
|
||||||
# that had already streamed is persisted and restored
|
# 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
|
page: Page, app_url: str, seeded_kb: None
|
||||||
) -> None:
|
) -> None:
|
||||||
page.set_default_timeout(30_000)
|
page.set_default_timeout(30_000)
|
||||||
# Admin (phase 16/19): only the admin sees the #nav-sources link the
|
# Admin (phase 16/19): only the admin sees the #nav-sources link.
|
||||||
# bug report clicks.
|
|
||||||
login(page, app_url, next="/")
|
login(page, app_url, next="/")
|
||||||
expect(page.locator("#nav-sources")).to_be_visible()
|
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-btn")).to_be_enabled()
|
||||||
expect(page.locator("#send-label")).to_have_text("Stop")
|
expect(page.locator("#send-label")).to_have_text("Stop")
|
||||||
|
|
||||||
# THE BUG REPORT, VERBATIM: click "Sources" while chat is generating.
|
# THE DEPARTURE, in its phase-76 form: a REAL cross-document
|
||||||
page.click("#nav-sources")
|
# navigation — page.goto to a genuine other document. /shared.html
|
||||||
expect(page).to_have_url(app_url + "/sources.html")
|
# is a plain document (stable for every session state) and — unlike
|
||||||
# The navigation really landed on the admin catalog (mid-stream state
|
# /login.html, which auto-redirects a signed-in session straight
|
||||||
# of the stream itself does not matter to the page — the fetch is
|
# back into the shell — it is a real departure, so the in-flight SSE
|
||||||
# aborted by the unload, which is the point).
|
# fetch is aborted by the unload (the point).
|
||||||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
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.
|
# Return to the chat.
|
||||||
page.goto(app_url + "/")
|
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
|
# 2. Navbar click to RAG mid-stream = a client-side VIEW SWITCH (phase 76,
|
||||||
# brain-side is persisted — the question comes back alone
|
# 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-btn")).to_be_enabled()
|
||||||
expect(page.locator("#send-label")).to_have_text("Stop")
|
expect(page.locator("#send-label")).to_have_text("Stop")
|
||||||
|
|
||||||
# Leave during the pause (no answer token has streamed — acc is empty,
|
# Leave during the pause via a REAL cross-document departure (no
|
||||||
# so the pagehide save point must persist nothing brain-side).
|
# answer token has streamed — acc is empty, so the pagehide save
|
||||||
|
# point must persist nothing brain-side).
|
||||||
page.goto(app_url + "/sources.html")
|
page.goto(app_url + "/sources.html")
|
||||||
|
|
||||||
# Return to the chat.
|
# 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 —
|
# 5. Completed turn: the done save point is byte-identical to before —
|
||||||
# the new pagehide save point must not duplicate or alter it
|
# 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
|
# the owner rework 2026-08-28) still clears the conversation
|
||||||
# (phase 14 contract)
|
# (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
|
model. The page is anonymously viewable — the catalog gate hides
|
||||||
the table, not the page-head."""
|
the table, not the page-head."""
|
||||||
page.goto(f"{app_url}/sources.html")
|
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()
|
expect(sub).to_be_visible()
|
||||||
text = sub.inner_text().lower()
|
text = sub.inner_text().lower()
|
||||||
assert "homelab" not in text, f"retired copy in the page-sub: {text!r}"
|
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"
|
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
||||||
NOTE = "STEEER-MARKER be concise"
|
NOTE = "STEEER-MARKER be concise"
|
||||||
XSS_NOTE = "<script>window.__xss = true; alert('xss')</script>"
|
XSS_NOTE = "<script>window.__xss = true; alert('xss')</script>"
|
||||||
#: Both index.html and tuning.html ship exactly three classic/module
|
#: The shell (index.html — served for BOTH / and /tuning.html since
|
||||||
#: script tags: the phase-39 brand.js classic layer + markdown.js + the
|
#: phase 76 task 01, when the Tuning view folded into it) ships exactly
|
||||||
#: page module (app.js / tuning.js).
|
#: FOUR classic/module script tags: the phase-39 brand.js classic layer
|
||||||
BASE_SCRIPT_COUNT = 3
|
#: + 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:
|
async def _import_fixtures(mock_port: int) -> ImportSummary:
|
||||||
|
|||||||
@@ -150,12 +150,23 @@ def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None:
|
|||||||
("path", "marker"),
|
("path", "marker"),
|
||||||
[
|
[
|
||||||
("/", "Brain of Reese"),
|
("/", "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
|
("/document.html", "Brain of Reese"), # phase 10: viewer page
|
||||||
("/login.html", "Sign in"), # phase 16: admin sign-in page
|
("/login.html", "Sign in"), # phase 16: admin sign-in page
|
||||||
("/tuning.html", "Global Tuning"), # phase 27: global tuning page
|
# Phase 76 (task 01): /tuning.html is a SHELL route — same
|
||||||
("/git-sources.html", "Git sources"), # phase 35: admin git sources page
|
# shell-body marker pattern as the two task-02 paths above.
|
||||||
("/history.html", "Saved chats"), # phase 50: admin saved-chats page
|
("/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
|
("/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(
|
@pytest.mark.parametrize(
|
||||||
"path",
|
"path",
|
||||||
["/sources.html", "/document.html", "/login.html", "/tuning.html",
|
["/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
|
"/shared.html"], # phase 51: + the anonymous shared page
|
||||||
)
|
)
|
||||||
def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
|
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
|
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:
|
def test_index_html_variant_no_cache_versioned(client) -> None:
|
||||||
"""/index.html is the same page as / — same caching treatment."""
|
"""/index.html is the same page as / — same caching treatment."""
|
||||||
from app.core.caching import asset_version
|
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"]
|
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:
|
def _page_file(path: str) -> Path:
|
||||||
"""The static file backing a page path (``/`` → ``index.html``)."""
|
"""The static file backing a page path (``/`` → ``index.html``;
|
||||||
name = path.lstrip("/") or "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
|
file = FRONTEND / name
|
||||||
assert file.is_file(), f"missing page file for {path}: {file}"
|
assert file.is_file(), f"missing page file for {path}: {file}"
|
||||||
return file
|
return file
|
||||||
|
|||||||
@@ -242,6 +242,18 @@ def test_header_module_is_imported_not_directly_loaded() -> None:
|
|||||||
js_path = ASSETS / name
|
js_path = ASSETS / name
|
||||||
assert js_path.is_file(), f"page script missing: {js_path}"
|
assert js_path.is_file(), f"page script missing: {js_path}"
|
||||||
body = js_path.read_text(encoding="utf-8")
|
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), (
|
assert re.search(r"""from\s+["']\./header\.js["']""", body), (
|
||||||
f"{name}: must import the shared header module relatively "
|
f"{name}: must import the shared header module relatively "
|
||||||
f'("from \\"./header.js\\"")'
|
f'("from \\"./header.js\\"")'
|
||||||
|
|||||||
@@ -26,11 +26,10 @@ from pathlib import Path
|
|||||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||||
APP_JS = FRONTEND / "assets" / "app.js"
|
APP_JS = FRONTEND / "assets" / "app.js"
|
||||||
INDEX_HTML = FRONTEND / "index.html"
|
INDEX_HTML = FRONTEND / "index.html"
|
||||||
SOURCES_HTML = FRONTEND / "sources.html"
|
|
||||||
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
|
|
||||||
DOCUMENT_HTML = FRONTEND / "document.html"
|
DOCUMENT_HTML = FRONTEND / "document.html"
|
||||||
LOGIN_HTML = FRONTEND / "login.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"
|
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
|
relocated the .chat-actions row from the top of the column to the
|
||||||
bottom: the button now sits BELOW the #messages section, directly
|
bottom: the button now sits BELOW the #messages section, directly
|
||||||
above the composer (the single module binding + the no-op guard are
|
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()
|
html = _index()
|
||||||
btn = re.search(r'<button[^>]*id="new-chat-btn"[^>]*>', html)
|
btn = re.search(r'<button[^>]*id="new-chat-btn"[^>]*>', html)
|
||||||
assert btn, "index.html must contain #new-chat-btn"
|
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
|
assert 'aria-label="New chat"' in tag
|
||||||
main_idx = html.find('main id="main"')
|
main_idx = html.find('main id="main"')
|
||||||
assert main_idx != -1 and btn.start() > main_idx, "the button belongs inside <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"')
|
shell_idx = html.find('class="container chat-shell"')
|
||||||
assert shell_idx != -1 and shell_idx < btn.start(), "the button belongs in .chat-shell"
|
assert shell_idx != -1 and shell_idx < btn.start(), "the button belongs in .chat-shell"
|
||||||
messages_idx = html.find('id="messages"')
|
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, (
|
assert -1 < messages_idx < messages_end < btn.start() < composer_idx, (
|
||||||
"the button sits below the #messages section, above the composer"
|
"the button sits below the #messages section, above the composer"
|
||||||
)
|
)
|
||||||
# No other page carries the button anymore (owner request 2026-08-28).
|
# No SURVIVING separate document carries the button (owner request
|
||||||
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML):
|
# 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"), (
|
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"
|
DOCUMENT_JS = FRONTEND / "assets" / "document.js"
|
||||||
MARKDOWN_JS = FRONTEND / "assets" / "markdown.js"
|
MARKDOWN_JS = FRONTEND / "assets" / "markdown.js"
|
||||||
MODAL_JS = FRONTEND / "assets" / "document-modal.js" # phase 26: the modal owner
|
MODAL_JS = FRONTEND / "assets" / "document-modal.js" # phase 26: the modal owner
|
||||||
INDEX_HTML = FRONTEND / "index.html"
|
INDEX_HTML = FRONTEND / "index.html" # the ONE-document shell (phase 76: sources.html folded in)
|
||||||
SOURCES_HTML = FRONTEND / "sources.html"
|
|
||||||
|
|
||||||
HAVE_NODE = shutil.which("node") is not None
|
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:
|
def test_render_document_exported_and_modal_imports_it() -> None:
|
||||||
"""Phase 26: document.js EXPORTS renderDocument(doc, { … }) — the
|
"""Phase 26: document.js EXPORTS renderDocument(doc, { … }) — the
|
||||||
exact renderer the standalone page and the modal share (no drift).
|
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 imports it relatively, and BOTH view scripts
|
||||||
the modal module relatively — no direct <script> tag (the header.js
|
(app.js — the chat view at shell boot — and sources.js — the RAG
|
||||||
single-evaluation design: esbuild inlines it into the page bundle,
|
view module) import the modal module relatively — no direct
|
||||||
one module instance per page)."""
|
<script> tag (the header.js single-evaluation design: esbuild
|
||||||
|
inlines it into the bundle, one module instance per document).
|
||||||
|
"""
|
||||||
doc_js = _read(DOCUMENT_JS)
|
doc_js = _read(DOCUMENT_JS)
|
||||||
# Task 03 signature: the page passes its #doc-title / #doc-meta /
|
# Task 03 signature: the page passes its #doc-title / #doc-meta /
|
||||||
# #doc-content elements under exactly these names.
|
# #doc-content elements under exactly these names.
|
||||||
@@ -315,11 +316,10 @@ def test_render_document_exported_and_modal_imports_it() -> None:
|
|||||||
assert 'from "./document-modal.js"' in js, (
|
assert 'from "./document-modal.js"' in js, (
|
||||||
f"{name}: must import the modal module relatively"
|
f"{name}: must import the modal module relatively"
|
||||||
)
|
)
|
||||||
for page in (INDEX_HTML, SOURCES_HTML):
|
text = _read(INDEX_HTML)
|
||||||
text = _read(page)
|
|
||||||
assert not re.search(r"<script[^>]*document-modal\.js", text), (
|
assert not re.search(r"<script[^>]*document-modal\.js", text), (
|
||||||
f"{page.name}: no direct document-modal.js <script> tag "
|
"index.html: no direct document-modal.js <script> tag "
|
||||||
"(single-evaluation design — the page script imports it)"
|
"(single-evaluation design — the view scripts import it)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -338,37 +338,43 @@ def test_document_js_page_init_is_import_safe() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_both_pages_carry_the_modal_skeleton() -> None:
|
def test_shell_carries_the_single_modal_skeleton() -> None:
|
||||||
"""Phase 26: chat AND Sources ship the same modal skeleton (the
|
"""Phase 26 + phase 76 (task 02) dedup pin: the shell ships the
|
||||||
task-01 markup) — the a11y frame included: role=dialog +
|
modal skeleton EXACTLY ONCE (the chat's, body level) — the RAG
|
||||||
aria-modal, a labelled close control, a focusable content target
|
view's second copy was dropped in the fold; BOTH view scripts
|
||||||
(tabindex=-1), and a role=status announcer. Hidden by default —
|
(app.js chat chips, sources.js RAG row links) open documents
|
||||||
inert until JS opens it."""
|
through openDocumentModal(...) against that single instance
|
||||||
for page in (INDEX_HTML, SOURCES_HTML):
|
(document-modal.js resolves it by document-level querySelector at
|
||||||
text = _read(page)
|
import). The a11y frame is included: role=dialog + aria-modal, a
|
||||||
assert '<div class="doc-modal" id="doc-modal" hidden>' in text, page.name
|
labelled close control, a focusable content target (tabindex=-1),
|
||||||
assert 'id="doc-modal-backdrop"' in text, page.name
|
and a role=status announcer. Hidden by default — inert until JS
|
||||||
assert 'id="doc-modal-panel"' in text, page.name
|
opens it."""
|
||||||
assert 'role="dialog"' in text and 'aria-modal="true"' in text, page.name
|
text = _read(INDEX_HTML)
|
||||||
assert 'id="doc-modal-title"' in text, page.name
|
assert text.count('id="doc-modal"') == 1, (
|
||||||
assert 'id="doc-modal-meta"' in text, page.name
|
"the shell must carry exactly ONE modal skeleton (the fold dedup)"
|
||||||
assert 'id="doc-modal-desc"' in text, page.name
|
)
|
||||||
assert 'id="doc-modal-open"' in text, page.name
|
assert '<div class="doc-modal" id="doc-modal" hidden>' in text
|
||||||
assert re.search(r'id="doc-modal-content"[^>]*tabindex="-1"', text), page.name
|
assert 'id="doc-modal-backdrop"' in text
|
||||||
assert re.search(
|
assert 'id="doc-modal-panel"' in text
|
||||||
r'id="doc-modal-close"[^>]*aria-label="Close document"', text
|
assert 'role="dialog"' in text and 'aria-modal="true"' in text
|
||||||
), page.name
|
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:
|
def test_shell_loads_markdown_before_its_modules() -> None:
|
||||||
"""Phase 26: the modal renders md documents on the Sources page too —
|
"""Phase 26 + phase 76 (task 02): the modal renders md documents
|
||||||
so sources.html loads the classic markdown.js (global renderMarkdown)
|
in the RAG view too — so the shell loads the classic markdown.js
|
||||||
via a relative <script src> BEFORE its module script, exactly like
|
(global renderMarkdown) via a relative <script src> BEFORE its
|
||||||
index.html does."""
|
module scripts (app.js — the chat view — and the lazy view
|
||||||
html = _read(SOURCES_HTML)
|
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 re.search(r'<script src="assets/markdown\.js"></script>', html)
|
||||||
assert html.index('src="assets/markdown.js"') < html.index('type="module"'), (
|
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
|
#: Every page in the app ships the brand layer (phase 39: "every visible
|
||||||
#: brand string on every page resolves from one place").
|
#: 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 = (
|
HTML_PAGES = (
|
||||||
"index.html",
|
"index.html",
|
||||||
"sources.html",
|
|
||||||
"tuning.html",
|
|
||||||
"document.html",
|
"document.html",
|
||||||
"login.html",
|
"login.html",
|
||||||
"git-sources.html",
|
|
||||||
"history.html", # phase 50: the admin saved-chats page
|
|
||||||
"shared.html", # phase 51: the anonymous shared-conversation page
|
"shared.html", # phase 51: the anonymous shared-conversation page
|
||||||
"doc-edit.html", # phase 59: the admin doc edit screen (flow 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,
|
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,
|
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
|
present/absent, counts only when total > 0), enterSyncRunningState
|
||||||
writing the full untruncated path to the button title + #sync-result,
|
writing the full untruncated path to the button title + #sync-result,
|
||||||
the two-job tick decision tree (sync running > upload running > sync
|
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"
|
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||||
SOURCES_JS = FRONTEND / "assets" / "sources.js"
|
SOURCES_JS = FRONTEND / "assets" / "sources.js"
|
||||||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
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_JS = FRONTEND / "assets" / "git-sources.js"
|
||||||
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
|
|
||||||
|
|
||||||
|
|
||||||
def _js() -> str:
|
def _js() -> str:
|
||||||
@@ -43,7 +47,7 @@ def _css() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _html() -> str:
|
def _html() -> str:
|
||||||
return SOURCES_HTML.read_text(encoding="utf-8")
|
return SHELL_HTML.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def _gjs() -> str:
|
def _gjs() -> str:
|
||||||
@@ -51,27 +55,38 @@ def _gjs() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _ghtml() -> 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:
|
def _gfn(js: str, name: str) -> str:
|
||||||
"""The source of the first `function <name>` in git-sources.js
|
"""The source of the first `function <name>` in git-sources.js
|
||||||
(up to the first line-leading closing brace — the house pin
|
(brace balanced — since phase 76 task 02 the functions live inside
|
||||||
pattern)."""
|
mount(root), so the closing brace is indented, not line-leading).
|
||||||
|
"""
|
||||||
fn = js.find(f"function {name}")
|
fn = js.find(f"function {name}")
|
||||||
assert fn != -1, f"{name} must be defined in git-sources.js"
|
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:
|
def _utick(js: str) -> str:
|
||||||
"""The upload poll tick inside startUploadPolling — from `const
|
"""The upload poll tick inside startUploadPolling — from `const
|
||||||
tick = async () => {` to the next top-level function
|
tick = async () => {` to the next function (initUploadStatus), so
|
||||||
(initUploadStatus), so the whole decision tree is in the slice."""
|
the whole decision tree is in the slice."""
|
||||||
fn = js.find("function startUploadPolling")
|
fn = js.find("function startUploadPolling")
|
||||||
assert fn != -1, "startUploadPolling must be defined in git-sources.js"
|
assert fn != -1, "startUploadPolling must be defined in git-sources.js"
|
||||||
tick = js.find("const tick = async () => {", fn)
|
tick = js.find("const tick = async () => {", fn)
|
||||||
assert tick != -1, "the tick must live inside startUploadPolling"
|
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"
|
assert end != -1, "initUploadStatus must follow startUploadPolling"
|
||||||
return js[tick:end]
|
return js[tick:end]
|
||||||
|
|
||||||
@@ -88,23 +103,34 @@ def _usubmit(js: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _fn(js: str, name: str) -> str:
|
def _fn(js: str, name: str) -> str:
|
||||||
"""The source of the first `function <name>` in js (up to the first
|
"""The source of the first `function <name>` in js (brace balanced —
|
||||||
line-leading closing brace — the house pin pattern from
|
since phase 76 task 02 the functions live inside mount(root), so
|
||||||
tests/unit/test_sync_button.py)."""
|
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}")
|
fn = js.find(f"function {name}")
|
||||||
assert fn != -1, f"{name} must be defined in sources.js"
|
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:
|
def _tick(js: str) -> str:
|
||||||
"""The poll tick inside startSyncPolling — from `const tick = async
|
"""The poll tick inside startSyncPolling — from `const tick = async
|
||||||
() => {` to the next top-level function (startSync), so the whole
|
() => {` to the next function (startSync), so the whole two-job
|
||||||
two-job decision tree is in the slice."""
|
decision tree is in the slice."""
|
||||||
fn = js.find("function startSyncPolling")
|
fn = js.find("function startSyncPolling")
|
||||||
assert fn != -1, "startSyncPolling must be defined in sources.js"
|
assert fn != -1, "startSyncPolling must be defined in sources.js"
|
||||||
tick = js.find("const tick = async () => {", fn)
|
tick = js.find("const tick = async () => {", fn)
|
||||||
assert tick != -1, "the tick must live inside startSyncPolling"
|
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"
|
assert end != -1, "startSync must follow startSyncPolling"
|
||||||
return js[tick:end]
|
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 '"upload", upload.current_file, upload.files_done, upload.files_total' in branch
|
||||||
assert 'emitSyncStatus({ state: "running" })' in branch
|
assert 'emitSyncStatus({ state: "running" })' in branch
|
||||||
assert "startSyncPolling()" in branch
|
assert "startSyncPolling()" in branch
|
||||||
# the idle settle is the fall-through (the last statement)
|
# the idle settle is the fall-through (the last statement — the
|
||||||
assert body.rstrip().endswith("applySyncIdle(status);")
|
# brace-balanced body ends with the closing brace)
|
||||||
|
assert body.rstrip().removesuffix("}").rstrip().endswith("applySyncIdle(status);")
|
||||||
|
|
||||||
|
|
||||||
# ---------- the section header + the page comment ----------
|
# ---------- 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:
|
def test_sources_html_comment_documents_the_live_announcer() -> None:
|
||||||
"""The #sync-result comment in sources.html documents the phase-64
|
"""The #sync-result comment in the shell's RAG view (formerly
|
||||||
dual role: the live file label (both kinds) while either job runs,
|
sources.html) documents the phase-64 dual role: the live file
|
||||||
untruncated for the aria-live announcer, and empty after an upload
|
label (both kinds) while either job runs, untruncated for the
|
||||||
settles (A3 — the upload's counts live on the Sources page)."""
|
aria-live announcer, and empty after an upload settles (A3 — the
|
||||||
|
upload's counts live on the Sources page)."""
|
||||||
html = _html()
|
html = _html()
|
||||||
idx = html.find('id="sync-result"')
|
idx = html.find('id="sync-result"')
|
||||||
assert idx != -1
|
assert idx != -1
|
||||||
@@ -634,23 +662,27 @@ def test_boot_reattach_branches() -> None:
|
|||||||
assert "status.error" in fail
|
assert "status.error" in fail
|
||||||
assert "uploadError.hidden = false" in fail
|
assert "uploadError.hidden = false" in fail
|
||||||
assert 'status.state === "idle"' not in body, "idle does nothing — no branch"
|
assert 'status.state === "idle"' not in body, "idle does nothing — no branch"
|
||||||
# The boot IIFE: after the list loads, the re-attach runs (admin
|
# The mount's tail (phase 76 task 02 — the boot IIFE is gone):
|
||||||
# branch only — the anonymous path returns before it).
|
# 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();")
|
i_boot = js.rfind("await loadSources();")
|
||||||
tail = js[i_boot:i_boot + 400]
|
tail = js[i_boot:i_boot + 400]
|
||||||
assert "await initUploadStatus();" in tail
|
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 ----------
|
# ---------- the page comment ----------
|
||||||
|
|
||||||
|
|
||||||
def test_git_sources_html_comment_documents_the_202_contract() -> None:
|
def test_git_sources_html_comment_documents_the_202_contract() -> None:
|
||||||
"""The #archive-upload-form comment in git-sources.html documents
|
"""The #archive-upload-form comment in the shell's Sources view
|
||||||
the phase-64 202 contract (the phase-49 synchronous paragraph
|
(formerly git-sources.html) documents the phase-64 202 contract
|
||||||
marked superseded): the 202 = "safely on disk" + the JS-created
|
(the phase-49 synchronous paragraph marked superseded): the 202 =
|
||||||
toast (no markup), the live "Processing…" label via the status
|
"safely on disk" + the JS-created toast (no markup), the live
|
||||||
poll, and the 409 re-attach without an error banner."""
|
"Processing…" label via the status poll, and the 409 re-attach
|
||||||
|
without an error banner."""
|
||||||
html = _ghtml()
|
html = _ghtml()
|
||||||
idx = html.find('id="archive-upload-form"')
|
idx = html.find('id="archive-upload-form"')
|
||||||
assert idx != -1
|
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
|
#: 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-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
|
#: 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
|
#: bar). Phase 76 (task 02): the folded RAG + Sources files are gone —
|
||||||
#: keep the identical header block, so they pin here too.
|
#: 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 = (
|
PAGES = (
|
||||||
FRONTEND / "index.html",
|
FRONTEND / "index.html",
|
||||||
FRONTEND / "sources.html",
|
|
||||||
FRONTEND / "document.html",
|
FRONTEND / "document.html",
|
||||||
FRONTEND / "git-sources.html",
|
|
||||||
FRONTEND / "login.html",
|
FRONTEND / "login.html",
|
||||||
FRONTEND / "tuning.html",
|
|
||||||
FRONTEND / "history.html",
|
|
||||||
FRONTEND / "shared.html",
|
FRONTEND / "shared.html",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -122,10 +120,10 @@ def _rule_block(css: str, selector: str) -> str:
|
|||||||
return m.group(1)
|
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
|
"""Every page carries the #nav-toggle button with the full aria
|
||||||
contract: a real button (type=button), aria-expanded defaulting to
|
contract: a real button (type=button), aria-expanded defaulting to
|
||||||
"false", aria-controls pointing at the nav, the accessible name
|
"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 nav keeps its single element + label and gains ONLY the id
|
||||||
(the whoami reveal targets the same four links — no duplicated
|
(the whoami reveal targets the same four links — no duplicated
|
||||||
markup, so the phase-19/35 visibility rules apply inside the menu
|
markup, so the phase-19/35 visibility rules apply inside the menu
|
||||||
|
|||||||