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
376 lines
16 KiB
JavaScript
376 lines
16 KiB
JavaScript
/* Brain of Reese — Global Tuning view (phase 27; phase 76 task 01:
|
||
* shell view module).
|
||
*
|
||
* The standalone manager for steering notes: create / list / edit /
|
||
* delete WITHOUT a chat conversation. This module is the single owner
|
||
* of the view's behaviour:
|
||
*
|
||
* • loadNotes() — GET /api/steering → the newest-first note list
|
||
* (#tune-list) + the empty state. A failed fetch (API down, or the
|
||
* anonymous direct-URL 403) keeps the LAST RENDERED list —
|
||
* progressive enhancement, never a blanked panel.
|
||
* • create — #tune-form submit → POST /api/steering. 201 clears the
|
||
* textarea, announces through the live region, and reloads the
|
||
* list; any failure keeps the form (the instruction survives) and
|
||
* shows the API detail inline under the button (role=alert).
|
||
* #tune-save is disabled while the request is out.
|
||
* • edit — a row's Edit button swaps the text for an inline
|
||
* .tuning-edit-form: a prefilled textarea (maxlength 2000) +
|
||
* Save / Cancel. Save → PUT /api/steering/{id}; 200 replaces the
|
||
* form with the .tuning-saved status (role=status) and announces;
|
||
* a failure keeps the form + an inline error; Cancel reverts to
|
||
* the text span. The note id rides on the form (data attribute).
|
||
* • delete — DELETE /api/steering/{id}. 204 removes the row
|
||
* immediately (optimistic) and announces; 404 also drops the row
|
||
* and reloads to resync; other failures re-enable the button and
|
||
* announce a retry. The empty state is re-checked on every removal.
|
||
* • announce(msg) — #tune-announcer (role=status, aria-live=polite),
|
||
* the screen-reader confirmation for create / edit / delete.
|
||
*
|
||
* Phase 76 (task 01) — shell view module (the "Global Tuning" view of
|
||
* the ONE-document shell; /tuning.html now serves the shell, and
|
||
* assets/router.js lazy-imports THIS module on first show):
|
||
*
|
||
* • the top-level boot is now `export async function mount(root)` —
|
||
* root is the view's <section id="view-tuning">, and every DOM
|
||
* lookup scopes to root (the view ids stay unique across the
|
||
* shell — scoped lookups keep the module honest and testable).
|
||
* The router mounts a view ONCE (mount-once, hide-forever), so
|
||
* the binding + state survive every switch.
|
||
* • the initSharedHeader() call is DROPPED: in the shell the shared
|
||
* header boots exactly once, via the chat module (app.js) at shell
|
||
* boot — the view never re-boots it. The admin gate keeps
|
||
* fetchIsAdmin() — the SAME cached /api/whoami promise header.js
|
||
* exports (zero extra requests; the flag decides whether the note
|
||
* list loads at all, the Sources-page gate pattern).
|
||
*
|
||
* Phase 77 (task 02) — 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 Tuning 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 `loadNotes()` (renderNotes
|
||
* already clears the list, so a re-call replaces it). A FAILED
|
||
* refresh keeps the last rendered list — loadNotes's documented
|
||
* contract (progressive enhancement, never a blanked panel). The
|
||
* listener is armed only in the ADMIN branch, after the whoami gate
|
||
* passes: anonymous gets the empty-state view and never fetches
|
||
* (the phase-27 gate).
|
||
*
|
||
* Anonymous-safe (phase 27 task 03, unchanged in the shell): the
|
||
* header hides the "Tuning" nav link for anonymous visitors; a DIRECT
|
||
* anonymous URL still gets a safe view — loadNotes() only runs when
|
||
* the cached whoami says admin, the list stays on its empty state,
|
||
* and the create form 403s gracefully on submit (the inline error
|
||
* carries the API detail). Note text is always rendered with
|
||
* textContent — never innerHTML (XSS-safe, like app.js's steering
|
||
* panel).
|
||
*
|
||
* The shared header module loads through this script's own relative
|
||
* import ("./header.js") — a hoisted import evaluated before this body
|
||
* runs (single-evaluation design: no direct <script> tag; in the
|
||
* image the Containerfile's esbuild stage inlines it — today into the
|
||
* router bundle, phase 76 task 01).
|
||
*/
|
||
|
||
import { fetchIsAdmin } from "./header.js";
|
||
|
||
export async function mount(root) {
|
||
/* ---------- page elements (the view's section, scoped to root) ---------- */
|
||
const tuneForm = root.querySelector("#tune-form");
|
||
const tuneNote = root.querySelector("#tune-note");
|
||
const tuneSave = root.querySelector("#tune-save");
|
||
const tuneList = root.querySelector("#tune-list");
|
||
const tuneEmpty = root.querySelector("#tune-empty");
|
||
const tuneAnnouncer = root.querySelector("#tune-announcer");
|
||
|
||
/* The create form's inline error (role=alert) — created once, hidden
|
||
by default, and kept between attempts: a failed POST keeps the
|
||
form AND its message until the next submit. */
|
||
const createError = document.createElement("p");
|
||
createError.className = "tuning-error";
|
||
createError.setAttribute("role", "alert");
|
||
createError.hidden = true;
|
||
if (tuneForm) tuneForm.appendChild(createError);
|
||
|
||
/* Polite live region: the screen-reader confirmation for create /
|
||
edit / delete (task 03). */
|
||
function announce(message) {
|
||
if (tuneAnnouncer) tuneAnnouncer.textContent = message;
|
||
}
|
||
|
||
/* Row-action icons — inline SVG constants (aria-hidden; the buttons
|
||
carry their own labels), the same marks as app.js's steering panel. */
|
||
const EDIT_ICON =
|
||
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20l1.2-4.2L16.7 4.3a2.1 2.1 0 0 1 3 3L8.2 18.8 4 20Z"/><path d="M14.7 6.3l3 3"/></svg>';
|
||
const DELETE_ICON =
|
||
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 7h14M10 7V5h4v2M8.5 7l.7 12h5.6l.7-12"/></svg>';
|
||
|
||
/* FastAPI error bodies: a string detail or the validation-error array
|
||
(the first entry's msg is the human line). Same extraction as app.js. */
|
||
async function apiDetail(r, fallback) {
|
||
try {
|
||
const data = await r.json();
|
||
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
|
||
return String(data.detail[0].msg);
|
||
}
|
||
if (typeof data.detail === "string" && data.detail) return data.detail;
|
||
} catch {
|
||
/* non-JSON error body */
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
/* ---------- load / render (newest first — the API's list order) ---------- */
|
||
|
||
/* GET /api/steering → render. A failed fetch (API down, or the 403 on
|
||
an anonymous direct-URL visit) keeps the last rendered list —
|
||
progressive enhancement, never a blanked panel. */
|
||
async function loadNotes() {
|
||
let r;
|
||
try {
|
||
r = await fetch("/api/steering");
|
||
} catch {
|
||
return; // API unreachable: keep the last rendered list
|
||
}
|
||
if (!r.ok) return; // e.g. anonymous 403: keep the last rendered list
|
||
let notes;
|
||
try {
|
||
notes = (await r.json()).notes || [];
|
||
} catch {
|
||
return; // corrupt body: keep the last rendered list
|
||
}
|
||
renderNotes(notes);
|
||
}
|
||
|
||
function renderNotes(notes) {
|
||
if (!tuneList) return;
|
||
tuneList.textContent = "";
|
||
for (const n of notes) tuneList.appendChild(makeNoteRow(n));
|
||
syncEmptyState(notes.length);
|
||
}
|
||
|
||
/* The empty state tracks the list's rendered rows (the HTML ships on
|
||
the "No tuning notes yet" text; it hides as soon as one row shows). */
|
||
function syncEmptyState(count) {
|
||
if (!tuneEmpty || !tuneList) return;
|
||
const rows = typeof count === "number" ? count : tuneList.children.length;
|
||
tuneEmpty.hidden = rows > 0;
|
||
}
|
||
|
||
/* One list row: the note text (textContent — XSS-safe, never
|
||
innerHTML) + the Edit and Delete buttons. */
|
||
function makeNoteRow(n) {
|
||
const li = document.createElement("li");
|
||
li.className = "tuning-note";
|
||
|
||
const text = document.createElement("span");
|
||
text.className = "tuning-note-text";
|
||
text.textContent = n.note; // rendered as text, never as HTML
|
||
li.appendChild(text);
|
||
|
||
const editBtn = document.createElement("button");
|
||
editBtn.type = "button";
|
||
editBtn.className = "tuning-edit";
|
||
editBtn.innerHTML = EDIT_ICON + "<span>Edit</span>";
|
||
editBtn.addEventListener("click", () => openEditForm(li, n));
|
||
|
||
const delBtn = document.createElement("button");
|
||
delBtn.type = "button";
|
||
delBtn.className = "tuning-delete";
|
||
delBtn.setAttribute("aria-label", `Delete tuning note: ${n.note}`);
|
||
delBtn.innerHTML = DELETE_ICON + "<span>Delete</span>";
|
||
delBtn.addEventListener("click", () => deleteNote(n.id, delBtn, li));
|
||
|
||
li.append(editBtn, delBtn);
|
||
return li;
|
||
}
|
||
|
||
/* ---------- create (POST /api/steering) ---------- */
|
||
|
||
if (tuneForm) {
|
||
tuneForm.addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
if (tuneSave) tuneSave.disabled = true; // one note per click
|
||
createError.hidden = true;
|
||
try {
|
||
const r = await fetch("/api/steering", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ note: tuneNote ? tuneNote.value : "" }),
|
||
});
|
||
if (r.ok) {
|
||
if (tuneNote) tuneNote.value = ""; // 201: the note is stored
|
||
announce("Tuning note added. Future answers will follow it.");
|
||
await loadNotes(); // the new note lands in the list, newest first
|
||
} else {
|
||
createError.textContent = await apiDetail(r, "Could not add the note — try again.");
|
||
createError.hidden = false; // form kept — the instruction survives
|
||
}
|
||
} catch {
|
||
createError.textContent = "Could not add the note — is the app reachable?";
|
||
createError.hidden = false;
|
||
} finally {
|
||
if (tuneSave) tuneSave.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
/* ---------- edit (inline form → PUT /api/steering/{id}) ----------
|
||
* The row swaps to the inline form — the is-editing class does the
|
||
* visual swap (styles.css hides the text + the row buttons). One open
|
||
* form view-wide: opening a new one reverts the others. Cancel reverts
|
||
* to the text span; a failed save keeps the form + the inline error.
|
||
*/
|
||
let editSeq = 0; // unique ids for the edit forms' labeled textareas
|
||
|
||
function openEditForm(li, n) {
|
||
if (li.classList.contains("is-editing")) return; // one per row
|
||
// One open form view-wide: close any other row's first.
|
||
root.querySelectorAll(".tuning-note.is-editing").forEach((other) => {
|
||
other.classList.remove("is-editing");
|
||
other.querySelector(".tuning-edit-form")?.remove();
|
||
});
|
||
li.querySelector(".tuning-saved")?.remove(); // a stale "Saved" pill
|
||
li.classList.add("is-editing");
|
||
|
||
editSeq += 1;
|
||
const inputId = `tuning-edit-input-${editSeq}`;
|
||
const form = document.createElement("form");
|
||
form.className = "tuning-edit-form";
|
||
form.dataset.noteId = n.id; // the note id rides on the form
|
||
|
||
const label = document.createElement("label");
|
||
label.className = "visually-hidden";
|
||
label.htmlFor = inputId;
|
||
label.textContent = `Edit tuning note: ${n.note}`;
|
||
|
||
const textarea = document.createElement("textarea");
|
||
textarea.id = inputId;
|
||
textarea.className = "tuning-edit-input";
|
||
textarea.rows = 2;
|
||
textarea.maxLength = 2000; // client-side 1–2000 contract (server re-validates)
|
||
textarea.required = true;
|
||
textarea.value = n.note; // prefilled with the current text
|
||
|
||
const actions = document.createElement("div");
|
||
actions.className = "tuning-edit-form-actions";
|
||
const saveBtn = document.createElement("button");
|
||
saveBtn.type = "submit";
|
||
saveBtn.className = "tune-save";
|
||
saveBtn.textContent = "Save";
|
||
const cancelBtn = document.createElement("button");
|
||
cancelBtn.type = "button";
|
||
cancelBtn.className = "tune-cancel";
|
||
cancelBtn.textContent = "Cancel";
|
||
actions.append(saveBtn, cancelBtn);
|
||
|
||
const error = document.createElement("p");
|
||
error.className = "tuning-error";
|
||
error.setAttribute("role", "alert");
|
||
error.hidden = true;
|
||
|
||
form.append(label, textarea, actions, error);
|
||
form.addEventListener("submit", (e) => handleEditSave(e, li, form, textarea, saveBtn, error));
|
||
cancelBtn.addEventListener("click", () => {
|
||
li.classList.remove("is-editing"); // revert to the text span
|
||
form.remove();
|
||
li.querySelector(".tuning-edit")?.focus();
|
||
});
|
||
|
||
li.insertBefore(form, li.querySelector(".tuning-edit"));
|
||
textarea.focus();
|
||
}
|
||
|
||
async function handleEditSave(e, li, form, textarea, saveBtn, error) {
|
||
e.preventDefault();
|
||
saveBtn.disabled = true;
|
||
error.hidden = true;
|
||
const id = form.dataset.noteId;
|
||
try {
|
||
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ note: textarea.value }),
|
||
});
|
||
if (r.ok) {
|
||
let saved = textarea.value.trim();
|
||
try {
|
||
saved = (await r.json()).note ?? saved;
|
||
} catch {
|
||
/* keep the trimmed local text */
|
||
}
|
||
const textEl = li.querySelector(".tuning-note-text");
|
||
if (textEl) textEl.textContent = saved; // the list shows the stored text
|
||
const savedPill = document.createElement("p");
|
||
savedPill.className = "tuning-saved";
|
||
savedPill.setAttribute("role", "status");
|
||
savedPill.textContent = "Saved";
|
||
form.replaceWith(savedPill);
|
||
li.classList.remove("is-editing"); // updated text + row buttons come back
|
||
announce("Tuning note updated.");
|
||
return;
|
||
}
|
||
error.textContent = await apiDetail(r, "Could not update the note — try again.");
|
||
error.hidden = false; // form kept — the edit survives the failure
|
||
saveBtn.disabled = false;
|
||
} catch {
|
||
error.textContent = "Could not update the note — is the app reachable?";
|
||
error.hidden = false;
|
||
saveBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
/* ---------- delete (DELETE /api/steering/{id}, optimistic) ----------
|
||
* The row leaves the DOM the moment the server agrees (204); a 404
|
||
* (already gone) also drops the row and reloads to resync; any other
|
||
* failure re-enables the button and says to retry. */
|
||
async function deleteNote(id, btn, li) {
|
||
btn.disabled = true;
|
||
try {
|
||
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||
if (r.status === 404) {
|
||
li.remove(); // already gone on the server — drop it and resync
|
||
syncEmptyState();
|
||
announce("That note was already removed.");
|
||
await loadNotes();
|
||
return;
|
||
}
|
||
if (!r.ok) {
|
||
announce("Could not delete the note — try again.");
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
li.remove(); // 204: the server confirmed — the row goes now
|
||
syncEmptyState();
|
||
announce("Tuning note deleted.");
|
||
} catch {
|
||
announce("Could not delete the note — is the app reachable?");
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
/* ---------- view boot (phase 76 task 01) ----------
|
||
* The New chat binding is module-owned (assets/header.js, phase 34
|
||
* task 02 — the SINGLE binding) and lives in the chat view only.
|
||
*
|
||
* Boot: the shared header is NOT booted here — in the shell it runs
|
||
* exactly once, via the chat module (app.js) at shell boot. The note
|
||
* list is admin data (the Sources page gate pattern): the gate reads
|
||
* fetchIsAdmin() — the SAME cached whoami promise the header uses
|
||
* (zero extra requests). An anonymous visitor gets the view frame
|
||
* with the empty state, and the create form 403s gracefully on
|
||
* submit if one tries. */
|
||
/* Phase 77 (task 02): a user-initiated re-show of this already-
|
||
mounted view makes the router dispatch bor:view-refresh on the
|
||
section — re-run loadNotes then (renderNotes clears the list
|
||
first, so a re-call replaces it; a failed refresh keeps the last
|
||
rendered list — loadNotes's documented contract). Armed ONLY
|
||
here, after the whoami gate passed: anonymous never fetches
|
||
(the phase-27 gate). */
|
||
if (await fetchIsAdmin()) {
|
||
root.addEventListener("bor:view-refresh", () => loadNotes());
|
||
loadNotes(); // phase 27: the list is admin-only
|
||
}
|
||
}
|