feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s

Single consolidated commit for four completed, validated phases (77, 78,
79, 80). The pipeline run left all work uncommitted because the harness
commits only with PHASE_COMMIT=1 while child executors are forbidden from
committing; the phases themselves all passed validation and moved to
.agents/phases/complete/.

Phase 77 — navbar view refresh
- router.js dispatches bor:view-refresh on re-show / active re-click /
  popstate (gated on wasMounted; first show and boot exempt)
- History / RAG / Sources / Tuning re-fetch on refresh (admin branch);
  Chat deliberately excluded (stream survival)
- History "Refresh" button (admin-only, in-flight disable + status line)
- New story suite tests/e2e/test_navbar_refresh.py (7 tests)

Phase 78 — static background
- Removed the animated glow layers; static 44px grid over the flat --bg
  canvas; default and reduced-motion renders byte-identical
- Updated background/theme E2E suites; removed bg-glow test pins

Phase 79 — API tokens
- api_tokens model + migration 0012; hash-only token service
- Admin tokens API + Tokens admin view; POST /api/token-auth;
  live-revoking require_user on chat / suggestions / document content
- Frontend token gate with localStorage cache; anonymous E2E suites
  migrated to token login
- New story suite tests/e2e/test_api_tokens.py (9 tests)

Phase 80 — history suggestion chips
- last_questions() endpoint with SEED fallback; startNewChat() refetch
- Seed-semantics docs (config.py, .env.example, README)
- Integration state matrix + E2E suite rewritten to the 4 chip states

Also included: phase-76 report artifacts and the repo restore-test-db
skill (previously untracked), scripts/* ruff fixes from phase 77.

Final gate state (phase 80 final pass, covers everything above):
- uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99%
- uv run ruff check . && uv run pyright → clean, 0 errors
- Per-phase story E2E suites green in isolation
This commit is contained in:
2026-09-07 12:39:01 -04:00
parent 495d042a98
commit 7fce6572d0
215 changed files with 10142 additions and 1643 deletions
@@ -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/`.