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
+53 -24
View File
@@ -6,15 +6,21 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_share_chat.py -v --no-cov
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
hidden) on an UNSAVED conversation saves AND shares in ONE action
(``POST /api/chats`` with ``share: true`` — the row appears in
``GET /api/chats`` with a non-null ``share_url`` of the shape
``/shared/<uuid4>``); 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);
hidden) on the AUTO-SAVED conversation shares the linked row via
``POST /api/chats/<id>/share`` — the row appears in ``GET
/api/chats`` with a non-null ``share_url`` of the shape
``/shared/<uuid4>`` (the unlinked save-then-share one-action wire
path — ``POST /api/chats`` with ``share: true`` — is pinned by the
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
cookies) opening ``/shared/<token>`` sees the full conversation
read-only through the same record shape: title = the auto-title,
@@ -49,6 +55,7 @@ from __future__ import annotations
import asyncio
import re
import time
from pathlib import Path
from threading import Thread
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)
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 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
# in one action; the API row carries the /shared/<uuid> link
# 1. Share from the chat page: the AUTO-SAVED conversation is shared
# (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)
# Admin: the Share pill is revealed (ships hidden, whoami reveals
# it — the same block as Save). The conversation is UNSAVED at this
# point: no row exists yet under the auto-title.
# it). Phase 55: the conversation is AUTO-SAVED by the time the
# 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")
expect(share).to_be_visible()
expect(share).to_have_attribute("aria-label", "Share chat")
cookies = _admin_cookies(page)
assert (
_find_row(_chats(app_url, cookies), _auto_title(q)) is None
), "the conversation is unsaved before the Share click"
row = _wait_saved_row(app_url, cookies, _auto_title(q))
assert row["message_count"] == 2, "auto-saved with both messages before the Share click"
_grant_clipboard(page, app_url)
_click_share_and_assert_status(page, app_url)
created: str | None = None
try:
# The API agrees: ONE action saved AND shared — the new row
# exists with a non-null share_url of the token shape.
# The API agrees: the Share click shared the auto-saved row —
# 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))
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"
share_url = row.get("share_url")
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)"
_ask(page, q)
# Save first (this test drives the History column, not the
# save-then-share one-action path — test 1 covers that).
page.locator("#save-chat-btn").click()
expect(page.locator("#send-status")).to_have_text("Conversation saved.")
# Phase 55: the conversation is already AUTO-SAVED by the time the
# answer settles (no Save pill) — wait for the row via the admin
# list (this test drives the History column).
cookies = _admin_cookies(page)
_grant_clipboard(page, app_url)
row = _find_row(_chats(app_url, cookies), _auto_title(q))
assert row is not None
row = _wait_saved_row(app_url, cookies, _auto_title(q))
chat_id: str = row["id"]
anon_ctx: BrowserContext | None = None
try: