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/`.
760 lines
35 KiB
JavaScript
760 lines
35 KiB
JavaScript
/* 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)
|
||
}
|