Files
ducoterra 7fce6572d0
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s
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
2026-09-07 12:39:01 -04:00

574 lines
25 KiB
JavaScript

/* Brain of Reese — History view (saved chats, phase 50 task 04;
* phase 76 task 03: shell view module).
*
* TODO.md L5 (owner 2026-08-29): "Need a way to save and view chat
* history in a new page, then return to that history with a click."
*
* Wires the admin-only `GET /api/chats` + `POST /api/chats/<id>/share`
* + `POST /api/chats/<id>/unshare` + `DELETE /api/chats/<id>`
* endpoints (phase 50 task 02; phase 51 task 01+02) into the view's
* full-width table:
*
* • Title — an `<a href="/?chat=<id>">`: Open IS the title link
* ("return to that history with a click") — the chat page boots
* into the saved conversation through ?chat= (task 03);
* • Messages — the row's message_count;
* • Updated — locale date+time, the full ISO in the title attribute;
* • Stale — phase 53 (task 04): the READ-ONLY staleness marker,
* rendered from the row's `stale` flag (the server computes it —
* the client never does staleness math): a rose "Stale" pill when
* the row was saved before the last KB-changing sync, an em-dash
* when fresh. The Regenerate action is NOT here — it lives on the
* chat-page banner (task 05); opening the row is the action;
* • Share — phase 51 (owner-locked 2026-08-29, TODO.md L6): the
* row's share state, rendered from the list's OWN share_url (the
* GET /api/chats endpoint populates it — no second fetch per row).
* Three states: unshared → [Create link] (POST share → the cell
* re-renders shared + the link is offered for copying); shared →
* [Copy] [Unshare]; confirming → the inline two-step "Unshare?
* [Yes] [No]" (the phase-50 Delete-confirm pattern + CSS — no
* native dialog) — Yes POSTs /api/chats/<id>/unshare (revokes),
* the cell re-renders unshared + the live region;
* • Actions — Delete, inline TWO-STEP confirm (owner-locked
* 2026-08-29: no native confirm dialog anywhere in this file) —
* the first click swaps the button for
* "Delete? [Yes] [No]" (focus moves to Yes, so the confirm is
* keyboard-reachable), Yes fires the DELETE and removes the row,
* No (or a failed request) keeps it.
*
* The share copy has the owner-locked inline-link fallback: a
* non-secure (http) homelab origin rejects navigator.clipboard, so the
* offer renders a transient .share-link-fallback <a> field (input-like,
* selects its full URL on focus) in the row's share cell — this file
* keeps its OWN copy of the ~10-line helper (the per-page duplication
* house style; app.js keeps the chat page's) rather than a new shared
* module.
*
* Every cell is built with the DOM APIs (textContent) — the title is
* user-derived (the auto-title is the first question), so it NEVER
* touches innerHTML (XSS-safe by construction, the sources.js house
* rule).
*
* The whoami gate (phase 19 shared-header module, cached promise):
* • anonymous → the #history-gate is shown, the table is hidden,
* and NO /api/chats request is made at all (the router 403s
* anonymous — the story E2E pins the request log);
* • admin → the gate hides and `loadChats()` renders the rows; a
* 0-row fetch reveals the empty-state row.
*
* Phase 76 (task 03) — shell view module (the "History" view of the
* ONE-document shell; /history.html now serves the shell, and
* assets/router.js lazy-imports THIS module on first show):
*
* • the top-level boot is now `export async function mount(root)` —
* root is the view's <section id="view-history">, and every DOM
* lookup scopes to root (the view ids stay unique across the
* shell — scoped lookups keep the module honest and testable).
* The router mounts a view ONCE (mount-once, hide-forever), so
* the binding + state survive every switch.
* • the initSharedHeader() call is DROPPED: in the shell the shared
* header boots exactly once, via the chat module (app.js) at shell
* boot — the view never re-boots it. The admin gate keeps
* fetchIsAdmin() — the SAME cached /api/whoami promise header.js
* exports (zero extra requests; the flag decides whether the
* table loads at all, the Sources-page gate pattern).
* • the row actions stay REAL navigations: the Open link
* (?chat=<id>) and the copy-link field are plain anchor targets —
* opening a saved chat is a chat-view concern handled by app.js
* at boot via ?chat=, and the router never intercepts them (they
* are not navbar links, and their query string keeps them out of
* the VIEW map).
*
* 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";
export async function mount(root) {
/* ---------- view elements (the view's section, scoped to root) ---------- */
const tableWrap = root.querySelector("#history-table-wrap");
const tbody = root.querySelector("#history-tbody");
const emptyRow = root.querySelector("#history-empty-row");
const gateEl = root.querySelector("#history-gate");
const statusEl = root.querySelector("#history-status");
// 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,
success or failure alike). */
function announce(message) {
if (statusEl) statusEl.textContent = message;
}
function fmtDate(iso) {
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
/* One row. The Title cell carries the Open link (/?chat=<id> — the
"return to that history with a click" requirement); the Updated
cell renders the locale date+time with the full ISO on hover. */
function makeRow(chat) {
const tr = document.createElement("tr");
const titleTd = document.createElement("td");
titleTd.className = "history-title-cell";
titleTd.title = chat.title; // full title on hover (the column ellipsizes)
const link = document.createElement("a");
link.className = "history-title-link";
link.href = "/?chat=" + chat.id; // Open: the chat page boots into this chat
link.textContent = chat.title; // user-derived — textContent only
titleTd.appendChild(link);
tr.appendChild(titleTd);
const countTd = document.createElement("td");
countTd.className = "history-count-cell";
countTd.textContent = String(chat.message_count);
tr.appendChild(countTd);
const updatedTd = document.createElement("td");
updatedTd.className = "history-updated-cell";
updatedTd.title = chat.updated_at; // full ISO on hover
updatedTd.textContent = fmtDate(chat.updated_at);
tr.appendChild(updatedTd);
// Phase 53 (task 04): the Stale cell (between Updated and Share) —
// the READ-ONLY staleness marker. `chat.stale` is computed server-
// side (task 03), so this branches on the flag, never on versions.
// Stale rows get the rose pill (the exact hover copy points at the
// Regenerate action on the chat page, task 05); fresh rows get a
// plain em-dash. The <td> carries its own aria-label in BOTH states
// — the marker must be conveyed without the visual (WCAG 2.1 AA).
const staleTd = document.createElement("td");
staleTd.className = "history-stale-cell";
if (chat.stale) {
staleTd.setAttribute("aria-label", "Stale — sources have changed since this chat was saved");
const pill = document.createElement("span");
pill.className = "stale-pill";
pill.title = "Sources have changed since this chat was saved — open the chat to Regenerate";
pill.textContent = "Stale";
staleTd.appendChild(pill);
} else {
staleTd.setAttribute("aria-label", "Current — saved against the latest sources");
staleTd.textContent = "—"; // the em-dash: fresh rows' marker
}
tr.appendChild(staleTd);
// Phase 51: the Share cell (between Updated and Actions) — the
// three-state share control (unshared / shared / confirming-unshare).
const shareTd = document.createElement("td");
shareTd.className = "history-share-cell";
shareTd.appendChild(makeShareControl(chat));
tr.appendChild(shareTd);
const actionsTd = document.createElement("td");
actionsTd.className = "history-actions-cell";
actionsTd.appendChild(makeDeleteControl(chat, tr));
tr.appendChild(actionsTd);
return tr;
}
/* The inline two-step Delete (owner-locked 2026-08-29 — NO native
confirm dialog anywhere on this page). The Delete button is
replaced, in place, by the "Delete? [Yes] [No]" pair; focus moves
to Yes (keyboard-reachable confirm). Yes → DELETE /api/chats/<id>
→ the row is removed + the live region line; No or a failed
request keeps the row (+ the error line on failure). */
function makeDeleteControl(chat, row) {
const cell = document.createElement("span");
cell.className = "history-actions";
const del = document.createElement("button");
del.type = "button";
del.className = "history-delete";
del.setAttribute("aria-label", `Delete saved chat: ${chat.title}`);
del.textContent = "Delete";
function restoreDelete() {
cell.replaceChildren(del);
del.focus(); // focus returns to the (restored) control
}
del.addEventListener("click", () => {
const label = document.createElement("span");
label.className = "history-confirm-text";
label.textContent = "Delete?";
const yes = document.createElement("button");
yes.type = "button";
yes.className = "history-confirm-yes";
yes.textContent = "Yes";
const no = document.createElement("button");
no.type = "button";
no.className = "history-confirm-no";
no.textContent = "No";
yes.addEventListener("click", () =>
confirmDelete(chat, row, yes, restoreDelete));
no.addEventListener("click", restoreDelete);
cell.replaceChildren(label, yes, no);
yes.focus(); // the confirm pair takes over the focus
});
cell.appendChild(del); // the shipped state IS the Delete button
return cell;
}
/* The confirmed delete: DELETE /api/chats/<id> → the row is removed
(+ the empty-state row reappears when it was the last one) and the
live region gets `Deleted "<title>".` A 404 means the row is gone
(deleted elsewhere) — drop the stale row and say so. Any other
failure or a network error keeps the row, restores the Delete
button (retryable), and lands the error line. */
async function confirmDelete(chat, row, yesBtn, restoreDelete) {
yesBtn.disabled = true; // no double-fire while the request is in flight
let r;
try {
r = await fetch(`/api/chats/${chat.id}`, { method: "DELETE" });
} catch {
announce(`Couldn't delete "${chat.title}" — is the app reachable?`);
restoreDelete();
return;
}
if (r.status === 404) {
row.remove();
showEmptyIfLast();
announce("That chat was already deleted.");
return;
}
if (!r.ok) {
announce(`Couldn't delete "${chat.title}" — try again.`);
restoreDelete();
return;
}
row.remove();
showEmptyIfLast();
announce(`Deleted "${chat.title}".`);
}
/* ---------- share column (phase 51, owner-locked 2026-08-29) ---------- */
/* Select every text node in the link field (an <a> has no .select();
a range does the job) — best-effort: a selection failure only means
the user copies by hand. */
function selectAllInField(el) {
try {
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
} catch {
/* selection is best-effort — the field still shows the full URL */
}
}
/* Clipboard + the owner-locked inline-link fallback: a non-secure
(http) homelab origin rejects navigator.clipboard, so the failure
path renders a TRANSIENT <a> link field in the row's share cell —
input-like, it selects its full URL on focus (click or Tab, then
Ctrl/Cmd+C). One field at a time (a new offer replaces the old).
Returns true when the clipboard took it. (The per-page duplication
house style — this is history.js's OWN copy of the ~10-line helper;
app.js keeps the chat page's, no new shared module.) */
async function copyShareLink(cell, absoluteUrl) {
cell.querySelectorAll(".share-link-fallback").forEach((el) => el.remove());
try {
await navigator.clipboard.writeText(absoluteUrl);
return true;
} catch {
const field = document.createElement("a");
field.className = "share-link-fallback";
field.href = absoluteUrl; // carries the full URL (copy link address works too)
field.textContent = absoluteUrl; // the URL is data — textContent, never innerHTML
field.title = "Share link — click, then copy (Ctrl/Cmd+C)";
field.addEventListener("focus", () => selectAllInField(field));
cell.appendChild(field);
field.focus({ preventScroll: true }); // selects the URL — ready to copy
return false;
}
}
/* The unshared state: the [Create link] button (a failed share keeps
the cell here — Create link is retryable). */
function renderShareUnshared(chat, cell) {
const create = document.createElement("button");
create.type = "button";
create.className = "history-share-create";
create.setAttribute("aria-label", `Create share link: ${chat.title}`);
create.textContent = "Create link";
create.addEventListener("click", () => void createShareLink(chat, cell, create));
cell.replaceChildren(create);
}
/* The shared state: [Copy] [Unshare]. Unshare is the inline two-step
(the phase-50 Delete-confirm pattern — same .history-confirm-* CSS,
focus moves to Yes so the confirm is keyboard-reachable); No or a
failed request restores this state (retryable). */
function renderShareShared(chat, cell) {
const copy = document.createElement("button");
copy.type = "button";
copy.className = "history-share-copy";
copy.setAttribute("aria-label", `Copy share link: ${chat.title}`);
copy.textContent = "Copy";
copy.addEventListener("click", () => void copyRowShareLink(chat, cell));
const unshare = document.createElement("button");
unshare.type = "button";
unshare.className = "history-unshare";
unshare.setAttribute("aria-label", `Unshare saved chat: ${chat.title}`);
unshare.textContent = "Unshare";
function restoreShared() {
cell.replaceChildren(copy, unshare);
unshare.focus({ preventScroll: true }); // focus returns to the (restored) control
}
unshare.addEventListener("click", () => {
const label = document.createElement("span");
label.className = "history-confirm-text";
label.textContent = "Unshare?";
const yes = document.createElement("button");
yes.type = "button";
yes.className = "history-confirm-yes";
yes.textContent = "Yes";
const no = document.createElement("button");
no.type = "button";
no.className = "history-confirm-no";
no.textContent = "No";
yes.addEventListener("click", () => void confirmUnshare(chat, cell, yes, restoreShared));
no.addEventListener("click", restoreShared);
cell.replaceChildren(label, yes, no);
yes.focus({ preventScroll: true }); // the confirm pair takes over the focus
});
cell.replaceChildren(copy, unshare);
}
/* The Share cell (phase 51): the span the row's Share <td> carries.
The shipped state comes from the row's share_url (the list endpoint
populates it — no second fetch): shared → Copy + Unshare, unshared →
Create link. No share action ever removes the ROW (only Delete
does) — the cell just re-renders between its states. */
function makeShareControl(chat) {
const cell = document.createElement("span");
cell.className = "history-share";
if (chat.share_url) {
renderShareShared(chat, cell);
} else {
renderShareUnshared(chat, cell);
}
return cell;
}
/* Create the link: POST /api/chats/<id>/share → the response's
share_url becomes the row's data (chat.share_url — the later Copy
uses it), the cell re-renders to the shared state, and the ABSOLUTE
link (the row's own origin supplies the scheme/host) is offered for
copying — clipboard → the inline-field fallback in the cell. A
non-2xx (a 404 — the row was deleted behind our back — or 5xx) or a
network error keeps the unshared state (Create link re-enabled,
retryable) and lands the error line. */
async function createShareLink(chat, cell, createBtn) {
createBtn.disabled = true; // no double-fire while the request is in flight
let r;
try {
r = await fetch(`/api/chats/${chat.id}/share`, { method: "POST" });
} catch {
announce(`Couldn't share "${chat.title}" — is the app reachable?`);
createBtn.disabled = false;
return;
}
if (!r.ok) {
announce(`Couldn't share "${chat.title}" — try again.`);
createBtn.disabled = false;
return;
}
const { share_url } = await r.json();
chat.share_url = share_url; // the row is shared from now on
renderShareShared(chat, cell);
const copied = await copyShareLink(
cell,
new URL(share_url, window.location.origin).toString(),
);
announce(copied ? "Share link copied." : "Share link ready — copy it from the field.");
}
/* Copy (shared state): re-copy the row's share_url — the per-page
clipboard + fallback helper; the live region lands the outcome. */
async function copyRowShareLink(chat, cell) {
const copied = await copyShareLink(
cell,
new URL(chat.share_url, window.location.origin).toString(),
);
announce(copied ? "Share link copied." : "Share link ready — copy it from the field.");
}
/* The confirmed unshare: POST /api/chats/<id>/unshare → the token is
NULL (revoked — the public link 404s from now on), the cell
re-renders to the unshared state (Create link) and the live region
gets `Unshared "<title>".` A non-2xx / a network error keeps the
shared state (restoreShared — Copy + Unshare, retryable) and lands
the error line. */
async function confirmUnshare(chat, cell, yesBtn, restoreShared) {
yesBtn.disabled = true; // no double-fire while the request is in flight
let r;
try {
r = await fetch(`/api/chats/${chat.id}/unshare`, { method: "POST" });
} catch {
announce(`Couldn't unshare "${chat.title}" — is the app reachable?`);
restoreShared();
return;
}
if (!r.ok) {
announce(`Couldn't unshare "${chat.title}" — try again.`);
restoreShared();
return;
}
chat.share_url = null; // revoked: the row is unshared again
renderShareUnshared(chat, cell);
announce(`Unshared "${chat.title}".`);
}
/* The empty-state row reappears exactly when the last data row was
removed (the empty row itself ships in the tbody, hidden). */
function showEmptyIfLast() {
if (!emptyRow || !tbody) return;
emptyRow.hidden = tbody.querySelectorAll("tr").length > 1;
}
/* 0-row fetches, non-2xx, and network failures all land on the
empty-state row (the sources.js house fallback — the safe state
in every case). */
function showEmptyState() {
if (!tbody) return;
tbody.replaceChildren(emptyRow);
if (emptyRow) emptyRow.hidden = false;
}
/* GET /api/chats → render the rows (latest activity first — the
server's order). A 0-row fetch shows the empty-state row.
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 false;
}
if (!r.ok) {
announce("Couldn't load saved chats — try again.");
showEmptyState();
return false;
}
const { chats } = await r.json();
if (!chats.length) {
showEmptyState();
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) ----------
* The shared header is NOT booted here — in the shell it runs
* exactly once, via the chat module (app.js) at shell boot. The
* whoami gate reads fetchIsAdmin() — the SAME cached whoami promise
* the header uses (zero extra requests). Anonymous: the gate in,
* the table out — and NO /api/chats request at all (the router
* 403s anonymous, so the view must never call it; the story E2E
* pins the request log). */
if (!(await fetchIsAdmin())) {
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();
}