/* 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` + `POST /api/chats//share` * + `POST /api/chats//unshare` + `DELETE /api/chats/` * endpoints (phase 50 task 02; phase 51 task 01+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; * • 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 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); // 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. */ 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(); })();