feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
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:
@@ -78,6 +78,35 @@
|
||||
* 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).
|
||||
*
|
||||
* Phase 77 (task 01) — the re-show refresh: the shell router
|
||||
* dispatches `bor:view-refresh` on the view's section when the user
|
||||
* RE-SHOWS an already-mounted view (a switch back onto it, a re-click
|
||||
* of the History nav link, or back/forward) — the first show (mount)
|
||||
* and boot never (the mount's own load is the first fetch). This
|
||||
* module listens on root and re-runs `loadChats()`, which is now
|
||||
* re-entrant: a re-load drops the data rows (the hidden
|
||||
* #history-empty-row stays in the tbody) before fetching, so the list
|
||||
* is REPLACED — never duplicated. The listener is armed only in the
|
||||
* ADMIN branch, after the whoami gate passes: anonymous shows the
|
||||
* gate and never fetches (the phase-50 contract the story E2E pins).
|
||||
*
|
||||
* Phase 77 (task 03) — the explicit refresh control (TODO.md L3:
|
||||
* "The history page should also have a refresh button."): the
|
||||
* #history-refresh button in the view's page-head (OUTSIDE the table
|
||||
* wrap — reachable while the empty state is showing too). Bound only
|
||||
* in the admin branch; the anonymous branch HIDES it (the gate is
|
||||
* what anonymous sees — no dead control beside the sign-in gate).
|
||||
* Lifecycle: click → disable (no double-fire while in flight) →
|
||||
* `loadChats()` (re-entrant — the list is replaced) → announce the
|
||||
* outcome in #history-status → re-enable (success AND failure, the
|
||||
* finally). Success — a 0-row fetch is a success — lands
|
||||
* `Saved chats refreshed.`; the failure lines now live INSIDE
|
||||
* loadChats itself (the house copy: `Couldn't load saved chats — is
|
||||
* the app reachable?` on a network error, `Couldn't load saved chats
|
||||
* — try again.` on a non-2xx), so EVERY caller of a failed load —
|
||||
* the mount's first load, a re-show, the button — sees the outcome
|
||||
* (the §7.4 never-stale contract; a silent empty table is gone).
|
||||
*/
|
||||
|
||||
import { fetchIsAdmin } from "./header.js";
|
||||
@@ -89,6 +118,9 @@ export async function mount(root) {
|
||||
const emptyRow = root.querySelector("#history-empty-row");
|
||||
const gateEl = root.querySelector("#history-gate");
|
||||
const statusEl = root.querySelector("#history-status");
|
||||
// Phase 77 (task 03): the explicit refresh control — the page-head
|
||||
// button (outside the table wrap, so the empty state never hides it).
|
||||
const refreshBtn = root.querySelector("#history-refresh");
|
||||
|
||||
/* Action feedback — the role="status" live region above the table
|
||||
(the "never stale" contract: every row action lands a line here,
|
||||
@@ -441,28 +473,60 @@ export async function mount(root) {
|
||||
}
|
||||
|
||||
/* 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.
|
||||
Re-entrant (phase 77 task 01): a re-show re-run must REPLACE the
|
||||
list, not append a duplicate set — the data rows (every <tr>
|
||||
EXCEPT the hidden #history-empty-row, which the load itself
|
||||
re-hides / reveals) are dropped before the fetch.
|
||||
Phase 77 (task 03): a FAILED load announces its line in the live
|
||||
region — the house copy (network: "is the app reachable?"; non-2xx:
|
||||
"try again.") — and the load RETURNS the outcome: true when the
|
||||
fetch settled (a 0-row fetch is a SUCCESS — the empty state is
|
||||
the honest view), false on non-2xx / network error, so the
|
||||
refresh button's handler can land its own success line. */
|
||||
async function loadChats() {
|
||||
if (tbody) {
|
||||
for (const tr of tbody.querySelectorAll("tr")) {
|
||||
if (tr !== emptyRow) tr.remove();
|
||||
}
|
||||
}
|
||||
if (emptyRow) emptyRow.hidden = true;
|
||||
let r;
|
||||
try {
|
||||
r = await fetch("/api/chats");
|
||||
} catch {
|
||||
announce("Couldn't load saved chats — is the app reachable?");
|
||||
showEmptyState();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (!r.ok) {
|
||||
announce("Couldn't load saved chats — try again.");
|
||||
showEmptyState();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const { chats } = await r.json();
|
||||
if (!chats.length) {
|
||||
showEmptyState();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
for (const chat of chats) {
|
||||
tbody.appendChild(makeRow(chat));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Phase 77 (task 03): the refresh button's in-flight run — the
|
||||
re-entrant load (the failure line lands inside it) + the success
|
||||
line + the re-enable (success AND failure — the finally, so a
|
||||
click can never leave the button stuck disabled). */
|
||||
async function refreshChats() {
|
||||
let ok = false;
|
||||
try {
|
||||
ok = await loadChats();
|
||||
} finally {
|
||||
if (refreshBtn) refreshBtn.disabled = false;
|
||||
}
|
||||
if (ok) announce("Saved chats refreshed.");
|
||||
}
|
||||
|
||||
/* ---------- view boot (phase 76 task 03) ----------
|
||||
@@ -476,8 +540,34 @@ export async function mount(root) {
|
||||
if (!(await fetchIsAdmin())) {
|
||||
if (tableWrap) tableWrap.hidden = true;
|
||||
if (gateEl) gateEl.hidden = false;
|
||||
if (refreshBtn) refreshBtn.hidden = true; // no dead control beside the gate
|
||||
return;
|
||||
}
|
||||
if (gateEl) gateEl.hidden = true;
|
||||
if (refreshBtn) refreshBtn.hidden = false; // admin: the control is live
|
||||
/* Phase 77: a user-initiated re-show of this already-mounted view
|
||||
makes the router dispatch bor:view-refresh on the section —
|
||||
re-load then (loadChats is re-entrant, so the list is replaced).
|
||||
The listener is armed ONLY here, after the whoami gate passed:
|
||||
anonymous shows the gate and must never fetch (phase 50). `started`
|
||||
flips true once the first loadChats() is made (the next line), so
|
||||
the listener can only ever re-run a load the mount already did. */
|
||||
let started = false;
|
||||
root.addEventListener("bor:view-refresh", () => {
|
||||
if (started) loadChats();
|
||||
});
|
||||
/* Phase 77 (task 03): the explicit Refresh control (TODO.md L3) —
|
||||
the button's own lifecycle: click → disable (no double-fire while
|
||||
the request is in flight) → refreshChats (the re-entrant load +
|
||||
the outcome line + the re-enable). It is bound HERE, in the admin
|
||||
branch only: the view is admin-gated, and anonymous never sees
|
||||
the button (it is hidden above). */
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener("click", () => {
|
||||
refreshBtn.disabled = true; // no double-fire while in flight
|
||||
void refreshChats();
|
||||
});
|
||||
}
|
||||
started = true;
|
||||
loadChats();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user