feat(chat): save by default + share anonymously — auto-saved chats, guest-facing Share, success toast, action row

This commit is contained in:
2026-08-31 05:20:25 -04:00
parent c564e317ed
commit 914097abcf
17 changed files with 1803 additions and 491 deletions
+67 -15
View File
@@ -1,9 +1,22 @@
"""Saved-chat API — save and view chat history (phase 50, task 02). """Saved-chat API — save and view chat history (phase 50, task 02).
Admin-only CRUD under ``/api/chats`` (the phase-16 Split gate (phase 55, task 01 — owner-locked A1, superseding the
:func:`app.core.auth.require_admin` gate, applied router-wide exactly phase-50 "save/history is admin-only" lock): under ``/api/chats`` the
like :mod:`app.api.steering`): conversations the owner explicitly WRITE surface is **public** (no session) — ``POST`` (create, including
**Saves** are stored in Postgres (``saved_chats``, migration 0008). the save-then-share ``share: true`` branch), ``PUT /{chat_id}`` (the
re-Save upsert), ``POST /{chat_id}/share``. WHY: a save is the
visitor's OWN conversation; the row id is an unguessable ``uuid4``, the
same trust model as the phase-51 share token — the id/token IS the
credential (a guest holds the handle to what they just saved, exactly
as a link-holder holds a token). The MANAGEMENT surface is
**admin-only** and keeps the phase-16 :func:`app.core.auth.require_admin`
gate — applied per-route on exactly those four decorators (list,
detail, delete, unshare — the owner's History surface; the
:mod:`app.api.steering` router-wide pattern is untouched). Guest chats
appear in the admin's History (saved by default — phase 55).
Conversations are stored in Postgres (``saved_chats``, migration
0008).
A10 extension (owner permission 2026-08-29, recorded per AGENTS.md A10 extension (owner permission 2026-08-29, recorded per AGENTS.md
rule 3 — a recorded revision, not a silent deviation): ``/api/chat`` rule 3 — a recorded revision, not a silent deviation): ``/api/chat``
@@ -27,8 +40,9 @@ PENDING row so it ships in the same INSERT),
only when supplied; re-stamps ``sources_version`` to the current only when supplied; re-stamps ``sources_version`` to the current
generation — a Re-Save is the owner affirming this content against generation — a Re-Save is the owner affirming this content against
the current KB), ``DELETE /{chat_id}``, and (phase 51, task 01) the current KB), ``DELETE /{chat_id}``, and (phase 51, task 01)
``POST /{chat_id}/share`` / ``POST /{chat_id}/unshare`` on this ``POST /{chat_id}/share`` (public, phase 55) /
admin-gated router. ``POST /{chat_id}/unshare`` (admin-only — the owner's History
action).
Staleness (phase 53, task 03): every saved row carries the Staleness (phase 53, task 03): every saved row carries the
``sources_meta`` generation it was saved against (``sources_version``, ``sources_meta`` generation it was saved against (``sources_version``,
@@ -82,10 +96,12 @@ from app.schemas import (
UnshareOut, UnshareOut,
) )
# Phase 55, task 01 (owner-locked A1): NO router-wide gate here — the
# write surface (create / re-Save / share) is public; exactly the four
# management routes below carry ``dependencies=[Depends(require_admin)]``.
router = APIRouter( router = APIRouter(
prefix="/chats", prefix="/chats",
tags=["chats"], tags=["chats"],
dependencies=[Depends(require_admin)], # phase 16: save/history is admin-only
) )
#: Auto-title cap (owner-locked convention, phase 50): the first user #: Auto-title cap (owner-locked convention, phase 50): the first user
@@ -151,12 +167,18 @@ def _to_row(row: SavedChat, current_version: int) -> SavedChatRow:
) )
@router.get("", response_model=SavedChatList) @router.get(
"",
response_model=SavedChatList,
dependencies=[Depends(require_admin)], # management surface (phase 55)
)
def list_chats( def list_chats(
db: Session = Depends(get_db), # noqa: B008 db: Session = Depends(get_db), # noqa: B008
) -> SavedChatList: ) -> SavedChatList:
"""All saved chats, latest activity first (``updated_at desc, id """All saved chats, latest activity first (``updated_at desc, id
desc``) — the History page's table order. Each row carries the desc``) — the History page's table order, admin-only (the owner's
History surface; phase 55 moved the gate per-route). Each row
carries the
phase-53 ``stale`` flag: the current generation is read ONCE per phase-53 ``stale`` flag: the current generation is read ONCE per
request (one PK read of the seeded row) and compared against every request (one PK read of the seeded row) and compared against every
row's stamp in the serializer helpers — no per-row queries.""" row's stamp in the serializer helpers — no per-row queries."""
@@ -174,6 +196,10 @@ def create_chat(
) -> SavedChatOut: ) -> SavedChatOut:
"""Store one explicitly saved conversation (201). """Store one explicitly saved conversation (201).
PUBLIC (phase 55, task 01) — no session required: the save is the
visitor's own conversation; the unguessable ``uuid4`` row id IS the
credential (the phase-51 token's trust model).
Auto-title when ``title`` is absent/blank: the first user message's Auto-title when ``title`` is absent/blank: the first user message's
text, whitespace-collapsed, truncated to 120 chars (owner-locked text, whitespace-collapsed, truncated to 120 chars (owner-locked
convention); a conversation with no user message (defensive) falls convention); a conversation with no user message (defensive) falls
@@ -217,13 +243,18 @@ def create_chat(
return _to_out(row, current_version) return _to_out(row, current_version)
@router.get("/{chat_id}", response_model=SavedChatOut) @router.get(
"/{chat_id}",
response_model=SavedChatOut,
dependencies=[Depends(require_admin)], # management surface (phase 55)
)
def get_chat( def get_chat(
chat_id: uuid.UUID, chat_id: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008 db: Session = Depends(get_db), # noqa: B008
) -> SavedChatOut: ) -> SavedChatOut:
"""One saved chat, full payload (the ``?chat=<id>`` load); 404 when """One saved chat, full payload (the ``?chat=<id>`` load) —
the id is unknown. The ``stale`` flag (phase 53) tells the chat admin-only (the ``?chat=<id>`` boot restore is the owner's "Open"
action, phase 55 A3); 404 when the id is unknown. The ``stale`` flag (phase 53) tells the chat
page whether to reveal its stale banner (task 05) before the page whether to reveal its stale banner (task 05) before the
messages render.""" messages render."""
row = db.get(SavedChat, chat_id) row = db.get(SavedChat, chat_id)
@@ -240,6 +271,10 @@ def update_chat(
) -> SavedChatOut: ) -> SavedChatOut:
"""Re-Save upsert: full ``messages`` replacement on the same row. """Re-Save upsert: full ``messages`` replacement on the same row.
PUBLIC (phase 55, task 01) — no session required (the auto-save
upsert for the visitor's own conversation; same row-id trust model
as :func:`create_chat`).
``title`` is replaced only when supplied (an absent/blank ``title`` ``title`` is replaced only when supplied (an absent/blank ``title``
keeps the current one); 404 when the id is unknown. ``updated_at`` keeps the current one); 404 when the id is unknown. ``updated_at``
bumps via the model's ``onupdate=func.now()`` — the attribute bumps via the model's ``onupdate=func.now()`` — the attribute
@@ -269,12 +304,17 @@ def update_chat(
return _to_out(row, current_version) return _to_out(row, current_version)
@router.delete("/{chat_id}", status_code=204) @router.delete(
"/{chat_id}",
status_code=204,
dependencies=[Depends(require_admin)], # management surface (phase 55)
)
def delete_chat( def delete_chat(
chat_id: uuid.UUID, chat_id: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008 db: Session = Depends(get_db), # noqa: B008
) -> Response: ) -> Response:
"""Remove a saved chat; 404 when the id is unknown.""" """Remove a saved chat — admin-only (the owner's History action,
phase 55); 404 when the id is unknown."""""
row = db.get(SavedChat, chat_id) row = db.get(SavedChat, chat_id)
if row is None: if row is None:
raise HTTPException(status_code=404, detail="unknown chat") raise HTTPException(status_code=404, detail="unknown chat")
@@ -290,6 +330,11 @@ def share_chat(
) -> ShareOut: ) -> ShareOut:
"""Turn a saved chat into a public link (phase 51, task 01). """Turn a saved chat into a public link (phase 51, task 01).
PUBLIC (phase 55, task 01) — no session required: sharing is what
the visitor does with their own conversation (the row-id trust
model of :func:`create_chat`; revoking, by contrast, is the
owner's History action — :func:`unshare_chat` stays admin-only).
Returns ``{"chat_id", "share_url"}`` with ``share_url = Returns ``{"chat_id", "share_url"}`` with ``share_url =
"/shared/<token>"`` (200, idempotent — an existing token is "/shared/<token>"`` (200, idempotent — an existing token is
returned unchanged; a new token is a 128-bit ``uuid4``). returned unchanged; a new token is a 128-bit ``uuid4``).
@@ -317,13 +362,20 @@ def share_chat(
return ShareOut(chat_id=row.id, share_url=f"/shared/{token}") return ShareOut(chat_id=row.id, share_url=f"/shared/{token}")
@router.post("/{chat_id}/unshare", response_model=UnshareOut) @router.post(
"/{chat_id}/unshare",
response_model=UnshareOut,
dependencies=[Depends(require_admin)], # management surface (phase 55)
)
def unshare_chat( def unshare_chat(
chat_id: uuid.UUID, chat_id: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008 db: Session = Depends(get_db), # noqa: B008
) -> UnshareOut: ) -> UnshareOut:
"""Revoke a shared chat (phase 51, task 01): ``share_token`` → NULL. """Revoke a shared chat (phase 51, task 01): ``share_token`` → NULL.
Admin-only (the owner's History action — phase 55 kept the
revocation off the guest's reach, so a guest cannot un-revoke).
Idempotent — an unshared chat unshares cleanly (200, no write). Idempotent — an unshared chat unshares cleanly (200, no write).
``updated_at`` is not bumped (raw SQL ``UPDATE`` touching only ``updated_at`` is not bumped (raw SQL ``UPDATE`` touching only
``share_token``, same reasoning as :func:`share_chat`); 404 when ``share_token``, same reasoning as :func:`share_chat`); 404 when
+247 -124
View File
@@ -124,44 +124,54 @@
* while a turn is in flight. No banner, no scroll (phase 42): the fresh * while a turn is in flight. No banner, no scroll (phase 42): the fresh
* bubble lands where the old one was. * bubble lands where the old one was.
* *
* Save the conversation (phase 50, owner-locked 2026-08-29, TODO.md L5): * Auto-save the conversation (phase 55, owner-locked A2, 2026-08-31 —
* the "Save" pill (#save-chat-btn — admin-only, SHIPS HIDDEN, revealed at * the phase-50 Save pill is RETIRED, TODO.md L4 "Save shouldn't be a
* boot only for admin: absent, not hidden, for anonymous) stores the * button"): every conversation upserts itself into Postgres
* CURRENT conversation in Postgres (saved_chats, migration 0008) through * (saved_chats, migration 0008) at the persistence save points — no
* the admin-only /api/chats CRUD. Upsert semantics keyed by * button, no explicit action. The headless persistConversation() helper
* `currentChatId` (module scope, string | null): a Save while unlinked * carries the phase-50 upsert semantics, keyed by `currentChatId`
* POSTs /api/chats (the server auto-titles from the first question, * (module scope, string | null): a save while unlinked POSTs /api/chats
* 120-char cap) and links the conversation to the created row's id; a * (the server auto-titles from the first question, 120-char cap) and
* re-Save while linked PUTs the SAME row — the same conversation never * links the conversation to the created row's id; a save while linked
* spawns a second row; a 404 from that PUT (the row was deleted on the * PUTs the SAME row — the same conversation never spawns a second row;
* History page behind our back) unlinks and retries as a create, so a * a 404 from that PUT (the row was deleted on the History page behind
* stale link can never leave the conversation unsaved. "New chat" * our back) unlinks and retries as a create, so a stale link can never
* unlinks (a fresh conversation is unlinked until saved again). Boot * wedge the conversation. The module-level `persisting` flag is the
* double-fire guard: the save points can overlap (pagehide during a
* stream), so a call while an upsert is in flight is a no-op — the
* next save point retries. The A2 quiet contract: a FAILED auto-save
* never blocks the conversation — a one-line #send-status note ("Couldn't
* save automatically — will try on the next message."), NO error banner;
* a SUCCESSFUL auto-save is silent (the History page is the visible
* proof — the toast is reserved for share). The row link survives
* reloads: the bor.chat.v1 record carries `chatId` (null when unlinked;
* a pre-55 record without the field reads as null — never throws), so a
* refresh restores the conversation AND its link. "New chat" unlinks
* (a fresh conversation creates a fresh row on its first message). Boot
* load: /?chat=<id> with a VALID uuid AND admin fetches the row and * load: /?chat=<id> with a VALID uuid AND admin fetches the row and
* renders its messages through the SAME renderStoredMessage loop as the * renders its messages through the SAME renderStoredMessage loop as the
* phase-14 local restore (sources / thinking / tools / stopped / * phase-14 local restore (sources / thinking / tools / stopped /
* deflection — pixel-identical), links currentChatId to the id, and * deflection — pixel-identical), links currentChatId to the id, and
* mirrors the conversation to localStorage (a plain refresh returns to * mirrors the conversation to localStorage (a plain refresh returns to
* it the phase-14 way). The ?chat= param is a ONE-SHOT boot instruction: * it the phase-14 way, link included). The ?chat= param is a ONE-SHOT
* the success path normalizes the URL back to / (history.replaceState), * boot instruction: the success path normalizes the URL back to /
* so a later refresh — or a "New chat" + refresh — restores the LOCAL * (history.replaceState), so a later refresh — or a "New chat" +
* session (the mirror) instead of re-opening the saved row and evicting * refresh — restores the LOCAL session (the mirror) instead of
* whatever the owner typed since. Invalid/absent param, anonymous (no * re-opening the saved row and evicting whatever the owner typed since.
* fetch — the gate would 403), 404, or network failure: the normal local * Invalid/absent param, anonymous (no fetch — the gate would 403), 404,
* restore runs instead (404/network also raise the error banner). Save * or network failure: the normal local restore runs instead (404/network
* feedback * also raise the error banner). Phase 14's local persistence is
* is status text only ("Conversation saved." / "Nothing to save yet.") * untouched: auto-save is an additional, automatic upsert.
* — the #send-status live region, never stale (PLAN §7.4); failures get
* the error banner. Phase 14's local persistence is untouched: saving is
* an additional, explicit action.
* *
* Share the conversation (phase 51, owner-locked 2026-08-29, TODO.md * Share the conversation (phase 51, owner-locked 2026-08-29, TODO.md
* L6): the "Share" pill (#share-chat-btn — admin-only, SHIPS HIDDEN, * L6 — visible to EVERY visitor since phase 55 task 03: the pill is
* revealed at boot in the SAME admin-reveal block as Save) turns the * static, always-visible markup with no reveal step, and the write
* CURRENT conversation into a public read-only link (/shared/<token>, * surface is public — task 01): the "Share" pill (#share-chat-btn)
* a 128-bit uuid4 on the saved_chats row). The save-then-share * turns the CURRENT conversation into a public read-only link
* contract: the SAME empty-conversation no-op guard as Save (live * (/shared/<token>, a 128-bit uuid4 on the saved_chats row). The
* region, no request); linked (currentChatId set) → POST * save-then-share contract: the same
* empty-conversation no-op guard as the auto-save (live region, no
* request); linked (currentChatId set) → POST
* /api/chats/<id>/share (idempotent — the existing token comes back * /api/chats/<id>/share (idempotent — the existing token comes back
* unchanged); unlinked → POST /api/chats with { messages: conversation, * unchanged); unlinked → POST /api/chats with { messages: conversation,
* share: true } and link currentChatId to the created id — one action * share: true } and link currentChatId to the created id — one action
@@ -173,9 +183,15 @@
* input-like that selects its full URL on focus, .share-link-fallback) * input-like that selects its full URL on focus, .share-link-fallback)
* and the live region reads "Share link ready — copy it from the * and the live region reads "Share link ready — copy it from the
* field." (the owner-locked fallback). Success (clipboard) reads * field." (the owner-locked fallback). Success (clipboard) reads
* "Share link copied." 403/5xx → the actionable error banner (signed- * "Share link copied." Phase 55 task 04 (owner-locked A4): BOTH
* in hint, like Save); a network failure → the "is the app reachable?" * success paths ALSO raise the visual-only share toast (showToast —
* banner. * top-right slide-down, auto-dismiss ~4s, single instance; the node is
* aria-hidden, so the #send-status live region remains the sole a11y
* announcer — no double screen-reader read); a failed share NEVER
* toasts (the error banner is the failure UI). 403/5xx → the
* actionable error banner (neutral "try again" — with a public write
* surface a 403 is no longer a sign-in problem for a guest, phase 55
* task 01); a network failure → the "is the app reachable?" banner.
* *
* Stale saved chats (phase 53, TODO.md L4): every sync that changes the * Stale saved chats (phase 53, TODO.md L4): every sync that changes the
* knowledge base bumps the sources generation; a row saved against an * knowledge base bumps the sources generation; a row saved against an
@@ -192,16 +208,16 @@
* runTurn promise so the handler can await the turn's completion * runTurn promise so the handler can await the turn's completion
* (behavior-neutral for the existing Retry click, which ignores it). * (behavior-neutral for the existing Retry click, which ignores it).
* Only when the turn completes WITHOUT the error banner does the handler * Only when the turn completes WITHOUT the error banner does the handler
* persist the linked row through the SAME upsert as Save — PUT * persist the linked row through the SAME upsert as the auto-save — PUT
* /api/chats/<id> (the server re-stamps sources_version → stale: false); * /api/chats/<id> (the server re-stamps sources_version → stale: false);
* a 404 (row deleted from History meanwhile) unlinks and recreates * a 404 (row deleted from History meanwhile) unlinks and recreates
* (saveCurrentChat's stale-link rule). A regenerate that errors * (persistConversation's stale-link rule). A regenerate that errors
* mid-stream leaves the row untouched (stale stays true); a regenerate * mid-stream leaves the row untouched (stale stays true); a regenerate
* STOPPED mid-stream (phase 48) persists the stopped partial. Success * STOPPED mid-stream (phase 48) persists the stopped partial. Success
* hides the banner and announces in the #send-status live region * hides the banner and announces in the #send-status live region
* (PLAN §7.4 never-stale). The banner also clears on "New chat" and on * (PLAN §7.4 never-stale). The banner also clears on "New chat" and on
* a successful manual re-Save (both make the row/conversation no longer * a successful auto-save re-stamp (both make the row/conversation no
* the one the banner describes). * longer the one the banner describes).
* *
* All DOM ids match frontend/index.html. * All DOM ids match frontend/index.html.
*/ */
@@ -225,8 +241,9 @@ const sendStatus = document.querySelector("#send-status");
const banner = document.querySelector("#kb-banner"); const banner = document.querySelector("#kb-banner");
const bannerText = document.querySelector("#kb-banner-text"); const bannerText = document.querySelector("#kb-banner-text");
const versionEl = document.querySelector("#app-version"); const versionEl = document.querySelector("#app-version");
const saveBtn = document.querySelector("#save-chat-btn"); // phase 50: admin-only Save pill (ships hidden) // Phase 55 (A2): the phase-50 #save-chat-btn query is GONE — there is no
const shareBtn = document.querySelector("#share-chat-btn"); // phase 51: admin-only Share pill (ships hidden) // Save control; persistConversation() auto-saves headless at the save points.
const shareBtn = document.querySelector("#share-chat-btn"); // phase 55 task 03: Share pill — static markup, visible to every visitor
const staleBanner = document.querySelector("#stale-banner"); // phase 53: the stale banner (ships hidden) const staleBanner = document.querySelector("#stale-banner"); // phase 53: the stale banner (ships hidden)
const staleRegenBtn = document.querySelector("#stale-regenerate"); // phase 53: the banner's Regenerate pill const staleRegenBtn = document.querySelector("#stale-regenerate"); // phase 53: the banner's Regenerate pill
@@ -499,15 +516,15 @@ function openTuneForm(wrap, toggleBtn) {
placeholder="e.g. be more concise — or: assume I'm on NixOS"></textarea>`; placeholder="e.g. be more concise — or: assume I'm on NixOS"></textarea>`;
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "tune-form-actions"; actions.className = "tune-form-actions";
const saveBtn = document.createElement("button"); const tuneSaveBtn = document.createElement("button");
saveBtn.type = "submit"; tuneSaveBtn.type = "submit";
saveBtn.className = "tune-save"; tuneSaveBtn.className = "tune-save";
saveBtn.textContent = "Save"; tuneSaveBtn.textContent = "Save";
const cancelBtn = document.createElement("button"); const cancelBtn = document.createElement("button");
cancelBtn.type = "button"; cancelBtn.type = "button";
cancelBtn.className = "tune-cancel"; cancelBtn.className = "tune-cancel";
cancelBtn.textContent = "Cancel"; cancelBtn.textContent = "Cancel";
actions.append(saveBtn, cancelBtn); actions.append(tuneSaveBtn, cancelBtn);
form.appendChild(actions); form.appendChild(actions);
const status = document.createElement("p"); const status = document.createElement("p");
status.className = "tune-error"; status.className = "tune-error";
@@ -517,7 +534,7 @@ function openTuneForm(wrap, toggleBtn) {
form.addEventListener("submit", async (e) => { form.addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
saveBtn.disabled = true; tuneSaveBtn.disabled = true;
status.hidden = true; status.hidden = true;
try { try {
const r = await fetch("/api/steering", { const r = await fetch("/api/steering", {
@@ -537,7 +554,7 @@ function openTuneForm(wrap, toggleBtn) {
} catch { /* non-JSON error body */ } } catch { /* non-JSON error body */ }
status.textContent = detail; status.textContent = detail;
status.hidden = false; status.hidden = false;
saveBtn.disabled = false; tuneSaveBtn.disabled = false;
return; // form kept on failure — the instruction survives return; // form kept on failure — the instruction survives
} }
const saved = document.createElement("p"); const saved = document.createElement("p");
@@ -550,7 +567,7 @@ function openTuneForm(wrap, toggleBtn) {
} catch { } catch {
status.textContent = "Could not save the note — is the app reachable?"; status.textContent = "Could not save the note — is the app reachable?";
status.hidden = false; status.hidden = false;
saveBtn.disabled = false; tuneSaveBtn.disabled = false;
} }
}); });
cancelBtn.addEventListener("click", () => { cancelBtn.addEventListener("click", () => {
@@ -938,11 +955,16 @@ function appendMaybeTry(wrap, suggestions) {
* *
* A durable LOCAL session (A10 unchanged: the API stays stateless — the * A durable LOCAL session (A10 unchanged: the API stays stateless — the
* server stores nothing about the conversation). The whole conversation * server stores nothing about the conversation). The whole conversation
* lives in localStorage under a versioned key; a format bump = clean start: * lives in localStorage under a versioned key; a format bump = clean start.
* Phase 55 (A2): the shape extends IN PLACE with `chatId` — the
* saved_chats row link, so a reload restores the conversation AND its
* link (the same conversation never spawns a second row). A pre-55
* record without the field reads as null (unlinked) — never throws:
* *
* bor.chat.v1 → { v: 1, messages: [{ who: "user"|"brain", text, * bor.chat.v1 → { v: 1, chatId: string | null,
* sources?, deflected?, suggestions?, * messages: [{ who: "user"|"brain", text,
* thinking?, tools?, stopped? }] } * sources?, deflected?, suggestions?,
* thinking?, tools?, stopped? }] }
* *
* Only RAW TEXT is stored — restore re-renders it through the escape-first * Only RAW TEXT is stored — restore re-renders it through the escape-first
* markdown renderer, so no HTML is ever persisted. Save points: the user * markdown renderer, so no HTML is ever persisted. Save points: the user
@@ -952,7 +974,9 @@ function appendMaybeTry(wrap, suggestions) {
* optional `stopped` marker; a pre-token stop persists nothing * optional `stopped` marker; a pre-token stop persists nothing
* brain-side), and the PARTIAL brain message on navigate-away (`pagehide`, * brain-side), and the PARTIAL brain message on navigate-away (`pagehide`,
* phase 20 — an in-flight turn keeps whatever had already streamed; * phase 20 — an in-flight turn keeps whatever had already streamed;
* thinking-only turns persist nothing brain-side). Every localStorage access * thinking-only turns persist nothing brain-side). Phase 55 (A2): every
* save point ALSO auto-saves the row — persistConversation() (headless,
* quiet on failure, silent on success). Every localStorage access
* is try/catch'd — private mode or quota exhaustion degrades silently to * is try/catch'd — private mode or quota exhaustion degrades silently to
* in-memory-only chat. If the serialized state outgrows the budget (~700k * in-memory-only chat. If the serialized state outgrows the budget (~700k
* chars, far under the ~5MB quota) the oldest messages are dropped first. * chars, far under the ~5MB quota) the oldest messages are dropped first.
@@ -963,23 +987,32 @@ export const STORAGE_BUDGET_CHARS = 700_000;
let conversation = []; // in-memory copy of the persisted messages let conversation = []; // in-memory copy of the persisted messages
function loadStoredConversation() { /* The stored record — { chatId, messages } or null. Phase 55 (A2): the
* row link rides the record so a reload restores it. `chatId` is
* OPTIONAL by contract (old-record safety): a pre-55 record without the
* field reads as null (unlinked) — never throws on the missing field. */
function loadStoredRecord() {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return []; if (!raw) return null;
const data = JSON.parse(raw); const data = JSON.parse(raw);
if (!data || data.v !== STORAGE_VERSION || !Array.isArray(data.messages)) return []; if (!data || data.v !== STORAGE_VERSION || !Array.isArray(data.messages)) return null;
const chatId =
typeof data.chatId === "string" && data.chatId.length ? data.chatId : null;
// Legacy/corrupt shape → clean start; keep only well-formed raw-text // Legacy/corrupt shape → clean start; keep only well-formed raw-text
// messages (nothing HTML-shaped can survive this filter). // messages (nothing HTML-shaped can survive this filter).
return data.messages.filter( return {
(m) => chatId,
m && messages: data.messages.filter(
(m.who === "user" || m.who === "brain") && (m) =>
typeof m.text === "string" && m &&
m.text.length > 0 (m.who === "user" || m.who === "brain") &&
); typeof m.text === "string" &&
m.text.length > 0
),
};
} catch { } catch {
return []; // unreadable storage: start clean, never throw return null; // unreadable storage: start clean, never throw
} }
} }
@@ -988,7 +1021,7 @@ function trimToBudget(messages) {
for (;;) { for (;;) {
let size = Infinity; let size = Infinity;
try { try {
size = JSON.stringify({ v: STORAGE_VERSION, messages: out }).length; size = JSON.stringify({ v: STORAGE_VERSION, chatId: null, messages: out }).length;
} catch { } catch {
break; // even one message cannot serialize — keep it in memory only break; // even one message cannot serialize — keep it in memory only
} }
@@ -1002,7 +1035,14 @@ function saveConversation() {
try { try {
localStorage.setItem( localStorage.setItem(
STORAGE_KEY, STORAGE_KEY,
JSON.stringify({ v: STORAGE_VERSION, messages: trimToBudget(conversation) }) JSON.stringify({
v: STORAGE_VERSION,
// Phase 55 (A2): the row link is persisted with the record (null
// = unlinked) — a reload restores it, so the next save point
// updates the SAME row instead of spawning a duplicate.
chatId: currentChatId,
messages: trimToBudget(conversation),
})
); );
} catch { } catch {
/* quota/private mode: chat keeps working with in-memory state only */ /* quota/private mode: chat keeps working with in-memory state only */
@@ -1057,20 +1097,28 @@ function renderStoredMessage(m) {
/* On load: re-render the stored conversation (markdown, source chips, /* On load: re-render the stored conversation (markdown, source chips,
deflected styling, maybe-try chips). addMessage hides the empty state, deflected styling, maybe-try chips). addMessage hides the empty state,
so a restored conversation starts right where it was left. */ so a restored conversation starts right where it was left. Phase 55
(A2): the row link is hydrated from the record here, at boot — before
any save point can run — so the next message updates the SAME row
(a pre-55 record restores unlinked, exactly as phase 14 did). */
function restoreConversation() { function restoreConversation() {
conversation = loadStoredConversation(); const record = loadStoredRecord();
conversation = record ? record.messages : [];
currentChatId = record ? record.chatId : null; // phase 55: the link survives reloads
for (const m of conversation) renderStoredMessage(m); for (const m of conversation) renderStoredMessage(m);
markLastRetryable(); // phase 49: the restored last brain bubble is retryable markLastRetryable(); // phase 49: the restored last brain bubble is retryable
} }
/* ---------- save & load saved chats (phase 50, owner-locked 2026-08-29) ---------- /* ---------- saved-chats row link (phase 50; auto-saved since phase 55) ----------
* *
* `currentChatId` links the local conversation to a saved_chats row: * `currentChatId` links the local conversation to a saved_chats row:
* set to the created row's id on a fresh Save, set to the opened id on a * set to the created row's id on a fresh auto-save (persistConversation
* successful /?chat=<id> boot load, cleared by "New chat" and by the * — the first save point after "New chat"), set to the opened id on a
* 404-PUT fallback (the row vanished — recreate, never lose the save). * successful /?chat=<id> boot load, hydrated from the bor.chat.v1
* null = unlinked (a plain local session, phase 14). * record on the local restore (phase 55 — the link survives reloads),
* and cleared by "New chat" and by the 404-PUT fallback (the row
* vanished — recreate, never lose the save). null = unlinked (a plain
* local session, phase 14).
*/ */
let currentChatId = null; // string | null — the linked saved_chats row id let currentChatId = null; // string | null — the linked saved_chats row id
@@ -1113,7 +1161,7 @@ async function restoreSavedChatFromUrl() {
return unavailable(); // malformed body — treat as unavailable return unavailable(); // malformed body — treat as unavailable
} }
// The API schema guarantees the record shape; the same defensive filter // The API schema guarantees the record shape; the same defensive filter
// as loadStoredConversation keeps a corrupted stored row from poisoning // as loadStoredRecord keeps a corrupted stored row from poisoning
// the restore (nothing HTML-shaped, ever). // the restore (nothing HTML-shaped, ever).
const messages = (Array.isArray(data?.messages) ? data.messages : []).filter( const messages = (Array.isArray(data?.messages) ? data.messages : []).filter(
(m) => (m) =>
@@ -1147,24 +1195,38 @@ async function restoreSavedChatFromUrl() {
return true; return true;
} }
/* Save the current conversation — the #save-chat-btn handler (phase 50). /* Auto-save the current conversation (phase 55, owner-locked A2) — the
* No-op with a live-region line when there is nothing to save. Upsert: * headless replacement of the phase-50 #save-chat-btn handler (the Save
* linked → PUT /api/chats/<id> (re-Save updates the same row; no title in * pill is gone: no button to press, no UI to update). Called fire-and-
* the body, so the row keeps its current one); unlinked → POST /api/chats * forget from the persistence save points (the user message on send,
* (the server auto-titles) and link to the created id. A 404 from the PUT * every brain-done through rememberBrainTurn, the pagehide partial —
* — the row was deleted on the History page — unlinks and retries as a * which rides rememberBrainTurn, so no extra wiring). The phase-50
* create, then announces the outcome: the owner is never left with an * upsert semantics, unchanged:
* unsaved conversation because of a stale link. 403/5xx/network → the *
* error banner with an actionable line (the conversation is intact * • empty conversation → no-op (nothing to save, nothing to say);
* locally either way). Success is status text only — the #send-status * • linked (currentChatId set) → PUT /api/chats/<id> — the SAME row
* live region, never stale (PLAN §7.4); no banner. */ * updates (no title in the body, so the row keeps its current one);
async function saveCurrentChat() { * a 404 from the PUT — the row was deleted on the History page —
if (!conversation.length) { * unlinks and retries as a create, so a stale link can never wedge
sendStatus.textContent = "Nothing to save yet."; * the conversation;
return; * • unlinked → POST /api/chats (the server auto-titles) and link to
} * the created id (201) — the first save point creates the row.
if (saveBtn.disabled) return; // one save at a time (double-click guard) *
saveBtn.disabled = true; * The `persisting` flag is the double-fire guard: the save points can
* overlap (pagehide during a stream), so a call while an upsert is in
* flight is a no-op — the next save point retries. The A2 quiet contract
* on failure (non-ok HTTP or network): a one-line #send-status note,
* NO error banner, the turn never blocks. Success is SILENT (the History
* page is the visible proof — the toast is reserved for share), apart
* from clearing the phase-53 stale banner (a successful re-save
* re-stamps the row to the current generation — the row is no longer
* stale). */
let persisting = false; // phase 55: one upsert at a time (double-fire guard)
async function persistConversation() {
if (!conversation.length) return; // nothing to save
if (persisting) return; // an upsert is already in flight (double-fire guard)
persisting = true;
const body = JSON.stringify({ messages: conversation }); const body = JSON.stringify({ messages: conversation });
const headers = { "Content-Type": "application/json" }; const headers = { "Content-Type": "application/json" };
try { try {
@@ -1181,23 +1243,27 @@ async function saveCurrentChat() {
res = await fetch("/api/chats", { method: "POST", headers, body }); res = await fetch("/api/chats", { method: "POST", headers, body });
} }
if (!res.ok) { if (!res.ok) {
showErrorBanner( // A2 quiet contract: a failed auto-save never blocks the
"Couldn't save the conversation — check you're still signed in and try again." // conversation — one status-line note, no error banner, and the
); // next save point retries.
sendStatus.textContent =
"Couldn't save automatically — will try on the next message.";
return; return;
} }
if (res.status === 201) { if (res.status === 201) {
const created = await res.json(); const created = await res.json();
currentChatId = String(created.id); // fresh Save: link to the new row currentChatId = String(created.id); // first save: link to the new row
} }
sendStatus.textContent = "Conversation saved."; // Silent on success (A2) — but a re-save re-stamps the row to the
// Phase 53: a re-Save re-stamps the row to the current generation // current generation (phase 53, task 03): the row is no longer
// (task 03) — the row is no longer stale, so the banner is done. // stale, so the banner is done.
if (staleBanner) staleBanner.hidden = true; if (staleBanner) staleBanner.hidden = true;
} catch { } catch {
showErrorBanner("Couldn't save the conversation — is the app reachable?"); // A2 quiet contract: a network failure is the same one-line note.
sendStatus.textContent =
"Couldn't save automatically — will try on the next message.";
} finally { } finally {
saveBtn.disabled = false; // released on EVERY outcome — never stale persisting = false; // released on EVERY outcome
} }
} }
@@ -1250,6 +1316,41 @@ async function copyShareLinkWithFallback(absoluteUrl) {
} }
} }
/* Share-success toast (phase 55, task 04 — TODO.md L5): the VISIBLE
* confirmation that a share worked. A4 owner-locked: the node is
* aria-hidden (visual only) — the #send-status live region remains the
* a11y announcer, so there is no double screen-reader read. Top-right,
* slides down, auto-dismisses in ~4s. A SINGLE instance: the node is
* lazy-created ONCE and reused — a new toast replaces a pending one
* (clear the prior dismiss timer, re-run the entry) and toasts never
* stack. The text lands via textContent only (XSS-safe). Shown on
* BOTH share-success paths; NEVER on a failure (the error
* banner is the failure UI). Page-script-local by design — the toast
* is chat-page only for this phase (no cross-page module). */
let toastEl = null; // the single toast node — lazy-created, reused
let toastTimer = 0; // the pending auto-dismiss (replaced by a new toast)
function showToast(message) {
if (!toastEl) {
toastEl = document.createElement("div");
toastEl.className = "toast";
toastEl.setAttribute("aria-hidden", "true"); // A4: visual only — #send-status is the announcer
document.body.appendChild(toastEl);
}
toastEl.textContent = message; // XSS-safe text assignment
// Re-trigger the entry even when a toast is already up (a second
// share while the first is showing): clear the pending dismiss,
// drop the visible state, force a reflow (restarts the CSS
// transition), then show again.
clearTimeout(toastTimer);
toastEl.classList.remove("is-visible");
void toastEl.offsetWidth; // force reflow — the entry transition restarts
toastEl.classList.add("is-visible");
toastTimer = setTimeout(() => {
toastEl.classList.remove("is-visible"); // auto-dismiss ~4s
}, 4000);
}
/* Share the current conversation — the #share-chat-btn handler /* Share the current conversation — the #share-chat-btn handler
* (phase 51, owner-locked 2026-08-29, TODO.md L6). No-op with a live- * (phase 51, owner-locked 2026-08-29, TODO.md L6). No-op with a live-
* region line when there is nothing to share (the same guard as * region line when there is nothing to share (the same guard as
@@ -1259,7 +1360,11 @@ async function copyShareLinkWithFallback(absoluteUrl) {
* action saves AND shares (owner-locked). Success copies the absolute * action saves AND shares (owner-locked). Success copies the absolute
* URL (clipboard → inline-field fallback); the live region reads * URL (clipboard → inline-field fallback); the live region reads
* "Share link copied." or "Share link ready — copy it from the * "Share link copied." or "Share link ready — copy it from the
* field." 403/5xx → the actionable banner (signed-out hint); a network * field." Phase 55 task 04: BOTH success paths additionally raise the
* visual-only toast (showToast — aria-hidden; the #send-status line
* stays the a11y announcer; a failed share NEVER toasts — the error
* banner is the failure UI). 403/5xx → the actionable banner
* (neutral — the write surface is public, phase 55 task 01); a network
* failure → the reachable? banner. The double-click guard releases in * failure → the reachable? banner. The double-click guard releases in
* the finally — never stale (PLAN §7.4). */ * the finally — never stale (PLAN §7.4). */
async function shareCurrentChat() { async function shareCurrentChat() {
@@ -1276,9 +1381,7 @@ async function shareCurrentChat() {
// token comes back unchanged, a new one is minted. // token comes back unchanged, a new one is minted.
const res = await fetch(`/api/chats/${currentChatId}/share`, { method: "POST" }); const res = await fetch(`/api/chats/${currentChatId}/share`, { method: "POST" });
if (!res.ok) { if (!res.ok) {
showErrorBanner( showErrorBanner("Couldn't share the conversation — try again.");
"Couldn't share the conversation — check you're still signed in and try again."
);
return; return;
} }
shareUrl = (await res.json()).share_url; shareUrl = (await res.json()).share_url;
@@ -1291,9 +1394,7 @@ async function shareCurrentChat() {
body: JSON.stringify({ messages: conversation, share: true }), body: JSON.stringify({ messages: conversation, share: true }),
}); });
if (!res.ok) { if (!res.ok) {
showErrorBanner( showErrorBanner("Couldn't share the conversation — try again.");
"Couldn't share the conversation — check you're still signed in and try again."
);
return; return;
} }
const created = await res.json(); const created = await res.json();
@@ -1301,9 +1402,19 @@ async function shareCurrentChat() {
shareUrl = created.share_url; shareUrl = created.share_url;
} }
const copied = await copyShareLinkWithFallback(absoluteShareUrl(shareUrl)); const copied = await copyShareLinkWithFallback(absoluteShareUrl(shareUrl));
// The #send-status lines stay the a11y announcer (PLAN §7.4 never-
// stale) — the toast below is visual only (aria-hidden, task 04).
sendStatus.textContent = copied sendStatus.textContent = copied
? "Share link copied." ? "Share link copied."
: "Share link ready — copy it from the field."; : "Share link ready — copy it from the field.";
// Task 04 (A4): the VISIBLE confirmation rides the same two
// success paths, each with its own text. A failed share never
// toasts — the error banner is the failure UI.
if (copied) {
showToast("Share link copied.");
} else {
showToast("Share link ready — copy it from the field.");
}
} catch { } catch {
showErrorBanner("Couldn't share the conversation — is the app reachable?"); showErrorBanner("Couldn't share the conversation — is the app reachable?");
} finally { } finally {
@@ -1319,11 +1430,12 @@ async function shareCurrentChat() {
* lastBrainWrap, no preceding user record — make a stale or superseded * lastBrainWrap, no preceding user record — make a stale or superseded
* click a no-op that resolves nothing). The handler AWAITs the returned * click a no-op that resolves nothing). The handler AWAITs the returned
* turn promise, and only when the turn completed WITHOUT the error * turn promise, and only when the turn completed WITHOUT the error
* banner persists the linked row through the SAME upsert as Save: * banner persists the linked row through the SAME upsert as the
* auto-save:
* PUT /api/chats/<id> (the server re-stamps sources_version → the row * PUT /api/chats/<id> (the server re-stamps sources_version → the row
* is fresh again); a 404 (the row was deleted from History meanwhile) * is fresh again); a 404 (the row was deleted from History meanwhile)
* follows saveCurrentChat's stale-link rule — unlink + recreate, so the * follows persistConversation's stale-link rule — unlink + recreate,
* owner is never left with an unsaved conversation. A regenerate that * so the owner is never left with an unsaved conversation. A regenerate that
* errors mid-stream leaves the row untouched (stale stays true — * errors mid-stream leaves the row untouched (stale stays true —
* Regenerate stays available); a regenerate STOPPED mid-stream (phase * Regenerate stays available); a regenerate STOPPED mid-stream (phase
* 48) persists the stopped partial (the owner engaged with the new * 48) persists the stopped partial (the owner engaged with the new
@@ -1388,6 +1500,10 @@ async function regenerateStaleChat() {
function rememberBrainTurn(rawText, meta) { function rememberBrainTurn(rawText, meta) {
conversation.push({ who: "brain", text: rawText || "…", ...meta }); conversation.push({ who: "brain", text: rawText || "…", ...meta });
saveConversation(); saveConversation();
// Phase 55 (A2): the auto-save rides the brain save point — the row
// updates with the new brain turn + metadata. The pagehide partial
// reuses this helper, so it rides the same path (no extra wiring).
persistConversation();
} }
/* ---------- new chat (phase 14) ---------- /* ---------- new chat (phase 14) ----------
@@ -1430,7 +1546,7 @@ function applyAuthState() {
function startNewChat() { function startNewChat() {
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return; if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
conversation = []; conversation = [];
currentChatId = null; // phase 50: a new conversation is unlinked until saved currentChatId = null; // phase 55: unlinked — a fresh row on its first message
if (staleBanner) staleBanner.hidden = true; // phase 53: the banner described the cleared conversation if (staleBanner) staleBanner.hidden = true; // phase 53: the banner described the cleared conversation
clearStoredConversation(); clearStoredConversation();
removeTyping(); removeTyping();
@@ -1564,6 +1680,10 @@ async function runTurn(text, { reask = false } = {}) {
// sent, so a failed/interrupted turn never loses it. // sent, so a failed/interrupted turn never loses it.
conversation.push({ who: "user", text }); conversation.push({ who: "user", text });
saveConversation(); saveConversation();
// Phase 55 (A2): the auto-save rides the save point — an unlinked
// conversation creates its row here (auto-title, server-side), a
// linked one refreshes. Fire-and-forget: it never blocks the turn.
persistConversation();
} }
let wrap = null; let wrap = null;
@@ -1797,14 +1917,14 @@ input.addEventListener("keydown", (e) => {
}); });
composer.addEventListener("submit", handleSend); composer.addEventListener("submit", handleSend);
/* Phase 50 (owner-locked 2026-08-29, TODO.md L5): the Save pill stores /* Phase 55 (owner-locked A2, 2026-08-31): the phase-50 Save binding is
* the current conversation in Postgres (the upsert semantics live in * GONE with the pill — there is no Save control; persistConversation()
* saveCurrentChat). The button ships hidden in index.html; the boot * auto-saves headless at the save points (fire-and-forget, quiet on
* IIFE below reveals it for admin (absent-not-hidden for anonymous, * failure, silent on success).
* phase 16). Status-only feedback — the live region, never stale. */ * Phase 51 (owner-locked 2026-08-29, TODO.md L6; visible to every
saveBtn?.addEventListener("click", saveCurrentChat); * visitor since phase 55 task 03): the Share pill is static,
/* Phase 51 (owner-locked 2026-08-29, TODO.md L6): the Share pill — * always-visible markup (no reveal step) — only the click binding
* same ship-hidden/reveal contract as Save (the boot IIFE below). */ * lives here. */
shareBtn?.addEventListener("click", shareCurrentChat); shareBtn?.addEventListener("click", shareCurrentChat);
/* Phase 53 (task 05): the stale banner's Regenerate pill. The binding /* Phase 53 (task 05): the stale banner's Regenerate pill. The binding
* is inert unless the banner is revealed — which only happens on the * is inert unless the banner is revealed — which only happens on the
@@ -1839,15 +1959,18 @@ window.addEventListener("pagehide", () => {
nav link; applyAuthState() then applies the chat-page-only gating. nav link; applyAuthState() then applies the chat-page-only gating.
Phase 34: the steering panel's admin boot refresh (count badge) and Phase 34: the steering panel's admin boot refresh (count badge) and
the anonymous removal of the tuning surface both happen inside the anonymous removal of the tuning surface both happen inside
initSharedHeader() now. */ initSharedHeader() now. Phase 55 (A2): the local restore hydrates
currentChatId from the record (restoreConversation), so the row link
survives a plain reload — no Save pill to reveal anymore. */
(async () => { (async () => {
await initSharedHeader(); // header.js: whoami + Sign in/out + steering gate await initSharedHeader(); // header.js: whoami + Sign in/out + steering gate
isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami
applyAuthState(); // chat page: the auth pair (idempotent with header.js) applyAuthState(); // chat page: the auth pair (idempotent with header.js)
if (saveBtn) saveBtn.hidden = !isAdmin; // phase 50: absent-not-hidden (phase 16) // Phase 55 (task 03): no Share-reveal step — the pill is static,
if (shareBtn) shareBtn.hidden = !isAdmin; // phase 51: the Share pill joins the same block // always-visible markup (visible to every visitor, phase 51 contract).
// Phase 50: /?chat=<id> (valid uuid + admin) boots into the saved // Phase 50: /?chat=<id> (valid uuid + admin) boots into the saved
// conversation; every other outcome falls through to the local restore. // conversation; every other outcome falls through to the local restore
// (which hydrates the row link from the record — phase 55).
const openedSaved = await restoreSavedChatFromUrl(); const openedSaved = await restoreSavedChatFromUrl();
if (!openedSaved) restoreConversation(); if (!openedSaved) restoreConversation();
loadSuggestions(); loadSuggestions();
+77 -43
View File
@@ -308,44 +308,18 @@ html::after {
whole control below 640px. */ whole control below 640px. */
.new-chat-btn svg { width: 16px; height: 16px; display: none; } .new-chat-btn svg { width: 16px; height: 16px; display: none; }
/* Phase 50 (owner-locked 2026-08-29, TODO.md L5): the "Save" pill — the /* Phase 55 (owner-locked A2, 2026-08-31): the phase-50 Save-pill rules
EXACT visual family of .new-chat-btn (same declarations, so the two are GONE with the pill — every conversation auto-saves (app.js);
chat-shell actions always read as a pair): solid brand pill, --bg text the chat-shell actions are New chat + Share only.
on --brand (5.2:1, WCAG AA), borderless, ≥44px target, hover lightens
the brand fill, focus-visible via the global 3px rule. Shipped hidden
in index.html — app.js reveals it for admin only (phase 16
absent-not-hidden); ≤640px overrides below mirror the New chat ones
(label stays visible in .chat-shell, icon hidden). */
.save-chat-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 44px;
padding: 0.5rem 0.9rem;
border-radius: 999px;
border: 0;
background: var(--brand);
color: var(--bg);
font: inherit;
font-weight: 700;
font-size: 0.95rem;
white-space: nowrap;
cursor: pointer;
}
.save-chat-btn:hover { background: #f55a72; color: var(--bg); }
/* The save mark is hidden on desktop (the label carries the pill); it is
the whole control below 640px (mirrored in the ≤640 block below). */
.save-chat-btn svg { width: 16px; height: 16px; display: none; }
/* Phase 51 (owner-locked 2026-08-29, TODO.md L6): the "Share" pill — Phase 51 (owner-locked 2026-08-29, TODO.md L6): the "Share" pill —
the EXACT visual family of .save-chat-btn (same declarations, so the the EXACT visual family of .new-chat-btn (same declarations, so the
chat-shell actions always read as a pair): solid brand pill, --bg chat-shell actions always read as a pair): solid brand pill, --bg
text on --brand (5.2:1, WCAG AA), borderless, ≥44px target, hover text on --brand (5.2:1, WCAG AA), borderless, ≥44px target, hover
lightens the brand fill, focus-visible via the global 3px rule. lightens the brand fill, focus-visible via the global 3px rule.
Shipped hidden in index.html — app.js reveals it for admin only Shipped hidden in index.html — app.js reveals it for admin only
(phase 16 absent-not-hidden); ≤640px overrides below mirror the (phase 16 absent-not-hidden); ≤640px overrides below mirror the New
Save ones (label stays visible in .chat-shell, icon hidden there). */ chat ones (label stays visible in .chat-shell, icon hidden there). */
.share-chat-btn { .share-chat-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -430,6 +404,24 @@ html::after {
flex: 1; flex: 1;
} }
/* Phase 55 (task 05, 2026-08-31, TODO.md L6, owner-locked A5): the
chat-actions row — New chat + Share share ONE .chat-actions element
(a normal .chat-shell column child). As a flex ITEM of the column it
spans the column width, but as a flex ROW with align-items: center
(not the column default stretch) each pill keeps its INTRINSIC
content width — two pills side by side, left-aligned in the column,
on desktop. The ≤640px block flips this to a vertical stack
(flex-direction: column + align-items: stretch — full-width pills,
New chat above Share; the existing ≤640px pill rules apply to the
stacked pills unchanged). The 46rem column contract is untouched
(PLAN §7). */
.chat-actions {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.6rem;
}
/* Phase 52 (owner revision 2026-08-30): `flex-grow` is the other half of /* Phase 52 (owner revision 2026-08-30): `flex-grow` is the other half of
the pin. `position: sticky` only ever pulls a box UP toward the the pin. `position: sticky` only ever pulls a box UP toward the
viewport bottom — it can never push a box DOWN to meet it — so on an viewport bottom — it can never push a box DOWN to meet it — so on an
@@ -1304,6 +1296,49 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.stale-regenerate:disabled { opacity: 0.6; cursor: wait; } .stale-regenerate:disabled { opacity: 0.6; cursor: wait; }
.stale-regenerate svg { width: 16px; height: 16px; display: block; } .stale-regenerate svg { width: 16px; height: 16px; display: block; }
/* Phase 55 (task 04): the share-success toast (TODO.md L5). Top-right,
just under the sticky header (--header-h + a small offset — the
variable already steps 64px → 58px at ≤640px), z-index 1000 (the
modal overlay contract — above the header's 20 and the skip-link's
100). VISUAL ONLY (A4 owner-locked): the node ships aria-hidden
from app.js — #send-status stays the a11y announcer (no double
screen-reader read). Solid brand fill: --bg text on --brand = 5.2:1
(AA — the .new-chat-btn family); rounded, shadowed, one line of
text that wraps under the small max-width. Hidden by default
(opacity 0 + pointer-events: none — it never intercepts clicks when
idle); .toast.is-visible (toggled by showToast) runs the entry:
slide-down + fade (translateY(-8px) → 0, 200ms). */
.toast {
position: fixed;
top: calc(var(--header-h) + 0.75rem);
right: 1rem;
z-index: 1000;
max-width: min(22rem, calc(100vw - 2rem));
padding: 0.6rem 1rem;
border-radius: var(--radius-sm);
background: var(--brand);
color: var(--bg);
font-size: 0.92rem;
font-weight: 600;
box-shadow: var(--shadow);
opacity: 0;
pointer-events: none;
transform: translateY(-8px);
transition: opacity 200ms ease, transform 200ms ease;
}
.toast.is-visible {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
/* No slide: BOTH states rest at transform: none (the .is-visible
rule would otherwise out-specify the bare .toast); the opacity
fade remains (120ms). */
.toast,
.toast.is-visible { transform: none; }
.toast { transition: opacity 120ms ease; }
}
/* ---------- Login page (phase 16) ---------- */ /* ---------- Login page (phase 16) ---------- */
/* Centered card in the standard frame: one admin, one password. */ /* Centered card in the standard frame: one admin, one password. */
.login-shell { .login-shell {
@@ -2586,7 +2621,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
designated clip target, pills squeeze next). */ designated clip target, pills squeeze next). */
.nav-link { padding: 0.4rem 0.5rem; font-size: 0.85rem; } .nav-link { padding: 0.4rem 0.5rem; font-size: 0.85rem; }
.app-nav { gap: 0.15rem; } .app-nav { gap: 0.15rem; }
.new-chat-btn, .save-chat-btn, .auth-link { padding: 0.45rem 0.5rem; } .new-chat-btn, .auth-link { padding: 0.45rem 0.5rem; }
} }
/* ---------- Responsive (mobile-first adjustments) ---------- */ /* ---------- Responsive (mobile-first adjustments) ---------- */
@@ -2689,13 +2724,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.new-chat-btn { padding: 0.4rem 0.3rem; } .new-chat-btn { padding: 0.4rem 0.3rem; }
.new-chat-label { display: none; } .new-chat-label { display: none; }
.new-chat-btn svg { display: block; } .new-chat-btn svg { display: block; }
/* Phase 50: the Save pill squeezes with New chat (same family, same /* Phase 51: the Share pill squeezes with New chat (same family, same
rules — the chat-shell overrides below keep both labels visible). */ rules — the chat-shell override below keeps the label visible). */
.save-chat-btn { padding: 0.4rem 0.3rem; }
.save-chat-label { display: none; }
.save-chat-btn svg { display: block; }
/* Phase 51: the Share pill squeezes with Save (same family, same
rules — the chat-shell overrides below keep both labels visible). */
.share-chat-btn { padding: 0.4rem 0.3rem; } .share-chat-btn { padding: 0.4rem 0.3rem; }
.share-chat-label { display: none; } .share-chat-label { display: none; }
.share-chat-btn svg { display: block; } .share-chat-btn svg { display: block; }
@@ -2703,10 +2733,14 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
hide the icon (the button lives inside .chat-shell, not the navbar). */ hide the icon (the button lives inside .chat-shell, not the navbar). */
.chat-shell .new-chat-label { display: inline; } .chat-shell .new-chat-label { display: inline; }
.chat-shell .new-chat-btn svg { display: none; } .chat-shell .new-chat-btn svg { display: none; }
.chat-shell .save-chat-label { display: inline; }
.chat-shell .save-chat-btn svg { display: none; }
.chat-shell .share-chat-label { display: inline; } .chat-shell .share-chat-label { display: inline; }
.chat-shell .share-chat-btn svg { display: none; } .chat-shell .share-chat-btn svg { display: none; }
/* Phase 55 (task 05, TODO.md L6, A5): the action row flips to a
vertical stack at the phone breakpoint — full-width pills, New
chat above Share. The pill rules above (padding, the
icon/label handling, the .chat-shell label overrides) apply to
the stacked pills unchanged. */
.chat-actions { flex-direction: column; align-items: stretch; gap: 0.5rem; }
/* Phase 53: the stale banner's row wraps at phone width (message /* Phase 53: the stale banner's row wraps at phone width (message
above, action below) — the pill takes a full-width comfortable above, action below) — the pill takes a full-width comfortable
row instead of squeezing into the text. */ row instead of squeezing into the text. */
+52 -47
View File
@@ -128,57 +128,62 @@
</section> </section>
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p> <p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
<!-- Phase 14 (module-owned since phase 34 task 02): "New chat" resets <!-- Phase 55 (task 05, 2026-08-31, TODO.md L6, owner-locked A5):
the local (localStorage) conversation and clears the rendered list. the chat-actions row — New chat + Share share ONE
The binding lives in header.js — dispatches "bor:new-chat" which .chat-actions wrapper (a normal .chat-shell column child,
app.js acts on (it owns the in-flight-turn guard + list reset). --> replacing the two pills as its direct children): a
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat"> HORIZONTAL row on desktop (side by side, left-aligned, each
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg> pill at its intrinsic width — the row's align-items: center
<span class="new-chat-label">New chat</span> beats the column's stretch) and a VERTICAL stack at the
</button> existing ≤640px breakpoint (New chat above Share,
full-width). DOM order New chat → Share in both
orientations; the 46rem column contract is untouched
(PLAN §7). The kb-banner / stale-banner / steering /
announcer structure around the row is unchanged.
<!-- Phase 50 (owner-locked 2026-08-29, `TODO.md` L5): "Save" stores Phase 14 (module-owned since phase 34 task 02): "New chat"
the current conversation in Postgres (saved_chats) — admin-only. resets the local (localStorage) conversation and clears the
SHIPS HIDDEN: app.js reveals it only when whoami says admin rendered list. The binding lives in header.js — dispatches
(phase 16 absent-not-hidden — `hidden` is display:none, so "bor:new-chat" which app.js acts on (it owns the
anonymous visitors see no trace). Handler in app.js, upsert + in-flight-turn guard + list reset). -->
?chat=<id> contract: an unlinked Save POSTs /api/chats (the <div class="chat-actions">
server auto-titles from the first question, 120-char cap) and <button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
links the conversation to the created row's id; a re-Save PUTs <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
the SAME row (the same conversation never spawns a second row); <span class="new-chat-label">New chat</span>
a 404 PUT unlinks and recreates; "New chat" unlinks. Booting at </button>
/?chat=<id> (valid uuid, admin) GETs the row and renders its
messages through the SAME restore path as the phase-14 local
session (pixel-identical), links the conversation, and mirrors
it to localStorage — a later Save updates that row. A deleted /
unknown id degrades to the normal local restore with a banner. -->
<button type="button" class="save-chat-btn" id="save-chat-btn" aria-label="Save chat" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>
<span class="save-chat-label">Save</span>
</button>
<!-- Phase 51 (owner-locked 2026-08-29, `TODO.md` L6): "Share" <!-- Phase 55 (owner-locked A2, 2026-08-31): there is NO Save
control — every conversation auto-saves itself (app.js
persistConversation at the phase-14/20 save points). The
History page is the visible proof; a failed auto-save leaves
only a one-line status note (never a button, never a banner).
Phase 55 (owner-locked A1, 2026-08-31): the "Share" pill is
STATIC, ALWAYS-VISIBLE markup — there is no reveal step
(phase 51's admin-only ship-hidden gate is gone with it; task
01 opened the save/share write surface to every visitor).
Phase 51 (owner-locked 2026-08-29, `TODO.md` L6): "Share"
turns the current conversation into a PUBLIC read-only link — turns the current conversation into a PUBLIC read-only link —
/shared/<token> (a 128-bit uuid4 on the saved_chats row, /shared/<token> (a 128-bit uuid4 on the saved_chats row,
migration 0009; the anonymous page is task 03). The migration 0009). The save-then-share contract: an UNSAVED
save-then-share contract: an UNSAVED (unlinked) conversation (unlinked) conversation is saved AND shared in ONE action —
is saved AND shared in ONE action — app.js POSTs /api/chats app.js POSTs /api/chats with { messages, share: true } (the
with { messages, share: true } (the server sets the token in server sets the token in the same commit) and links the
the same commit) and links the conversation to the created conversation to the created row; a saved (linked) one just
row; a saved (linked) one just POSTs /api/chats/<id>/share POSTs /api/chats/<id>/share (idempotent — the existing token
(idempotent — the existing token comes back unchanged). On comes back unchanged). On success the ABSOLUTE link is
success the ABSOLUTE link is copied to the clipboard; a copied to the clipboard; a non-secure (http) homelab origin
non-secure (http) homelab origin that rejects the clipboard that rejects the clipboard gets the inline link-field
gets the inline link-field fallback instead (owner-locked — fallback instead (owner-locked — app.js renders
app.js renders .share-link-fallback near the status line). .share-link-fallback near the status line). app.js binds the
Admin-only — ships HIDDEN exactly like Save (absent-not- click to shareCurrentChat. Unsharing lives on the History
hidden, phase 16); app.js reveals it at boot (the same page's Share column (admin-only). -->
admin-reveal block) and binds the click to shareCurrentChat. <button type="button" class="share-chat-btn" id="share-chat-btn" aria-label="Share chat">
Unsharing lives on the History page's Share column (task 04). --> <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
<button type="button" class="share-chat-btn" id="share-chat-btn" aria-label="Share chat" hidden> <span class="share-chat-label">Share</span>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg> </button>
<span class="share-chat-label">Share</span> </div>
</button>
<!-- Phase 49 (2026-08-29, TODO.md L4): the meta row under a brain <!-- Phase 49 (2026-08-29, TODO.md L4): the meta row under a brain
bubble can carry JS-injected actions (app.js) — Tune (admin bubble can carry JS-injected actions (app.js) — Tune (admin
+69 -45
View File
@@ -6,13 +6,17 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_chat_history.py -v --no-cov uv run pytest tests/e2e/test_chat_history.py -v --no-cov
The owner-locked loop under test (A10 extension, 2026-08-29): The owner-locked loop under test (A10 extension, 2026-08-29; phase 55
replaced the Save pill with auto-save — the tests below wait for the
auto-saved row via the admin list, since auto-saves are SILENT, A2):
* **Save** — on the chat page, admin-only (the pill ships hidden and * **Auto-save** — on the chat page, no control (the pill is GONE,
whoami reveals it): the current conversation POSTs to ``/api/chats`` phase 55): the current conversation upserts itself at the save
(auto-title = the first question, whitespace-collapsed, 120-char cap) points — create on the first user message (auto-title = the first
and links to the created row; a re-Save PUTs the SAME row (upsert); question, whitespace-collapsed, 120-char cap) and update on each
"New chat" unlinks, so the next Save creates again; brain-done; the SAME row updates (upsert — the conversation never
spawns a second row, the link survives reloads); "New chat" unlinks,
so the next conversation creates a fresh row;
* **History** — ``/history.html`` lists the saved chats in a full-width * **History** — ``/history.html`` lists the saved chats in a full-width
table (Title | Messages | Updated | Actions); the Title cell IS the table (Title | Messages | Updated | Actions); the Title cell IS the
Open link (``/?chat=<id>`` — "return to that history with a click"), Open link (``/?chat=<id>`` — "return to that history with a click"),
@@ -24,9 +28,12 @@ The owner-locked loop under test (A10 extension, 2026-08-29):
session (pixel-identical), links it, and a subsequent Save updates session (pixel-identical), links it, and a subsequent Save updates
that row; a deleted/unknown id degrades to the local restore with the that row; a deleted/unknown id degrades to the local restore with the
error banner; error banner;
* **Anonymous** — no Save button, no History nav link, the History page * **Anonymous** — no Save control (the element is absent from the DOM
shows the gated state WITHOUT ever fetching ``/api/chats`` (the router at every width — phase 55), but the Share pill IS visible (phase 55
403s them — pinned via the request log), and the API 403s. task 03 — the write surface is public, the pill is static markup),
no History nav link, the History page shows the gated state WITHOUT
ever fetching ``/api/chats`` (the router 403s them — pinned via the
request log), and the API 403s.
DB isolation: the shared e2e Postgres keeps ``saved_chats`` rows across DB isolation: the shared e2e Postgres keeps ``saved_chats`` rows across
suites, so every test here uses a DISTINCTIVE question text (its suites, so every test here uses a DISTINCTIVE question text (its
@@ -38,6 +45,7 @@ embeddings); ``saved_chats`` is never touched by the reset.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import time
from pathlib import Path from pathlib import Path
from threading import Thread from threading import Thread
from typing import Any from typing import Any
@@ -144,15 +152,30 @@ def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies) httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
def _save(page: Page) -> None: def _wait_saved_row(
"""Press Save and wait for the live-region confirmation (the app_url: str,
never-stale contract: the status line is the success feedback).""" cookies: dict[str, str],
page.locator("#save-chat-btn").click() title: str,
expect(page.locator("#send-status")).to_have_text("Conversation saved.") messages: int = 2,
) -> dict[str, Any]:
"""Wait for the auto-saved row (phase 55: auto-saves are SILENT —
A2 — so there is no status line to wait on). The upsert is
fire-and-forget from the UI's point of view, so poll the admin
list until the row with the conversation's auto-title appears with
the expected message count."""
deadline = time.monotonic() + 15
last: dict[str, Any] | None = None
while time.monotonic() < deadline:
last = _find_row(_chats(app_url, cookies), title)
if last is not None and last["message_count"] >= messages:
return last
time.sleep(0.2)
raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 1. Save on the chat page → the row exists (UI + API agree) # 1. Auto-save on the chat page (no Save control) → the row exists
# (API is the proof — auto-saves are silent, A2)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -168,21 +191,19 @@ def test_save_and_see_history(
q = "How is my Kubernetes cluster set up? (hist-save)" q = "How is my Kubernetes cluster set up? (hist-save)"
_ask(page, q) _ask(page, q)
# Admin: the Save pill is revealed (ship-hidden, whoami reveals it). # Phase 55: there is NO Save control — the conversation auto-saved
save = page.locator("#save-chat-btn") # at the save points (create on the first question, update on the
expect(save).to_be_visible() # brain-done). There is nothing to click and no status line to wait
expect(save).to_have_attribute("aria-label", "Save chat") # on (A2 silent): the API is the proof.
# Also: the pill is gone from the DOM at every width.
save.click() expect(page.locator("#save-chat-btn")).to_have_count(0)
expect(page.locator("#send-status")).to_have_text("Conversation saved.")
cookies = _admin_cookies(page) cookies = _admin_cookies(page)
created: str | None = None created: str | None = None
try: try:
# The API agrees: the row exists, auto-titled from the first # The API agrees: the row exists, auto-titled from the first
# question (whitespace-collapsed, <=120 chars), two messages. # question (whitespace-collapsed, <=120 chars), two messages.
row = _find_row(_chats(app_url, cookies), " ".join(q.split())[:120]) row = _wait_saved_row(app_url, cookies, " ".join(q.split())[:120])
assert row is not None, "the saved chat row must exist"
assert row["message_count"] == 2 assert row["message_count"] == 2
created = row["id"] created = row["id"]
@@ -221,10 +242,10 @@ def test_open_chat_returns_to_history(
# The answer text the History session saw (rendered bubble). # The answer text the History session saw (rendered bubble).
answer_before = page.locator(".msg.brain .bubble").first.inner_text() answer_before = page.locator(".msg.brain .bubble").first.inner_text()
_save(page) # Phase 55: the conversation auto-saved (no Save pill) — wait for
# the row via the admin list.
cookies = _admin_cookies(page) cookies = _admin_cookies(page)
row = _find_row(_chats(app_url, cookies), q) row = _wait_saved_row(app_url, cookies, q)
assert row is not None
chat_id = row["id"] chat_id = row["id"]
try: try:
# From the History page, the title IS the Open link… # From the History page, the title IS the Open link…
@@ -267,12 +288,13 @@ def test_open_chat_returns_to_history(
_ask(page, "How is my Kubernetes cluster set up? (hist-open-2)") _ask(page, "How is my Kubernetes cluster set up? (hist-open-2)")
expect(page.locator(".msg.user .bubble")).to_have_count(2) expect(page.locator(".msg.user .bubble")).to_have_count(2)
# …and a re-Save UPSERTS: the same single row, count grown to 4. # …and the next brain-done auto-save UPSERTS: the same single
_save(page) # row, count grown to 4 (phase 55 — no Save pill).
row2 = _wait_saved_row(app_url, cookies, q, messages=4)
mine = [c for c in _chats(app_url, cookies) if c["title"] == q] mine = [c for c in _chats(app_url, cookies) if c["title"] == q]
assert len(mine) == 1, "the re-Save must not spawn a second row" assert len(mine) == 1, "the auto-save must not spawn a second row"
assert mine[0]["id"] == chat_id, "the re-Save updates the SAME row" assert mine[0]["id"] == chat_id, "the auto-save updates the SAME row"
assert mine[0]["message_count"] == 4 assert row2["message_count"] == 4
finally: finally:
_delete_chat(app_url, cookies, chat_id) _delete_chat(app_url, cookies, chat_id)
@@ -294,12 +316,10 @@ def test_new_chat_unlinks(
q1 = "How is my Kubernetes cluster set up? (hist-unlink)" q1 = "How is my Kubernetes cluster set up? (hist-unlink)"
_ask(page, q1) _ask(page, q1)
_save(page)
cookies = _admin_cookies(page) cookies = _admin_cookies(page)
cleanup: list[str] = [] cleanup: list[str] = []
try: try:
row1 = _find_row(_chats(app_url, cookies), q1) row1 = _wait_saved_row(app_url, cookies, q1) # auto-saved (phase 55)
assert row1 is not None
cleanup.append(row1["id"]) cleanup.append(row1["id"])
# New chat clears the conversation AND unlinks it from the row. # New chat clears the conversation AND unlinks it from the row.
@@ -307,11 +327,12 @@ def test_new_chat_unlinks(
expect(page.locator("#send-status")).to_contain_text("New chat started") expect(page.locator("#send-status")).to_contain_text("New chat started")
expect(page.locator(".msg")).to_have_count(0) expect(page.locator(".msg")).to_have_count(0)
# A fresh conversation, saved: a NEW row (a create, not the # A fresh conversation, auto-saved (phase 55): a NEW row (a
# previous row's update) — the list now carries two of ours. # create, not the previous row's update) — the list now carries
# two of ours.
q2 = "How is my Kubernetes cluster set up? (hist-unlink-2)" q2 = "How is my Kubernetes cluster set up? (hist-unlink-2)"
_ask(page, q2) _ask(page, q2)
_save(page) _wait_saved_row(app_url, cookies, q2) # wait for the fresh auto-save
rows = _chats(app_url, cookies) rows = _chats(app_url, cookies)
mine = [c for c in rows if c["title"] in (q1, q2)] mine = [c for c in rows if c["title"] in (q1, q2)]
assert len(mine) == 2, "Save after New chat must create a second row" assert len(mine) == 2, "Save after New chat must create a second row"
@@ -343,10 +364,8 @@ def test_delete_two_step(
q = "How is my Kubernetes cluster set up? (hist-delete)" q = "How is my Kubernetes cluster set up? (hist-delete)"
_ask(page, q) _ask(page, q)
_save(page)
cookies = _admin_cookies(page) cookies = _admin_cookies(page)
row = _find_row(_chats(app_url, cookies), q) row = _wait_saved_row(app_url, cookies, q) # auto-saved (phase 55)
assert row is not None
chat_id = row["id"] chat_id = row["id"]
try: try:
page.goto(app_url + "/history.html") page.goto(app_url + "/history.html")
@@ -396,7 +415,8 @@ def test_delete_two_step(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 5. Anonymous: no Save button, no History nav link, the History page is # 5. Anonymous: no Save control (absent), the Share pill visible
# (phase 55 task 03), no History nav link, the History page is
# gated WITHOUT fetching /api/chats, and the API 403s # gated WITHOUT fetching /api/chats, and the API 403s
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -414,9 +434,13 @@ def test_anonymous_cannot(
page.goto(app_url + "/") page.goto(app_url + "/")
# Settled anonymous state (the whoami round-trip has landed)… # Settled anonymous state (the whoami round-trip has landed)…
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000) expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
# …and the phase-50 surface is absent for anonymous: no Save pill, # …and the phase-50 surface is absent for anonymous: no Save control
# no History nav link (both ship hidden and stay hidden). # (the element is GONE from the DOM — phase 55 — there is nothing
expect(page.locator("#save-chat-btn")).to_be_hidden() # to hide) and no History nav link (ships hidden and stays hidden)
# — but the Share pill IS visible (phase 55 task 03: the write
# surface is public, the pill is static markup).
expect(page.locator("#save-chat-btn")).to_have_count(0)
expect(page.locator("#share-chat-btn")).to_be_visible()
expect(page.locator("#nav-history")).to_be_hidden() expect(page.locator("#nav-history")).to_be_hidden()
# Direct visit to the History page: it loads and shows the gated # Direct visit to the History page: it loads and shows the gated
+562
View File
@@ -0,0 +1,562 @@
"""Phase 55 E2E (Playwright): save by default + anonymous share + action-row layout.
TODO.md L3–L6 (owner 2026-08-31, roadmap confirmation — the four items
this suite verifies, in the browser):
L3 "Share chat should work anonymously without login"
L4 "Save shouldn't be a button, every chat should be saved by default"
L5 "Need feedback (probably dropdown notification toast) to show share worked"
L6 "New Chat and Share buttons should only be vertically stacked when in
mobile, otherwise they should be horizontally next to each other"
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_save_share_ux.py -v --no-cov
The owner-locked loop under test (2026-08-31):
* **Anonymous auto-save (L4 / A2)** — there is NO Save control in the
DOM at any width; a signed-out visitor's first question auto-upserts
EXACTLY ONE ``saved_chats`` row (the auto-title, both messages) —
verified through the admin's ``GET /api/chats`` (the management
surface stays admin-only, phase 55 task 01) with no button press
anywhere;
* **No duplicate across reload (L4)** — a plain reload restores the
conversation from localStorage AND the row link (``chatId`` in the
``bor.chat.v1`` record, task 02): a second question updates the SAME
row — the title's row count stays one, the message count grows
2 → 4;
* **Anonymous share + toast (L3 / L5 / A4)** — the Share pill is
visible WITHOUT login; clicking it on a non-empty conversation mints
the public link (clipboard path, the inline field on non-secure
origins) and raises the top-right toast ("Share link copied.",
``aria-hidden`` visual-only, a single instance, auto-dismiss ~4s); a
FRESH incognito context opens ``/shared/<token>`` read-only (title +
both bubbles, the phase-51 zero-controls surface); the admin's
History Unshare (inline two-step) revokes — the SAME URL then shows
the "invalid or revoked" state;
* **Layout (L6 / A5)** — desktop 1280×800: ``#new-chat-btn`` and
``#share-chat-btn`` share one horizontal row inside ``.chat-actions``
(overlapping y bands, Share's x beyond New chat's x + width, each
pill at intrinsic width — never the full 46rem column); mobile
390×844: stacked vertically (Share below New chat, full-width);
360px wide: no horizontal page overflow;
* **Admin still works (A1 sanity)** — the same auto-save machinery
fires for a signed-in admin (the write surface is public either way;
the row lands in the admin's History).
DB isolation: the shared e2e Postgres keeps ``saved_chats`` rows across
suites, so every test uses a DISTINCTIVE question (its auto-title is
therefore unique), selects rows by auto-title (never absolute counts),
``_reset_db``s first (the KB tables are truncated + re-seeded the house
way — deterministic mock embeddings; ``saved_chats`` is never touched
by the reset), and deletes the rows it creates in a ``finally`` (admin
cookie).
"""
from __future__ import annotations
import asyncio
import re
import time
from pathlib import Path
from threading import Thread
from typing import Any
import httpx
from playwright.sync_api import Browser, BrowserContext, Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$")
#: The invalid/revoked card's line (frontend/shared.html, phase 51).
INVALID_TEXT = "This share link is invalid or was revoked."
#: The toast's clipboard-path line (app.js shareCurrentChat success).
TOAST_CLIP_TEXT = "Share link copied."
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread.
Playwright's sync API keeps an asyncio loop running on the test
thread, so ``asyncio.run`` cannot be called directly from a test
body.
"""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
"""Truncate the KB (and query log + steering notes — deterministic
mock answers), then optionally re-import fixtures. ``saved_chats``
is deliberately NOT touched: rows persist across suites and every
test here cleans up after itself."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
db.commit()
if not seed:
return None
return _run_in_thread(_import_fixtures(mock_port))
def _ask(page: Page, question: str) -> None:
"""Send one turn and wait until the grounded answer has fully
landed (the ``done`` event restored the Send button)."""
page.fill("#message-input", question)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
expect(page.locator(".msg.brain .bubble").last).to_contain_text(
MOCK_ANSWER_MARKER, timeout=30_000
)
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Send")
def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form login —
used to call the admin API with plain httpx (the test's API side
sees exactly what the signed-in browser sees)."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _open_admin(browser: Browser, app_url: str) -> tuple[BrowserContext, Page, dict[str, str]]:
"""A SECOND, logged-in context — the admin's eyes for the management
surface (``GET /api/chats`` list/detail, delete, the History
Unshare), which stays admin-only since phase 55 task 01."""
ctx = browser.new_context(viewport={"width": 1280, "height": 800})
pg = ctx.new_page()
pg.set_default_timeout(30_000)
login(pg, app_url, next="/")
return ctx, pg, _admin_cookies(pg)
def _chats(app_url: str, cookies: dict[str, str]) -> list[dict[str, Any]]:
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
assert r.status_code == 200
return r.json()["chats"]
def _auto_title(question: str) -> str:
"""The phase-50 auto-title convention: the first question,
whitespace-collapsed, capped at 120 chars."""
return " ".join(question.split())[:120]
def _find_row(rows: list[dict[str, Any]], title: str) -> dict[str, Any] | None:
return next((c for c in rows if c["title"] == title), None)
def _title_rows(app_url: str, cookies: dict[str, str], title: str) -> list[dict[str, Any]]:
"""Every row carrying ``title`` — the "exactly one row" pin (never
an absolute count: the shared DB keeps other suites' rows)."""
return [c for c in _chats(app_url, cookies) if c["title"] == title]
def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
"""Best-effort row cleanup (a 404 — already deleted — is fine)."""
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
def _wait_saved_row(
app_url: str,
cookies: dict[str, str],
title: str,
messages: int = 2,
) -> dict[str, Any]:
"""Wait for the auto-saved row (phase 55: auto-saves are SILENT —
A2 — so there is no status line to wait on). The upsert is
fire-and-forget from the UI's point of view, so poll the admin
list until the row with the conversation's auto-title appears with
the expected message count."""
deadline = time.monotonic() + 15
last: dict[str, Any] | None = None
while time.monotonic() < deadline:
last = _find_row(_chats(app_url, cookies), title)
if last is not None and last["message_count"] >= messages:
return last
time.sleep(0.2)
raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})")
def _grant_clipboard(page: Page, app_url: str) -> None:
"""Grant the async-clipboard permissions on the context.
``http://127.0.0.1`` is a secure context, so ``navigator.clipboard``
exists — but headless Chromium still requires the permission grant
before ``writeText``/``readText`` resolve (without it the
owner-locked inline-link fallback fires). The assertion below
branches on the API's availability, so a non-secure origin still
passes through the fallback branch deterministically.
"""
page.context.grant_permissions(
["clipboard-read", "clipboard-write"], origin=app_url
)
def _wait_toast_dismissed(page: Page, timeout_s: float = 8.0) -> None:
"""Poll until the toast's ``is-visible`` state class is gone.
The auto-dismiss (~4s, A4) drops the class (opacity/transform stay
— opacity is not part of Playwright's visibility model, so the
class is the honest state pin). The ~8s deadline leaves headroom
over the 4s contract without masking a stuck toast."""
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
visible = page.evaluate(
"() => { const t = document.querySelector('.toast');"
" return !!(t && t.classList.contains('is-visible')); }"
)
if not visible:
return
time.sleep(0.2)
raise AssertionError("the share toast did not auto-dismiss (~4s contract)")
# ---------------------------------------------------------------------------
# 1. Anonymous auto-save (L4 / A2): no Save control anywhere, one
# question → exactly one saved_chats row (auto-title, both messages),
# verified through the admin's list — no button press in this test
# ---------------------------------------------------------------------------
def test_anonymous_auto_save(
page: Page, browser: Browser, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
# Anonymous by construction: the page fixture's context never logs
# in — this is the signed-out visitor.
page.goto(app_url + "/")
# L4: there is NO Save control at any width — the element is gone
# from the DOM (phase 55, task 02), the pills are New chat + Share.
expect(page.locator("#save-chat-btn")).to_have_count(0)
expect(page.locator("#new-chat-btn")).to_be_visible()
expect(page.locator("#share-chat-btn")).to_be_visible()
q = "How is my Kubernetes cluster set up? (save-ux-auto)"
_ask(page, q) # the ONLY click in this test — no Save press exists
admin_ctx, _admin, cookies = _open_admin(browser, app_url)
created: str | None = None
try:
# The admin's management list sees the auto-saved row: the
# auto-title, both messages, created by the anonymous visitor.
row = _wait_saved_row(app_url, cookies, _auto_title(q), messages=2)
assert row["message_count"] == 2, "the auto-saved row holds both messages"
assert len(_title_rows(app_url, cookies, _auto_title(q))) == 1, (
"exactly ONE row for the auto-title — the auto-save must not duplicate"
)
created = row["id"]
# A2 quiet contract: a successful auto-save is SILENT — no toast
# (reserved for share) and no error banner on the anonymous page.
expect(page.locator(".toast")).to_have_count(0)
expect(page.locator("#kb-banner")).to_be_hidden()
finally:
if created is not None:
_delete_chat(app_url, cookies, created)
admin_ctx.close()
# ---------------------------------------------------------------------------
# 2. No duplicate across reload (L4): the row link (chatId in
# bor.chat.v1, task 02) survives a plain reload — the second
# question updates the SAME row (count one, messages 2 → 4)
# ---------------------------------------------------------------------------
def test_no_duplicate_row_across_reload(
page: Page, browser: Browser, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url + "/")
q1 = "How is my Kubernetes cluster set up? (save-ux-reload)"
_ask(page, q1)
admin_ctx, _admin, cookies = _open_admin(browser, app_url)
created: str | None = None
try:
row = _wait_saved_row(app_url, cookies, _auto_title(q1), messages=2)
chat_id: str = row["id"]
created = chat_id
# Plain reload: the conversation restores from localStorage
# (both bubbles) — and the row link restores with it.
page.reload()
expect(page.locator(".msg.user .bubble").last).to_contain_text(q1)
expect(page.locator(".msg.brain .bubble").last).to_contain_text(
MOCK_ANSWER_MARKER, timeout=30_000
)
# The second question must UPDATE the same row — not spawn a
# second one (the unlinked-upgrade path would create a fresh
# row; the chatId persistence is what prevents that).
q2 = "Which node runs the Kubernetes control plane? (save-ux-reload)"
_ask(page, q2)
row = _wait_saved_row(app_url, cookies, _auto_title(q1), messages=4)
assert row["id"] == chat_id, "the reloaded conversation kept its row link"
assert row["message_count"] == 4, "both turns' four messages landed on the row"
assert len(_title_rows(app_url, cookies, _auto_title(q1))) == 1, (
"still exactly ONE row after the reload — no duplicate"
)
finally:
if created is not None:
_delete_chat(app_url, cookies, created)
admin_ctx.close()
# ---------------------------------------------------------------------------
# 3. Anonymous share + toast (L3 / L5 / A4): the Share pill is visible
# without login; the click mints the link + the top-right toast; a
# fresh incognito context reads it read-only; the admin's History
# Unshare revokes the same URL
# ---------------------------------------------------------------------------
def test_anonymous_share_toast_and_unshare(
page: Page, browser: Browser, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
# Anonymous by construction — no login anywhere in this test.
page.goto(app_url + "/")
# L3: the Share pill is visible WITHOUT login (static markup — the
# phase-51 admin-only reveal gate is gone, task 03).
share = page.locator("#share-chat-btn")
expect(share).to_be_visible()
expect(share).to_have_attribute("aria-label", "Share chat")
q = "How is my Kubernetes cluster set up? (save-ux-share)"
_ask(page, q)
# A4: the toast is reserved for share — nothing before the click
# (the auto-save that just fired is silent).
expect(page.locator(".toast")).to_have_count(0)
_grant_clipboard(page, app_url)
share.click()
# The top-right toast: a SINGLE instance, the visible state class,
# the success text, aria-hidden (visual only — #send-status is the
# a11y announcer and keeps its phase-51 line).
toast = page.locator(".toast")
expect(toast).to_have_count(1)
expect(toast).to_have_class(re.compile(r"\bis-visible\b"), timeout=15_000)
expect(toast).to_have_text(TOAST_CLIP_TEXT)
expect(toast).to_have_attribute("aria-hidden", "true")
expect(page.locator("#send-status")).to_have_text(TOAST_CLIP_TEXT, timeout=15_000)
# A4: it auto-dismisses in ~4s (the class drops; the node stays).
_wait_toast_dismissed(page)
# Read the link — the clipboard when the origin allows it, else the
# inline fallback field (the phase-51 owner-locked branch).
if page.evaluate("() => !!navigator.clipboard"):
link: str | None = page.evaluate("() => navigator.clipboard.readText()")
expect(page.locator(".share-link-fallback")).to_have_count(0)
else:
field = page.locator(".share-link-fallback")
expect(field).to_be_visible()
link = field.get_attribute("href")
assert link is not None and link.startswith(app_url), f"bad share link: {link!r}"
path = link.removeprefix(app_url)
assert SHARE_URL_RE.fullmatch(path), f"bad share path shape: {path!r}"
admin_ctx, admin, cookies = _open_admin(browser, app_url)
anon_ctx: BrowserContext | None = None
created: str | None = None
try:
# A FRESH incognito context: the guest's only credential is the
# token in the URL — the conversation renders read-only.
anon_ctx = browser.new_context()
anon = anon_ctx.new_page()
anon.set_default_timeout(30_000)
anon.goto(app_url + path)
expect(anon.locator("#shared-title")).to_have_text(_auto_title(q))
expect(anon.locator(".msg.user .bubble")).to_have_count(1)
expect(anon.locator(".msg.user .bubble")).to_contain_text(q)
expect(anon.locator(".msg.brain .bubble")).to_have_count(1)
expect(anon.locator(".msg.brain .bubble").first).to_contain_text(
MOCK_ANSWER_MARKER, timeout=30_000
)
# The phase-51 zero-controls surface: no composer, no pills,
# no Tune/Retry, no button chips — and the guest header.
expect(anon.locator("#composer")).to_have_count(0)
expect(anon.locator("#save-chat-btn, #share-chat-btn")).to_have_count(0)
expect(anon.locator(".tune-btn")).to_have_count(0)
expect(anon.locator(".retry-btn")).to_have_count(0)
expect(anon.locator("button.suggestion-chip")).to_have_count(0)
expect(anon.locator("#sign-in-link")).to_be_visible(timeout=15_000)
# The API agrees (the admin's eyes): the guest's auto-saved row
# carries the SAME /shared/<token> link.
row = _find_row(_chats(app_url, cookies), _auto_title(q))
assert row is not None, "the guest's auto-saved row must exist"
created = row["id"]
assert row["share_url"] == path, "the shared row carries the link just used"
# The admin revokes from the History page (inline two-step —
# no native dialog).
admin.goto(app_url + "/history.html")
tr = admin.locator(
"#history-tbody tr", has=admin.locator(f"a[href='/?chat={created}']")
)
expect(tr.locator("button.history-unshare")).to_be_visible(timeout=15_000)
tr.locator("button.history-unshare").click()
expect(tr.locator(".history-confirm-text")).to_have_text("Unshare?")
expect(tr.locator(".history-confirm-yes")).to_be_visible()
tr.locator(".history-confirm-yes").click()
expect(tr.locator("button.history-share-create")).to_be_visible(timeout=15_000)
# The SAME URL is revoked now — in the SAME fresh context.
anon.goto(app_url + path)
expect(anon.locator("#shared-invalid")).to_be_visible(timeout=15_000)
expect(anon.locator("#shared-invalid")).to_contain_text(INVALID_TEXT)
expect(anon.locator(".msg")).to_have_count(0)
# The API agrees: share_url is ABSENT (the omission rule).
r = httpx.get(f"{app_url}/api/chats/{created}", timeout=10, cookies=cookies)
assert r.status_code == 200
assert "share_url" not in r.json(), "unshared → share_url must be absent"
finally:
if anon_ctx is not None:
anon_ctx.close()
if created is not None:
_delete_chat(app_url, cookies, created)
admin_ctx.close()
# ---------------------------------------------------------------------------
# 4. Action-row layout (L6 / A5): horizontal at desktop, stacked at
# ≤640px, no horizontal overflow at 360px
# ---------------------------------------------------------------------------
def test_action_row_layout(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
# No conversation needed — the pills are static markup, always
# present (the page fixture's viewport is the 1280×800 desktop).
page.goto(app_url + "/")
new_btn = page.locator("#new-chat-btn")
share_btn = page.locator("#share-chat-btn")
expect(new_btn).to_be_visible()
expect(share_btn).to_be_visible()
# Both pills live in ONE .chat-actions row, New chat before Share.
expect(page.locator(".chat-actions")).to_have_count(1)
row_el = page.locator(".chat-actions")
assert row_el.locator("#new-chat-btn").count() == 1
assert row_el.locator("#share-chat-btn").count() == 1
assert page.evaluate(
"() => {"
" const n = document.getElementById('new-chat-btn');"
" const s = document.getElementById('share-chat-btn');"
" return !!(n && s && (n.compareDocumentPosition(s) & Node.DOCUMENT_POSITION_FOLLOWING));"
" }"
), "the DOM order must be New chat, then Share"
# Desktop (1280×800): one horizontal row — overlapping y bands,
# Share to the right of New chat, each pill at its INTRINSIC width
# (never the full 46rem chat column).
nb = new_btn.bounding_box()
sb = share_btn.bounding_box()
assert nb is not None and sb is not None
assert nb["y"] < sb["y"] + sb["height"] and sb["y"] < nb["y"] + nb["height"], (
f"the pills must share one row (new={nb}, share={sb})"
)
assert sb["x"] > nb["x"] + nb["width"], "Share must sit right of New chat"
column_w = page.evaluate(
"() => document.querySelector('.chat-shell').getBoundingClientRect().width"
)
assert nb["width"] < column_w / 2 and sb["width"] < column_w / 2, (
"each pill must keep its intrinsic width on desktop, not stretch the column"
)
# Mobile (390×844): stacked vertically — Share BELOW New chat, both
# full-width (the ≤640px stretch rule).
page.set_viewport_size({"width": 390, "height": 844})
nb = new_btn.bounding_box()
sb = share_btn.bounding_box()
assert nb is not None and sb is not None
assert sb["y"] > nb["y"] + nb["height"], (
f"the pills must stack at 390px, Share below New chat (new={nb}, share={sb})"
)
# Full-width within the column: the stacked pills stretch to the
# .chat-actions row (the column's content box — .chat-shell is a
# .container, whose getBoundingClientRect includes its padding).
row_box = page.locator(".chat-actions").bounding_box()
assert row_box is not None
assert abs(nb["width"] - sb["width"]) < 2, "the stacked pills share one full width"
assert abs(nb["width"] - row_box["width"]) < 2, "the stacked pills stretch the column"
# 360px wide: no horizontal page overflow (the two stacked pills +
# container padding must fit).
page.set_viewport_size({"width": 360, "height": 800})
scroll_w = page.evaluate("() => document.documentElement.scrollWidth")
assert scroll_w <= 360, f"horizontal overflow at 360px: scrollWidth={scroll_w}"
# ---------------------------------------------------------------------------
# 5. Admin still works (A1 sanity): the same auto-save machinery fires
# for a signed-in admin — session or not, the row lands
# ---------------------------------------------------------------------------
def test_admin_auto_save_still_works(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
q = "How is my Kubernetes cluster set up? (save-ux-admin)"
_ask(page, q)
cookies = _admin_cookies(page)
created: str | None = None
try:
row = _wait_saved_row(app_url, cookies, _auto_title(q), messages=2)
assert row["message_count"] == 2, "the admin's auto-saved row holds both messages"
created = row["id"]
expect(page.locator(".toast")).to_have_count(0) # auto-save is silent (A2)
finally:
if created is not None:
_delete_chat(app_url, cookies, created)
+53 -24
View File
@@ -6,15 +6,21 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_share_chat.py -v --no-cov uv run pytest tests/e2e/test_share_chat.py -v --no-cov
The owner-locked loop under test (2026-08-29, roadmap confirmation): The owner-locked loop under test (2026-08-29, roadmap confirmation;
phase 55 replaced the Save pill with auto-save — by the time the answer
settles the conversation is already saved, so the chat page's Share
click is the idempotent share of the linked row):
* **Share from the chat page** — the Share pill (admin-only, ships * **Share from the chat page** — the Share pill (admin-only, ships
hidden) on an UNSAVED conversation saves AND shares in ONE action hidden) on the AUTO-SAVED conversation shares the linked row via
(``POST /api/chats`` with ``share: true`` — the row appears in ``POST /api/chats/<id>/share`` — the row appears in ``GET
``GET /api/chats`` with a non-null ``share_url`` of the shape /api/chats`` with a non-null ``share_url`` of the shape
``/shared/<uuid4>``); the absolute URL is copied to the clipboard, ``/shared/<uuid4>`` (the unlinked save-then-share one-action wire
with the inline-link fallback on a non-secure origin (the assertion path — ``POST /api/chats`` with ``share: true`` — is pinned by the
branches on ``navigator.clipboard`` availability); integration suite, guest-reachable since phase 55 task 01); the
absolute URL is copied to the clipboard, with the inline-link
fallback on a non-secure origin (the assertion branches on
``navigator.clipboard`` availability);
* **Anonymous view** — a FRESH browser context (a separate session, no * **Anonymous view** — a FRESH browser context (a separate session, no
cookies) opening ``/shared/<token>`` sees the full conversation cookies) opening ``/shared/<token>`` sees the full conversation
read-only through the same record shape: title = the auto-title, read-only through the same record shape: title = the auto-title,
@@ -49,6 +55,7 @@ from __future__ import annotations
import asyncio import asyncio
import re import re
import time
from pathlib import Path from pathlib import Path
from threading import Thread from threading import Thread
from typing import Any from typing import Any
@@ -163,6 +170,27 @@ def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies) httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
def _wait_saved_row(
app_url: str,
cookies: dict[str, str],
title: str,
messages: int = 2,
) -> dict[str, Any]:
"""Wait for the auto-saved row (phase 55: auto-saves are SILENT —
A2 — so there is no status line to wait on). The upsert is
fire-and-forget from the UI's point of view, so poll the admin
list until the row with the conversation's auto-title appears with
the expected message count."""
deadline = time.monotonic() + 15
last: dict[str, Any] | None = None
while time.monotonic() < deadline:
last = _find_row(_chats(app_url, cookies), title)
if last is not None and last["message_count"] >= messages:
return last
time.sleep(0.2)
raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})")
def _grant_clipboard(page: Page, app_url: str) -> None: def _grant_clipboard(page: Page, app_url: str) -> None:
"""Grant the async-clipboard permissions on the admin context. """Grant the async-clipboard permissions on the admin context.
@@ -203,8 +231,9 @@ def _click_share_and_assert_status(page: Page, app_url: str) -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 1. Share from the chat page: an UNSAVED conversation is saved + shared # 1. Share from the chat page: the AUTO-SAVED conversation is shared
# in one action; the API row carries the /shared/<uuid> link # (the idempotent share of the linked row — phase 55); the API row
# carries the /shared/<uuid> link
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -221,25 +250,28 @@ def test_share_from_chat_page(
_ask(page, q) _ask(page, q)
# Admin: the Share pill is revealed (ships hidden, whoami reveals # Admin: the Share pill is revealed (ships hidden, whoami reveals
# it — the same block as Save). The conversation is UNSAVED at this # it). Phase 55: the conversation is AUTO-SAVED by the time the
# point: no row exists yet under the auto-title. # answer settles (the Save pill is gone) — the row exists under the
# auto-title, and the Share click below shares the linked row.
share = page.locator("#share-chat-btn") share = page.locator("#share-chat-btn")
expect(share).to_be_visible() expect(share).to_be_visible()
expect(share).to_have_attribute("aria-label", "Share chat") expect(share).to_have_attribute("aria-label", "Share chat")
cookies = _admin_cookies(page) cookies = _admin_cookies(page)
assert ( row = _wait_saved_row(app_url, cookies, _auto_title(q))
_find_row(_chats(app_url, cookies), _auto_title(q)) is None assert row["message_count"] == 2, "auto-saved with both messages before the Share click"
), "the conversation is unsaved before the Share click"
_grant_clipboard(page, app_url) _grant_clipboard(page, app_url)
_click_share_and_assert_status(page, app_url) _click_share_and_assert_status(page, app_url)
created: str | None = None created: str | None = None
try: try:
# The API agrees: ONE action saved AND shared — the new row # The API agrees: the Share click shared the auto-saved row —
# exists with a non-null share_url of the token shape. # it carries a non-null share_url of the token shape. (The
# unlinked save-then-share one-action path is pinned by the
# integration suite — from the chat page the conversation is
# always linked by the time there is anything to share.)
row = _find_row(_chats(app_url, cookies), _auto_title(q)) row = _find_row(_chats(app_url, cookies), _auto_title(q))
assert row is not None, "the Share click must have saved the conversation" assert row is not None, "the auto-saved row must exist"
assert row["message_count"] == 2, "the saved conversation holds both messages" assert row["message_count"] == 2, "the saved conversation holds both messages"
share_url = row.get("share_url") share_url = row.get("share_url")
assert share_url is not None, "the share_url must be present (non-null)" assert share_url is not None, "the share_url must be present (non-null)"
@@ -370,15 +402,12 @@ def test_share_from_history_and_unshare(
q = "How is my Kubernetes cluster set up? (share-history)" q = "How is my Kubernetes cluster set up? (share-history)"
_ask(page, q) _ask(page, q)
# Save first (this test drives the History column, not the # Phase 55: the conversation is already AUTO-SAVED by the time the
# save-then-share one-action path — test 1 covers that). # answer settles (no Save pill) — wait for the row via the admin
page.locator("#save-chat-btn").click() # list (this test drives the History column).
expect(page.locator("#send-status")).to_have_text("Conversation saved.")
cookies = _admin_cookies(page) cookies = _admin_cookies(page)
_grant_clipboard(page, app_url) _grant_clipboard(page, app_url)
row = _find_row(_chats(app_url, cookies), _auto_title(q)) row = _wait_saved_row(app_url, cookies, _auto_title(q))
assert row is not None
chat_id: str = row["id"] chat_id: str = row["id"]
anon_ctx: BrowserContext | None = None anon_ctx: BrowserContext | None = None
try: try:
+8 -2
View File
@@ -255,10 +255,16 @@ def test_no_orphan_brain_message_when_navigated_before_first_token(
# Wait until the scratchpad's tail is rendered (phase-17 thinking body, # Wait until the scratchpad's tail is rendered (phase-17 thinking body,
# ~2 700 chars / ≈4.5s, lengthened in phase 21) — the 4s pre-content # ~2 700 chars / ≈4.5s, lengthened in phase 21) — the 4s pre-content
# pause (SLOW_PRETOKEN_TRIGGER) is now running, so the navigation below # pause (SLOW_PRETOKEN_TRIGGER) is now running, so the navigation below
# lands inside pure thinking with a wide margin. # lands inside pure thinking with a wide margin. Explicit timeout:
# the mock streams the tail at ≈12 chars / 0.02s (≈4.7s end-to-end),
# which outruns Playwright's 5s assertion auto-wait on a loaded host
# (the wait started at the FIRST thinking frame — the pre-existing
# race, phase 55 task 02 fix).
thinking = page.locator(".msg.brain").last.locator("details.thinking") thinking = page.locator(".msg.brain").last.locator("details.thinking")
thinking.wait_for(state="attached", timeout=10_000) thinking.wait_for(state="attached", timeout=10_000)
expect(thinking.locator(".thinking-text")).to_contain_text(THINKING_TAIL) expect(thinking.locator(".thinking-text")).to_contain_text(
THINKING_TAIL, timeout=30_000
)
# Still pre-token: the button is the enabled Stop control (phase 48 — # Still pre-token: the button is the enabled Stop control (phase 48 —
# the old disabled "Thinking…" busy state is gone). # the old disabled "Thinking…" busy state is gone).
expect(page.locator("#send-btn")).to_be_enabled() expect(page.locator("#send-btn")).to_be_enabled()
+29 -16
View File
@@ -8,10 +8,11 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
The full user-visible invalidation loop under test: The full user-visible invalidation loop under test:
* **Save (fresh)** — as admin: ask (the mock answers deterministically), * **Auto-save (fresh, phase 55)** — as admin: ask (the mock answers
Save via the chat-page pill; ``GET /api/chats`` (admin cookie) reports deterministically) — the conversation auto-saves at the save points
the row with ``stale: false``; opening the FRESH row at ``/?chat=<id>`` (the Save pill is gone); ``GET /api/chats`` (admin cookie) reports
shows NO banner; the row with ``stale: false``; opening the FRESH row at
``/?chat=<id>`` shows NO banner;
* **KB change** — the test process (which shares the app's environment) * **KB change** — the test process (which shares the app's environment)
bumps the ``sources_meta`` seed row through ``bump_sources_version`` bumps the ``sources_meta`` seed row through ``bump_sources_version``
over a short ``SessionLocal()`` — the exact helper BOTH real sync over a short ``SessionLocal()`` — the exact helper BOTH real sync
@@ -60,6 +61,7 @@ the click.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import time
import uuid import uuid
from pathlib import Path from pathlib import Path
from threading import Thread from threading import Thread
@@ -174,11 +176,25 @@ def _ask(page: Page, question: str) -> None:
expect(page.locator("#send-label")).to_have_text("Send") expect(page.locator("#send-label")).to_have_text("Send")
def _save(page: Page) -> None: def _wait_saved_row(
"""Press Save and wait for the live-region confirmation (the app_url: str,
never-stale contract: the status line is the success feedback).""" cookies: dict[str, str],
page.locator("#save-chat-btn").click() title: str,
expect(page.locator("#send-status")).to_have_text("Conversation saved.") messages: int = 2,
) -> dict[str, Any]:
"""Wait for the auto-saved row (phase 55: auto-saves are SILENT —
A2 — so there is no status line to wait on). The upsert is
fire-and-forget from the UI's point of view, so poll the admin
list until the row with the conversation's auto-title appears with
the expected message count."""
deadline = time.monotonic() + 15
last: dict[str, Any] | None = None
while time.monotonic() < deadline:
last = _find_row(_chats(app_url, cookies), title)
if last is not None and last["message_count"] >= messages:
return last
time.sleep(0.2)
raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})")
def _admin_cookies(page: Page) -> dict[str, str]: def _admin_cookies(page: Page) -> dict[str, str]:
@@ -298,11 +314,10 @@ def test_full_invalidation_loop(
q = "How is my Kubernetes cluster set up? (stale-loop)" q = "How is my Kubernetes cluster set up? (stale-loop)"
_ask(page, q) _ask(page, q)
# --- Save (fresh): the API agrees, and a fresh open shows NO banner. # --- Auto-save (fresh, phase 55 — no Save pill): the API agrees,
_save(page) # and a fresh open shows NO banner.
cookies = _admin_cookies(page) cookies = _admin_cookies(page)
row = _find_row(_chats(app_url, cookies), _auto_title(q)) row = _wait_saved_row(app_url, cookies, _auto_title(q))
assert row is not None, "the saved chat row must exist"
assert row["message_count"] == 2 assert row["message_count"] == 2
assert row["stale"] is False, "a just-saved chat is fresh, not stale" assert row["stale"] is False, "a just-saved chat is fresh, not stale"
chat_id: str = row["id"] chat_id: str = row["id"]
@@ -467,11 +482,9 @@ def test_anonymous_shared_snapshot_has_no_staleness_surface(
q = "How is my Kubernetes cluster set up? (stale-share)" q = "How is my Kubernetes cluster set up? (stale-share)"
_ask(page, q) _ask(page, q)
_save(page)
cookies = _admin_cookies(page) cookies = _admin_cookies(page)
row = _find_row(_chats(app_url, cookies), _auto_title(q)) row = _wait_saved_row(app_url, cookies, _auto_title(q)) # auto-saved (phase 55)
assert row is not None
chat_id: str = row["id"] chat_id: str = row["id"]
anon_ctx: BrowserContext | None = None anon_ctx: BrowserContext | None = None
try: try:
+136 -12
View File
@@ -1,10 +1,13 @@
"""Integration: saved-chat CRUD (phase 50, task 02) — the ``/api/chats`` """Integration: saved-chat CRUD (phase 50, task 02) — the ``/api/chats``
contract. contract.
Real Postgres (``podman compose up -d db``). The router sits behind the Real Postgres (``podman compose up -d db``). Phase 55 (task 01) split
phase-16 ``require_admin`` gate exactly like ``/api/steering`` (the the phase-16 ``require_admin`` gate: the WRITE surface (``POST`` create
house pattern of ``test_steering_api.py``): anonymous callers get 403 on incl. save-then-share, ``PUT`` re-Save, ``POST /{id}/share``) is public
every route; the admin CRUD exercises the auto-title convention (first — the guest pins below exercise exactly that — while the MANAGEMENT
surface (list / detail / delete / unshare) stays admin-only (guests get
403 on exactly those four routes). The admin CRUD pins stay green
unchanged: the auto-title convention (first
user message, whitespace-collapsed, 120-char cap + the no-user-message user message, whitespace-collapsed, 120-char cap + the no-user-message
fallback), the list order (``updated_at desc, id desc``), the fallback), the list order (``updated_at desc, id desc``), the
full-payload round-trip (a ``bor.chat.v1``-shaped brain record carrying full-payload round-trip (a ``bor.chat.v1``-shaped brain record carrying
@@ -161,29 +164,150 @@ def _assert_no_chats(admin_client: TestClient) -> None:
assert admin_client.get("/api/chats").json() == {"chats": []} assert admin_client.get("/api/chats").json() == {"chats": []}
# ---------- anonymous: 403 on every route (phase 16 gate) ---------- # ---------- guest (no session): the public write surface (phase 55,
# task 01 — the router-wide phase-16 gate moved off POST/PUT/share,
# onto exactly the four management routes) ----------
def test_anonymous_every_route_returns_403(client: TestClient) -> None: def test_guest_create_returns_201_with_id_and_auto_title(client: TestClient) -> None:
"""A guest saves their own conversation (no session cookie): 201,
a valid row id, and the same auto-title convention as the admin
(first user message) — the save surface is public since phase 55,
task 01."""
r = client.post("/api/chats", json={"messages": _simple_conversation()})
assert r.status_code == 201
body = r.json()
assert set(body) == OUT_KEYS
assert body["title"] == FIRST_QUESTION # auto-title = first user message
assert body["message_count"] == 2
assert body["messages"] == _expect(_simple_conversation())
uuid.UUID(body["id"]) # valid UUID
assert "share_url" not in body # unshared: the key is ABSENT
def test_guest_put_replaces_messages_and_bumps_updated_at(client: TestClient) -> None:
"""The re-Save upsert is guest-reachable (phase 55, task 01): the
same row's messages are fully replaced and ``updated_at`` moves —
the auto-save contract (task 02) relies on this working without a
session."""
created = client.post("/api/chats", json={"messages": _simple_conversation()}).json()
updated_before = created["updated_at"]
time.sleep(0.1) # now() has µs resolution — make the bump observable
new_messages = [
_user("How do I prune deleted docs?"),
{"who": "brain", "text": "Use --prune."},
]
r = client.put(f"/api/chats/{created['id']}", json={"messages": new_messages})
assert r.status_code == 200
body = r.json()
assert body["id"] == created["id"]
assert body["title"] == FIRST_QUESTION # absent title keeps the current one
assert body["messages"] == _expect(new_messages) # full replacement
assert datetime.fromisoformat(body["updated_at"]) > datetime.fromisoformat(
updated_before
), "updated_at must bump on a guest re-Save (onupdate=func.now())"
def test_guest_share_returns_share_url_and_is_idempotent(
client: TestClient, db: Session
) -> None:
"""``POST /{id}/share`` is guest-reachable (phase 55, task 01):
200 + ``share_url`` (token shape), the token persists on the row,
and a second guest share returns the SAME token (idempotent)."""
created = client.post("/api/chats", json={"messages": _simple_conversation()}).json()
r1 = client.post(f"/api/chats/{created['id']}/share")
assert r1.status_code == 200
body1 = r1.json()
assert set(body1) == {"chat_id", "share_url"}
assert body1["chat_id"] == created["id"]
assert SHARE_URL_RE.fullmatch(body1["share_url"]), (
f"share_url must be /shared/<lowercase uuid>: {body1['share_url']}"
)
token = uuid.UUID(body1["share_url"].removeprefix("/shared/"))
assert _stored_token(db, created["id"]) == token # persisted on the row
r2 = client.post(f"/api/chats/{created['id']}/share")
assert r2.status_code == 200
assert r2.json() == body1, "a guest re-share returns the SAME token"
assert _stored_token(db, created["id"]) == token
def test_guest_create_with_share_saves_and_shares_in_one_action(
client: TestClient, db: Session
) -> None:
"""The save-then-share contract, now guest-reachable (phase 55,
task 01): ONE request — 201 + ``share_url``, the token persisted in
the SAME commit (no second request), and the link reads
anonymously the moment the 201 lands."""
r = client.post(
"/api/chats", json={"messages": _simple_conversation(), "share": True}
)
assert r.status_code == 201
body = r.json()
assert set(body) == OUT_KEYS | {"share_url"}
assert body["title"] == FIRST_QUESTION # auto-title applies as for admin
assert SHARE_URL_RE.fullmatch(body["share_url"]), (
f"share_url must be /shared/<lowercase uuid>: {body['share_url']}"
)
token = uuid.UUID(body["share_url"].removeprefix("/shared/"))
assert _stored_token(db, body["id"]) == token # same commit, one INSERT
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
got = anon.get(f"/api{body['share_url']}")
assert got.status_code == 200
assert set(got.json()) == SHARED_OUT_KEYS
assert got.json()["messages"] == _expect(_simple_conversation())
def test_guest_is_403_only_on_the_management_surface(client: TestClient) -> None:
"""The router-wide phase-16 gate MOVED, it did not disappear: a
guest (no session cookie) is 403 ``admin only`` on exactly the
four management routes — list / detail / delete / unshare (the
owner's History surface). The public ``/api/shared/<token>`` read
is NOT in this list (it is anonymous by design, as before)."""
anon = TestClient(fastapi_app) # fresh jar: truly anonymous anon = TestClient(fastapi_app) # fresh jar: truly anonymous
unknown = uuid.uuid4() unknown = uuid.uuid4()
cases = [ cases = [
("GET", "/api/chats", None), ("GET", "/api/chats", None),
("POST", "/api/chats", {"messages": _simple_conversation()}),
("GET", f"/api/chats/{unknown}", None), ("GET", f"/api/chats/{unknown}", None),
("PUT", f"/api/chats/{unknown}", {"messages": _simple_conversation()}),
("DELETE", f"/api/chats/{unknown}", None), ("DELETE", f"/api/chats/{unknown}", None),
("POST", f"/api/chats/{unknown}/share", None),
("POST", f"/api/chats/{unknown}/unshare", None), ("POST", f"/api/chats/{unknown}/unshare", None),
] ]
# The public read is NOT in this list — it is anonymous by design
# (a wrong token 404s there, it never 403s).
for method, path, body in cases: for method, path, body in cases:
r = anon.request(method, path, json=body) r = anon.request(method, path, json=body)
assert r.status_code == 403, f"{method} {path} must be 403 for anonymous" assert r.status_code == 403, f"{method} {path} must be 403 for a guest"
assert r.json() == {"detail": "admin only"} assert r.json() == {"detail": "admin only"}
def test_guest_unshare_403s_but_admin_revocation_still_works(
admin_client: TestClient,
) -> None:
"""Revocation end-to-end across the split gate (phase 55, task
01): the guest who created + shared the chat cannot unshare (403 —
the link stays live), but the admin's unshare revokes it — the
public ``GET /api/shared/<token>`` read 404s afterwards for guest
AND admin (the public read itself is unaffected by this task)."""
guest = TestClient(fastapi_app) # fresh jar: truly anonymous
created = guest.post("/api/chats", json={"messages": _simple_conversation()}).json()
share_url = guest.post(f"/api/chats/{created['id']}/share").json()["share_url"]
anon = TestClient(fastapi_app) # a second guest, to prove the read
assert anon.get(f"/api{share_url}").status_code == 200 # live
r = guest.post(f"/api/chats/{created['id']}/unshare")
assert r.status_code == 403 # management surface — a guest cannot revoke
assert r.json() == {"detail": "admin only"}
assert anon.get(f"/api{share_url}").status_code == 200 # still live
assert (
admin_client.post(f"/api/chats/{created['id']}/unshare").status_code == 200
)
assert anon.get(f"/api{share_url}").status_code == 404, "guest: revoked"
assert admin_client.get(f"/api{share_url}").status_code == 404, "admin: revoked"
# ---------- create ---------- # ---------- create ----------
+23 -5
View File
@@ -6,7 +6,10 @@ styles.css so a silent regression (key rename, dropped try/catch, missing
restore, New chat control lost) is caught without a browser. restore, New chat control lost) is caught without a browser.
Pinned design (PLAN §7.4 note / phase 14): Pinned design (PLAN §7.4 note / phase 14):
* versioned key ``bor.chat.v1`` → ``{v: 1, messages: [...]}``, raw text only; * versioned key ``bor.chat.v1`` → ``{v: 1, chatId: string | null,
messages: [...]}`` (phase 55 A2: the shape extends IN PLACE with the
saved_chats row link — a pre-55 record without it reads as null),
raw text only;
* save points: user message on send, brain message on ``done``; * save points: user message on send, brain message on ``done``;
* every ``localStorage`` access wrapped in try/catch (failure-safe); * every ``localStorage`` access wrapped in try/catch (failure-safe);
* size budget ~700k chars, oldest dropped first; * size budget ~700k chars, oldest dropped first;
@@ -45,13 +48,28 @@ def _index() -> str:
def test_versioned_storage_key_and_v1_payload() -> None: def test_versioned_storage_key_and_v1_payload() -> None:
"""`bor.chat.v1` (versioned — a format bump is a clean start) with the """`bor.chat.v1` (versioned — a format bump is a clean start) with the
{v, messages} payload shape (A11: raw localStorage JSON, no library).""" {v, chatId, messages} payload shape (A11: raw localStorage JSON, no
library). Phase 55 (A2): the shape extends IN PLACE with `chatId` —
the saved_chats row link (null when unlinked), so a reload restores
the conversation AND its link; the trimToBudget size probe measures
the same shape."""
js = _js() js = _js()
assert 'const STORAGE_KEY = "bor.chat.v1"' in js assert 'const STORAGE_KEY = "bor.chat.v1"' in js
assert "export const STORAGE_VERSION = 1" in js assert "export const STORAGE_VERSION = 1" in js
# The payload written to the key is always {v: STORAGE_VERSION, messages} # The write carries the version, the row link, and the trimmed
# (two write paths: saveConversation and the trimToBudget size probe). # messages (the single write path — saveConversation).
assert js.count("v: STORAGE_VERSION, messages") >= 2 save_start = js.find("function saveConversation")
save_body = js[save_start : js.find("\n}\n", save_start)]
assert "v: STORAGE_VERSION" in save_body
assert "chatId: currentChatId" in save_body, (
"phase 55: the link is written with the record (null when unlinked)"
)
assert "trimToBudget(conversation)" in save_body
# The size probe measures the same shape (the link is a fixed-length
# field — null stands in for the size estimate).
probe_start = js.find("function trimToBudget")
probe = js[probe_start : js.find("\n}\n", probe_start)]
assert "v: STORAGE_VERSION" in probe and "chatId: null" in probe
# Restore validates the version before trusting anything. # Restore validates the version before trusting anything.
assert "data.v !== STORAGE_VERSION" in js assert "data.v !== STORAGE_VERSION" in js
+480 -158
View File
@@ -1,18 +1,60 @@
"""Unit: the phase-50 task-03 save-chat contract on the chat page. """Unit: the save/share contract on the chat page (phase 50 → phase 55).
The browser behavior itself is E2E-gated by the story suite (task 05); The browser behavior itself is E2E-gated by the story suites (phase 50
like the other frontend-adjacent unit files, this module pins the + phase 55 task 06); like the other frontend-adjacent unit files, this
JS/CSS/HTML markers the save/load contract depends on, so a silent module pins the JS/CSS/HTML markers the save/load contract depends on,
regression is caught without a browser: so a silent regression is caught without a browser:
* the ``currentChatId`` lifecycle (set on create/open, cleared by New * phase 55 (A2): the Save pill is GONE — no ``#save-chat-btn`` in
chat and by the 404-PUT fallback); index.html, no ``.save-chat-btn`` in styles.css, no ``saveBtn`` /
* the upsert branch (PUT when linked, POST when not, the 404→recreate ``saveCurrentChat`` symbol in app.js; the headless
fallback, the live-region feedback strings); ``persistConversation()`` upsert (PUT when linked, POST when not, the
404→recreate fallback) is wired to the save points (the user send in
runTurn's ``!reask`` block, ``rememberBrainTurn`` — the pagehide
partial rides it, no direct call there) with the A2 quiet contract
(one-line status note on failure, NO error banner, silent success)
and the module-level ``persisting`` double-fire guard;
* the ``bor.chat.v1`` record carries ``chatId`` (the row link survives
reloads; a pre-55 record without the field reads as null — never
throws);
* the ``currentChatId`` lifecycle (set on create/open, hydrated from the
record on the local restore, cleared by New chat and by the 404-PUT
fallback);
* the boot-load precedence (a valid ``?chat=`` uuid + admin replaces the * the boot-load precedence (a valid ``?chat=`` uuid + admin replaces the
local restore and mirrors it to localStorage; anonymous / invalid / local restore and mirrors it to localStorage; anonymous / invalid /
404 / network → the local restore); 404 / network → the local restore);
* the ship-hidden / reveal-for-admin gate on ``#save-chat-btn``. * the phase-55 task-03 Share contract on ``#share-chat-btn``: static,
ALWAYS-VISIBLE markup — no ``hidden`` attribute, NO reveal step
(no ``shareBtn.hidden`` assignment anywhere in app.js), and NEUTRAL
error copy on a failed share (the write surface is public — no
sign-in wording);
* phase 55 task 04 (the share-success toast, owner-locked A4):
``showToast`` — the SINGLE aria-hidden ``.toast`` node (lazy-created
once, reused — no stacking; text via ``textContent``, never
``innerHTML``; the pending dismiss cleared + reflow forced so a
second toast re-runs the entry; ~4s auto-dismiss) is called from
BOTH ``shareCurrentChat`` success branches with their own texts and
NEVER from a failure branch (the error banner is the failure UI);
styles.css ``.toast`` — fixed top-right just under the sticky header
(z-index 1000, brand fill — --bg on --brand 5.2:1 AA, a small
max-width, hidden by default with ``pointer-events: none``), a ~200ms
slide-down + fade entry via ``.toast.is-visible``, and the
reduced-motion override (transform dropped for BOTH states, the
opacity fade kept);
* phase 55 task 05 (the action row, owner-locked A5): a single
``<div class="chat-actions">`` in index.html wraps BOTH pills as its
element children (DOM order New chat → Share), replacing the two
pills as direct children of ``.chat-shell`` — the row sits inside the
shell, above ``#messages``, with the kb-banner / stale-banner /
steering / announcer structure around it untouched; styles.css
``.chat-actions`` — base ``display: flex; flex-direction: row;
align-items: center; gap: 0.6rem`` (the row's cross-axis override of
the column's stretch: the pills keep their intrinsic widths, side by
side, left-aligned) and the ≤640px override
``flex-direction: column; align-items: stretch; gap: 0.5rem`` (full-
width stack, New chat above Share) with the existing ≤640px pill
rules (padding, icon/label handling, the ``.chat-shell`` label
overrides) left intact for the stacked pills.
""" """
from __future__ import annotations from __future__ import annotations
@@ -52,62 +94,29 @@ def _fn(js: str, name: str) -> str:
# ---------- the Save button on the chat page ---------- # ---------- the Save button on the chat page ----------
def test_save_button_ships_hidden_beside_new_chat() -> None: def test_save_pill_is_gone_from_the_chat_page() -> None:
"""#save-chat-btn: a real type=button with the accessible name """Phase 55 (A2): the Save control is RETIRED — there is no
"Save chat", SHIPPED HIDDEN (app.js reveals it for admin only), #save-chat-btn anywhere in index.html (at no width), no
beside #new-chat-btn in .chat-shell inside <main>, above .save-chat-btn rule anywhere in styles.css (base, ≤900px squeeze,
#messages — the two chat-shell actions read as a pair. No other ≤640px overrides), and no saveBtn / saveCurrentChat symbol left in
page carries it (chat-page only, like New chat).""" app.js (the headless persistConversation() replaced the handler)."""
html = _index() html = _index()
btn = re.search(r'<button[^>]*id="save-chat-btn"[^>]*>', html) assert 'id="save-chat-btn"' not in html, "index.html must not carry #save-chat-btn"
assert btn, "index.html must contain #save-chat-btn" assert "save-chat-label" not in html, "no Save label left in index.html"
tag = btn.group(0)
assert 'type="button"' in tag
assert 'aria-label="Save chat"' in tag
assert "hidden" in tag, "the button ships hidden (reveal is app.js's job)"
# Beside New chat: after it, still inside .chat-shell, above #messages.
main_idx = html.find('main id="main"')
shell_idx = html.find('class="container chat-shell"')
new_idx = html.find('id="new-chat-btn"')
messages_idx = html.find('id="messages"')
assert -1 < main_idx < shell_idx < new_idx < btn.start() < messages_idx, (
"the button must sit beside #new-chat-btn in .chat-shell, above #messages"
)
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML):
assert 'id="save-chat-btn"' not in other.read_text(encoding="utf-8"), (
f"{other.name}: the Save button is chat-page only"
)
def test_save_button_css_is_the_exact_new_chat_family() -> None:
"""styles.css: .save-chat-btn carries the EXACT visual family of
.new-chat-btn — solid brand pill (--bg on --brand = 5.2:1, AA),
borderless, 999px radius, ≥44px target, hover lightens the brand
fill; the ≤640px block mirrors the New chat overrides (label stays
visible in .chat-shell, icon hidden there; icon-only elsewhere)."""
css = _css() css = _css()
block = re.search(r"\.save-chat-btn \{([\s\S]*?)\n\}", css) assert "save-chat-btn" not in css, "styles.css must not style .save-chat-btn"
assert block, "styles.css must style .save-chat-btn" assert "save-chat-label" not in css, "styles.css must not style .save-chat-label"
body = block.group(1) # The ≤900px combined squeeze rule drops the Save pill (New chat +
assert "min-height: 44px" in body # auth only).
assert "border-radius: 999px" in body tablet = re.search(r"@media \(max-width: 900px\) \{([\s\S]*?)\n\}", css)
assert "border: 0" in body assert tablet, "tablet media query missing"
assert "background: var(--brand)" in body, "same solid brand fill as New chat" assert ".new-chat-btn, .auth-link { padding: 0.45rem 0.5rem; }" in tablet.group(1), (
assert "color: var(--bg)" in body, "--bg text on --brand = 5.2:1 (AA)" "the tablet squeeze rule is New chat + auth only"
hover = re.search(r"\.save-chat-btn:hover \{([\s\S]*?)\n\}", css)
assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill"
svg = re.search(r"\.save-chat-btn svg \{([\s\S]*?)\n\}", css)
assert svg and "display: none" in svg.group(1), "icon hidden on desktop (like New chat)"
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
assert mobile, "mobile media query missing"
mbody = mobile.group(1)
assert ".save-chat-btn { padding: 0.4rem 0.3rem; }" in mbody, "squeezes with New chat"
assert ".save-chat-label { display: none; }" in mbody
assert ".save-chat-btn svg { display: block; }" in mbody
assert ".chat-shell .save-chat-label { display: inline; }" in mbody, (
"in .chat-shell the label stays visible, as for New chat"
) )
assert ".chat-shell .save-chat-btn svg { display: none; }" in mbody js = _js()
assert "saveBtn" not in js, "no saveBtn symbol left in app.js"
assert "saveCurrentChat" not in js, "no saveCurrentChat symbol left in app.js"
assert 'querySelector("#save-chat-btn")' not in js, "the pill query is gone"
# ---------- currentChatId lifecycle ---------- # ---------- currentChatId lifecycle ----------
@@ -115,20 +124,27 @@ def test_save_button_css_is_the_exact_new_chat_family() -> None:
def test_current_chat_id_module_scope_and_lifecycle() -> None: def test_current_chat_id_module_scope_and_lifecycle() -> None:
"""currentChatId: module scope, string | null — set to the created """currentChatId: module scope, string | null — set to the created
row's id on a fresh Save (201), set to the opened id on a row's id on a fresh auto-save (201), set to the opened id on a
successful boot load, cleared by "New chat" AND by the 404-PUT successful boot load, hydrated from the record on the local restore
fallback (a stale link must never leave the conversation unsaved).""" (phase 55 — the link survives reloads), cleared by "New chat" AND by
the 404-PUT fallback (a stale link must never wedge the
conversation)."""
js = _js() js = _js()
assert "let currentChatId = null" in js, "module-scope link, null = unlinked" assert "let currentChatId = null" in js, "module-scope link, null = unlinked"
# Set on create: the 201 branch links to the created row's id. # Set on create: the 201 branch links to the created row's id.
save_body = _fn(js, "saveCurrentChat") save_body = _fn(js, "persistConversation")
assert "res.status === 201" in save_body assert "res.status === 201" in save_body
assert "currentChatId = String(created.id)" in save_body, ( assert "currentChatId = String(created.id)" in save_body, (
"a fresh Save links to the created row's id" "a fresh auto-save links to the created row's id"
) )
# Set on open: the boot load links to the fetched id. # Set on open: the boot load links to the fetched id.
load_body = _fn(js, "restoreSavedChatFromUrl") load_body = _fn(js, "restoreSavedChatFromUrl")
assert "currentChatId = chatId" in load_body assert "currentChatId = chatId" in load_body
# Hydrated on the local restore: the record carries the link.
restore_body = _fn(js, "restoreConversation")
assert "currentChatId = record ? record.chatId : null" in restore_body, (
"the local restore hydrates the link from the record (phase 55)"
)
# Cleared by New chat. # Cleared by New chat.
new_body = _fn(js, "startNewChat") new_body = _fn(js, "startNewChat")
assert "currentChatId = null" in new_body, "New chat unlinks" assert "currentChatId = null" in new_body, "New chat unlinks"
@@ -137,24 +153,24 @@ def test_current_chat_id_module_scope_and_lifecycle() -> None:
assert save_body.count("currentChatId = null") >= 1 assert save_body.count("currentChatId = null") >= 1
# ---------- the upsert branch ---------- # ---------- the headless auto-save (phase 55, A2) ----------
def test_save_upsert_put_when_linked_post_when_not() -> None: def test_persist_conversation_upsert_semantics() -> None:
"""saveCurrentChat: linked → PUT /api/chats/<id> with the messages """persistConversation (the headless replacement of the phase-50
payload (re-Save updates the SAME row — no title in the body, so the Save handler): the EXACT upsert semantics, unchanged — linked →
row keeps its current one); unlinked → POST /api/chats (the server PUT /api/chats/<id> with the messages payload (the SAME row updates
auto-titles). The 404 from the PUT unlinks and retries as a create. — no title in the body, so the row keeps its current one);
Empty conversation → no request, live-region "Nothing to save unlinked → POST /api/chats (the server auto-titles) and link to the
yet."; success → live-region "Conversation saved." (status text created id (201). The 404 from the PUT unlinks and retries as a
only, no banner); 403/5xx/network → the error banner.""" create — a stale link can never wedge the conversation. Empty
conversation → no-op (no request, no feedback line)."""
js = _js() js = _js()
body = _fn(js, "saveCurrentChat") body = _fn(js, "persistConversation")
# No-op first: nothing to save → live-region line, no fetch. # No-op first: nothing to save → silent return before any fetch.
noop = body.find('sendStatus.textContent = "Nothing to save yet."') noop = body.find("if (!conversation.length) return;")
first_fetch = body.find("await fetch(") first_fetch = body.find("await fetch(")
assert 0 < noop < first_fetch, "the empty-conversation no-op precedes any fetch" assert 0 < noop < first_fetch, "the empty-conversation no-op precedes any fetch"
assert "if (!conversation.length)" in body
# The branch: PUT when linked, POST when not. # The branch: PUT when linked, POST when not.
assert "if (currentChatId)" in body assert "if (currentChatId)" in body
assert '`/api/chats/${currentChatId}`' in body assert '`/api/chats/${currentChatId}`' in body
@@ -170,46 +186,125 @@ def test_save_upsert_put_when_linked_post_when_not() -> None:
assert notfound_idx != -1, "the PUT 404 must be handled" assert notfound_idx != -1, "the PUT 404 must be handled"
fallback = body[notfound_idx:post_idx] fallback = body[notfound_idx:post_idx]
assert "currentChatId = null" in fallback, "the stale link is dropped" assert "currentChatId = null" in fallback, "the stale link is dropped"
# Success is status text only — the live region, never stale — and # The 201 branch links to the created row.
# nothing between the 201 link and the success line may raise a assert "res.status === 201" in body
# banner (the !res.ok branch returns before either). assert "currentChatId = String(created.id)" in body
assert body.count('sendStatus.textContent = "Conversation saved."') == 1
saved_line = 'sendStatus.textContent = "Conversation saved."'
between = body[body.find("res.status === 201") : body.find(saved_line)] def test_persist_conversation_is_headless_and_quiet() -> None:
assert "showErrorBanner" not in between, "no banner on the success path" """The A2 quiet contract: NO error banner anywhere in the helper
# Failures raise an actionable banner (non-ok HTTP + network). (the phase-50 banner lines are gone), NO success status text
assert 'showErrorBanner("Couldn\'t save the conversation — is the app reachable?")' in body ("Conversation saved." is retired — success is silent; the History
assert "check you're still signed in and try again" in body, "403/5xx: actionable line" page is the visible proof), and the failure feedback is the
# The double-click guard releases on EVERY outcome. one-line #send-status note — on BOTH failure paths (non-ok HTTP and
finally_idx = body.rfind("finally") network) — with the "next save point retries" promise. The
assert finally_idx != -1 and "saveBtn.disabled = false" in body[finally_idx:], ( module-level `persisting` flag is the double-fire guard (released
"the button is re-enabled in the finally — never stale" in the finally — never stuck)."""
js = _js()
body = _fn(js, "persistConversation")
assert "showErrorBanner" not in body, "A2: a failed auto-save never raises a banner"
assert "Conversation saved." not in body, "A2: success is silent (no status text)"
note = "Couldn't save automatically — will try on the next message."
assert body.count(note) == 2, "the one-line note covers non-ok AND network failure"
# The non-ok branch notes and returns (no banner, no 201 handling).
notok = body.find("if (!res.ok)")
first_note = body.find(note)
assert -1 < notok < first_note, "the non-ok branch lands on the one-line note"
# The network path (catch) notes too.
catch_idx = body.find("} catch {")
assert catch_idx != -1 and first_note < body.rfind(note) < body.rfind("finally"), (
"the catch branch carries the second note"
) )
# The module-level double-fire guard, released on EVERY outcome.
assert re.search(r"^let persisting = false", js, re.M), (
"the persisting flag is module scope (save points can overlap)"
)
assert "if (persisting) return;" in body, "an in-flight upsert skips the second call"
assert "persisting = true;" in body
finally_idx = body.rfind("finally")
assert finally_idx != -1 and "persisting = false" in body[finally_idx:], (
"the flag is released in the finally — never stuck"
)
def test_auto_save_wired_to_the_save_points() -> None:
"""The headless helper is referenced from the save points: the user
send (runTurn's ``!reask`` block — after the localStorage
saveConversation()) and the brain save point (rememberBrainTurn —
after its saveConversation()). The pagehide partial rides
rememberBrainTurn: NO second direct call there."""
js = _js()
# Save point 1: the user send in runTurn's !reask block.
turn = js.find("async function runTurn")
reask_block = js[js.find("if (!reask) {", turn) : js.find("let wrap = null", turn)]
save1 = reask_block.find("saveConversation();")
persist1 = reask_block.find("persistConversation();")
assert -1 < save1 < persist1, "the user-send save point rides persistConversation()"
# Save point 2: rememberBrainTurn (the brain-done + stop + pagehide path).
body = _fn(js, "rememberBrainTurn")
save2 = body.find("saveConversation();")
persist2 = body.find("persistConversation();")
assert -1 < save2 < persist2, "the brain save point rides persistConversation()"
# The pagehide handler itself carries no direct persist call — the
# partial rides rememberBrainTurn (no extra wiring, phase 20
# contract untouched).
m = re.search(r'window\.addEventListener\("pagehide", \(\) => \{([\s\S]*?)\n\}\);', js)
assert m, "the pagehide handler must exist"
assert "persistConversation" not in m.group(1), (
"the pagehide partial rides rememberBrainTurn — no second call"
)
def test_record_carries_the_row_link() -> None:
"""Phase 55 (A2): the bor.chat.v1 record carries ``chatId``. The
write (saveConversation) persists the CURRENT currentChatId (null
when unlinked) with the versioned record; the reader
(loadStoredRecord) reads it back with old-record safety — a
pre-55 record without the field (or a non-string) reads as null,
never throws; the restore validates the version before trusting
anything."""
js = _js()
save_body = _fn(js, "saveConversation")
assert "chatId: currentChatId" in save_body, ("the write persists the current link")
assert "v: STORAGE_VERSION" in save_body
assert "trimToBudget(conversation)" in save_body
read_body = _fn(js, "loadStoredRecord")
assert "data.v !== STORAGE_VERSION" in read_body, "version validated first"
assert "Array.isArray(data.messages)" in read_body
# Old-record safety: optional field, string check, null fallback.
assert 'typeof data.chatId === "string"' in read_body
assert "data.chatId.length ? data.chatId : null" in read_body
# The defensive message filter survives the reshape.
assert 'm.who === "user" || m.who === "brain"' in read_body
assert 'typeof m.text === "string"' in read_body
# ---------- boot-load precedence ---------- # ---------- boot-load precedence ----------
def test_boot_load_precedence_saved_chat_over_local_restore() -> None: def test_boot_load_precedence_saved_chat_over_local_restore() -> None:
"""Inside the boot IIFE: after fetchIsAdmin() + the reveal gate, """Inside the boot IIFE: after fetchIsAdmin(), restoreSavedChatFromUrl()
restoreSavedChatFromUrl() runs; only when it returns false does the runs; only when it returns false does the phase-14 local restore run
phase-14 local restore run. Header init stays first (shared-module (which hydrates the row link from the record — phase 55). Header init
contract).""" stays first (shared-module contract). The phase-50 Save-reveal line is
GONE — and phase 55 task 03 removed the Share-reveal line too (the
pill is static, always-visible markup: no reveal step at boot)."""
js = _js() js = _js()
boot_start = js.find("(async () => {") boot_start = js.find("(async () => {")
assert boot_start != -1, "the boot IIFE must exist" assert boot_start != -1, "the boot IIFE must exist"
boot = js[boot_start:] boot = js[boot_start:]
init_i = boot.find("await initSharedHeader();") init_i = boot.find("await initSharedHeader();")
admin_i = boot.find("isAdmin = await fetchIsAdmin();") admin_i = boot.find("isAdmin = await fetchIsAdmin();")
reveal_i = boot.find("saveBtn.hidden = !isAdmin")
saved_i = boot.find("await restoreSavedChatFromUrl();") saved_i = boot.find("await restoreSavedChatFromUrl();")
local_i = boot.find("restoreConversation();") local_i = boot.find("restoreConversation();")
assert -1 < init_i < admin_i < reveal_i < saved_i < local_i, ( assert -1 < init_i < admin_i < saved_i < local_i, (
"boot order: header init → whoami → Save reveal → ?chat= load → local fallback" "boot order: header init → whoami → ?chat= load → local fallback"
) )
assert "shareBtn.hidden" not in boot, ("no Share-reveal line left in boot (phase 55 task 03)")
assert "if (!openedSaved) restoreConversation();" in boot, ( assert "if (!openedSaved) restoreConversation();" in boot, (
"the local restore runs ONLY when the saved-chat load did not open" "the local restore runs ONLY when the saved-chat load did not open"
) )
assert "saveBtn" not in boot, "no Save-reveal line left in boot (phase 55)"
def test_boot_load_gates_valid_uuid_and_admin_only() -> None: def test_boot_load_gates_valid_uuid_and_admin_only() -> None:
@@ -261,20 +356,17 @@ def test_boot_load_gates_valid_uuid_and_admin_only() -> None:
# ---------- the reveal gate ---------- # ---------- the reveal gate ----------
def test_save_button_revealed_only_for_admin() -> None: def test_no_save_pill_wiring_left_in_app_js() -> None:
"""The ship-hidden/reveal-for-admin contract: app.js queries """Phase 55 (A2): the Save pill's wiring is GONE — no
#save-chat-btn, binds the click to saveCurrentChat, and the boot #save-chat-btn query, no click binding to a save handler, no boot
IIFE sets saveBtn.hidden = !isAdmin (phase 16 absent-not-hidden — reveal line. The headless persistConversation() replaces all of it
hidden is display:none, no trace for anonymous).""" (no button, no admin gate: every visitor's conversation auto-saves
— the write surface is public, phase 55 task 01)."""
js = _js() js = _js()
assert 'document.querySelector("#save-chat-btn")' in js assert 'querySelector("#save-chat-btn")' not in js, "the pill query is gone"
assert 'saveBtn?.addEventListener("click", saveCurrentChat)' in js assert "saveCurrentChat" not in js, "the button handler is gone"
assert "saveBtn.hidden = !isAdmin" in js, "revealed for admin only, at boot" assert 'addEventListener("click", saveCurrentChat)' not in js, "no save binding"
# The reveal happens in the boot IIFE (after whoami), not at module assert "saveBtn" not in js, "no saveBtn symbol anywhere (the tune form uses its own)"
# evaluation (isAdmin is false there).
boot_start = js.find("(async () => {")
reveal = js.find("saveBtn.hidden = !isAdmin")
assert boot_start < reveal, "the reveal must run at boot, after whoami resolves"
def test_boot_load_adds_no_direct_storage_access() -> None: def test_boot_load_adds_no_direct_storage_access() -> None:
@@ -296,29 +388,32 @@ def test_no_cdn_added() -> None:
# ---------- the Share button on the chat page (phase 51, task 02) ---------- # ---------- the Share button on the chat page (phase 51, task 02) ----------
def test_share_button_ships_hidden_beside_save() -> None: def test_share_button_ships_visible_beside_new_chat() -> None:
"""#share-chat-btn: a real type=button with the accessible name """#share-chat-btn: a real type=button with the accessible name
"Share chat", SHIPPED HIDDEN (app.js reveals it for admin only), "Share chat", SHIPPED VISIBLE to every visitor (phase 55 task 03 —
BESIDE #save-chat-btn in .chat-shell inside <main>, above NO ``hidden`` attribute, no reveal step; the phase-51 admin-only
#messages — the chat-shell actions read as a pair (Save | Share). ship-hidden gate is gone), BESIDE #new-chat-btn in .chat-shell
No other page carries it (chat-page only, like Save).""" inside <main>, above #messages — the chat-shell actions read as a
pair (New chat | Share; the Save pill is gone, phase 55). No other
page carries it (chat-page only, like New chat)."""
html = _index() html = _index()
btn = re.search(r'<button[^>]*id="share-chat-btn"[^>]*>', html) btn = re.search(r'<button[^>]*id="share-chat-btn"[^>]*>', html)
assert btn, "index.html must contain #share-chat-btn" assert btn, "index.html must contain #share-chat-btn"
tag = btn.group(0) tag = btn.group(0)
assert 'type="button"' in tag assert 'type="button"' in tag
assert 'aria-label="Share chat"' in tag assert 'aria-label="Share chat"' in tag
assert "hidden" in tag, "the button ships hidden (reveal is app.js's job)" assert "hidden" not in tag, ("the button ships visible — no reveal step (phase 55 task 03)")
# The label: the visible text is "Share" (the link SVG is aria-hidden # The label: the visible text is "Share" (the link SVG is aria-hidden
# decoration; the aria-label carries the accessible name). # decoration; the aria-label carries the accessible name).
btn_block = html[btn.start() : html.find("</button>", btn.start())] btn_block = html[btn.start() : html.find("</button>", btn.start())]
assert '>Share</span>' in btn_block assert '>Share</span>' in btn_block
# Beside Save: after it, still inside .chat-shell, above #messages. # Beside New chat (the Save pill is gone): after it, still inside
# .chat-shell, above #messages.
shell_idx = html.find('class="container chat-shell"') shell_idx = html.find('class="container chat-shell"')
save_idx = html.find('id="save-chat-btn"') new_idx = html.find('id="new-chat-btn"')
messages_idx = html.find('id="messages"') messages_idx = html.find('id="messages"')
assert -1 < shell_idx < save_idx < btn.start() < messages_idx, ( assert -1 < shell_idx < new_idx < btn.start() < messages_idx, (
"the button must sit beside #save-chat-btn in .chat-shell, above #messages" "the button must sit beside #new-chat-btn in .chat-shell, above #messages"
) )
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
TUNING_HTML, Path(FRONTEND / "history.html")): TUNING_HTML, Path(FRONTEND / "history.html")):
@@ -328,11 +423,13 @@ def test_share_button_ships_hidden_beside_save() -> None:
def test_share_button_css_is_the_exact_save_family() -> None: def test_share_button_css_is_the_exact_save_family() -> None:
"""styles.css: .share-chat-btn carries the EXACT visual family of """styles.css: .share-chat-btn carries the EXACT visual family of the
.save-chat-btn (same solid brand pill — --bg on --brand = 5.2:1, AA; phase-50 Save pill (now the .new-chat-btn family — the Save rules
borderless; 999px radius; ≥44px target; hover lightens the brand are gone with the pill, phase 55): same solid brand pill — --bg on
fill); the ≤640px block mirrors the Save overrides (label stays --brand = 5.2:1, AA; borderless; 999px radius; ≥44px target; hover
visible in .chat-shell, icon hidden there; icon-only elsewhere).""" lightens the brand fill; the ≤640px block mirrors the New chat
overrides (label stays visible in .chat-shell, icon hidden there).
"""
css = _css() css = _css()
block = re.search(r"\.share-chat-btn \{([\s\S]*?)\n\}", css) block = re.search(r"\.share-chat-btn \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .share-chat-btn" assert block, "styles.css must style .share-chat-btn"
@@ -368,9 +465,11 @@ def test_share_current_chat_save_then_share_branch() -> None:
copied — the clipboard try succeeds → the live region reads copied — the clipboard try succeeds → the live region reads
"Share link copied."; the rejection (a non-secure http origin) "Share link copied."; the rejection (a non-secure http origin)
renders the .share-link-fallback field + "Share link ready — copy it renders the .share-link-fallback field + "Share link ready — copy it
from the field." 403/5xx → the actionable banner (signed-out hint); from the field." 403/5xx → the actionable banner (NEUTRAL "try
network → the reachable? banner. The double-click guard releases in again" — the write surface is public, phase 55 task 01, so a 403
the finally — never stale.""" is no longer a sign-in problem for a guest); network → the
reachable? banner. The double-click guard releases in the finally —
never stale."""
js = _js() js = _js()
body = _fn(js, "shareCurrentChat") body = _fn(js, "shareCurrentChat")
# No-op first: nothing to share → live-region line, no fetch. # No-op first: nothing to share → live-region line, no fetch.
@@ -405,11 +504,15 @@ def test_share_current_chat_save_then_share_branch() -> None:
' : "Share link ready — copy it from the field."' ' : "Share link ready — copy it from the field."'
) in body ) in body
# Failures raise an actionable banner (non-ok HTTP + network). # Failures raise an actionable banner (non-ok HTTP + network).
# Phase 55 task 03: the 403/5xx copy is NEUTRAL ("try again") — no
# sign-in wording anywhere in the share handler (the write surface
# is public); the network banner keeps its own line.
assert 'showErrorBanner("Couldn\'t share the conversation — is the app reachable?")' in body assert 'showErrorBanner("Couldn\'t share the conversation — is the app reachable?")' in body
assert "check you're still signed in and try again" in body, "403/5xx: actionable line" neutral = "Couldn't share the conversation — try again."
assert body.count("check you're still signed in and try again") == 2, ( assert body.count(neutral) == 2, (
"both the linked and the unlinked branch carry the non-ok banner" "both the linked and the unlinked branch carry the neutral non-ok banner"
) )
assert "signed in" not in body, "no sign-in wording left in the share handler (task 03)"
# The double-click guard releases on EVERY outcome. # The double-click guard releases on EVERY outcome.
finally_idx = body.rfind("finally") finally_idx = body.rfind("finally")
assert finally_idx != -1 and "shareBtn.disabled = false" in body[finally_idx:], ( assert finally_idx != -1 and "shareBtn.disabled = false" in body[finally_idx:], (
@@ -430,23 +533,239 @@ def test_share_current_chat_save_then_share_branch() -> None:
assert "document.createRange()" in sel and "selectNodeContents(el)" in sel assert "document.createRange()" in sel and "selectNodeContents(el)" in sel
def test_share_button_revealed_only_for_admin() -> None: def test_share_button_has_no_reveal_gate() -> None:
"""The ship-hidden/reveal-for-admin contract: app.js queries """Phase 55 task 03: the pill is VISIBLE TO EVERY VISITOR — app.js
#share-chat-btn, binds the click to shareCurrentChat, and the boot queries #share-chat-btn and binds the click to shareCurrentChat, but
IIFE sets shareBtn.hidden = !isAdmin in the SAME admin-reveal block there is NO reveal step: no ``shareBtn.hidden`` assignment ANYWHERE
as Save (phase 16 absent-not-hidden — no trace for anonymous).""" in app.js (the phase-51 ship-hidden/admin-reveal gate is gone; the
markup ships visible and task 01 opened the write surface to all).
"""
js = _js() js = _js()
assert 'document.querySelector("#share-chat-btn")' in js assert 'document.querySelector("#share-chat-btn")' in js
assert 'shareBtn?.addEventListener("click", shareCurrentChat)' in js assert 'shareBtn?.addEventListener("click", shareCurrentChat)' in js
assert "shareBtn.hidden = !isAdmin" in js, "revealed for admin only, at boot" assert "shareBtn.hidden" not in js, "no reveal step — the pill ships visible to all"
# The reveal happens in the boot IIFE (after whoami), not at module
# evaluation — and right next to Save's own reveal line.
boot_start = js.find("(async () => {") # ---------- the share-success toast (phase 55, task 04, A4) ----------
reveal = js.find("shareBtn.hidden = !isAdmin")
save_reveal = js.find("saveBtn.hidden = !isAdmin")
assert boot_start < save_reveal < reveal, ( def test_show_toast_helper_single_instance_and_aria_hidden() -> None:
"the Share reveal joins the same admin-reveal block as Save" """showToast (A4 owner-locked): a SINGLE node — lazy-created on the
first call and REUSED thereafter (toasts never stack), a plain
``<div class="toast">`` appended to ``document.body``; the text
lands via ``textContent`` (XSS-safe — never innerHTML); the node is
``aria-hidden="true"`` (visual only — #send-status is the
announcer). Re-triggering the entry (a second share while the first
toast is up): clear the pending dismiss timer, remove the visible
state class, force a reflow (``offsetWidth`` — restarts the CSS
transition), re-add the class. Auto-dismiss: a 4000ms timer set
AFTER the visible class is added, removing the class on fire."""
js = _js()
body = _fn(js, "showToast")
# Lazy single instance, appended to <body>, marked visual-only.
assert "if (!toastEl)" in body, "the node is created once, on first use"
assert 'document.createElement("div")' in body
assert 'toastEl.className = "toast"' in body
assert 'toastEl.setAttribute("aria-hidden", "true")' in body, ("A4: visual only")
assert "document.body.appendChild(toastEl)" in body
# textContent only — never innerHTML.
assert "toastEl.textContent = message" in body
assert "innerHTML" not in body, "XSS contract: textContent only"
# Single instance: module-scope node + timer, reused (no stacking).
assert re.search(r"^let toastEl = null", js, re.M), ("the node is module scope")
assert re.search(r"^let toastTimer = 0", js, re.M), ("the timer is module scope")
# Re-trigger order: clear dismiss → remove class → force reflow →
# re-add the visible class.
clear_i = body.find("clearTimeout(toastTimer)")
remove_i = body.find('toastEl.classList.remove("is-visible")')
reflow_i = body.find("void toastEl.offsetWidth")
add_i = body.find('toastEl.classList.add("is-visible")')
assert -1 < clear_i < remove_i < reflow_i < add_i, (
"dismiss cleared → class removed → reflow forced → visible re-added"
) )
# Auto-dismiss ~4s, armed AFTER the visible class is set.
timer_i = body.find("setTimeout")
assert -1 < add_i < timer_i and "4000" in body
assert 'toastEl.classList.remove("is-visible")' in body[timer_i:], (
"the pending dismiss removes the visible state"
)
def test_toast_called_from_both_share_success_branches_only() -> None:
"""shareCurrentChat (task 04): BOTH success paths call showToast
with their own text — the clipboard path → "Share link copied.",
the fallback-field path → "Share link ready — copy it from the
field." — and both calls ride the SUCCESS branch (after the copy,
after the untouched #send-status live-region lines). showToast
appears EXACTLY twice in the handler and never in a failure branch
(the two !res.ok banners precede the copy; the network catch —
the error banner is the failure UI — carries no toast)."""
js = _js()
body = _fn(js, "shareCurrentChat")
assert body.count("showToast(") == 2, "exactly one toast per success path"
copy_i = body.find("copyShareLinkWithFallback(absoluteShareUrl(shareUrl))")
assert copy_i != -1, "the copy (the success branch) must exist"
t1 = body.find('showToast("Share link copied.")')
t2 = body.find('showToast("Share link ready — copy it from the field.")')
assert t1 != -1 and t2 != -1, "both success paths toast their own text"
assert -1 < copy_i < min(t1, t2), ("the toasts ride the SUCCESS branch (after the copy)")
# The #send-status lines stay exactly as they were (the a11y
# announcer) and precede the toast calls.
status_i = body.find("sendStatus.textContent = copied")
assert -1 < status_i < min(t1, t2)
# Never on failure: the catch block carries no toast.
catch_i = body.rfind("} catch {")
assert catch_i != -1 and "showToast" not in body[catch_i:], (
"a failed share shows the error banner, no toast"
)
def test_toast_css_top_right_brand_family_and_reduced_motion() -> None:
"""styles.css (task 04): .toast — position: fixed, top-right just
under the sticky header (--header-h + offset — the variable steps
64px → 58px at ≤640px), z-index 1000 (the modal overlay contract —
above the header's 20), a small max-width so long text wraps, the
solid brand fill (--bg text on --brand = 5.2:1, AA — the
.new-chat-btn family), rounded + shadowed. Hidden by default
(opacity 0 + pointer-events: none — it never intercepts clicks when
idle) and resting at translateY(-8px), with the ~200ms entry
transition; .toast.is-visible lands at opacity 1 / translateY(0).
Under prefers-reduced-motion: reduce the transform is dropped for
BOTH states (.is-visible would otherwise out-specify the bare
.toast) and the opacity fade remains."""
css = _css()
block = re.search(r"\.toast \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .toast"
body = block.group(1)
for prop in (
"position: fixed",
"top: calc(var(--header-h) + 0.75rem)",
"right: 1rem",
"z-index: 1000",
"max-width: min(22rem, calc(100vw - 2rem))",
"background: var(--brand)",
"color: var(--bg)",
"border-radius: var(--radius-sm)",
"box-shadow: var(--shadow)",
"opacity: 0",
"pointer-events: none",
"transform: translateY(-8px)",
):
assert prop in body, f".toast must keep {prop}"
assert "transition:" in body and "200ms" in body, ("the entry is a ~200ms slide-down + fade")
visible = re.search(r"\.toast\.is-visible \{([\s\S]*?)\n\}", css)
assert visible, "the .toast.is-visible state class (toggled by showToast) must exist"
assert "opacity: 1" in visible.group(1)
assert "transform: translateY(0)" in visible.group(1)
# The reduced-motion override: transform dropped (BOTH states
# named), the opacity fade kept.
rm = None
for m in re.finditer(r"@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}", css):
if ".toast" in m.group(1):
rm = m.group(1)
break
assert rm is not None, "a reduced-motion block must cover .toast"
assert ".toast.is-visible { transform: none; }" in rm, ("the slide is dropped for BOTH states")
assert re.search(r"\.toast \{ transition: opacity", rm), ("the opacity fade remains")
# ---------- the chat-actions row (phase 55, task 05, A5) ----------
def test_chat_actions_wrapper_holds_both_pills_in_order() -> None:
"""index.html (task 05, A5): ONE ``<div class="chat-actions">``
wraps BOTH pills — its element children are exactly the two
buttons, in the A5 order New chat → Share. The wrapper replaces
the two pills as direct children of ``.chat-shell`` (a normal
column child): inside the shell, above ``#messages``; nothing else
lands between the steering announcer and the row, and nothing but
the phase-49 comment lands between the row and ``#messages``. No
other page carries ``.chat-actions`` (chat-page only, like the
pills)."""
html = _index()
start = html.find('<div class="chat-actions">')
assert start != -1, "index.html must carry the .chat-actions wrapper"
end = html.find("</div>", start)
assert end != -1, "the wrapper must close"
wrap = html[start:end]
# Exactly two element children: the two pill buttons, New chat first.
assert wrap.count("<div") == 1, "no nested div inside the row wrapper"
assert wrap.count("<button") == 2, "the row holds exactly the two pills"
new_i = wrap.find('id="new-chat-btn"')
share_i = wrap.find('id="share-chat-btn"')
assert -1 < new_i < share_i, "A5 order: New chat first, then Share"
# Position: a .chat-shell column child, above #messages — the
# kb-banner / stale-banner / steering / announcer structure is
# untouched (nothing else with an id around the row).
shell_idx = html.find('class="container chat-shell"')
messages_idx = html.find('id="messages"')
assert -1 < shell_idx < start < end < messages_idx, (
"the row is a .chat-shell column child, above #messages"
)
ann_idx = html.find('id="steering-announcer"')
between = html[html.find("</p>", ann_idx):start]
assert "id=" not in between and "<button" not in between, (
"no other element lands between the announcer and the row"
)
after = html[end:messages_idx]
assert "id=" not in after and "<button" not in after, (
"nothing but the phase-49 comment lands between the row and #messages"
)
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
TUNING_HTML, Path(FRONTEND / "history.html")):
assert "chat-actions" not in other.read_text(encoding="utf-8"), (
f"{other.name}: the action row is chat-page only"
)
def test_chat_actions_row_on_desktop_and_stack_at_640() -> None:
"""styles.css (task 05, A5): the base ``.chat-actions`` rule is a
horizontal flex row — ``display: flex; flex-direction: row;
align-items: center; gap: 0.6rem``. The ``align-items: center`` is
load-bearing: the wrapper is a flex ITEM of the ``.chat-shell``
column (which stretches its items), and the row's own
cross-axis ``center`` (not the column default ``stretch``) keeps
each pill at its intrinsic content width — two pills side by side,
left-aligned, never full-column. The ≤640px override flips the
row to a full-width vertical stack — ``flex-direction: column;
align-items: stretch; gap: 0.5rem`` (New chat above Share) — and
the EXISTING ≤640px pill rules (padding squeeze, the icon/label
handling, the ``.chat-shell`` label overrides) stay in place for
the stacked pills."""
css = _css()
block = re.search(r"\.chat-actions \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .chat-actions (base row)"
for prop in (
"display: flex",
"flex-direction: row",
"align-items: center",
"gap: 0.6rem",
):
assert prop in block.group(1), f".chat-actions must keep {prop}"
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
assert mobile, "mobile media query missing"
mbody = mobile.group(1)
m = re.search(r"\.chat-actions \{([^}]*)\}", mbody)
assert m, "the ≤640px override (vertical stack) must exist"
for prop in (
"flex-direction: column",
"align-items: stretch",
"gap: 0.5rem",
):
assert prop in m.group(1), f"the ≤640px .chat-actions must keep {prop}"
# The stacked pills keep their existing mobile treatment (the rules
# the phase-50/51 pairs established — untouched by this task).
for rule in (
".new-chat-btn { padding: 0.4rem 0.3rem; }",
".share-chat-btn { padding: 0.4rem 0.3rem; }",
".new-chat-label { display: none; }",
".share-chat-label { display: none; }",
".chat-shell .new-chat-label { display: inline; }",
".chat-shell .new-chat-btn svg { display: none; }",
".chat-shell .share-chat-label { display: inline; }",
".chat-shell .share-chat-btn svg { display: none; }",
):
assert rule in mbody, f"the existing ≤640px pill rule must stay: {rule}"
# ---------- stale saved chat: banner + Regenerate (phase 53, task 05) ---------- # ---------- stale saved chat: banner + Regenerate (phase 53, task 05) ----------
@@ -571,7 +890,7 @@ def test_stale_regenerate_persists_the_linked_row() -> None:
"""The post-regenerate persist (the existing upsert path): linked → """The post-regenerate persist (the existing upsert path): linked →
PUT /api/chats/<id> (the server re-stamps sources_version → the row PUT /api/chats/<id> (the server re-stamps sources_version → the row
is fresh); a 404 (the row was deleted from History meanwhile) is fresh); a 404 (the row was deleted from History meanwhile)
follows saveCurrentChat's stale-link rule — unlink + recreate follows persistConversation's stale-link rule — unlink + recreate
(POST), and the recreate links the new id. Success hides the banner (POST), and the recreate links the new id. Success hides the banner
AND announces the outcome in the #send-status live region; 403/5xx AND announces the outcome in the #send-status live region; 403/5xx
→ the actionable error banner (the row stays as the turn left it); → the actionable error banner (the row stays as the turn left it);
@@ -616,19 +935,22 @@ def test_stale_regenerate_binding_and_element_queries() -> None:
assert 'staleRegenBtn?.addEventListener("click", regenerateStaleChat)' in js assert 'staleRegenBtn?.addEventListener("click", regenerateStaleChat)' in js
def test_stale_banner_cleared_on_new_chat_and_resave() -> None: def test_stale_banner_cleared_on_new_chat_and_autosave() -> None:
"""Never-stale (PLAN §7.4): "New chat" replaces the conversation the """Never-stale (PLAN §7.4): "New chat" replaces the conversation the
banner described (and unlinks it) — the banner hides; a successful banner described (and unlinks it) — the banner hides; a successful
manual re-Save re-stamps the row to the current generation (task auto-save re-stamps the row to the current generation (task 03) —
03) — the banner is done the moment the save succeeds.""" the banner is done the moment the save succeeds (on the success
path only — after the !res.ok early return and the 201 link)."""
js = _js() js = _js()
new_body = _fn(js, "startNewChat") new_body = _fn(js, "startNewChat")
assert "staleBanner.hidden = true" in new_body, "New chat hides the banner" assert "staleBanner.hidden = true" in new_body, "New chat hides the banner"
save_body = _fn(js, "saveCurrentChat") save_body = _fn(js, "persistConversation")
saved_line = 'sendStatus.textContent = "Conversation saved."' notok_idx = save_body.find("if (!res.ok)")
after = save_body[save_body.find(saved_line):] two01_idx = save_body.find("res.status === 201")
assert "staleBanner.hidden = true" in after, ( hide_idx = save_body.find("staleBanner.hidden = true")
"a successful re-save re-stamps the row — the banner is done" catch_idx = save_body.find("} catch {")
assert -1 < notok_idx < two01_idx < hide_idx < catch_idx, (
"the banner clears on the success path, after the 201 link, never on failure"
) )