feat(chat): save by default + share anonymously — auto-saved chats, guest-facing Share, success toast, action row
This commit is contained in:
+67
-15
@@ -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
|
||||||
|
|||||||
+240
-117
@@ -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,9 +955,14 @@ 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,
|
||||||
|
* messages: [{ who: "user"|"brain", text,
|
||||||
* sources?, deflected?, suggestions?,
|
* sources?, deflected?, suggestions?,
|
||||||
* thinking?, tools?, stopped? }] }
|
* thinking?, tools?, stopped? }] }
|
||||||
*
|
*
|
||||||
@@ -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 {
|
||||||
|
chatId,
|
||||||
|
messages: data.messages.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
m &&
|
m &&
|
||||||
(m.who === "user" || m.who === "brain") &&
|
(m.who === "user" || m.who === "brain") &&
|
||||||
typeof m.text === "string" &&
|
typeof m.text === "string" &&
|
||||||
m.text.length > 0
|
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
@@ -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. */
|
||||||
|
|||||||
+45
-40
@@ -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
|
||||||
|
HORIZONTAL row on desktop (side by side, left-aligned, each
|
||||||
|
pill at its intrinsic width — the row's align-items: center
|
||||||
|
beats the column's stretch) and a VERTICAL stack at the
|
||||||
|
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 14 (module-owned since phase 34 task 02): "New chat"
|
||||||
|
resets the local (localStorage) conversation and clears the
|
||||||
|
rendered list. The binding lives in header.js — dispatches
|
||||||
|
"bor:new-chat" which app.js acts on (it owns the
|
||||||
|
in-flight-turn guard + list reset). -->
|
||||||
|
<div class="chat-actions">
|
||||||
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
||||||
<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>
|
<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>
|
||||||
<span class="new-chat-label">New chat</span>
|
<span class="new-chat-label">New chat</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Phase 50 (owner-locked 2026-08-29, `TODO.md` L5): "Save" stores
|
<!-- Phase 55 (owner-locked A2, 2026-08-31): there is NO Save
|
||||||
the current conversation in Postgres (saved_chats) — admin-only.
|
control — every conversation auto-saves itself (app.js
|
||||||
SHIPS HIDDEN: app.js reveals it only when whoami says admin
|
persistConversation at the phase-14/20 save points). The
|
||||||
(phase 16 absent-not-hidden — `hidden` is display:none, so
|
History page is the visible proof; a failed auto-save leaves
|
||||||
anonymous visitors see no trace). Handler in app.js, upsert +
|
only a one-line status note (never a button, never a banner).
|
||||||
?chat=<id> contract: an unlinked Save POSTs /api/chats (the
|
|
||||||
server auto-titles from the first question, 120-char cap) and
|
|
||||||
links the conversation to the created row's id; a re-Save PUTs
|
|
||||||
the SAME row (the same conversation never spawns a second row);
|
|
||||||
a 404 PUT unlinks and recreates; "New chat" unlinks. Booting at
|
|
||||||
/?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 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). -->
|
|
||||||
<button type="button" class="share-chat-btn" id="share-chat-btn" aria-label="Share 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="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>
|
<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>
|
||||||
<span class="share-chat-label">Share</span>
|
<span class="share-chat-label">Share</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 ----------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user