/* 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. * Phase 156 (task 02, A3): the Create link / Copy / Unshare / * Delete visible texts are .cell-clip spans — a squeezed * Actions/Share cell shows "…" instead of a hard cut; every one * of these buttons' aria-label names the chat title (the * phase-66 idiom), so the full text stays reachable. * * 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). * * Phase 175 (task 02) — the list pager (TODO.md L3, owner 2026-10-10: * "Need pagination on the history page - it gets long fast"): * * • the load is parameterized: `loadChats(pageOffset)` fetches * `/api/chats?limit=&offset=` (the manual * string join — the file's house style) and reads the body's * `total` (the server's full count) into the pager via * `updatePager(total)`. Module state: `PAGE_SIZE` (25 — the * server's default page; A1 — no size picker in the UI, the E2E * suite catches a drift) and the current page's `offset` * (starts at 0 — the view opens on page 1). * • `updatePager(total)` runs on EVERY 200 (a 0-row fetch included * — the deleted-behind-us race): `total <= PAGE_SIZE` → the * pager is hidden (A4 — no dead "Page 1 of 1" control; it * disappears with the empty state); otherwise it shows, the * status reads `Page N of M`, Prev is disabled on page 1, Next * on the last page (the real `disabled` — the Refresh idiom; * the edges make the click clamping trivial). A FAILED load * (non-2xx / network) leaves the pager as it was — the * previous state is the last known truth. * • Prev/Next (bound in the ADMIN branch only — the SAME gate as * Refresh, D5; the anonymous branch never touches the pager) * move the offset by PAGE_SIZE, re-run the re-entrant load (the * rows are REPLACED, never appended), and land the never-stale * `Showing – of .` line in #history-status. * Refresh (the button AND the re-show listener) re-fetches the * CURRENT page — `loadChats(offset)`, never 0 (A5). A local * Delete does NOT touch the pager or the offset — the next load * (a page click or a Refresh) corrects it (D4). * * • a settled load lands its OWN never-stale `Showing …` line * (task 03): the mount is fire-and-forget (the house-style * `loadChats(0);` line), so the announcement lives in * `loadChats` itself, where the settled `total` is in hand. It * lands ONLY while the pager is visible (total > PAGE_SIZE): * a one-page list IS the whole list (A4 — no redundant line), * and the 0-row branch shows the empty state (nothing to show). * A page change's identical line overwrites it (changePage), * and Refresh's `Saved chats refreshed.` overwrites it on the * refresh path. */ 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"); // Phase 175 (task 02, D3): the list pager — under the table card // (Prev / "Page N of M" / Next). It SHIPS hidden in the markup; // only the ADMIN branch's load lifecycle (updatePager) reveals it, // and only while total > PAGE_SIZE (A4 — no dead "Page 1 of 1" // control). The anonymous branch never touches it (D5 — no dead // control beside the gate). const pagerEl = root.querySelector("#history-pager"); const pagerPrevBtn = root.querySelector("#history-pager-prev"); const pagerNextBtn = root.querySelector("#history-pager-next"); const pagerStatusEl = root.querySelector("#history-pager-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}`); /* Phase 156 (task 02, A3): the visible text is the .cell-clip span — a squeezed Actions cell shows "…" instead of a hard cut; the aria-label above names the chat title (the phase-66 idiom), so the full text stays reachable. */ const delClip = document.createElement("span"); delClip.className = "cell-clip"; delClip.textContent = "Delete"; // textContent only (XSS contract) del.append(delClip); 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}`); /* Phase 156 (task 02, A3): the visible text is the .cell-clip span (the aria-label above names the chat title — the phase-66 idiom, the full text stays reachable). */ const createClip = document.createElement("span"); createClip.className = "cell-clip"; createClip.textContent = "Create link"; // textContent only (XSS contract) create.append(createClip); 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}`); /* Phase 156 (task 02, A3): the visible texts are .cell-clip spans (the aria-labels above name the chat title — the phase-66 idiom, the full text stays reachable). */ const copyClip = document.createElement("span"); copyClip.className = "cell-clip"; copyClip.textContent = "Copy"; // textContent only (XSS contract) copy.append(copyClip); 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}`); const unshareClip = document.createElement("span"); unshareClip.className = "cell-clip"; unshareClip.textContent = "Unshare"; // textContent only (XSS contract) unshare.append(unshareClip); 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; } /* Phase 175 (task 02, D4): the pager's state. PAGE_SIZE matches the server's default page (A1: no size picker in the UI — the E2E suite catches a limit drift); `offset` is the current page's offset (it starts at 0 — the view opens on page 1); `total` is the server's last full count — the "of N" truth (a failed load leaves it; the previous state is the last known truth). */ const PAGE_SIZE = 25; let offset = 0; let total = 0; /* Phase 175 (task 02, D3): the pager's single source of truth — the load's `updatePager(total)` runs it on EVERY 200 (a 0-row fetch included — the deleted-behind-us race): • `total <= PAGE_SIZE` → the pager is hidden (A4 — no dead "Page 1 of 1" control; it disappears with the empty state); • otherwise it shows, #history-pager-status reads `Page N of M`, Prev is disabled on page 1, Next on the last page (the real `disabled` attribute — the Refresh idiom; the edges make the click clamping trivial). A FAILED load (non-2xx / network) never reaches here — the pager keeps its previous state, the last known truth. */ function updatePager(count) { total = count; if (!pagerEl) return; const pages = Math.ceil(total / PAGE_SIZE); if (total <= PAGE_SIZE) { pagerEl.hidden = true; // A4 — a one-page list never shows a dead control return; } pagerEl.hidden = false; if (pagerStatusEl) { pagerStatusEl.textContent = "Page " + (offset / PAGE_SIZE + 1) + " of " + pages; } if (pagerPrevBtn) pagerPrevBtn.disabled = offset === 0; if (pagerNextBtn) pagerNextBtn.disabled = offset + PAGE_SIZE >= total; } /* Phase 175 (task 02, D4): a page change — the Prev/Next click has ALREADY moved the module's offset by PAGE_SIZE (the button's disabled edge made it a legal move), this re-runs the re-entrant load (rows are REPLACED, never appended) and, on success, lands the never-stale `Showing <first>–<last> of <total>.` line in #history-status. A failed load keeps the pager (loadChats' own error line is the feedback — the §7.4 contract). */ async function changePage() { const ok = await loadChats(offset); if (ok) { announce( "Showing " + (offset + 1) + "–" + Math.min(offset + PAGE_SIZE, total) + " of " + total + ".", ); } } /* GET /api/chats?limit=<PAGE_SIZE>&offset=<pageOffset> (phase 175: the parameterized fetch) → 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. A failed load leaves the pager as it was (the previous state is the last known truth). Phase 175 (task 02): the 200 path reads the body's `total` (the server's full count) and runs updatePager — the pager tracks every settled load. */ async function loadChats(pageOffset) { if (tbody) { for (const tr of tbody.querySelectorAll("tr")) { if (tr !== emptyRow) tr.remove(); } } if (emptyRow) emptyRow.hidden = true; let r; try { /* Phase 175 (task 02, D4): the parameterized fetch — the manual string join (the file's house style): the 25-row page (PAGE_SIZE — the server's default) at the given offset. */ r = await fetch("/api/chats?limit=" + PAGE_SIZE + "&offset=" + pageOffset); } 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, total } = await r.json(); updatePager(total); // phase 175: the pager tracks the fresh total (a 0-row fetch included — the deleted-behind-us race) if (!chats.length) { showEmptyState(); return true; } for (const chat of chats) { tbody.appendChild(makeRow(chat)); } /* Phase 175 (task 03, the never-stale contract's first-load half): a settled load lands its own `Showing <first>–<last> of <total>.` line (the same formula a page change lands — changePage's line overwrites it with the identical text; Refresh's `Saved chats refreshed.` overwrites it on the refresh path). It lands ONLY while the pager is visible (total > PAGE_SIZE): a one-page list IS the whole list (A4 — no redundant line) and the 0-row branch above never reaches here (the empty state is the honest view). */ if (total > PAGE_SIZE) { announce( "Showing " + (pageOffset + 1) + "–" + Math.min(pageOffset + PAGE_SIZE, total) + " of " + total + ".", ); } 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 { /* Phase 175 (task 02, A5): refresh re-fetches the CURRENT page — the module's offset, never 0 (a page-2 refresh must land page-2 rows, not reset the list). */ ok = await loadChats(offset); } 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(offset); // phase 175 (A5): the re-show re-fetches the CURRENT page (never resets to 0) }); /* 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(); }); } /* Phase 175 (task 02, D5): the pager's Prev / Next — bound HERE, in the admin branch only (the SAME gate as Refresh; the anonymous branch never touches the pager — it keeps its markup-shipped hidden state, D5). Each click moves the module's offset by PAGE_SIZE in its direction (the button's disabled edge made it a legal move — clamping is trivial) and re-runs the re-entrant load; on success the `Showing …` line lands in the live region (changePage). A local Delete does NOT touch the pager or the offset — the next load (a page click or a Refresh) corrects it (D4). */ if (pagerPrevBtn) { pagerPrevBtn.addEventListener("click", () => { offset -= PAGE_SIZE; void changePage(); }); } if (pagerNextBtn) { pagerNextBtn.addEventListener("click", () => { offset += PAGE_SIZE; void changePage(); }); } started = true; loadChats(0); // phase 175: the view opens on page 1 (offset 0) }