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

This commit is contained in:
2026-08-31 05:20:25 -04:00
parent c564e317ed
commit 914097abcf
17 changed files with 1803 additions and 491 deletions
+69 -45
View File
@@ -6,13 +6,17 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_chat_history.py -v --no-cov
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
whoami reveals it): the current conversation POSTs to ``/api/chats``
(auto-title = the first question, whitespace-collapsed, 120-char cap)
and links to the created row; a re-Save PUTs the SAME row (upsert);
"New chat" unlinks, so the next Save creates again;
* **Auto-save** — on the chat page, no control (the pill is GONE,
phase 55): the current conversation upserts itself at the save
points — create on the first user message (auto-title = the first
question, whitespace-collapsed, 120-char cap) and update on each
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
table (Title | Messages | Updated | Actions); the Title cell IS the
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
that row; a deleted/unknown id degrades to the local restore with the
error banner;
* **Anonymous** — no Save button, 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.
* **Anonymous** — no Save control (the element is absent from the DOM
at every width — phase 55), but the Share pill IS visible (phase 55
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
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
import asyncio
import time
from pathlib import Path
from threading import Thread
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)
def _save(page: Page) -> None:
"""Press Save and wait for the live-region confirmation (the
never-stale contract: the status line is the success feedback)."""
page.locator("#save-chat-btn").click()
expect(page.locator("#send-status")).to_have_text("Conversation saved.")
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})")
# ---------------------------------------------------------------------------
# 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)"
_ask(page, q)
# Admin: the Save pill is revealed (ship-hidden, whoami reveals it).
save = page.locator("#save-chat-btn")
expect(save).to_be_visible()
expect(save).to_have_attribute("aria-label", "Save chat")
save.click()
expect(page.locator("#send-status")).to_have_text("Conversation saved.")
# Phase 55: there is NO Save control — the conversation auto-saved
# at the save points (create on the first question, update on the
# brain-done). There is nothing to click and no status line to wait
# on (A2 silent): the API is the proof.
# Also: the pill is gone from the DOM at every width.
expect(page.locator("#save-chat-btn")).to_have_count(0)
cookies = _admin_cookies(page)
created: str | None = None
try:
# The API agrees: the row exists, auto-titled from the first
# question (whitespace-collapsed, <=120 chars), two messages.
row = _find_row(_chats(app_url, cookies), " ".join(q.split())[:120])
assert row is not None, "the saved chat row must exist"
row = _wait_saved_row(app_url, cookies, " ".join(q.split())[:120])
assert row["message_count"] == 2
created = row["id"]
@@ -221,10 +242,10 @@ def test_open_chat_returns_to_history(
# The answer text the History session saw (rendered bubble).
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)
row = _find_row(_chats(app_url, cookies), q)
assert row is not None
row = _wait_saved_row(app_url, cookies, q)
chat_id = row["id"]
try:
# 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)")
expect(page.locator(".msg.user .bubble")).to_have_count(2)
# …and a re-Save UPSERTS: the same single row, count grown to 4.
_save(page)
# …and the next brain-done auto-save UPSERTS: the same single
# 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]
assert len(mine) == 1, "the re-Save must not spawn a second row"
assert mine[0]["id"] == chat_id, "the re-Save updates the SAME row"
assert mine[0]["message_count"] == 4
assert len(mine) == 1, "the auto-save must not spawn a second row"
assert mine[0]["id"] == chat_id, "the auto-save updates the SAME row"
assert row2["message_count"] == 4
finally:
_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)"
_ask(page, q1)
_save(page)
cookies = _admin_cookies(page)
cleanup: list[str] = []
try:
row1 = _find_row(_chats(app_url, cookies), q1)
assert row1 is not None
row1 = _wait_saved_row(app_url, cookies, q1) # auto-saved (phase 55)
cleanup.append(row1["id"])
# 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(".msg")).to_have_count(0)
# A fresh conversation, saved: a NEW row (a create, not the
# previous row's update) — the list now carries two of ours.
# A fresh conversation, auto-saved (phase 55): a NEW row (a
# 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)"
_ask(page, q2)
_save(page)
_wait_saved_row(app_url, cookies, q2) # wait for the fresh auto-save
rows = _chats(app_url, cookies)
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"
@@ -343,10 +364,8 @@ def test_delete_two_step(
q = "How is my Kubernetes cluster set up? (hist-delete)"
_ask(page, q)
_save(page)
cookies = _admin_cookies(page)
row = _find_row(_chats(app_url, cookies), q)
assert row is not None
row = _wait_saved_row(app_url, cookies, q) # auto-saved (phase 55)
chat_id = row["id"]
try:
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
# ---------------------------------------------------------------------------
@@ -414,9 +434,13 @@ def test_anonymous_cannot(
page.goto(app_url + "/")
# Settled anonymous state (the whoami round-trip has landed)…
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
# …and the phase-50 surface is absent for anonymous: no Save pill,
# no History nav link (both ship hidden and stay hidden).
expect(page.locator("#save-chat-btn")).to_be_hidden()
# …and the phase-50 surface is absent for anonymous: no Save control
# (the element is GONE from the DOM — phase 55 — there is nothing
# 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()
# Direct visit to the History page: it loads and shows the gated