/* 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//share` * + `POST /api/chats//unshare` + `DELETE /api/chats/` * endpoints (phase 50 task 02; phase 51 task 01+02) into the view's * full-width table: * * • Title — an ``: 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//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 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
, 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=) 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= — 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 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/ → 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/ → the row is removed (+ the empty-state row reappears when it was the last one) and the live region gets `Deleted "".` 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(); }