Files
brain-of-reese/frontend/assets/history.js
T
ducoterra 375169bbfc
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 12s
phase: 175_history_pagination
All criteria verified. This pass found no defects — the phase was fully implemented by prior task runs, and every gate is green.

**Phase 175 final verification pass — report**

- Verified tasks 01–03 implementation in-tree: `list_chats` `?limit=` (25, 1–100) / `?offset=` + `func.count` `total`; `SavedChatList.total` required; `#history-pager` below `#history-table-wrap` (hidden default, aria-labels, aria-hidden SVGs); `history.js` `PAGE_SIZE=25`/offset state, parameterized fetch, `updatePager`, refresh-keeps-current-page; CSS pill union + ≤640px block. No code changes were needed.
- Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 99%** (>90% gate).
- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors, 0 warnings.
- Phase E2E in isolation: `uv run pytest tests/e2e/test_history_pagination.py -v --no-cov` → **9 passed** (page 1-of-2 / 2-of-2 walk, replace-not-append, refresh-on-page-2, delete-to-empty + single-page hide, anonymous no-fetch, 375px mobile, a11y/no-CDN).
- Regression set, each in isolation: test_chat_history 5✓, test_history_page_width 5✓, test_share_chat 4✓, test_stale_saved_chats 3✓, test_history_copy 1✓; integration `test_chats_api.py` green (incl. updated `test_list_empty` two-key pin and all five new pagination pins).
- Completion criteria: all nine boxes satisfied (API slice/422/honest-total pins ✓; E2E walk + status lines ✓; refresh/delete/hide ✓; anonymous gate ✓; mobile ✓; gates ✓; E2E + regression ✓; docs ✓; commit/phase-move is the harness's).
- Documentation: README.md reviewed in full — the saved-chats section already carries the `?limit=`/`?offset=`/`total` contract from this phase; all 31 `.env.example` vars documented; AGENTS.md verified accurate. **Consistent — no changes needed.**
- Deviations: none. No defects found in previously completed work.
- Next pending phase: none — 175 is the only entry in `todo/`.
2026-10-11 04:12:53 -04:00

760 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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/<id>/share`
* + `POST /api/chats/<id>/unshare` + `DELETE /api/chats/<id>`
* endpoints (phase 50 task 02; phase 51 task 01+02) into the view's
* full-width table:
*
* • Title — an `<a href="/?chat=<id>">`: 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/<id>/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 <a> 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 <section id="view-history">, 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=<id>) 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=<PAGE_SIZE>&offset=<pageOffset>` (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 <first>–<last> of <total>.` 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=<id> — 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 <td> 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/<id>
→ 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/<id> → the row is removed
(+ the empty-state row reappears when it was the last one) and the
live region gets `Deleted "<title>".` 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)
}