/* Brain of Reese — History page (saved chats, phase 50 task 04). * * 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` + `DELETE /api/chats/` * endpoints (phase 50 task 02) into the page'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; * • Actions — Delete ONLY (phase 51 adds the share column), 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. * * 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 19/34: the page joins the shared header — initSharedHeader() * runs first (whoami + nav reveal + the steering panel), and the gate * below reuses the SAME cached /api/whoami promise (one request per * page). */ import { fetchIsAdmin, initSharedHeader } from "./header.js"; const tableWrap = document.querySelector("#history-table-wrap"); const tbody = document.querySelector("#history-tbody"); const emptyRow = document.querySelector("#history-empty-row"); const gateEl = document.querySelector("#history-gate"); const statusEl = document.querySelector("#history-status"); /* 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); 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}".`); } /* 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. */ async function loadChats() { if (emptyRow) emptyRow.hidden = true; let r; try { r = await fetch("/api/chats"); } catch { showEmptyState(); return; } if (!r.ok) { showEmptyState(); return; } const { chats } = await r.json(); if (!chats.length) { showEmptyState(); return; } for (const chat of chats) { tbody.appendChild(makeRow(chat)); } } (async () => { // Phase 19/34: the shared header first (whoami + nav reveal + the // steering panel) — the whoami promise is cached, so the gate below // reuses the SAME single /api/whoami request. await initSharedHeader(); if (!(await fetchIsAdmin())) { // Anonymous: the gate in, the table out — and NO /api/chats // request at all: the router 403s anonymous, so the page must // never call it (the story E2E pins the request log). if (tableWrap) tableWrap.hidden = true; if (gateEl) gateEl.hidden = false; return; } if (gateEl) gateEl.hidden = true; loadChats(); })();