feat(chat): save and view chat history — admin-only saved_chats, History page, open-a-chat return
This commit is contained in:
+2
-1
@@ -20,10 +20,11 @@ RUN mkdir -p /out/assets \
|
||||
&& esbuild ./assets/login.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/login.js \
|
||||
&& esbuild ./assets/tuning.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/tuning.js \
|
||||
&& esbuild ./assets/git-sources.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/git-sources.js \
|
||||
&& esbuild ./assets/history.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/history.js \
|
||||
&& esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js \
|
||||
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
|
||||
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
|
||||
&& cp ./index.html ./sources.html ./document.html ./login.html ./tuning.html ./git-sources.html /out/
|
||||
&& cp ./index.html ./sources.html ./document.html ./login.html ./tuning.html ./git-sources.html ./history.html /out/
|
||||
|
||||
# ---------- Stage 2: python dependencies ----------
|
||||
FROM docker.io/python:3.12-slim AS python
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""saved_chats: owner-saved chat conversations
|
||||
|
||||
Revision ID: 0008
|
||||
Revises: 0007
|
||||
Create Date: 2026-08-29
|
||||
|
||||
Phase 50 (save-and-view-chat-history story, A13 — one additive,
|
||||
reversible table, no other schema change):
|
||||
|
||||
* ``saved_chats`` — one row per conversation the owner explicitly
|
||||
Saves (the A10 extension, owner permission 2026-08-29):
|
||||
``/api/chat`` stays stateless and nothing is stored about a
|
||||
conversation that was not saved. ``title`` (the API-side auto-title —
|
||||
first question) is plain String(500) so a future rename needs no
|
||||
migration; ``messages`` is JSONB holding the exact ``bor.chat.v1``
|
||||
localStorage record shape (phase 14) so a saved chat restores
|
||||
pixel-identical through the existing ``renderStoredMessage`` path;
|
||||
``created_at``/``updated_at`` are stamped server-side (``updated_at``
|
||||
additionally bumps on every row update via the ORM ``onupdate``).
|
||||
No share-related columns — phase 51 adds ``share_token`` in ``0009``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0008"
|
||||
down_revision = "0007"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"saved_chats",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("title", sa.String(500), nullable=False),
|
||||
sa.Column("messages", postgresql.JSONB(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("saved_chats")
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Saved-chat API — save and view chat history (phase 50, task 02).
|
||||
|
||||
Admin-only CRUD under ``/api/chats`` (the phase-16
|
||||
:func:`app.core.auth.require_admin` gate, applied router-wide exactly
|
||||
like :mod:`app.api.steering`): conversations the owner explicitly
|
||||
**Saves** are stored in Postgres (``saved_chats``, migration 0008).
|
||||
|
||||
A10 extension (owner permission 2026-08-29, recorded per AGENTS.md
|
||||
rule 3 — a recorded revision, not a silent deviation): ``/api/chat``
|
||||
itself stays stateless; nothing is stored about a conversation that was
|
||||
not saved, and phase 14's browser-local persistence is untouched
|
||||
(saving is an additional, explicit action). The stored ``messages``
|
||||
payload is the exact ``bor.chat.v1`` localStorage record shape
|
||||
(phase 14 — raw text, never HTML), so a saved chat restores
|
||||
pixel-identical through the existing ``renderStoredMessage`` path.
|
||||
|
||||
Routes: ``GET`` (list, latest activity first — no payloads), ``POST``
|
||||
(create — auto-title from the first question when no ``title`` is
|
||||
supplied), ``GET /{chat_id}`` (full payload), ``PUT /{chat_id}``
|
||||
(re-Save upsert — full ``messages`` replacement, ``title`` replaced
|
||||
only when supplied), ``DELETE /{chat_id}``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import require_admin
|
||||
from app.db import get_db
|
||||
from app.models import SavedChat
|
||||
from app.schemas import (
|
||||
ChatMessage,
|
||||
SavedChatCreate,
|
||||
SavedChatList,
|
||||
SavedChatOut,
|
||||
SavedChatRow,
|
||||
SavedChatUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/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
|
||||
#: message's text, whitespace-collapsed, truncated to 120 chars.
|
||||
_AUTO_TITLE_MAX = 120
|
||||
|
||||
|
||||
def _auto_title(messages: list[ChatMessage]) -> str:
|
||||
"""The auto-title (owner-locked, phase 50): the **first user
|
||||
message**'s text, whitespace-collapsed, truncated to 120 chars.
|
||||
|
||||
Returns "" when the conversation has no user message (defensive —
|
||||
the UI cannot produce one; the route then falls back to
|
||||
``"Chat <id-hex8>"``).
|
||||
"""
|
||||
first_user = next((m.text for m in messages if m.who == "user"), None)
|
||||
if first_user is None:
|
||||
return ""
|
||||
return " ".join(first_user.split())[:_AUTO_TITLE_MAX]
|
||||
|
||||
|
||||
def _to_out(row: SavedChat) -> SavedChatOut:
|
||||
"""The full-payload response shape (create/get/put)."""
|
||||
return SavedChatOut(
|
||||
id=row.id,
|
||||
title=row.title,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
message_count=len(row.messages),
|
||||
messages=[ChatMessage.model_validate(m) for m in row.messages],
|
||||
)
|
||||
|
||||
|
||||
def _to_row(row: SavedChat) -> SavedChatRow:
|
||||
"""The list-page row shape (no payloads in the list)."""
|
||||
return SavedChatRow(
|
||||
id=row.id,
|
||||
title=row.title,
|
||||
updated_at=row.updated_at,
|
||||
message_count=len(row.messages),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=SavedChatList)
|
||||
def list_chats(
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
) -> SavedChatList:
|
||||
"""All saved chats, latest activity first (``updated_at desc, id
|
||||
desc``) — the History page's table order."""
|
||||
rows = db.scalars(
|
||||
select(SavedChat).order_by(SavedChat.updated_at.desc(), SavedChat.id.desc())
|
||||
).all()
|
||||
return SavedChatList(chats=[_to_row(row) for row in rows])
|
||||
|
||||
|
||||
@router.post("", response_model=SavedChatOut, status_code=201)
|
||||
def create_chat(
|
||||
payload: SavedChatCreate,
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
) -> SavedChatOut:
|
||||
"""Store one explicitly saved conversation (201).
|
||||
|
||||
Auto-title when ``title`` is absent/blank: the first user message's
|
||||
text, whitespace-collapsed, truncated to 120 chars (owner-locked
|
||||
convention); a conversation with no user message (defensive) falls
|
||||
back to ``"Chat <id-hex8>"``.
|
||||
"""
|
||||
title = (payload.title or "").strip() or _auto_title(payload.messages)
|
||||
row = SavedChat(
|
||||
title=title,
|
||||
# Plain model_dump (no exclude_none): the stored JSONB keeps
|
||||
# every bor.chat.v1 key, explicit null included — the phase-37
|
||||
# tool records carry `argument: null` in localStorage, so this
|
||||
# is what makes a real payload round-trip byte-identical (the
|
||||
# restore path is null-safe for every optional key).
|
||||
messages=[m.model_dump() for m in payload.messages],
|
||||
)
|
||||
db.add(row)
|
||||
db.flush() # python-side uuid default lands the id before the fallback
|
||||
if not row.title:
|
||||
row.title = f"Chat {row.id.hex[:8]}"
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return _to_out(row)
|
||||
|
||||
|
||||
@router.get("/{chat_id}", response_model=SavedChatOut)
|
||||
def get_chat(
|
||||
chat_id: uuid.UUID,
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
) -> SavedChatOut:
|
||||
"""One saved chat, full payload (the ``?chat=<id>`` load); 404 when
|
||||
the id is unknown."""
|
||||
row = db.get(SavedChat, chat_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="unknown chat")
|
||||
return _to_out(row)
|
||||
|
||||
|
||||
@router.put("/{chat_id}", response_model=SavedChatOut)
|
||||
def update_chat(
|
||||
chat_id: uuid.UUID,
|
||||
payload: SavedChatUpdate,
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
) -> SavedChatOut:
|
||||
"""Re-Save upsert: full ``messages`` replacement on the same row.
|
||||
|
||||
``title`` is replaced only when supplied (an absent/blank ``title``
|
||||
keeps the current one); 404 when the id is unknown. ``updated_at``
|
||||
bumps via the model's ``onupdate=func.now()`` — the attribute
|
||||
assignment above is the ORM change that triggers it, so the History
|
||||
page's "latest activity first" order follows re-Saves.
|
||||
"""
|
||||
row = db.get(SavedChat, chat_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="unknown chat")
|
||||
row.messages = [m.model_dump() for m in payload.messages]
|
||||
title = (payload.title or "").strip()
|
||||
if title:
|
||||
row.title = title
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return _to_out(row)
|
||||
|
||||
|
||||
@router.delete("/{chat_id}", status_code=204)
|
||||
def delete_chat(
|
||||
chat_id: uuid.UUID,
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
) -> Response:
|
||||
"""Remove a saved chat; 404 when the id is unknown."""
|
||||
row = db.get(SavedChat, chat_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="unknown chat")
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return Response(status_code=204)
|
||||
+7
-5
@@ -5,9 +5,10 @@ Two layers, one module:
|
||||
* **Version token** (``asset_version()``) — the value the HTML pages
|
||||
append to their asset URLs (``?v=<token>``).
|
||||
* **Response middleware** (``CachingMiddleware`` / ``configure_caching``)
|
||||
— applies the caching behavior at the transport layer: the five known
|
||||
HTML pages are always revalidated (``no-cache``) and their local asset
|
||||
references are rewritten to carry ``?v=<token>``; ``/assets/*`` is
|
||||
— applies the caching behavior at the transport layer: the known
|
||||
HTML pages (``HTML_PAGES``) are always revalidated (``no-cache``) and
|
||||
their local asset references are rewritten to carry ``?v=<token>``;
|
||||
``/assets/*`` is
|
||||
served ``immutable`` for a year; everything else — all of ``/api/*``,
|
||||
including the SSE chat stream — passes through byte-identical.
|
||||
|
||||
@@ -123,6 +124,7 @@ HTML_PAGES: tuple[str, ...] = (
|
||||
"/login.html",
|
||||
"/tuning.html",
|
||||
"/git-sources.html", # phase 35: the admin git sources page
|
||||
"/history.html", # phase 50: the admin saved-chats page
|
||||
)
|
||||
|
||||
#: Prefix of the versioned static assets (header-only caching; the body is
|
||||
@@ -192,8 +194,8 @@ class CachingMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
* ``/assets/*`` — ``Cache-Control: public, max-age=31536000, immutable``
|
||||
(header only — the body is never read).
|
||||
* the five known HTML pages — ``Cache-Control: no-cache``, and (for
|
||||
``text/html`` bodies) every local asset reference gains
|
||||
* the known HTML pages (``HTML_PAGES``) — ``Cache-Control: no-cache``,
|
||||
and (for ``text/html`` bodies) every local asset reference gains
|
||||
``?v=<token>``.
|
||||
|
||||
Everything else — all of ``/api/*`` (including the SSE chat stream) —
|
||||
|
||||
@@ -21,6 +21,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.chat import router as chat_router
|
||||
from app.api.chats import router as chats_router
|
||||
from app.api.config import router as config_router
|
||||
from app.api.docs import router as docs_router
|
||||
from app.api.git_sources import router as git_sources_router
|
||||
@@ -71,6 +72,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(chat_router, prefix="/api")
|
||||
app.include_router(steering_router, prefix="/api")
|
||||
app.include_router(sync_router, prefix="/api")
|
||||
app.include_router(chats_router, prefix="/api")
|
||||
|
||||
# Cache busting (phase 33): the five HTML pages revalidate (no-cache)
|
||||
# with ?v=<token> asset refs; /assets/* becomes immutable for a year.
|
||||
|
||||
+36
-1
@@ -17,6 +17,11 @@ Data model — see ``.agent/PLAN.md`` §Data Model:
|
||||
directories) the Sync button and import_docs
|
||||
import (phase 35; ``kind`` discriminator added in
|
||||
phase 38).
|
||||
* ``saved_chats`` — owner-saved chat conversations: one row per
|
||||
explicitly Saved conversation (auto-``title`` +
|
||||
the ``bor.chat.v1`` message list as JSONB, phase
|
||||
14 shape) — phase 50; ``/api/chat`` stays
|
||||
stateless.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -35,7 +40,7 @@ from sqlalchemy import (
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.config import get_settings
|
||||
@@ -162,3 +167,33 @@ class GitSource(Base):
|
||||
#: Unique — Postgres treats NULLs as distinct under a unique index.
|
||||
path: Mapped[str | None] = mapped_column(Text, unique=True)
|
||||
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class SavedChat(Base):
|
||||
"""One owner-saved chat conversation (phase 50).
|
||||
|
||||
Only conversations the owner explicitly **Saves** are stored —
|
||||
``/api/chat`` itself stays stateless (A10, owner-locked extension
|
||||
2026-08-29) and nothing is stored about a conversation that was not
|
||||
saved. ``messages`` holds the exact ``bor.chat.v1`` localStorage
|
||||
record shape (phase 14 — raw text, never HTML), so a saved chat
|
||||
restores pixel-identical through the existing
|
||||
``renderStoredMessage`` path. No share-related columns here —
|
||||
phase 51 adds ``share_token`` in migration 0009.
|
||||
"""
|
||||
|
||||
__tablename__ = "saved_chats"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
#: Auto-title (the first question, API-side). Plain column on purpose:
|
||||
#: a future rename needs no migration.
|
||||
title: Mapped[str] = mapped_column(String(500))
|
||||
#: ``list[dict]`` in the ``bor.chat.v1`` record shape
|
||||
#: (``{who, text, sources?, deflected?, suggestions?, thinking?,
|
||||
#: tools?, stopped?}``); the API always supplies a list, so no
|
||||
#: default is needed.
|
||||
messages: Mapped[list] = mapped_column(JSONB)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
+101
-1
@@ -5,7 +5,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
@@ -282,3 +282,103 @@ class UploadOut(BaseModel):
|
||||
errors: int
|
||||
chunks: int
|
||||
overview: bool
|
||||
|
||||
|
||||
class ToolCall(BaseModel):
|
||||
"""One agent tool-call record (the phase-37 ``tools`` record shape).
|
||||
|
||||
Mirrors the ``{name, argument}`` pair the SSE ``tool`` frames carry
|
||||
(PLAN §4 extension): ``argument`` is the read document's
|
||||
``"source/path"`` for ``read_document`` and null otherwise. Stored
|
||||
inside :class:`ChatMessage.tools` so a saved chat restores the
|
||||
"calling tool" lines pixel-identical (phase 50).
|
||||
"""
|
||||
|
||||
name: str
|
||||
argument: str | None = None
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
"""One conversation record in the ``bor.chat.v1`` localStorage shape
|
||||
(phase 14) — the stored ``messages`` payload of a saved chat (phase 50).
|
||||
|
||||
``{who, text, sources?, deflected?, suggestions?, thinking?, tools?,
|
||||
stopped?}`` — raw text, never HTML, so a saved chat restores
|
||||
pixel-identical through the existing ``renderStoredMessage`` path.
|
||||
``extra="forbid"`` rejects unknown keys (a corrupted or HTML-shaped
|
||||
payload, e.g. a stray ``<b>``-ish extra key) at the boundary with a
|
||||
422, so nothing outside this shape can poison a restored
|
||||
conversation.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
who: Literal["user", "brain"]
|
||||
text: str = Field(min_length=1)
|
||||
sources: list[SourceRef] | None = None
|
||||
deflected: bool | None = None
|
||||
suggestions: list[str] | None = None
|
||||
thinking: str | None = None
|
||||
tools: list[ToolCall] | None = None
|
||||
stopped: bool | None = None
|
||||
|
||||
|
||||
class SavedChatCreate(BaseModel):
|
||||
"""``POST /api/chats`` body (phase 50, task 02).
|
||||
|
||||
``title`` is optional: when absent or blank the API auto-titles the
|
||||
row (the first user message's text, whitespace-collapsed, truncated
|
||||
to 120 chars — the owner-locked convention). ``messages`` must be
|
||||
non-empty — a saved chat with nothing to restore is meaningless.
|
||||
"""
|
||||
|
||||
title: str | None = Field(default=None, max_length=500)
|
||||
messages: list[ChatMessage] = Field(min_length=1)
|
||||
|
||||
|
||||
class SavedChatUpdate(BaseModel):
|
||||
"""``PUT /api/chats/{chat_id}`` body (phase 50, task 02).
|
||||
|
||||
``messages`` is a full replacement (the re-Save upsert semantics —
|
||||
re-Saving the same conversation updates the same row, never a new
|
||||
one). ``title`` is replaced only when supplied — an absent (or
|
||||
blank) ``title`` keeps the row's current title.
|
||||
"""
|
||||
|
||||
title: str | None = Field(default=None, max_length=500)
|
||||
messages: list[ChatMessage] = Field(min_length=1)
|
||||
|
||||
|
||||
class SavedChatOut(BaseModel):
|
||||
"""One saved chat, full payload (create/get/put response, phase 50).
|
||||
|
||||
``messages`` round-trips the ``bor.chat.v1`` record list losslessly
|
||||
— the restore path is pixel-identical by construction.
|
||||
"""
|
||||
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
message_count: int
|
||||
messages: list[ChatMessage]
|
||||
|
||||
|
||||
class SavedChatRow(BaseModel):
|
||||
"""One row of ``GET /api/chats`` (the History page's list shape).
|
||||
|
||||
No payloads in the list — the row carries only what the table needs
|
||||
(Title, Messages count, Updated).
|
||||
"""
|
||||
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
updated_at: datetime
|
||||
message_count: int
|
||||
|
||||
|
||||
class SavedChatList(BaseModel):
|
||||
"""``GET /api/chats`` response: saved chats, latest activity first
|
||||
(``updated_at desc, id desc``)."""
|
||||
|
||||
chats: list[SavedChatRow]
|
||||
|
||||
+167
-1
@@ -124,6 +124,37 @@
|
||||
* while a turn is in flight. No banner, no scroll (phase 42): the fresh
|
||||
* bubble lands where the old one was.
|
||||
*
|
||||
* Save the conversation (phase 50, owner-locked 2026-08-29, TODO.md L5):
|
||||
* the "Save" pill (#save-chat-btn — admin-only, SHIPS HIDDEN, revealed at
|
||||
* boot only for admin: absent, not hidden, for anonymous) stores the
|
||||
* CURRENT conversation in Postgres (saved_chats, migration 0008) through
|
||||
* the admin-only /api/chats CRUD. Upsert semantics keyed by
|
||||
* `currentChatId` (module scope, string | null): a Save while unlinked
|
||||
* 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 while linked PUTs the SAME row — the same conversation never
|
||||
* spawns a second row; a 404 from that PUT (the row was deleted on the
|
||||
* History page behind our back) unlinks and retries as a create, so a
|
||||
* stale link can never leave the conversation unsaved. "New chat"
|
||||
* unlinks (a fresh conversation is unlinked until saved again). Boot
|
||||
* load: /?chat=<id> with a VALID uuid AND admin fetches the row and
|
||||
* renders its messages through the SAME renderStoredMessage loop as the
|
||||
* phase-14 local restore (sources / thinking / tools / stopped /
|
||||
* deflection — pixel-identical), links currentChatId to the id, and
|
||||
* mirrors the conversation to localStorage (a plain refresh returns to
|
||||
* it the phase-14 way). The ?chat= param is a ONE-SHOT boot instruction:
|
||||
* the success path normalizes the URL back to / (history.replaceState),
|
||||
* so a later refresh — or a "New chat" + refresh — restores the LOCAL
|
||||
* session (the mirror) instead of re-opening the saved row and evicting
|
||||
* whatever the owner typed since. Invalid/absent param, anonymous (no
|
||||
* fetch — the gate would 403), 404, or network failure: the normal local
|
||||
* restore runs instead (404/network also raise the error banner). Save
|
||||
* feedback
|
||||
* is status text only ("Conversation saved." / "Nothing to save yet.")
|
||||
* — 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.
|
||||
*
|
||||
* All DOM ids match frontend/index.html.
|
||||
*/
|
||||
|
||||
@@ -146,6 +177,7 @@ const sendStatus = document.querySelector("#send-status");
|
||||
const banner = document.querySelector("#kb-banner");
|
||||
const bannerText = document.querySelector("#kb-banner-text");
|
||||
const versionEl = document.querySelector("#app-version");
|
||||
const saveBtn = document.querySelector("#save-chat-btn"); // phase 50: admin-only Save pill (ships hidden)
|
||||
|
||||
/* Phase 39: the display name resolves from one place — window.BOR_BRAND
|
||||
* (the classic assets/brand.js sets it at parse time; its /api/config
|
||||
@@ -981,6 +1013,128 @@ function restoreConversation() {
|
||||
markLastRetryable(); // phase 49: the restored last brain bubble is retryable
|
||||
}
|
||||
|
||||
/* ---------- save & load saved chats (phase 50, owner-locked 2026-08-29) ----------
|
||||
*
|
||||
* `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
|
||||
* successful /?chat=<id> boot load, 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
|
||||
|
||||
/* A uuid — for the ?chat=<id> param. The API's path param is uuid.UUID,
|
||||
* so anything else would 422; the client gate keeps the no-fetch rule
|
||||
* (invalid/absent param → no request, plain local restore). */
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/* Boot load (?chat=<id>, phase 50): when the URL carries a VALID uuid
|
||||
* AND whoami says admin, GET the row and render it through the SAME
|
||||
* renderStoredMessage loop as the local restore (pixel-identical), then
|
||||
* link the conversation to the id and mirror it to localStorage (a plain
|
||||
* refresh returns to it the phase-14 way). Returns true on success. Every
|
||||
* other outcome — invalid or absent param, anonymous (no fetch: the gate
|
||||
* would 403), 404 (deleted), network failure, or an unusable payload —
|
||||
* returns false and the caller falls through to the normal local restore;
|
||||
* the 404/network failures also raise the error banner. The ?chat= param
|
||||
* is a one-shot boot instruction: on success the URL is normalized back
|
||||
* to / (replaceState), so a later refresh or a "New chat" + refresh
|
||||
* restores the LOCAL session (the mirror above) instead of re-opening
|
||||
* the saved row. */
|
||||
async function restoreSavedChatFromUrl() {
|
||||
const chatId = new URLSearchParams(window.location.search).get("chat");
|
||||
if (!chatId || !UUID_RE.test(chatId) || !isAdmin) return false;
|
||||
const unavailable = () => {
|
||||
showErrorBanner("That saved chat isn't available — it may have been deleted.");
|
||||
return false;
|
||||
};
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`/api/chats/${chatId}`);
|
||||
} catch {
|
||||
return unavailable(); // network failure → banner + local restore
|
||||
}
|
||||
if (!res.ok) return unavailable(); // 404 (deleted) / 403 (signed out) / 5xx
|
||||
let data = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
return unavailable(); // malformed body — treat as unavailable
|
||||
}
|
||||
// The API schema guarantees the record shape; the same defensive filter
|
||||
// as loadStoredConversation keeps a corrupted stored row from poisoning
|
||||
// the restore (nothing HTML-shaped, ever).
|
||||
const messages = (Array.isArray(data?.messages) ? data.messages : []).filter(
|
||||
(m) =>
|
||||
m &&
|
||||
(m.who === "user" || m.who === "brain") &&
|
||||
typeof m.text === "string" &&
|
||||
m.text.length > 0
|
||||
);
|
||||
if (!messages.length) return unavailable();
|
||||
conversation = messages; // REPLACES the local conversation (owner-locked)
|
||||
for (const m of conversation) renderStoredMessage(m);
|
||||
markLastRetryable(); // parity with the local restore: Retry on the last brain bubble
|
||||
currentChatId = chatId; // linked: a subsequent Save updates THIS row
|
||||
saveConversation(); // mirror to localStorage — a plain refresh returns here
|
||||
// The ?chat= param is a one-shot boot instruction: normalize the URL
|
||||
// back to / so a later refresh / "New chat" + refresh restores the
|
||||
// LOCAL session (the mirror above) instead of re-opening this row.
|
||||
history.replaceState(null, "", "/");
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Save the current conversation — the #save-chat-btn handler (phase 50).
|
||||
* No-op with a live-region line when there is nothing to save. Upsert:
|
||||
* linked → PUT /api/chats/<id> (re-Save updates the same row; no title in
|
||||
* the body, so the row keeps its current one); unlinked → POST /api/chats
|
||||
* (the server auto-titles) and link to the created id. A 404 from the PUT
|
||||
* — the row was deleted on the History page — unlinks and retries as a
|
||||
* create, then announces the outcome: the owner is never left with an
|
||||
* unsaved conversation because of a stale link. 403/5xx/network → the
|
||||
* error banner with an actionable line (the conversation is intact
|
||||
* locally either way). Success is status text only — the #send-status
|
||||
* live region, never stale (PLAN §7.4); no banner. */
|
||||
async function saveCurrentChat() {
|
||||
if (!conversation.length) {
|
||||
sendStatus.textContent = "Nothing to save yet.";
|
||||
return;
|
||||
}
|
||||
if (saveBtn.disabled) return; // one save at a time (double-click guard)
|
||||
saveBtn.disabled = true;
|
||||
const body = JSON.stringify({ messages: conversation });
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
try {
|
||||
let res;
|
||||
if (currentChatId) {
|
||||
res = await fetch(`/api/chats/${currentChatId}`, { method: "PUT", headers, body });
|
||||
if (res.status === 404) {
|
||||
// Stale link: the row is gone (deleted from History) — unlink and
|
||||
// retry as a create, so the save never silently dies.
|
||||
currentChatId = null;
|
||||
res = await fetch("/api/chats", { method: "POST", headers, body });
|
||||
}
|
||||
} else {
|
||||
res = await fetch("/api/chats", { method: "POST", headers, body });
|
||||
}
|
||||
if (!res.ok) {
|
||||
showErrorBanner(
|
||||
"Couldn't save the conversation — check you're still signed in and try again."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (res.status === 201) {
|
||||
const created = await res.json();
|
||||
currentChatId = String(created.id); // fresh Save: link to the new row
|
||||
}
|
||||
sendStatus.textContent = "Conversation saved.";
|
||||
} catch {
|
||||
showErrorBanner("Couldn't save the conversation — is the app reachable?");
|
||||
} finally {
|
||||
saveBtn.disabled = false; // released on EVERY outcome — never stale
|
||||
}
|
||||
}
|
||||
|
||||
/* Brain message save point (on `done`): raw accumulated text + metadata.
|
||||
Phase 17: meta.thinking and phase 37: meta.tools are optional —
|
||||
`undefined` drops the key from the JSON, so turns without them persist
|
||||
@@ -1031,6 +1185,7 @@ function applyAuthState() {
|
||||
function startNewChat() {
|
||||
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
|
||||
conversation = [];
|
||||
currentChatId = null; // phase 50: a new conversation is unlinked until saved
|
||||
clearStoredConversation();
|
||||
removeTyping();
|
||||
messagesEl.querySelectorAll(".msg").forEach((el) => el.remove());
|
||||
@@ -1389,6 +1544,13 @@ input.addEventListener("keydown", (e) => {
|
||||
});
|
||||
composer.addEventListener("submit", handleSend);
|
||||
|
||||
/* Phase 50 (owner-locked 2026-08-29, TODO.md L5): the Save pill stores
|
||||
* the current conversation in Postgres (the upsert semantics live in
|
||||
* saveCurrentChat). The button ships hidden in index.html; the boot
|
||||
* IIFE below reveals it for admin (absent-not-hidden for anonymous,
|
||||
* phase 16). Status-only feedback — the live region, never stale. */
|
||||
saveBtn?.addEventListener("click", saveCurrentChat);
|
||||
|
||||
/* Navigate-away save point (phase 20, owner choice 2026-08-24 A1):
|
||||
* leaving the chat mid-turn would otherwise drop the in-flight
|
||||
* answer — the brain message persists only on `done`, and
|
||||
@@ -1422,7 +1584,11 @@ window.addEventListener("pagehide", () => {
|
||||
await initSharedHeader(); // header.js: whoami + Sign in/out + steering gate
|
||||
isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami
|
||||
applyAuthState(); // chat page: the auth pair (idempotent with header.js)
|
||||
restoreConversation();
|
||||
if (saveBtn) saveBtn.hidden = !isAdmin; // phase 50: absent-not-hidden (phase 16)
|
||||
// Phase 50: /?chat=<id> (valid uuid + admin) boots into the saved
|
||||
// conversation; every other outcome falls through to the local restore.
|
||||
const openedSaved = await restoreSavedChatFromUrl();
|
||||
if (!openedSaved) restoreConversation();
|
||||
loadSuggestions();
|
||||
loadHealth();
|
||||
})();
|
||||
|
||||
@@ -123,6 +123,12 @@ export async function initSharedHeader() {
|
||||
// contract as the Sources link.
|
||||
const navTuning = document.querySelector("#nav-tuning");
|
||||
if (navTuning) navTuning.hidden = !admin;
|
||||
// Phase 50 (owner permission 2026-08-29, `TODO.md` L5): the History
|
||||
// nav link (the phase-34 one-bar contract — it ships on every page)
|
||||
// — admin-only, the same ship-hidden / reveal-for-admin contract as
|
||||
// the Tuning link above.
|
||||
const navHistory = document.querySelector("#nav-history");
|
||||
if (navHistory) navHistory.hidden = !admin;
|
||||
// Phase 34: the steering panel (phase 15) is module-owned. The
|
||||
// navbar #steering-toggle was removed at owner request (2026-08-28)
|
||||
// — the panel ships hidden and is only kept fresh. Admin: refresh
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/* Brain of Reese — History page (saved chats, phase 50 task 04).
|
||||
*
|
||||
* TODO.md L5 (owner 2026-08-29): "Need a way to save and view chat
|
||||
* history in a new page, then return to that history with a click."
|
||||
*
|
||||
* Wires the admin-only `GET /api/chats` + `DELETE /api/chats/<id>`
|
||||
* endpoints (phase 50 task 02) into the page's full-width table:
|
||||
*
|
||||
* • Title — an `<a href="/?chat=<id>">`: Open IS the title link
|
||||
* ("return to that history with a click") — the chat page boots
|
||||
* into the saved conversation through ?chat= (task 03);
|
||||
* • Messages — the row's message_count;
|
||||
* • Updated — locale date+time, the full ISO in the title attribute;
|
||||
* • Actions — Delete ONLY (phase 51 adds the share column), inline
|
||||
* TWO-STEP confirm (owner-locked 2026-08-29: no native confirm
|
||||
* dialog anywhere in this file) — the first click swaps the button for
|
||||
* "Delete? [Yes] [No]" (focus moves to Yes, so the confirm is
|
||||
* keyboard-reachable), Yes fires the DELETE and removes the row,
|
||||
* No (or a failed request) keeps it.
|
||||
*
|
||||
* Every cell is built with the DOM APIs (textContent) — the title is
|
||||
* user-derived (the auto-title is the first question), so it NEVER
|
||||
* touches innerHTML (XSS-safe by construction, the sources.js house
|
||||
* rule).
|
||||
*
|
||||
* The whoami gate (phase 19 shared-header module, cached promise):
|
||||
* • anonymous → the #history-gate is shown, the table is hidden,
|
||||
* and NO /api/chats request is made at all (the router 403s
|
||||
* anonymous — the story E2E pins the request log);
|
||||
* • admin → the gate hides and `loadChats()` renders the rows; a
|
||||
* 0-row fetch reveals the empty-state row.
|
||||
*
|
||||
* Phase 19/34: the page joins the shared header — initSharedHeader()
|
||||
* runs first (whoami + nav reveal + the steering panel), and the gate
|
||||
* below reuses the SAME cached /api/whoami promise (one request per
|
||||
* page).
|
||||
*/
|
||||
|
||||
import { fetchIsAdmin, initSharedHeader } from "./header.js";
|
||||
|
||||
const tableWrap = document.querySelector("#history-table-wrap");
|
||||
const tbody = document.querySelector("#history-tbody");
|
||||
const emptyRow = document.querySelector("#history-empty-row");
|
||||
const gateEl = document.querySelector("#history-gate");
|
||||
const statusEl = document.querySelector("#history-status");
|
||||
|
||||
/* Action feedback — the role="status" live region above the table
|
||||
(the "never stale" contract: every row action lands a line here,
|
||||
success or failure alike). */
|
||||
function announce(message) {
|
||||
if (statusEl) statusEl.textContent = message;
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
/* One row. The Title cell carries the Open link (/?chat=<id> — the
|
||||
"return to that history with a click" requirement); the Updated
|
||||
cell renders the locale date+time with the full ISO on hover. */
|
||||
function makeRow(chat) {
|
||||
const tr = document.createElement("tr");
|
||||
|
||||
const titleTd = document.createElement("td");
|
||||
titleTd.className = "history-title-cell";
|
||||
titleTd.title = chat.title; // full title on hover (the column ellipsizes)
|
||||
const link = document.createElement("a");
|
||||
link.className = "history-title-link";
|
||||
link.href = "/?chat=" + chat.id; // Open: the chat page boots into this chat
|
||||
link.textContent = chat.title; // user-derived — textContent only
|
||||
titleTd.appendChild(link);
|
||||
tr.appendChild(titleTd);
|
||||
|
||||
const countTd = document.createElement("td");
|
||||
countTd.className = "history-count-cell";
|
||||
countTd.textContent = String(chat.message_count);
|
||||
tr.appendChild(countTd);
|
||||
|
||||
const updatedTd = document.createElement("td");
|
||||
updatedTd.className = "history-updated-cell";
|
||||
updatedTd.title = chat.updated_at; // full ISO on hover
|
||||
updatedTd.textContent = fmtDate(chat.updated_at);
|
||||
tr.appendChild(updatedTd);
|
||||
|
||||
const actionsTd = document.createElement("td");
|
||||
actionsTd.className = "history-actions-cell";
|
||||
actionsTd.appendChild(makeDeleteControl(chat, tr));
|
||||
tr.appendChild(actionsTd);
|
||||
return tr;
|
||||
}
|
||||
|
||||
/* The inline two-step Delete (owner-locked 2026-08-29 — NO native
|
||||
confirm dialog anywhere on this page). The Delete button is
|
||||
replaced, in place, by the "Delete? [Yes] [No]" pair; focus moves
|
||||
to Yes (keyboard-reachable confirm). Yes → DELETE /api/chats/<id>
|
||||
→ the row is removed + the live region line; No or a failed
|
||||
request keeps the row (+ the error line on failure). */
|
||||
function makeDeleteControl(chat, row) {
|
||||
const cell = document.createElement("span");
|
||||
cell.className = "history-actions";
|
||||
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "history-delete";
|
||||
del.setAttribute("aria-label", `Delete saved chat: ${chat.title}`);
|
||||
del.textContent = "Delete";
|
||||
|
||||
function restoreDelete() {
|
||||
cell.replaceChildren(del);
|
||||
del.focus(); // focus returns to the (restored) control
|
||||
}
|
||||
|
||||
del.addEventListener("click", () => {
|
||||
const label = document.createElement("span");
|
||||
label.className = "history-confirm-text";
|
||||
label.textContent = "Delete?";
|
||||
const yes = document.createElement("button");
|
||||
yes.type = "button";
|
||||
yes.className = "history-confirm-yes";
|
||||
yes.textContent = "Yes";
|
||||
const no = document.createElement("button");
|
||||
no.type = "button";
|
||||
no.className = "history-confirm-no";
|
||||
no.textContent = "No";
|
||||
yes.addEventListener("click", () =>
|
||||
confirmDelete(chat, row, yes, restoreDelete));
|
||||
no.addEventListener("click", restoreDelete);
|
||||
cell.replaceChildren(label, yes, no);
|
||||
yes.focus(); // the confirm pair takes over the focus
|
||||
});
|
||||
|
||||
cell.appendChild(del); // the shipped state IS the Delete button
|
||||
return cell;
|
||||
}
|
||||
|
||||
/* The confirmed delete: DELETE /api/chats/<id> → the row is removed
|
||||
(+ the empty-state row reappears when it was the last one) and the
|
||||
live region gets `Deleted "<title>".` A 404 means the row is gone
|
||||
(deleted elsewhere) — drop the stale row and say so. Any other
|
||||
failure or a network error keeps the row, restores the Delete
|
||||
button (retryable), and lands the error line. */
|
||||
async function confirmDelete(chat, row, yesBtn, restoreDelete) {
|
||||
yesBtn.disabled = true; // no double-fire while the request is in flight
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(`/api/chats/${chat.id}`, { method: "DELETE" });
|
||||
} catch {
|
||||
announce(`Couldn't delete "${chat.title}" — is the app reachable?`);
|
||||
restoreDelete();
|
||||
return;
|
||||
}
|
||||
if (r.status === 404) {
|
||||
row.remove();
|
||||
showEmptyIfLast();
|
||||
announce("That chat was already deleted.");
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
announce(`Couldn't delete "${chat.title}" — try again.`);
|
||||
restoreDelete();
|
||||
return;
|
||||
}
|
||||
row.remove();
|
||||
showEmptyIfLast();
|
||||
announce(`Deleted "${chat.title}".`);
|
||||
}
|
||||
|
||||
/* The empty-state row reappears exactly when the last data row was
|
||||
removed (the empty row itself ships in the tbody, hidden). */
|
||||
function showEmptyIfLast() {
|
||||
if (!emptyRow || !tbody) return;
|
||||
emptyRow.hidden = tbody.querySelectorAll("tr").length > 1;
|
||||
}
|
||||
|
||||
/* 0-row fetches, non-2xx, and network failures all land on the
|
||||
empty-state row (the sources.js house fallback — the safe state
|
||||
in every case). */
|
||||
function showEmptyState() {
|
||||
if (!tbody) return;
|
||||
tbody.replaceChildren(emptyRow);
|
||||
if (emptyRow) emptyRow.hidden = false;
|
||||
}
|
||||
|
||||
/* GET /api/chats → render the rows (latest activity first — the
|
||||
server's order). A 0-row fetch shows the empty-state row. */
|
||||
async function loadChats() {
|
||||
if (emptyRow) emptyRow.hidden = true;
|
||||
let r;
|
||||
try {
|
||||
r = await fetch("/api/chats");
|
||||
} catch {
|
||||
showEmptyState();
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
showEmptyState();
|
||||
return;
|
||||
}
|
||||
const { chats } = await r.json();
|
||||
if (!chats.length) {
|
||||
showEmptyState();
|
||||
return;
|
||||
}
|
||||
for (const chat of chats) {
|
||||
tbody.appendChild(makeRow(chat));
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// Phase 19/34: the shared header first (whoami + nav reveal + the
|
||||
// steering panel) — the whoami promise is cached, so the gate below
|
||||
// reuses the SAME single /api/whoami request.
|
||||
await initSharedHeader();
|
||||
if (!(await fetchIsAdmin())) {
|
||||
// Anonymous: the gate in, the table out — and NO /api/chats
|
||||
// request at all: the router 403s anonymous, so the page must
|
||||
// never call it (the story E2E pins the request log).
|
||||
if (tableWrap) tableWrap.hidden = true;
|
||||
if (gateEl) gateEl.hidden = false;
|
||||
return;
|
||||
}
|
||||
if (gateEl) gateEl.hidden = true;
|
||||
loadChats();
|
||||
})();
|
||||
+173
-1
@@ -308,6 +308,36 @@ html::after {
|
||||
whole control below 640px. */
|
||||
.new-chat-btn svg { width: 16px; height: 16px; display: none; }
|
||||
|
||||
/* Phase 50 (owner-locked 2026-08-29, TODO.md L5): the "Save" pill — the
|
||||
EXACT visual family of .new-chat-btn (same declarations, so the two
|
||||
chat-shell actions always read as a pair): solid brand pill, --bg text
|
||||
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 16: header auth controls (Sign in link / Sign out button) — the
|
||||
same ghost pill as New chat, so the bar keeps one visual language.
|
||||
ink-soft on surface ≈6.9:1; hover pair brand-ink/brand-soft ≈6.9:1.
|
||||
@@ -1746,6 +1776,136 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* ---------- History page (phase 50) ----------
|
||||
/history.html: the admin-only saved-chats list (task 04). The
|
||||
FULL-WIDTH table in the 72rem frame (AGENTS.md rule 5 — no skinny
|
||||
single-column list), the same table language as the Sources page
|
||||
(the sources-table family: --line hairlines, the brand-soft-tinted
|
||||
thead, row hover, the scrollable .table-wrap). Every pair reuses
|
||||
the Phase-08 AA palette: brand-ink on brand-soft 6.9:1, ink-soft
|
||||
>=6.9:1, err 9.1:1. :focus-visible via the global 3px outline
|
||||
rule. No CDN, system fonts. */
|
||||
.history-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
/* Action feedback line (role=status): the sync-result shape —
|
||||
ink-soft on surface (>=4.5:1), small mono; the min-height holds
|
||||
the layout so a delete's line never reflows the table. */
|
||||
.history-status {
|
||||
display: block;
|
||||
min-height: 1.2em;
|
||||
color: var(--ink-soft);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8rem;
|
||||
padding-block: 0.25rem;
|
||||
}
|
||||
/* The table is FULL-WIDTH (AGENTS.md rule 5): width 100% inside the
|
||||
standard .container; the .table-wrap card + its horizontal scroll
|
||||
cover narrow widths (the phase-07 responsive contract). */
|
||||
.history-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 640px;
|
||||
font-size: 0.93rem;
|
||||
}
|
||||
.history-table th, .history-table td {
|
||||
text-align: left;
|
||||
padding: 0.7rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.history-table th {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand-ink);
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.history-table tbody tr:hover { background: var(--bg); }
|
||||
.history-table tbody tr:last-child td { border-bottom: 0; }
|
||||
/* Title cell: the Open link — the accent link (brand-ink on surface
|
||||
6.9:1); the column ellipsizes, the full title sits in the title
|
||||
attribute (on the cell AND the link). */
|
||||
.history-title-cell {
|
||||
max-width: 34rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.history-title-link {
|
||||
color: var(--brand-ink);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.history-title-link:hover { text-decoration: underline; }
|
||||
.history-title-link:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
|
||||
/* Messages: the mono numeric readout (ink-soft >=6.9:1). */
|
||||
.history-count-cell { font-family: var(--mono); color: var(--ink-soft); }
|
||||
/* Updated: locale date+time (ink-soft), the full ISO in the title
|
||||
attribute (history.js). */
|
||||
.history-updated-cell { color: var(--ink-soft); white-space: nowrap; }
|
||||
/* Actions: the Delete ghost button (the tuning row-action language)
|
||||
+ the inline two-step confirm pair (phase 50 task 04). */
|
||||
.history-actions { display: inline-flex; align-items: center; gap: 0.4rem; }
|
||||
.history-delete {
|
||||
min-height: 44px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.history-delete:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
|
||||
.history-delete:disabled { opacity: 0.5; cursor: wait; }
|
||||
.history-confirm-text { color: var(--err-ink); font-size: 0.82rem; font-weight: 700; white-space: nowrap; }
|
||||
/* Yes: the error-rose treatment (err-ink on err-bg 9.1:1, err-line
|
||||
border); No: the ghost (transparent, --line border). */
|
||||
.history-confirm-yes {
|
||||
min-height: 44px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--err-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--err-bg);
|
||||
color: var(--err-ink);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.history-confirm-yes:hover:not(:disabled) { background: rgb(239 68 68 / 0.18); }
|
||||
.history-confirm-yes:disabled { opacity: 0.6; cursor: wait; }
|
||||
.history-confirm-no {
|
||||
min-height: 44px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.history-confirm-no:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
||||
/* Empty-state row: the muted centered message at full table width
|
||||
(the .git-sources-empty language, inline in the table). */
|
||||
.history-empty-row td {
|
||||
padding: 2.25rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--ink-soft);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---------- Document viewer (phase 10; two-row header since phase 34) ---------- */
|
||||
/* Phase 34 (owner confirmation 2026-08-26): the viewer header is TWO
|
||||
rows in one sticky <header> — row 1 reuses the standard .app-header /
|
||||
@@ -2177,7 +2337,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
designated clip target, pills squeeze next). */
|
||||
.nav-link { padding: 0.4rem 0.5rem; font-size: 0.85rem; }
|
||||
.app-nav { gap: 0.15rem; }
|
||||
.new-chat-btn, .auth-link { padding: 0.45rem 0.5rem; }
|
||||
.new-chat-btn, .save-chat-btn, .auth-link { padding: 0.45rem 0.5rem; }
|
||||
}
|
||||
|
||||
/* ---------- Responsive (mobile-first adjustments) ---------- */
|
||||
@@ -2280,10 +2440,17 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
.new-chat-btn { padding: 0.4rem 0.3rem; }
|
||||
.new-chat-label { display: none; }
|
||||
.new-chat-btn svg { display: block; }
|
||||
/* Phase 50: the Save pill squeezes with New chat (same family, same
|
||||
rules — the chat-shell overrides below keep both labels visible). */
|
||||
.save-chat-btn { padding: 0.4rem 0.3rem; }
|
||||
.save-chat-label { display: none; }
|
||||
.save-chat-btn svg { display: block; }
|
||||
/* But on the chat page there is room — keep the label visible and
|
||||
hide the icon (the button lives inside .chat-shell, not the navbar). */
|
||||
.chat-shell .new-chat-label { display: inline; }
|
||||
.chat-shell .new-chat-btn svg { display: none; }
|
||||
.chat-shell .save-chat-label { display: inline; }
|
||||
.chat-shell .save-chat-btn svg { display: none; }
|
||||
/* Phase 16: the auth pill goes icon-only like New chat — brand text
|
||||
ellipsizes as the designated squeeze target, no bar overflow. */
|
||||
.auth-link { padding: 0.4rem 0.3rem; }
|
||||
@@ -2392,6 +2559,11 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
#archive-upload-file { min-width: 0; }
|
||||
#git-source-add,
|
||||
#archive-upload-btn { width: 100%; }
|
||||
/* Phase 50: the History table keeps its full width (the .table-wrap
|
||||
horizontal scroll already covers it); the actions cell wraps so
|
||||
the two-step confirm pair fits the phone width. */
|
||||
.history-actions-cell { white-space: normal; }
|
||||
.history-actions { flex-wrap: wrap; }
|
||||
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
|
||||
main { padding-bottom: env(safe-area-inset-bottom, 0); }
|
||||
/* Sync button goes icon-only on mobile; the label hides, aria-label
|
||||
|
||||
@@ -53,6 +53,11 @@
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
<!-- Phase 50 (owner permission 2026-08-29, `TODO.md` L5): the
|
||||
History link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Tuning link above. -->
|
||||
<a href="/history.html" class="nav-link" id="nav-history" hidden>History</a>
|
||||
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
|
||||
|
||||
@@ -50,6 +50,11 @@
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
reveals it once whoami says admin. -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
<!-- Phase 50 (owner permission 2026-08-29, `TODO.md` L5): the
|
||||
History link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Tuning link above. -->
|
||||
<a href="/history.html" class="nav-link" id="nav-history" hidden>History</a>
|
||||
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="description" content="Saved chats — every conversation you saved, one click back.">
|
||||
<title>Saved chats · Brain of Reese</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<header class="app-header">
|
||||
<div class="container header-inner">
|
||||
<span class="brand">
|
||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||
</span>
|
||||
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||
mobile hamburger — visible ≤640px only (CSS); opens the nav as
|
||||
an animated dropdown. Behavior: assets/header.js. -->
|
||||
<button type="button" class="nav-toggle" id="nav-toggle"
|
||||
aria-expanded="false" aria-controls="app-nav" aria-label="Menu">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||
</button>
|
||||
<nav class="app-nav" id="app-nav" aria-label="Primary">
|
||||
<a href="/" class="nav-link">Chat</a>
|
||||
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Sources link is admin-only (owner
|
||||
permission 2026-08-23) — hidden by default, header.js
|
||||
reveals it once whoami says admin. The soft-gated page
|
||||
itself is unchanged. -->
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>
|
||||
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||
link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>
|
||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Global Tuning link is admin-only (owner
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
<!-- Phase 50 (owner permission 2026-08-29, `TODO.md` L5): the
|
||||
History link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Tuning link above. This page IS the current one, so the
|
||||
link carries is-active + aria-current like Tuning on
|
||||
tuning.html. -->
|
||||
<a href="/history.html" class="nav-link is-active" aria-current="page" id="nav-history" hidden>History</a>
|
||||
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" 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 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" 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="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign out</span>
|
||||
</button>
|
||||
</nav>
|
||||
<!-- Phase 15: the tuning-notes panel (stored in Postgres, read
|
||||
into every system prompt) — owned by the shared header
|
||||
module (assets/header.js); the #steering-panel section
|
||||
ships in every page's <main>. The navbar toggle was
|
||||
removed at owner request (2026-08-28): note management
|
||||
lives on /tuning.html. -->
|
||||
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
||||
out is visible; /api/whoami decides at load (the shared
|
||||
header module). Icon-only below 640px (aria-labels keep the
|
||||
accessible names). -->
|
||||
<a href="/login.html?next=/history.html" class="auth-link sign-in-link" id="sign-in-link" 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 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
<button type="button" class="auth-link sign-out-btn" id="sign-out-btn" aria-label="Sign out" 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="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main" class="app-main" tabindex="-1">
|
||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the tuning-notes panel (stored notes, newest
|
||||
first) — rendered + driven by assets/header.js (shared), not
|
||||
the page script. First child of <main> on the non-chat pages;
|
||||
the chat page keeps it after #kb-banner. -->
|
||||
<section class="steering-panel" id="steering-panel" role="region"
|
||||
aria-label="Tuning notes" hidden>
|
||||
<div class="steering-panel-head">
|
||||
<h2 class="steering-panel-title">Tuning notes</h2>
|
||||
<p class="steering-panel-sub">Every note below steers all future answers.</p>
|
||||
</div>
|
||||
<ul class="steering-list" id="steering-list"></ul>
|
||||
<p class="steering-empty" id="steering-empty">No tuning notes yet — press “Tune” under any answer to add one.</p>
|
||||
</section>
|
||||
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||
<div class="container history-shell">
|
||||
<div class="page-head">
|
||||
<h1>Saved chats</h1>
|
||||
<p class="page-sub">
|
||||
Every conversation you pressed <strong>Save</strong> on —
|
||||
newest activity first. Click a title to return to that chat.
|
||||
</p>
|
||||
</div>
|
||||
<!-- Phase 50 (owner permission 2026-08-29): anonymous sign-in
|
||||
gate — the EXACT #sources-gate pattern (phase 16) and the
|
||||
same .sources-gate visual language (phase 35, git-sources):
|
||||
the saved-chat list is what the login locks. Visible for
|
||||
anonymous, hidden for the admin (history.js) — and the
|
||||
page never fetches /api/chats for an anonymous visitor
|
||||
(the router 403s them; the story E2E pins the request
|
||||
log). -->
|
||||
<section class="sources-gate" id="history-gate" aria-labelledby="history-gate-title" hidden>
|
||||
<div class="sources-gate-glyph" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg>
|
||||
</div>
|
||||
<h2 id="history-gate-title">Sign in to view your saved chats</h2>
|
||||
<p class="sources-gate-sub">
|
||||
Saved conversations are admin-only. Chat — and any document an
|
||||
answer cites — stays open to everyone.
|
||||
</p>
|
||||
<a class="sources-gate-link" href="/login.html?next=/history.html">Sign in</a>
|
||||
</section>
|
||||
|
||||
<!-- Live-region feedback for row actions (the "never stale"
|
||||
contract): history.js sets textContent here — a delete's
|
||||
outcome, its error line, nothing else. -->
|
||||
<span class="history-status" id="history-status" role="status" aria-live="polite"></span>
|
||||
|
||||
<!-- Phase 50: the full-width table (AGENTS.md rule 5 — no skinny
|
||||
list): Title (the Open link → /?chat=<id>) | Messages |
|
||||
Updated | Actions (Delete, inline two-step confirm).
|
||||
history.js fills #history-tbody; #history-empty-row ships
|
||||
hidden and is revealed by a 0-row fetch. The Actions column
|
||||
header is visually-hidden — the row buttons carry their own
|
||||
aria-labels. -->
|
||||
<div class="table-wrap history-table-wrap" id="history-table-wrap" role="region" aria-label="Saved chats" tabindex="0">
|
||||
<table class="history-table">
|
||||
<caption class="visually-hidden">Saved chats — click a title to return to that conversation</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Messages</th>
|
||||
<th scope="col">Updated</th>
|
||||
<th scope="col"><span class="visually-hidden">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="history-tbody">
|
||||
<tr class="history-empty-row" id="history-empty-row" hidden>
|
||||
<td colspan="4">No saved chats yet — finish a conversation and press <strong>Save</strong> in the chat.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<div class="container footer-inner">
|
||||
<span>Powered by Reese's self-hosted models</span>
|
||||
<span class="footer-version" id="app-version"></span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Phase 50: the shared header module loads through the page script's
|
||||
own `import "./header.js"` — a hoisted import that is evaluated
|
||||
before the page script body calls initSharedHeader() at boot
|
||||
(no direct header.js <script> tag — single-evaluation design).
|
||||
Phase 39: the brand layer — classic script, first on the page:
|
||||
window.BOR_BRAND at parse time, refreshed from /api/config. -->
|
||||
<script src="assets/brand.js"></script>
|
||||
<script type="module" src="/assets/history.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -43,6 +43,11 @@
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
<!-- Phase 50 (owner permission 2026-08-29, `TODO.md` L5): the
|
||||
History link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Tuning link above. -->
|
||||
<a href="/history.html" class="nav-link" id="nav-history" hidden>History</a>
|
||||
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
|
||||
@@ -109,6 +114,26 @@
|
||||
<span class="new-chat-label">New chat</span>
|
||||
</button>
|
||||
|
||||
<!-- Phase 50 (owner-locked 2026-08-29, `TODO.md` L5): "Save" stores
|
||||
the current conversation in Postgres (saved_chats) — admin-only.
|
||||
SHIPS HIDDEN: app.js reveals it only when whoami says admin
|
||||
(phase 16 absent-not-hidden — `hidden` is display:none, so
|
||||
anonymous visitors see no trace). Handler in app.js, upsert +
|
||||
?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 49 (2026-08-29, TODO.md L4): the meta row under a brain
|
||||
bubble can carry JS-injected actions (app.js) — Tune (admin
|
||||
only, phase 15) and Retry (every visitor; the LAST brain
|
||||
|
||||
@@ -46,6 +46,11 @@
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
<!-- Phase 50 (owner permission 2026-08-29, `TODO.md` L5): the
|
||||
History link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Tuning link above. -->
|
||||
<a href="/history.html" class="nav-link" id="nav-history" hidden>History</a>
|
||||
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
|
||||
|
||||
@@ -43,6 +43,11 @@
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
<!-- Phase 50 (owner permission 2026-08-29, `TODO.md` L5): the
|
||||
History link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Tuning link above. -->
|
||||
<a href="/history.html" class="nav-link" id="nav-history" hidden>History</a>
|
||||
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
|
||||
|
||||
@@ -43,6 +43,11 @@
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/tuning.html" class="nav-link is-active" aria-current="page" id="nav-tuning" hidden>Tuning</a>
|
||||
<!-- Phase 50 (owner permission 2026-08-29, `TODO.md` L5): the
|
||||
History link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Tuning link above. -->
|
||||
<a href="/history.html" class="nav-link" id="nav-history" hidden>History</a>
|
||||
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
|
||||
|
||||
@@ -102,8 +102,9 @@ def test_html_pages_are_no_cache_and_versioned(page: Page, app_url: str) -> None
|
||||
|
||||
|
||||
def test_other_pages_share_the_token(page: Page, app_url: str) -> None:
|
||||
"""/sources.html and /login.html: each document revalidates, and both
|
||||
pages' stylesheet requests carry the same process token."""
|
||||
"""/sources.html, /login.html and /history.html (phase 50): each
|
||||
document revalidates, and all three pages' stylesheet requests carry
|
||||
the same process token."""
|
||||
token = _expected_token()
|
||||
assert token
|
||||
|
||||
@@ -118,7 +119,8 @@ def test_other_pages_share_the_token(page: Page, app_url: str) -> None:
|
||||
|
||||
sources_token = navigate("/sources.html")
|
||||
login_token = navigate("/login.html")
|
||||
assert sources_token == login_token == token
|
||||
history_token = navigate("/history.html") # phase 50: the new page
|
||||
assert sources_token == login_token == history_token == token
|
||||
|
||||
|
||||
def test_api_responses_unaffected(page: Page, app_url: str, db_ready: None) -> None:
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
"""Phase 50 E2E (Playwright): save & view chat history.
|
||||
|
||||
TODO.md L5 (owner 2026-08-29): "Need a way to save and view chat
|
||||
history in a new page, then return to that history with a click"
|
||||
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):
|
||||
|
||||
* **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;
|
||||
* **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"),
|
||||
and Delete is the inline two-step confirm (owner-locked: no native
|
||||
confirm dialog — a real ``window.confirm`` would hang Playwright, so
|
||||
the inline pair appearing is itself pinned);
|
||||
* **Open** — ``/?chat=<id>`` (valid uuid + admin) boots into the saved
|
||||
conversation through the SAME restore path as the phase-14 local
|
||||
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.
|
||||
|
||||
DB isolation: the shared e2e Postgres keeps ``saved_chats`` rows across
|
||||
suites, so every test here uses a DISTINCTIVE question text (its
|
||||
auto-title is therefore unique), never asserts on absolute row counts,
|
||||
and deletes the rows it creates in a ``finally`` (admin cookie). The KB
|
||||
tables are truncated + re-seeded the house way (deterministic mock
|
||||
embeddings); ``saved_chats`` is never touched by the reset.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from playwright.sync_api import 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"
|
||||
#: Phase 10 viewer URL + phase 13 back=/ (the restored chip must be
|
||||
#: byte-identical to the live-rendered one).
|
||||
CHIP_HREF = "/document.html?source=docs&path=homelab%2Fkubernetes.md&back=%2F"
|
||||
|
||||
|
||||
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 _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 _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 _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 _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.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Save on the chat page → the row exists (UI + API agree)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_save_and_see_history(
|
||||
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? (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.")
|
||||
|
||||
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"
|
||||
assert row["message_count"] == 2
|
||||
created = row["id"]
|
||||
|
||||
# The History page shows it: the row for THIS chat carries the
|
||||
# auto-title and the message count.
|
||||
page.goto(app_url + "/history.html")
|
||||
link = page.locator(f"#history-tbody a[href='/?chat={created}']")
|
||||
expect(link).to_be_visible(timeout=15_000)
|
||||
expect(link).to_have_text(" ".join(q.split())[:120])
|
||||
row_tr = page.locator(
|
||||
"#history-tbody tr", has=page.locator(f"a[href='/?chat={created}']")
|
||||
)
|
||||
expect(row_tr.locator(".history-count-cell")).to_have_text("2")
|
||||
finally:
|
||||
if created is not None:
|
||||
_delete_chat(app_url, cookies, created)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. History → click the title → back in the saved conversation; the
|
||||
# conversation continues and a re-Save updates the SAME row
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_open_chat_returns_to_history(
|
||||
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? (hist-open)"
|
||||
_ask(page, q)
|
||||
# The answer text the History session saw (rendered bubble).
|
||||
answer_before = page.locator(".msg.brain .bubble").first.inner_text()
|
||||
|
||||
_save(page)
|
||||
cookies = _admin_cookies(page)
|
||||
row = _find_row(_chats(app_url, cookies), q)
|
||||
assert row is not None
|
||||
chat_id = row["id"]
|
||||
try:
|
||||
# From the History page, the title IS the Open link…
|
||||
page.goto(app_url + "/history.html")
|
||||
link = page.locator(f"#history-tbody a[href='/?chat={chat_id}']")
|
||||
expect(link).to_be_visible(timeout=15_000)
|
||||
|
||||
doc_requests: list[str] = []
|
||||
page.on(
|
||||
"request",
|
||||
lambda r: doc_requests.append(r.url)
|
||||
if r.resource_type == "document"
|
||||
else None,
|
||||
)
|
||||
link.click()
|
||||
|
||||
# …and the click navigates to /?chat=<uuid> ("return to that
|
||||
# history with a click"). app.js then normalizes the one-shot
|
||||
# ?chat= param back to /, so the navigation target itself is
|
||||
# what gets pinned here.
|
||||
assert any(u == app_url + "/?chat=" + chat_id for u in doc_requests), (
|
||||
f"the title link must navigate to /?chat={chat_id}: {doc_requests}"
|
||||
)
|
||||
|
||||
# The chat rendered the saved conversation…
|
||||
expect(page.locator(".msg.user .bubble")).to_have_count(1)
|
||||
expect(page.locator(".msg.user .bubble")).to_contain_text(q)
|
||||
bubble = page.locator(".msg.brain .bubble").first
|
||||
expect(bubble).to_contain_text(q)
|
||||
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER)
|
||||
# …the SAME answer text the History session saw (pixel-identical
|
||||
# restore through renderStoredMessage)…
|
||||
assert bubble.inner_text() == answer_before
|
||||
# …with its source chip restored byte-identically.
|
||||
chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
|
||||
expect(chip).to_have_count(1)
|
||||
expect(chip.first).to_have_attribute("href", CHIP_HREF)
|
||||
|
||||
# The conversation continues: a new turn streams fine…
|
||||
_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)
|
||||
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
|
||||
finally:
|
||||
_delete_chat(app_url, cookies, chat_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. "New chat" unlinks: the next Save is a fresh create, not an update
|
||||
# of the previously saved conversation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_new_chat_unlinks(
|
||||
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)
|
||||
|
||||
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
|
||||
cleanup.append(row1["id"])
|
||||
|
||||
# New chat clears the conversation AND unlinks it from the row.
|
||||
page.locator("#new-chat-btn").click()
|
||||
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.
|
||||
q2 = "How is my Kubernetes cluster set up? (hist-unlink-2)"
|
||||
_ask(page, q2)
|
||||
_save(page)
|
||||
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"
|
||||
a = _find_row(rows, q1)
|
||||
b = _find_row(rows, q2)
|
||||
assert a is not None and b is not None
|
||||
assert a["id"] != b["id"], "the fresh Save must not reuse the old row"
|
||||
assert a["message_count"] == 2, "the unlinked conversation was untouched"
|
||||
cleanup.append(b["id"])
|
||||
finally:
|
||||
for chat_id in cleanup:
|
||||
_delete_chat(app_url, cookies, chat_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Delete: the inline two-step confirm (no native dialog), the row is
|
||||
# gone (the API 404s), and /?chat=<id> degrades to the local restore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_delete_two_step(
|
||||
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? (hist-delete)"
|
||||
_ask(page, q)
|
||||
_save(page)
|
||||
cookies = _admin_cookies(page)
|
||||
row = _find_row(_chats(app_url, cookies), q)
|
||||
assert row is not None
|
||||
chat_id = row["id"]
|
||||
try:
|
||||
page.goto(app_url + "/history.html")
|
||||
tr = page.locator(
|
||||
"#history-tbody tr", has=page.locator(f"a[href='/?chat={chat_id}']")
|
||||
)
|
||||
expect(tr.locator("a.history-title-link")).to_be_visible(timeout=15_000)
|
||||
|
||||
# Step 1: the Delete button swaps, IN PLACE, for the "Delete?
|
||||
# [Yes] [No]" pair — a real window.confirm would hang Playwright
|
||||
# here, so the pair appearing is the pinned contract.
|
||||
tr.locator("button.history-delete").click()
|
||||
expect(tr.locator(".history-confirm-yes")).to_be_visible()
|
||||
expect(tr.locator(".history-confirm-no")).to_be_visible()
|
||||
|
||||
# "No" cancels: the pair is gone, the Delete button returns, the
|
||||
# row stays.
|
||||
tr.locator(".history-confirm-no").click()
|
||||
expect(tr.locator(".history-confirm-yes")).to_have_count(0)
|
||||
expect(tr.locator("button.history-delete")).to_be_visible()
|
||||
expect(page.locator(f"#history-tbody a[href='/?chat={chat_id}']")).to_have_count(1)
|
||||
|
||||
# Step 2: Delete → "Yes" removes the row + the live-region line.
|
||||
tr.locator("button.history-delete").click()
|
||||
tr.locator(".history-confirm-yes").click()
|
||||
expect(page.locator(f"#history-tbody a[href='/?chat={chat_id}']")).to_have_count(0)
|
||||
expect(page.locator("#history-status")).to_contain_text(f'Deleted "{q}".')
|
||||
|
||||
# The API agrees: the id is unknown now.
|
||||
r = httpx.get(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
|
||||
assert r.status_code == 404
|
||||
|
||||
# Degradation: /?chat=<deleted id> shows the error banner and
|
||||
# falls through to the local restore (this context's
|
||||
# localStorage still holds the conversation from the ask above).
|
||||
page.goto(app_url + f"/?chat={chat_id}")
|
||||
banner = page.locator("#kb-banner")
|
||||
expect(banner).to_be_visible(timeout=15_000)
|
||||
expect(banner).to_contain_text("That saved chat isn't available")
|
||||
expect(page.locator(".msg.user .bubble")).to_contain_text(q)
|
||||
expect(page.locator(".msg.brain .bubble").first).to_contain_text(
|
||||
MOCK_ANSWER_MARKER, timeout=15_000
|
||||
)
|
||||
finally:
|
||||
# No-op when the delete above succeeded (the 404 is handled).
|
||||
_delete_chat(app_url, cookies, chat_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Anonymous: no Save button, no History nav link, the History page is
|
||||
# gated WITHOUT fetching /api/chats, and the API 403s
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_anonymous_cannot(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=True)
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
# A fresh context is anonymous by construction (no login).
|
||||
requests: list[str] = []
|
||||
page.on("request", lambda r: requests.append(r.url))
|
||||
|
||||
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()
|
||||
expect(page.locator("#nav-history")).to_be_hidden()
|
||||
|
||||
# Direct visit to the History page: it loads and shows the gated
|
||||
# state (the table wrapped away) — and NEVER calls /api/chats (the
|
||||
# router 403s anonymous, so the page must not even try).
|
||||
page.goto(app_url + "/history.html")
|
||||
expect(page.locator("#history-gate")).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#history-table-wrap")).to_be_hidden()
|
||||
assert not any("/api/chats" in u for u in requests), (
|
||||
f"the anonymous History page must not fetch /api/chats: {requests}"
|
||||
)
|
||||
|
||||
# And the API gate itself: 403 without the admin cookie.
|
||||
r = httpx.get(f"{app_url}/api/chats", timeout=10)
|
||||
assert r.status_code == 403
|
||||
@@ -18,9 +18,11 @@ sources, document viewer, global tuning, login): one shared markup block
|
||||
|
||||
Per role, the VISIBLE inventory:
|
||||
|
||||
* admin: brand + nav [Chat, #nav-sources, #nav-git-sources, #nav-tuning]
|
||||
(four links, that order — the "Sources" link joined in phase 35 as
|
||||
"Git sources", owner permission 2026-08-26) + #sign-out-btn (with
|
||||
* admin: brand + nav [Chat, #nav-sources, #nav-git-sources, #nav-tuning,
|
||||
#nav-history] (five links, that order — the "Sources" link joined in
|
||||
phase 35 as "Git sources", owner permission 2026-08-26; the History
|
||||
link joined in phase 50, owner permission 2026-08-29) + #sign-out-btn
|
||||
(with
|
||||
#sign-in-link
|
||||
hidden) — on all five pages, same id+class inventory, same DOM order.
|
||||
The #sync-btn (Sources page only) and the #new-chat-btn (chat page
|
||||
@@ -29,7 +31,7 @@ Per role, the VISIBLE inventory:
|
||||
exactly those two); the #steering-toggle was removed from the navbar
|
||||
the same day; note management lives on /tuning.html;
|
||||
* anonymous: brand + nav [Chat] (#nav-sources / #nav-git-sources /
|
||||
#nav-tuning hidden — locked A10 UI revision) + #sign-in-link (with
|
||||
#nav-tuning / #nav-history hidden — locked A10 UI revision) + #sign-in-link (with
|
||||
#sign-out-btn hidden; the Sources page's #sync-btn stays ship-hidden)
|
||||
on all five pages — and the steering toggle (removed at owner
|
||||
request, 2026-08-28) + panel are ABSENT from the DOM (the panel via
|
||||
@@ -233,6 +235,9 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
|
||||
# RAG and Tuning.
|
||||
expect(page.locator("#nav-git-sources")).to_be_visible()
|
||||
expect(page.locator("#nav-tuning")).to_be_visible()
|
||||
# Phase 50: the FIFTH admin-only nav link (History) is revealed
|
||||
# on every page, after Tuning.
|
||||
expect(page.locator("#nav-history")).to_be_visible()
|
||||
# The steering toggle was removed from the navbar at owner
|
||||
# request (2026-08-28) — absent on every page, admin included.
|
||||
assert page.locator("#steering-toggle").count() == 0, (
|
||||
@@ -255,6 +260,9 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
|
||||
expect(page.locator("#nav-sources")).to_be_hidden()
|
||||
expect(page.locator("#nav-git-sources")).to_be_hidden()
|
||||
expect(page.locator("#nav-tuning")).to_be_hidden()
|
||||
# Phase 50: the History link ships hidden and stays hidden for
|
||||
# anonymous (the same A10 UI revision).
|
||||
expect(page.locator("#nav-history")).to_be_hidden()
|
||||
expect(page.locator("#sync-btn")).to_be_hidden()
|
||||
expect(page.locator("#sign-out-btn")).to_be_hidden()
|
||||
expect(page.locator("#sign-in-link")).to_be_visible()
|
||||
@@ -323,6 +331,9 @@ def _admin_login_page_inventory(page: Page, app_url: str) -> list[str]:
|
||||
expect(page.locator("#nav-sources")).to_be_visible()
|
||||
expect(page.locator("#nav-git-sources")).to_be_visible()
|
||||
expect(page.locator("#nav-tuning")).to_be_visible()
|
||||
# Phase 50: the History link joins the admin bar on the login
|
||||
# page too (the one-bar contract).
|
||||
expect(page.locator("#nav-history")).to_be_visible()
|
||||
assert page.locator("#steering-toggle").count() == 0, (
|
||||
"login: the steering toggle was removed from the navbar"
|
||||
)
|
||||
|
||||
@@ -88,6 +88,7 @@ def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None:
|
||||
("/login.html", "Sign in"), # phase 16: admin sign-in page
|
||||
("/tuning.html", "Global Tuning"), # phase 27: global tuning page
|
||||
("/git-sources.html", "Git sources"), # phase 35: admin git sources page
|
||||
("/history.html", "Saved chats"), # phase 50: admin saved-chats page
|
||||
],
|
||||
)
|
||||
def test_html_pages_served_locally_no_cdn(client, path: str, marker: str) -> None:
|
||||
@@ -124,7 +125,8 @@ def test_index_page_no_cache_with_versioned_asset_refs(client) -> None:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/sources.html", "/document.html", "/login.html", "/tuning.html", "/git-sources.html"],
|
||||
["/sources.html", "/document.html", "/login.html", "/tuning.html",
|
||||
"/git-sources.html", "/history.html"], # phase 50: + the History page
|
||||
)
|
||||
def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
|
||||
"""Each of the other four pages revalidates and carries at least one
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
"""Integration: saved-chat CRUD (phase 50, task 02) — the ``/api/chats``
|
||||
contract.
|
||||
|
||||
Real Postgres (``podman compose up -d db``). The router sits behind the
|
||||
phase-16 ``require_admin`` gate exactly like ``/api/steering`` (the
|
||||
house pattern of ``test_steering_api.py``): anonymous callers get 403 on
|
||||
every route; the admin CRUD exercises the auto-title convention (first
|
||||
user message, whitespace-collapsed, 120-char cap + the no-user-message
|
||||
fallback), the list order (``updated_at desc, id desc``), the
|
||||
full-payload round-trip (a ``bor.chat.v1``-shaped brain record carrying
|
||||
``sources``/``thinking``/``tools``/``stopped`` survives losslessly),
|
||||
the PUT upsert semantics (replacement + title-keep + title-set +
|
||||
``updated_at`` bump), and the delete 404/204.
|
||||
|
||||
Requires: podman compose up -d db
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.main import app as fastapi_app
|
||||
from app.models import SavedChat
|
||||
|
||||
FIRST_QUESTION = "How did I install gitlab?"
|
||||
EXPLICIT_TITLE = "My backup notes"
|
||||
|
||||
#: A full ``bor.chat.v1`` brain record (phase 14 shape) — every optional
|
||||
#: key present; the round-trip test asserts it survives byte-identical.
|
||||
FULL_BRAIN: dict[str, Any] = {
|
||||
"who": "brain",
|
||||
"text": "Your k3s cluster runs on three nodes — you've got this.",
|
||||
"sources": [
|
||||
{"source": "Homelab", "path": "kubernetes.md", "title": "Kubernetes Cluster"}
|
||||
],
|
||||
"deflected": False,
|
||||
"suggestions": ["What ports does Traefik expose?"],
|
||||
"thinking": "The kubernetes doc covers the cluster layout…",
|
||||
"tools": [
|
||||
{"name": "read_document", "argument": "Homelab/kubernetes.md"},
|
||||
{"name": "list_documents", "argument": None},
|
||||
],
|
||||
"stopped": False,
|
||||
}
|
||||
|
||||
OUT_KEYS = {"id", "title", "created_at", "updated_at", "message_count", "messages"}
|
||||
ROW_KEYS = {"id", "title", "updated_at", "message_count"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_chats(db: Session) -> Iterator[None]:
|
||||
"""``saved_chats`` is global state: reset around every test."""
|
||||
db.execute(text("TRUNCATE saved_chats"))
|
||||
db.commit()
|
||||
yield
|
||||
db.execute(text("TRUNCATE saved_chats"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _user(text: str) -> dict[str, Any]:
|
||||
return {"who": "user", "text": text}
|
||||
|
||||
|
||||
def _simple_conversation() -> list[dict[str, Any]]:
|
||||
return [_user(FIRST_QUESTION), {"who": "brain", "text": "You've got this!"}]
|
||||
|
||||
|
||||
def _expect(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""The stored shape of a record list (see app/api/chats.py):
|
||||
every record carries all ``bor.chat.v1`` keys, explicit nulls where
|
||||
an optional key does not apply (the restore path is null-safe).
|
||||
A record that already carries every key (``FULL_BRAIN``) is
|
||||
unchanged by this."""
|
||||
return [
|
||||
{
|
||||
"who": m["who"],
|
||||
"text": m["text"],
|
||||
"sources": m.get("sources"),
|
||||
"deflected": m.get("deflected"),
|
||||
"suggestions": m.get("suggestions"),
|
||||
"thinking": m.get("thinking"),
|
||||
"tools": m.get("tools"),
|
||||
"stopped": m.get("stopped"),
|
||||
}
|
||||
for m in records
|
||||
]
|
||||
|
||||
|
||||
def _assert_no_chats(admin_client: TestClient) -> None:
|
||||
assert admin_client.get("/api/chats").json() == {"chats": []}
|
||||
|
||||
|
||||
# ---------- anonymous: 403 on every route (phase 16 gate) ----------
|
||||
|
||||
|
||||
def test_anonymous_every_route_returns_403(client: TestClient) -> None:
|
||||
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
|
||||
unknown = uuid.uuid4()
|
||||
cases = [
|
||||
("GET", "/api/chats", None),
|
||||
("POST", "/api/chats", {"messages": _simple_conversation()}),
|
||||
("GET", f"/api/chats/{unknown}", None),
|
||||
("PUT", f"/api/chats/{unknown}", {"messages": _simple_conversation()}),
|
||||
("DELETE", f"/api/chats/{unknown}", None),
|
||||
]
|
||||
for method, path, body in cases:
|
||||
r = anon.request(method, path, json=body)
|
||||
assert r.status_code == 403, f"{method} {path} must be 403 for anonymous"
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
|
||||
|
||||
# ---------- create ----------
|
||||
|
||||
|
||||
def test_create_returns_201_and_auto_titles(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
r = admin_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
|
||||
# Fresh row: nothing has updated it, so both stamps agree.
|
||||
assert body["created_at"] and body["updated_at"]
|
||||
assert abs(
|
||||
datetime.fromisoformat(body["created_at"])
|
||||
- datetime.fromisoformat(body["updated_at"])
|
||||
).total_seconds() < 5
|
||||
rows = db.scalars(select(SavedChat)).all()
|
||||
assert [row.title for row in rows] == [FIRST_QUESTION]
|
||||
|
||||
|
||||
def test_create_auto_title_collapses_whitespace_and_truncates_to_120(
|
||||
admin_client: TestClient,
|
||||
) -> None:
|
||||
long_text = "How did I install " + "x" * 200
|
||||
r = admin_client.post(
|
||||
"/api/chats", json={"messages": [_user(long_text), {"who": "brain", "text": "ok"}]}
|
||||
)
|
||||
assert r.status_code == 201
|
||||
assert len(r.json()["title"]) == 120
|
||||
assert r.json()["title"] == long_text[:120]
|
||||
|
||||
# Multi-space / tab / newline runs collapse to single spaces.
|
||||
r = admin_client.post(
|
||||
"/api/chats", json={"messages": [_user("What is\nmy\tTraefik port?")]}
|
||||
)
|
||||
assert r.status_code == 201
|
||||
assert r.json()["title"] == "What is my Traefik port?"
|
||||
|
||||
|
||||
def test_create_honors_explicit_title(admin_client: TestClient) -> None:
|
||||
r = admin_client.post(
|
||||
"/api/chats",
|
||||
json={"title": f" {EXPLICIT_TITLE} ", "messages": _simple_conversation()},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
assert r.json()["title"] == EXPLICIT_TITLE # trimmed, not auto-titled
|
||||
|
||||
|
||||
def test_create_blank_title_falls_back_to_auto_title(admin_client: TestClient) -> None:
|
||||
r = admin_client.post(
|
||||
"/api/chats", json={"title": " \t\n ", "messages": _simple_conversation()}
|
||||
)
|
||||
assert r.status_code == 201
|
||||
assert r.json()["title"] == FIRST_QUESTION
|
||||
|
||||
|
||||
def test_create_without_user_message_falls_back_to_chat_id(admin_client: TestClient) -> None:
|
||||
# Defensive — the UI cannot produce a conversation with no user
|
||||
# message; the auto-title then names the row after its own id.
|
||||
r = admin_client.post(
|
||||
"/api/chats", json={"messages": [{"who": "brain", "text": "hello"}]}
|
||||
)
|
||||
assert r.status_code == 201
|
||||
body = r.json()
|
||||
assert body["title"] == f"Chat {body['id'][:8]}"
|
||||
|
||||
|
||||
def test_create_round_trips_full_brain_record(admin_client: TestClient) -> None:
|
||||
r = admin_client.post(
|
||||
"/api/chats", json={"messages": [_user(FIRST_QUESTION), FULL_BRAIN]}
|
||||
)
|
||||
assert r.status_code == 201
|
||||
# The bor.chat.v1-shaped payload round-trips losslessly: every
|
||||
# optional key (sources/deflected/suggestions/thinking/tools/
|
||||
# stopped) survives identical.
|
||||
assert r.json()["messages"][1] == FULL_BRAIN
|
||||
|
||||
|
||||
def test_create_rejects_empty_messages(admin_client: TestClient) -> None:
|
||||
assert admin_client.post("/api/chats", json={"messages": []}).status_code == 422
|
||||
_assert_no_chats(admin_client)
|
||||
|
||||
|
||||
def test_create_rejects_unknown_who(admin_client: TestClient) -> None:
|
||||
r = admin_client.post(
|
||||
"/api/chats", json={"messages": [{"who": "alien", "text": "hi"}]}
|
||||
)
|
||||
assert r.status_code == 422
|
||||
_assert_no_chats(admin_client)
|
||||
|
||||
|
||||
def test_create_rejects_empty_text(admin_client: TestClient) -> None:
|
||||
assert (
|
||||
admin_client.post("/api/chats", json={"messages": [_user("")]})
|
||||
).status_code == 422
|
||||
_assert_no_chats(admin_client)
|
||||
|
||||
|
||||
def test_create_rejects_extra_message_keys(admin_client: TestClient) -> None:
|
||||
# A corrupted / HTML-shaped payload must not cross the boundary.
|
||||
message = _user(FIRST_QUESTION)
|
||||
message["html"] = "<b>not allowed</b>"
|
||||
assert admin_client.post("/api/chats", json={"messages": [message]}).status_code == 422
|
||||
_assert_no_chats(admin_client)
|
||||
|
||||
|
||||
def test_create_rejects_title_over_500(admin_client: TestClient) -> None:
|
||||
assert (
|
||||
admin_client.post(
|
||||
"/api/chats", json={"title": "t" * 501, "messages": _simple_conversation()}
|
||||
)
|
||||
).status_code == 422
|
||||
|
||||
|
||||
# ---------- list ----------
|
||||
|
||||
|
||||
def test_list_empty(admin_client: TestClient) -> None:
|
||||
r = admin_client.get("/api/chats")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"chats": []}
|
||||
|
||||
|
||||
def test_list_orders_by_updated_at_desc(admin_client: TestClient, db: Session) -> None:
|
||||
base = datetime.now(UTC)
|
||||
db.add_all(
|
||||
[
|
||||
SavedChat(
|
||||
title="oldest",
|
||||
messages=[_user("one")],
|
||||
updated_at=base,
|
||||
),
|
||||
SavedChat(
|
||||
title="newest",
|
||||
messages=[_user("two"), _user("three")],
|
||||
updated_at=base + timedelta(hours=2),
|
||||
),
|
||||
SavedChat(
|
||||
title="middle",
|
||||
messages=[_user("four")],
|
||||
updated_at=base + timedelta(hours=1),
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
r = admin_client.get("/api/chats")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert [c["title"] for c in body["chats"]] == ["newest", "middle", "oldest"]
|
||||
for c in body["chats"]:
|
||||
assert set(c) == ROW_KEYS
|
||||
uuid.UUID(c["id"])
|
||||
assert "messages" not in c # no payloads in the list
|
||||
|
||||
|
||||
def test_list_reports_message_count(admin_client: TestClient) -> None:
|
||||
admin_client.post("/api/chats", json={"messages": _simple_conversation()})
|
||||
body = admin_client.get("/api/chats").json()
|
||||
assert [c["message_count"] for c in body["chats"]] == [2]
|
||||
|
||||
|
||||
# ---------- get ----------
|
||||
|
||||
|
||||
def test_get_returns_full_payload_round_trip(admin_client: TestClient) -> None:
|
||||
created = admin_client.post(
|
||||
"/api/chats",
|
||||
json={"title": EXPLICIT_TITLE, "messages": [_user(FIRST_QUESTION), FULL_BRAIN]},
|
||||
).json()
|
||||
|
||||
r = admin_client.get(f"/api/chats/{created['id']}")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == OUT_KEYS
|
||||
assert body["id"] == created["id"]
|
||||
assert body["title"] == EXPLICIT_TITLE
|
||||
assert body["message_count"] == 2
|
||||
# Byte-identical payload: the brain record with sources/thinking/
|
||||
# tools/stopped (incl. the `argument: null` tool) survives the trip
|
||||
# to Postgres and back.
|
||||
assert body["messages"] == _expect([_user(FIRST_QUESTION), FULL_BRAIN])
|
||||
|
||||
|
||||
def test_get_unknown_chat_returns_404(admin_client: TestClient) -> None:
|
||||
r = admin_client.get(f"/api/chats/{uuid.uuid4()}")
|
||||
assert r.status_code == 404
|
||||
assert r.json() == {"detail": "unknown chat"}
|
||||
|
||||
|
||||
def test_get_invalid_id_returns_422(admin_client: TestClient) -> None:
|
||||
assert admin_client.get("/api/chats/not-a-uuid").status_code == 422
|
||||
|
||||
|
||||
# ---------- update (PUT) — the re-Save upsert ----------
|
||||
|
||||
|
||||
def test_put_replaces_messages_and_bumps_updated_at(admin_client: TestClient) -> None:
|
||||
created = admin_client.post(
|
||||
"/api/chats",
|
||||
json={"title": EXPLICIT_TITLE, "messages": _simple_conversation()},
|
||||
).json()
|
||||
# A second, newer chat — it currently lists first.
|
||||
other = admin_client.post(
|
||||
"/api/chats", json={"messages": [_user("second question")], "title": "Other"}
|
||||
).json()
|
||||
assert admin_client.get("/api/chats").json()["chats"][0]["id"] == other["id"]
|
||||
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 = admin_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"] == EXPLICIT_TITLE # absent title keeps the current one
|
||||
assert body["message_count"] == 2
|
||||
assert body["messages"] == _expect(new_messages) # full replacement
|
||||
assert datetime.fromisoformat(body["created_at"]) == datetime.fromisoformat(
|
||||
created["created_at"]
|
||||
) # editing does not redate creation
|
||||
assert datetime.fromisoformat(body["updated_at"]) > datetime.fromisoformat(
|
||||
updated_before
|
||||
), "updated_at must bump on a re-Save (onupdate=func.now())"
|
||||
# The list order follows the bump: this row is first again.
|
||||
body_list = admin_client.get("/api/chats").json()["chats"]
|
||||
assert body_list[0]["id"] == created["id"]
|
||||
|
||||
|
||||
def test_put_sets_title_when_supplied(admin_client: TestClient) -> None:
|
||||
created = admin_client.post(
|
||||
"/api/chats", json={"messages": _simple_conversation()}
|
||||
).json()
|
||||
|
||||
r = admin_client.put(
|
||||
f"/api/chats/{created['id']}",
|
||||
json={"title": "Renamed notes", "messages": _simple_conversation()},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json()["title"] == "Renamed notes"
|
||||
assert (
|
||||
admin_client.get(f"/api/chats/{created['id']}").json()["title"] == "Renamed notes"
|
||||
)
|
||||
|
||||
|
||||
def test_put_blank_title_keeps_current(admin_client: TestClient) -> None:
|
||||
created = admin_client.post(
|
||||
"/api/chats",
|
||||
json={"title": EXPLICIT_TITLE, "messages": _simple_conversation()},
|
||||
).json()
|
||||
|
||||
r = admin_client.put(
|
||||
f"/api/chats/{created['id']}", json={"title": " ", "messages": _simple_conversation()}
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json()["title"] == EXPLICIT_TITLE
|
||||
|
||||
|
||||
def test_put_unknown_chat_returns_404(admin_client: TestClient) -> None:
|
||||
r = admin_client.put(
|
||||
f"/api/chats/{uuid.uuid4()}", json={"messages": _simple_conversation()}
|
||||
)
|
||||
assert r.status_code == 404
|
||||
assert r.json() == {"detail": "unknown chat"}
|
||||
|
||||
|
||||
def test_put_rejects_empty_messages(admin_client: TestClient) -> None:
|
||||
created = admin_client.post(
|
||||
"/api/chats", json={"messages": _simple_conversation()}
|
||||
).json()
|
||||
assert (
|
||||
admin_client.put(f"/api/chats/{created['id']}", json={"messages": []})
|
||||
).status_code == 422
|
||||
# The original payload is untouched.
|
||||
assert (
|
||||
admin_client.get(f"/api/chats/{created['id']}").json()["messages"]
|
||||
== _expect(_simple_conversation())
|
||||
)
|
||||
|
||||
|
||||
def test_put_rejects_extra_message_keys(admin_client: TestClient) -> None:
|
||||
created = admin_client.post(
|
||||
"/api/chats", json={"messages": _simple_conversation()}
|
||||
).json()
|
||||
bad = _user("hi")
|
||||
bad["innerHTML"] = "<script>alert(1)</script>"
|
||||
r = admin_client.put(
|
||||
f"/api/chats/{created['id']}", json={"messages": [bad, FULL_BRAIN]}
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert (
|
||||
admin_client.get(f"/api/chats/{created['id']}").json()["messages"]
|
||||
== _expect(_simple_conversation())
|
||||
)
|
||||
|
||||
|
||||
# ---------- delete ----------
|
||||
|
||||
|
||||
def test_delete_returns_204_and_removes(admin_client: TestClient, db: Session) -> None:
|
||||
created = admin_client.post("/api/chats", json={"messages": _simple_conversation()}).json()
|
||||
|
||||
assert admin_client.delete(f"/api/chats/{created['id']}").status_code == 204
|
||||
assert admin_client.get(f"/api/chats/{created['id']}").status_code == 404
|
||||
assert admin_client.get("/api/chats").json() == {"chats": []}
|
||||
assert db.scalars(select(SavedChat)).all() == []
|
||||
|
||||
|
||||
def test_delete_unknown_chat_returns_404(admin_client: TestClient) -> None:
|
||||
r = admin_client.delete(f"/api/chats/{uuid.uuid4()}")
|
||||
assert r.status_code == 404
|
||||
assert r.json() == {"detail": "unknown chat"}
|
||||
|
||||
|
||||
def test_delete_invalid_id_returns_422(admin_client: TestClient) -> None:
|
||||
assert admin_client.delete("/api/chats/not-a-uuid").status_code == 422
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Integration: migration 0008 (saved_chats) schema contract.
|
||||
|
||||
Drives the **real Alembic engine** against the live dev database
|
||||
(``podman compose up -d db``), mirroring the house pattern of
|
||||
``test_migration_0005.py`` / ``test_migration_0007.py``
|
||||
(information_schema assertions on the state the migration must leave).
|
||||
The tests target revision ``0008`` explicitly so later migrations
|
||||
cannot break them:
|
||||
|
||||
* upgrade 0007 → 0008 → a ``saved_chats`` table exists with
|
||||
``id UUID`` PK, ``title VARCHAR(500) NOT NULL``,
|
||||
``messages JSONB NOT NULL`` (the ``bor.chat.v1`` record list), and
|
||||
``created_at`` / ``updated_at TIMESTAMPTZ NOT NULL`` — both stamped
|
||||
server-side by ``now()`` (an insert that omits them still lands
|
||||
with both set);
|
||||
* the ``updated_at`` ORM ``onupdate`` bumps the timestamp on a row
|
||||
update while ``created_at`` stays put (the phase-50 History page
|
||||
orders by it);
|
||||
* downgrade to 0007 → the table is gone;
|
||||
* upgrade back to 0008 → it is back (round-trip).
|
||||
|
||||
The ``alembic`` fixture guarantees the DB ends at head even if a test
|
||||
fails or the process is interrupted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from alembic import command
|
||||
from app.db import db_available
|
||||
from app.models import SavedChat
|
||||
|
||||
TITLE_BASE = "Mig 0008"
|
||||
MESSAGE_SHAPE = [{"who": "user", "text": "How did I install gitlab?"}]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def alembic(db: Session) -> Iterator[Config]:
|
||||
"""Real Alembic config bound to the dev DB (URL from app settings).
|
||||
|
||||
Starts at head (repairs an interrupted earlier run); teardown upgrades
|
||||
to head no matter what happened, so the dev DB is never left below
|
||||
head.
|
||||
"""
|
||||
if not db_available():
|
||||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||||
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
|
||||
cfg.set_main_option("script_location", "alembic")
|
||||
command.upgrade(cfg, "head")
|
||||
try:
|
||||
yield cfg
|
||||
finally:
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
def _table_exists(db: Session) -> bool:
|
||||
"""1 iff ``saved_chats`` is a table in this database."""
|
||||
count: Any = db.execute(
|
||||
text(
|
||||
"SELECT count(*) FROM information_schema.tables"
|
||||
" WHERE table_name = 'saved_chats'"
|
||||
)
|
||||
).scalar()
|
||||
assert count is not None, "information_schema count must be an int"
|
||||
return int(count) == 1
|
||||
|
||||
|
||||
def _column(db: Session, column: str) -> tuple[Any, ...] | None:
|
||||
"""(data_type, is_nullable, column_default) for one saved_chats column."""
|
||||
row = db.execute(
|
||||
text(
|
||||
"SELECT data_type, is_nullable, column_default"
|
||||
" FROM information_schema.columns"
|
||||
" WHERE table_name = 'saved_chats' AND column_name = :c"
|
||||
),
|
||||
{"c": column},
|
||||
).fetchone()
|
||||
return tuple(row) if row is not None else None
|
||||
|
||||
|
||||
def _pk_columns(db: Session) -> set[str]:
|
||||
"""Primary-key columns of ``saved_chats`` (empty if it does not exist)."""
|
||||
rows = db.execute(
|
||||
text(
|
||||
"SELECT kcu.column_name"
|
||||
" FROM information_schema.table_constraints tc"
|
||||
" JOIN information_schema.key_column_usage kcu"
|
||||
" ON tc.constraint_name = kcu.constraint_name"
|
||||
" AND tc.table_schema = kcu.table_schema"
|
||||
" WHERE tc.table_name = 'saved_chats'"
|
||||
" AND tc.constraint_type = 'PRIMARY KEY'"
|
||||
)
|
||||
).fetchall()
|
||||
return {r[0] for r in rows}
|
||||
|
||||
|
||||
def _version(db: Session) -> str | None:
|
||||
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
||||
|
||||
|
||||
def _raw_insert(db: Session, title: str) -> uuid.UUID:
|
||||
"""Insert one saved_chats row omitting the timestamps (server-stamped)."""
|
||||
id: uuid.UUID = db.execute(
|
||||
text(
|
||||
"INSERT INTO saved_chats (id, title, messages)"
|
||||
" VALUES (gen_random_uuid(), :t, CAST(:m AS jsonb))"
|
||||
" RETURNING id"
|
||||
),
|
||||
{"t": title, "m": json.dumps(MESSAGE_SHAPE)},
|
||||
).scalar_one()
|
||||
db.commit()
|
||||
return id
|
||||
|
||||
|
||||
def _delete(db: Session, id: uuid.UUID) -> None:
|
||||
db.execute(text("DELETE FROM saved_chats WHERE id = :i"), {"i": id})
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_upgrade_to_0008_creates_saved_chats(db: Session, alembic: Config) -> None:
|
||||
"""Upgrade 0007 → 0008: ``saved_chats`` exists with the locked
|
||||
columns, types, nullability, PK, and server ``now()`` defaults."""
|
||||
command.downgrade(alembic, "0007") # start from the pre-0008 state
|
||||
assert _version(db) == "0007"
|
||||
assert not _table_exists(db), "saved_chats must not exist before 0008"
|
||||
|
||||
command.upgrade(alembic, "0008")
|
||||
assert _version(db) == "0008", "alembic_version must be at 0008"
|
||||
assert _table_exists(db), "saved_chats is missing after 0008"
|
||||
|
||||
assert _pk_columns(db) == {"id"}, "saved_chats must have a single id PK"
|
||||
|
||||
id_col = _column(db, "id")
|
||||
assert id_col is not None, "saved_chats.id is missing"
|
||||
assert id_col[0] == "uuid", "saved_chats.id must be UUID"
|
||||
assert id_col[1] == "NO", "saved_chats.id must be NOT NULL"
|
||||
|
||||
title = _column(db, "title")
|
||||
assert title is not None, "saved_chats.title is missing"
|
||||
assert title[0] == "character varying", "saved_chats.title must be VARCHAR"
|
||||
assert title[1] == "NO", "saved_chats.title must be NOT NULL"
|
||||
|
||||
messages = _column(db, "messages")
|
||||
assert messages is not None, "saved_chats.messages is missing"
|
||||
assert messages[0] == "jsonb", "saved_chats.messages must be JSONB"
|
||||
assert messages[1] == "NO", "saved_chats.messages must be NOT NULL"
|
||||
|
||||
for column in ("created_at", "updated_at"):
|
||||
col = _column(db, column)
|
||||
assert col is not None, f"saved_chats.{column} is missing"
|
||||
assert col[0] == "timestamp with time zone", (
|
||||
f"saved_chats.{column} must be TIMESTAMPTZ"
|
||||
)
|
||||
assert col[1] == "NO", f"saved_chats.{column} must be NOT NULL"
|
||||
assert col[2] is not None and "now()" in col[2], (
|
||||
f"saved_chats.{column} must default to now()"
|
||||
)
|
||||
|
||||
# Phase 51 (share_token) must not leak into this minimal migration.
|
||||
assert _column(db, "share_token") is None, (
|
||||
"0008 stays minimal — share_token lands in 0009 (phase 51)"
|
||||
)
|
||||
|
||||
|
||||
def test_server_timestamps_stamped_on_insert(db: Session, alembic: Config) -> None:
|
||||
"""An insert that omits created_at/updated_at (the API's shape) still
|
||||
lands with both stamped by the server defaults."""
|
||||
command.upgrade(alembic, "head")
|
||||
chat_id = _raw_insert(db, f"{TITLE_BASE}: server stamps")
|
||||
try:
|
||||
created_at, updated_at = db.execute(
|
||||
text("SELECT created_at, updated_at FROM saved_chats WHERE id = :i"),
|
||||
{"i": chat_id},
|
||||
).one()
|
||||
assert isinstance(created_at, datetime), "created_at must be server-stamped"
|
||||
assert isinstance(updated_at, datetime), "updated_at must be server-stamped"
|
||||
assert created_at.tzinfo is not None, "created_at must be timezone-aware"
|
||||
# Fresh row: nothing has updated it, so both stamps agree (now).
|
||||
assert (created_at - updated_at).total_seconds() < 5, (
|
||||
"a fresh row must have created_at ≈ updated_at"
|
||||
)
|
||||
finally:
|
||||
_delete(db, chat_id)
|
||||
|
||||
|
||||
def test_updated_at_bumps_on_row_update(db: Session, alembic: Config) -> None:
|
||||
"""The ORM ``onupdate=func.now()`` (the History page's Updated column)
|
||||
bumps ``updated_at`` on a row update while ``created_at`` stays put."""
|
||||
command.upgrade(alembic, "head")
|
||||
chat = SavedChat(title=f"{TITLE_BASE}: before update", messages=MESSAGE_SHAPE)
|
||||
db.add(chat)
|
||||
db.commit()
|
||||
try:
|
||||
created_before: datetime = chat.created_at
|
||||
updated_before: datetime = chat.updated_at
|
||||
assert created_before is not None and updated_before is not None
|
||||
|
||||
time.sleep(0.1) # now() has µs resolution — make the bump observable
|
||||
chat.title = f"{TITLE_BASE}: after update"
|
||||
chat.messages = [
|
||||
{"who": "user", "text": "How did I install gitlab?"},
|
||||
{"who": "brain", "text": "You've got this!", "sources": []},
|
||||
]
|
||||
db.commit()
|
||||
db.expire(chat)
|
||||
|
||||
created_after: datetime = chat.created_at
|
||||
updated_after: datetime = chat.updated_at
|
||||
assert created_after == created_before, "created_at must not move on update"
|
||||
assert updated_after > updated_before, (
|
||||
"updated_at must bump on a row update (onupdate=func.now())"
|
||||
)
|
||||
finally:
|
||||
db.expire_all()
|
||||
db.execute(text("DELETE FROM saved_chats WHERE id = :i"), {"i": chat.id})
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_downgrade_to_0007_drops_table(db: Session, alembic: Config) -> None:
|
||||
"""Downgrade to 0007: ``saved_chats`` is dropped (A13 — reversible)."""
|
||||
command.downgrade(alembic, "0007")
|
||||
assert _version(db) == "0007"
|
||||
assert not _table_exists(db), "saved_chats must be dropped by the downgrade"
|
||||
assert _column(db, "id") is None, "saved_chats.id must be gone"
|
||||
|
||||
|
||||
def test_upgrade_round_trip_restores_table(db: Session, alembic: Config) -> None:
|
||||
"""Downgrade to 0007, then upgrade back to 0008: the table is back
|
||||
with its locked columns and PK."""
|
||||
command.downgrade(alembic, "0007")
|
||||
command.upgrade(alembic, "0008")
|
||||
assert _version(db) == "0008", "round-trip upgrade must land at 0008"
|
||||
|
||||
assert _table_exists(db), "saved_chats must be back after the round-trip"
|
||||
assert _pk_columns(db) == {"id"}, "saved_chats.id PK must be back"
|
||||
|
||||
messages = _column(db, "messages")
|
||||
assert messages is not None and messages[0] == "jsonb", (
|
||||
"saved_chats.messages must be JSONB after the round-trip"
|
||||
)
|
||||
|
||||
col = _column(db, "updated_at")
|
||||
assert col is not None and col[2] is not None and "now()" in col[2], (
|
||||
"saved_chats.updated_at must keep its now() default after the round-trip"
|
||||
)
|
||||
@@ -185,6 +185,30 @@ def test_empty_static_dir_is_dev(tmp_path) -> None:
|
||||
assert asset_version(str(empty)) == "dev"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTML_PAGES registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_html_pages_include_history() -> None:
|
||||
"""Phase 50: the History page is registered in HTML_PAGES — without
|
||||
this entry it would serve unversioned asset refs, which the
|
||||
immutable-for-a-year asset caching would pin to stale CSS after a
|
||||
deploy (the phase-35 git-sources lesson). The entry is ADDED —
|
||||
every pre-phase-50 page stays registered."""
|
||||
for path in (
|
||||
"/",
|
||||
"/index.html",
|
||||
"/sources.html",
|
||||
"/document.html",
|
||||
"/login.html",
|
||||
"/tuning.html",
|
||||
"/git-sources.html",
|
||||
"/history.html",
|
||||
):
|
||||
assert path in caching.HTML_PAGES, f"{path} must be in HTML_PAGES"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rewrite_asset_refs (task 02)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -28,6 +28,7 @@ HTML_PAGES = (
|
||||
"document.html",
|
||||
"login.html",
|
||||
"git-sources.html",
|
||||
"history.html", # phase 50: the admin saved-chats page
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
"""Unit: the phase-50 task-04 History-page contract.
|
||||
|
||||
The browser behavior itself is E2E-gated by the story suite (task 05);
|
||||
like the other frontend-adjacent unit files, this module pins the
|
||||
JS/CSS/HTML markers the History page depends on, so a silent
|
||||
regression is caught without a browser:
|
||||
|
||||
* the anonymous no-fetch gate (the gate in, the table out, and the
|
||||
single ``GET /api/chats`` fetch lives ONLY in ``loadChats`` —
|
||||
unreachable from the anonymous branch);
|
||||
* the inline two-step Delete (the "Delete? [Yes] [No]" pair, focus to
|
||||
Yes, the row kept on No / a failed request, ``Deleted "<title>".``
|
||||
on success) and the ``window.confirm`` absence in ``history.js``
|
||||
(owner-locked 2026-08-29: no native confirm dialog on this page);
|
||||
* the ``/?chat=<id>`` Open-link href shape (TODO.md L5 — "return to
|
||||
that history with a click");
|
||||
* ``#nav-history`` on ALL SEVEN pages (the phase-34 one-bar contract)
|
||||
+ ``header.js``'s reveal-for-admin block;
|
||||
* the full-width table CSS (AGENTS.md rule 5) + the confirm pair +
|
||||
the empty-state row.
|
||||
|
||||
The Containerfile stage-1 coverage (history.html copied, history.js
|
||||
bundled) is pinned dynamically by
|
||||
``tests/integration/test_containerfile_assets.py`` — a page or module
|
||||
missing from stage 1 fails there.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
ASSETS = FRONTEND / "assets"
|
||||
|
||||
INDEX_HTML = FRONTEND / "index.html"
|
||||
SOURCES_HTML = FRONTEND / "sources.html"
|
||||
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
|
||||
TUNING_HTML = FRONTEND / "tuning.html"
|
||||
DOCUMENT_HTML = FRONTEND / "document.html"
|
||||
LOGIN_HTML = FRONTEND / "login.html"
|
||||
HISTORY_HTML = FRONTEND / "history.html"
|
||||
HISTORY_JS = ASSETS / "history.js"
|
||||
HEADER_JS = ASSETS / "header.js"
|
||||
STYLES_CSS = ASSETS / "styles.css"
|
||||
|
||||
#: The phase-34 one-bar contract + the new History page: SEVEN pages.
|
||||
ALL_PAGES = (
|
||||
INDEX_HTML,
|
||||
SOURCES_HTML,
|
||||
GIT_SOURCES_HTML,
|
||||
TUNING_HTML,
|
||||
DOCUMENT_HTML,
|
||||
LOGIN_HTML,
|
||||
HISTORY_HTML,
|
||||
)
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
assert path.is_file(), f"missing frontend file: {path}"
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _js() -> str:
|
||||
return _text(HISTORY_JS)
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return _text(STYLES_CSS)
|
||||
|
||||
|
||||
def _fn(js: str, name: str) -> str:
|
||||
"""The source of a top-level ``function <name>(...)`` (to its close)."""
|
||||
start = js.find(f"function {name}(")
|
||||
assert start != -1, f"{name}() must exist in history.js"
|
||||
return js[start : js.find("\n}\n", start) + 4]
|
||||
|
||||
|
||||
def _nav_history_tag(html: str) -> str:
|
||||
tag = re.search(r'<a[^>]*id="nav-history"[^>]*>', html)
|
||||
assert tag, "the #nav-history link is missing"
|
||||
return tag.group(0)
|
||||
|
||||
|
||||
# ---------- the #nav-history link: all seven pages ----------
|
||||
|
||||
|
||||
def test_nav_history_present_on_all_seven_pages() -> None:
|
||||
"""The phase-34 one-bar contract extended by phase 50: the admin-only
|
||||
History link SHIPS hidden (revealed by header.js for admin) on every
|
||||
page, after the Tuning link, pointing at /history.html. The page's
|
||||
own link is the active one (is-active + aria-current)."""
|
||||
for html in ALL_PAGES:
|
||||
text = _text(html)
|
||||
tag = _nav_history_tag(text)
|
||||
assert 'href="/history.html"' in tag
|
||||
assert "hidden" in tag, f"{html.name}: #nav-history must ship hidden"
|
||||
# Placed after the Tuning link (the owner-locked position).
|
||||
assert text.find('id="nav-tuning"') < text.find('id="nav-history"'), (
|
||||
f"{html.name}: #nav-history must follow #nav-tuning"
|
||||
)
|
||||
# The history page is the only one whose link is active.
|
||||
for html in ALL_PAGES:
|
||||
tag = _nav_history_tag(_text(html))
|
||||
if html.name == "history.html":
|
||||
assert 'class="nav-link is-active"' in tag
|
||||
assert 'aria-current="page"' in tag
|
||||
else:
|
||||
assert "is-active" not in tag, (
|
||||
f"{html.name}: no nav link is current there"
|
||||
)
|
||||
|
||||
|
||||
def test_nav_history_count_is_exactly_seven_pages() -> None:
|
||||
"""The pin counting occurrences across ``frontend/*.html`` — exactly
|
||||
one ``id="nav-history"`` per page, seven pages, no duplicates and no
|
||||
extra page that forgot (or added twice)."""
|
||||
total = 0
|
||||
for html in sorted(FRONTEND.glob("*.html")):
|
||||
count = html.read_text(encoding="utf-8").count('id="nav-history"')
|
||||
assert count in (0, 1), f"{html.name}: #nav-history appears {count} times"
|
||||
total += count
|
||||
assert total == 7, f"expected #nav-history on 7 pages, found {total}"
|
||||
|
||||
|
||||
def test_header_js_reveals_nav_history_for_admin() -> None:
|
||||
"""header.js reveals #nav-history for admin exactly like
|
||||
#nav-tuning — the same ship-hidden / reveal-for-admin contract,
|
||||
inside initSharedHeader (null-safe: a page without the link is a
|
||||
no-op)."""
|
||||
js = _text(HEADER_JS)
|
||||
fn = js.find("function initSharedHeader")
|
||||
assert fn != -1
|
||||
body = js[fn : js.find("\n}", fn)]
|
||||
assert 'querySelector("#nav-history")' in body
|
||||
assert "navHistory.hidden = !admin" in body
|
||||
|
||||
|
||||
# ---------- history.html: the page scaffold ----------
|
||||
|
||||
|
||||
def test_history_page_scaffold_and_landmarks() -> None:
|
||||
"""The standard page scaffold (AGENTS.md rule 5): skip link, the
|
||||
shared header, the steering panel + announcer (phase 34 — ships on
|
||||
every page), the page-head, the gate (ship-hidden), the
|
||||
role="status" live region, and the table inside the
|
||||
.table-wrap card. Footer with the version span (the index.html
|
||||
shape)."""
|
||||
html = _text(HISTORY_HTML)
|
||||
assert '<a class="skip-link" href="#main">' in html
|
||||
assert 'class="app-header"' in html
|
||||
assert 'nav class="app-nav" id="app-nav" aria-label="Primary"' in html
|
||||
tag = re.search(r'<section[^>]*id="steering-panel"[^>]*>', html)
|
||||
assert tag and "hidden" in tag.group(0), "the steering panel ships hidden"
|
||||
assert re.search(r'<p[^>]*id="steering-announcer"[^>]*role="status"[^>]*>', html)
|
||||
assert "<main id=\"main\" class=\"app-main\" tabindex=\"-1\">" in html
|
||||
assert '<h1>Saved chats</h1>' in html
|
||||
# The anonymous gate — the #sources-gate pattern, ship-hidden.
|
||||
gate = re.search(r'<section[^>]*id="history-gate"[^>]*>', html)
|
||||
assert gate and "hidden" in gate.group(0), "#history-gate must ship hidden"
|
||||
assert 'href="/login.html?next=/history.html"' in html, (
|
||||
"the gate's Sign in returns to the History page (no-JS fallback)"
|
||||
)
|
||||
# The action-feedback live region.
|
||||
assert re.search(r'<span[^>]*id="history-status"[^>]*role="status"[^>]*>', html)
|
||||
# The table wrapper: the .table-wrap card (scrollable) with its
|
||||
# own id, a labeled region, focusable.
|
||||
wrap = re.search(r'<div[^>]*class="table-wrap history-table-wrap"[^>]*>', html)
|
||||
assert wrap, "the table must live in the .table-wrap card"
|
||||
assert 'id="history-table-wrap"' in wrap.group(0)
|
||||
assert 'role="region"' in wrap.group(0) and 'tabindex="0"' in wrap.group(0)
|
||||
# Footer with the version span.
|
||||
assert 'class="footer-version" id="app-version"' in html
|
||||
|
||||
|
||||
def test_history_table_skeleton() -> None:
|
||||
"""The table skeleton: ``.history-table`` with the four columns —
|
||||
Title | Messages | Updated | Actions (the Actions header text is
|
||||
visually-hidden — the row buttons carry their own aria-labels) —
|
||||
and the empty-state row (ship-hidden, the exact copy)."""
|
||||
html = _text(HISTORY_HTML)
|
||||
assert '<table class="history-table">' in html
|
||||
for col in ('<th scope="col">Title</th>', '<th scope="col">Messages</th>',
|
||||
'<th scope="col">Updated</th>'):
|
||||
assert col in html
|
||||
actions_th = re.search(
|
||||
r'<th scope="col">([^<]*)<span class="visually-hidden">Actions</span></th>',
|
||||
html,
|
||||
)
|
||||
assert actions_th, "the Actions column header must be visually-hidden text"
|
||||
assert actions_th.group(1) == "", "no visible text beside the hidden header"
|
||||
# The empty-state row: ship-hidden, colspan 4, the exact copy.
|
||||
row = re.search(r'<tr[^>]*class="history-empty-row"[^>]*>', html)
|
||||
assert row, "the empty-state row must ship in the skeleton"
|
||||
assert "hidden" in row.group(0)
|
||||
assert 'id="history-empty-row"' in row.group(0)
|
||||
assert "<td colspan=\"4\">" in html
|
||||
assert (
|
||||
"No saved chats yet — finish a conversation and press"
|
||||
" <strong>Save</strong> in the chat."
|
||||
) in html
|
||||
|
||||
|
||||
def test_history_page_scripts_and_no_cdn() -> None:
|
||||
"""Script load order (the house pattern): brand.js classic FIRST,
|
||||
the history.js module second, NO direct header.js <script> tag
|
||||
(single-evaluation design — history.js imports it relatively).
|
||||
No-CDN rule (AGENTS.md rule 6): no external script/link tags."""
|
||||
html = _text(HISTORY_HTML)
|
||||
srcs = re.findall(r'<script[^>]*src="([^"]+)"', html)
|
||||
assert srcs == ["assets/brand.js", "/assets/history.js"], (
|
||||
f"history.html must load brand.js (classic, first) + the history.js "
|
||||
f"module, got {srcs}"
|
||||
)
|
||||
js = _js()
|
||||
assert 'from "./header.js"' in js, (
|
||||
"history.js must import the shared header module relatively"
|
||||
)
|
||||
assert '"/assets/header.js"' not in js
|
||||
assert 'src="http' not in html and 'href="http' not in html, (
|
||||
"no CDN: every asset is local (AGENTS.md rule 6)"
|
||||
)
|
||||
|
||||
|
||||
# ---------- the anonymous no-fetch gate ----------
|
||||
|
||||
|
||||
def test_anonymous_boot_makes_no_chats_request() -> None:
|
||||
"""The whoami gate in the boot IIFE: ``initSharedHeader()`` first
|
||||
(shared-header contract), then the anonymous branch hides the
|
||||
table, shows the gate, and RETURNS — no ``/api/chats`` request on
|
||||
the wire (the router 403s anonymous; the story E2E pins the
|
||||
request log). Only the admin path reaches ``loadChats()``. The
|
||||
single ``fetch("/api/chats")`` in the file lives in loadChats."""
|
||||
js = _js()
|
||||
assert js.count('fetch("/api/chats")') == 1, (
|
||||
"exactly ONE list fetch — the anonymous path must never add one"
|
||||
)
|
||||
load = _fn(js, "loadChats")
|
||||
assert 'fetch("/api/chats")' in load, "the list fetch lives in loadChats"
|
||||
|
||||
boot = js[js.find("(async () => {"):]
|
||||
assert boot, "the boot IIFE must exist"
|
||||
assert "await initSharedHeader()" in boot
|
||||
gate_i = boot.find("if (!(await fetchIsAdmin()))")
|
||||
assert gate_i != -1, "the whoami gate must run in boot"
|
||||
# The anonymous branch: gate in, table out, then a bare return —
|
||||
# and NO fetch call anywhere inside it.
|
||||
branch = boot[gate_i : boot.find("return;", gate_i)]
|
||||
assert "fetch(" not in branch, "the anonymous branch must not fetch anything"
|
||||
assert "tableWrap.hidden = true" in branch
|
||||
assert "gateEl.hidden = false" in branch
|
||||
# The admin path: the gate hides, then the list loads.
|
||||
after = boot[boot.find("return;", gate_i):]
|
||||
assert "gateEl.hidden = true" in after
|
||||
assert "loadChats();" in after
|
||||
|
||||
|
||||
def test_admin_load_renders_rows_or_empty_state() -> None:
|
||||
"""loadChats: a 0-row fetch (and non-2xx / a network failure)
|
||||
reveals the empty-state row; a populated fetch renders one row per
|
||||
chat, in the server's order (latest activity first)."""
|
||||
js = _js()
|
||||
load = _fn(js, "loadChats")
|
||||
# Every no-data outcome lands on the empty state.
|
||||
assert load.count("showEmptyState()") == 3, (
|
||||
"network failure, non-2xx and a 0-row list all show the empty state"
|
||||
)
|
||||
assert "chats.length" in load
|
||||
assert "makeRow(chat)" in load
|
||||
empty = _fn(js, "showEmptyState")
|
||||
assert "emptyRow.hidden = false" in empty
|
||||
|
||||
|
||||
# ---------- the /?chat=<id> Open link ----------
|
||||
|
||||
|
||||
def test_open_link_is_the_title_with_chat_href() -> None:
|
||||
"""makeRow: the Title cell is the Open link — ``/?chat=<id>``
|
||||
("return to that history with a click", TODO.md L5) — rendered
|
||||
through textContent (the auto-title is user-derived; never
|
||||
innerHTML). The Updated cell carries the locale date+time with the
|
||||
full ISO in the title attribute; Messages is the message_count."""
|
||||
js = _js()
|
||||
row = _fn(js, "makeRow")
|
||||
assert 'link.href = "/?chat=" + chat.id' in row, (
|
||||
"the Open link returns to /?chat=<id> (task 03's boot load)"
|
||||
)
|
||||
assert 'link.className = "history-title-link"' in row
|
||||
assert "link.textContent = chat.title" in row, "XSS contract: textContent only"
|
||||
assert 'innerHTML' not in row, "makeRow must never build HTML"
|
||||
assert "String(chat.message_count)" in row
|
||||
assert "updatedTd.title = chat.updated_at" in row, "full ISO on hover"
|
||||
assert "fmtDate(chat.updated_at)" in row
|
||||
assert "link.title" in row or "titleTd.title = chat.title" in row
|
||||
|
||||
|
||||
# ---------- the inline two-step Delete ----------
|
||||
|
||||
|
||||
def test_two_step_delete_confirm_pair() -> None:
|
||||
"""makeDeleteControl: the first click swaps the Delete button for
|
||||
the "Delete? [Yes] [No]" pair IN PLACE (keyboard-reachable — focus
|
||||
moves to Yes); No restores the Delete button (focus returns); the
|
||||
Delete button carries a labeled aria-name."""
|
||||
js = _js()
|
||||
# Owner-locked 2026-08-29: no native confirm dialog in the file.
|
||||
assert "window.confirm" not in js, "history.js must use the inline two-step only"
|
||||
fn = _fn(js, "makeDeleteControl")
|
||||
assert 'del.className = "history-delete"' in fn
|
||||
assert 'del.setAttribute("aria-label", `Delete saved chat: ${chat.title}`)' in fn
|
||||
assert 'label.textContent = "Delete?"' in fn
|
||||
assert 'yes.className = "history-confirm-yes"' in fn
|
||||
assert 'no.className = "history-confirm-no"' in fn
|
||||
# The shipped state of the actions cell IS the Delete button
|
||||
# (before any click) — a cell that only gains the button on
|
||||
# restore would render an empty Actions column.
|
||||
append = fn.find("cell.appendChild(del)")
|
||||
ret = fn.rfind("return cell")
|
||||
assert -1 < append < ret, "the Delete button is appended before the return"
|
||||
# The swap + the focus handoff.
|
||||
assert "cell.replaceChildren(label, yes, no)" in fn
|
||||
yes_swap = fn.find("cell.replaceChildren(label, yes, no)")
|
||||
assert fn.find("yes.focus()", yes_swap) > 0, "focus moves to Yes after the swap"
|
||||
# No (and the restore helper) bring the Delete button back, focused.
|
||||
restore_start = fn.find("function restoreDelete")
|
||||
restore_end = fn.find("\n }", restore_start)
|
||||
restore = fn[restore_start:restore_end]
|
||||
assert "cell.replaceChildren(del)" in restore
|
||||
assert "del.focus()" in restore
|
||||
assert 'no.addEventListener("click", restoreDelete)' in fn
|
||||
|
||||
|
||||
def test_confirmed_delete_outcomes() -> None:
|
||||
"""confirmDelete: double-fire guarded; 2xx → the row is removed +
|
||||
the empty-state row reappears when it was the last + the live
|
||||
region `Deleted "<title>".`; a 404 (already gone) drops the stale
|
||||
row and says so; any other failure / a network error KEEPS the row
|
||||
(restore) and lands the error line."""
|
||||
js = _js()
|
||||
fn = _fn(js, "confirmDelete")
|
||||
assert "yesBtn.disabled = true" in fn
|
||||
assert "fetch(`/api/chats/${chat.id}`, { method: \"DELETE\" })" in fn
|
||||
# Success: remove + empty-state check + the exact live-region line.
|
||||
assert "row.remove()" in fn
|
||||
assert "showEmptyIfLast()" in fn
|
||||
assert 'announce(`Deleted "${chat.title}".`)' in fn
|
||||
# 404: the row is stale — drop it, no restore. (The branch slices
|
||||
# stop at the NEXT branch boundary — a template-literal `}` inside
|
||||
# an announce line must not end the slice early.)
|
||||
nf = fn.find("r.status === 404")
|
||||
assert nf != -1, "the 404 branch must be handled"
|
||||
notok = fn.find("if (!r.ok)")
|
||||
nf_branch = fn[nf:notok]
|
||||
assert "row.remove()" in nf_branch
|
||||
assert "already deleted" in nf_branch
|
||||
assert "restoreDelete()" not in nf_branch
|
||||
# !ok (non-404) and network: the row stays, the button is
|
||||
# retryable, and the error line lands.
|
||||
# !ok (non-404) and network: the row stays, the button is
|
||||
# retryable, and the error line lands. (The try/catch wraps the
|
||||
# FETCH, so it precedes the status branches; the !ok slice runs to
|
||||
# the function's close — the success tail after it carries neither
|
||||
# a restore nor that line.)
|
||||
assert notok != -1
|
||||
notok_branch = fn[notok:]
|
||||
assert "restoreDelete()" in notok_branch
|
||||
assert "try again" in notok_branch
|
||||
# The network-error catch: the reachable? line + the restore (the
|
||||
# catch wraps the fetch, so it precedes the status branches).
|
||||
catch_i = fn.find("} catch {")
|
||||
assert catch_i != -1
|
||||
catch_branch = fn[catch_i : fn.find("if (r.status === 404)")]
|
||||
assert "is the app reachable?" in catch_branch
|
||||
assert "restoreDelete()" in catch_branch
|
||||
# The empty-state row reappears exactly when the last data row is
|
||||
# gone (the hidden empty row itself ships in the tbody).
|
||||
empty = _fn(js, "showEmptyIfLast")
|
||||
assert "querySelectorAll(\"tr\").length > 1" in empty
|
||||
|
||||
|
||||
# ---------- the table CSS ----------
|
||||
|
||||
|
||||
def test_history_table_css_full_width_and_palette() -> None:
|
||||
"""styles.css: .history-table is the full-width sources-table family
|
||||
(width 100%, --line borders, the brand-soft thead, row hover); the
|
||||
title link is the accent link (brand-ink, focus-visible); the
|
||||
confirm pair is Yes-on-error-rose + No-ghost; the empty-state row
|
||||
is the muted centered message. Every pair is Phase-08 AA
|
||||
(brand-ink/brand-soft 6.9:1, err 9.1:1, ink-soft >=6.9:1)."""
|
||||
css = _css()
|
||||
block = re.search(r"\.history-table \{([\s\S]*?)\n\}", css)
|
||||
assert block, "styles.css must style .history-table"
|
||||
body = block.group(1)
|
||||
assert "width: 100%" in body, "the table is FULL-WIDTH (AGENTS.md rule 5)"
|
||||
assert "min-width: 640px" in body
|
||||
th = re.search(r"\.history-table th \{([\s\S]*?)\n\}", css)
|
||||
assert th and "var(--brand-soft)" in th.group(1) and "var(--brand-ink)" in th.group(1)
|
||||
hover = re.search(r"\.history-table tbody tr:hover \{([^}]*)\}", css)
|
||||
assert hover, "row hover is part of the table family"
|
||||
link = re.search(r"\.history-title-link \{([\s\S]*?)\n\}", css)
|
||||
assert link and "var(--brand-ink)" in link.group(1), "the Open link is the accent link"
|
||||
assert re.search(r"\.history-title-link:focus-visible \{[^}]*outline[^}]*3px", css), (
|
||||
"the Open link keeps a :focus-visible outline"
|
||||
)
|
||||
yes = re.search(r"\.history-confirm-yes \{([\s\S]*?)\n\}", css)
|
||||
assert yes, "the confirm Yes button must be styled"
|
||||
ybody = yes.group(1)
|
||||
assert "var(--err-bg)" in ybody and "var(--err-ink)" in ybody and "var(--err-line)" in ybody
|
||||
no = re.search(r"\.history-confirm-no \{([\s\S]*?)\n\}", css)
|
||||
assert no and "background: transparent" in no.group(1), "No is the ghost"
|
||||
empty = re.search(r"\.history-empty-row td \{([\s\S]*?)\n\}", css)
|
||||
assert empty, "the empty-state row must be styled"
|
||||
ebody = empty.group(1)
|
||||
assert "text-align: center" in ebody and "var(--ink-soft)" in ebody
|
||||
|
||||
|
||||
def test_history_table_mobile_behavior() -> None:
|
||||
"""≤640px (the phase-07 responsive contract): the table keeps its
|
||||
full width (the .table-wrap's horizontal scroll already covers
|
||||
it) and the actions cell wraps so the two-step confirm pair fits
|
||||
the phone width."""
|
||||
css = _css()
|
||||
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css)
|
||||
assert mobile, "the mobile media query must exist"
|
||||
mbody = mobile.group(1)
|
||||
assert ".history-actions-cell { white-space: normal; }" in mbody
|
||||
assert ".history-actions { flex-wrap: wrap; }" in mbody
|
||||
@@ -0,0 +1,293 @@
|
||||
"""Unit: the phase-50 task-03 save-chat contract on the chat page.
|
||||
|
||||
The browser behavior itself is E2E-gated by the story suite (task 05);
|
||||
like the other frontend-adjacent unit files, this module pins the
|
||||
JS/CSS/HTML markers the save/load contract depends on, so a silent
|
||||
regression is caught without a browser:
|
||||
|
||||
* the ``currentChatId`` lifecycle (set on create/open, cleared by New
|
||||
chat and by the 404-PUT fallback);
|
||||
* the upsert branch (PUT when linked, POST when not, the 404→recreate
|
||||
fallback, the live-region feedback strings);
|
||||
* the boot-load precedence (a valid ``?chat=`` uuid + admin replaces the
|
||||
local restore and mirrors it to localStorage; anonymous / invalid /
|
||||
404 / network → the local restore);
|
||||
* the ship-hidden / reveal-for-admin gate on ``#save-chat-btn``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
APP_JS = FRONTEND / "assets" / "app.js"
|
||||
INDEX_HTML = FRONTEND / "index.html"
|
||||
SOURCES_HTML = FRONTEND / "sources.html"
|
||||
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
|
||||
DOCUMENT_HTML = FRONTEND / "document.html"
|
||||
LOGIN_HTML = FRONTEND / "login.html"
|
||||
TUNING_HTML = FRONTEND / "tuning.html"
|
||||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||
|
||||
|
||||
def _js() -> str:
|
||||
return APP_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return STYLES_CSS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _index() -> str:
|
||||
return INDEX_HTML.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _fn(js: str, name: str) -> str:
|
||||
"""The source of a top-level ``function <name>(...)`` (to its close)."""
|
||||
start = js.find(f"function {name}(")
|
||||
assert start != -1, f"{name}() must exist in app.js"
|
||||
return js[start : js.find("\n}\n", start) + 4]
|
||||
|
||||
|
||||
# ---------- the Save button on the chat page ----------
|
||||
|
||||
|
||||
def test_save_button_ships_hidden_beside_new_chat() -> None:
|
||||
"""#save-chat-btn: a real type=button with the accessible name
|
||||
"Save chat", SHIPPED HIDDEN (app.js reveals it for admin only),
|
||||
beside #new-chat-btn in .chat-shell inside <main>, above
|
||||
#messages — the two chat-shell actions read as a pair. No other
|
||||
page carries it (chat-page only, like New chat)."""
|
||||
html = _index()
|
||||
btn = re.search(r'<button[^>]*id="save-chat-btn"[^>]*>', html)
|
||||
assert btn, "index.html must contain #save-chat-btn"
|
||||
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()
|
||||
block = re.search(r"\.save-chat-btn \{([\s\S]*?)\n\}", css)
|
||||
assert block, "styles.css must style .save-chat-btn"
|
||||
body = block.group(1)
|
||||
assert "min-height: 44px" in body
|
||||
assert "border-radius: 999px" in body
|
||||
assert "border: 0" in body
|
||||
assert "background: var(--brand)" in body, "same solid brand fill as New chat"
|
||||
assert "color: var(--bg)" in body, "--bg text on --brand = 5.2:1 (AA)"
|
||||
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
|
||||
|
||||
|
||||
# ---------- currentChatId lifecycle ----------
|
||||
|
||||
|
||||
def test_current_chat_id_module_scope_and_lifecycle() -> None:
|
||||
"""currentChatId: module scope, string | null — set to the created
|
||||
row's id on a fresh Save (201), set to the opened id on a
|
||||
successful boot load, cleared by "New chat" AND by the 404-PUT
|
||||
fallback (a stale link must never leave the conversation unsaved)."""
|
||||
js = _js()
|
||||
assert "let currentChatId = null" in js, "module-scope link, null = unlinked"
|
||||
# Set on create: the 201 branch links to the created row's id.
|
||||
save_body = _fn(js, "saveCurrentChat")
|
||||
assert "res.status === 201" in save_body
|
||||
assert "currentChatId = String(created.id)" in save_body, (
|
||||
"a fresh Save links to the created row's id"
|
||||
)
|
||||
# Set on open: the boot load links to the fetched id.
|
||||
load_body = _fn(js, "restoreSavedChatFromUrl")
|
||||
assert "currentChatId = chatId" in load_body
|
||||
# Cleared by New chat.
|
||||
new_body = _fn(js, "startNewChat")
|
||||
assert "currentChatId = null" in new_body, "New chat unlinks"
|
||||
# Cleared by the 404-PUT fallback (see the upsert test for the branch).
|
||||
assert "res.status === 404" in save_body
|
||||
assert save_body.count("currentChatId = null") >= 1
|
||||
|
||||
|
||||
# ---------- the upsert branch ----------
|
||||
|
||||
|
||||
def test_save_upsert_put_when_linked_post_when_not() -> None:
|
||||
"""saveCurrentChat: linked → PUT /api/chats/<id> with the messages
|
||||
payload (re-Save updates the SAME row — no title in the body, so the
|
||||
row keeps its current one); unlinked → POST /api/chats (the server
|
||||
auto-titles). The 404 from the PUT unlinks and retries as a create.
|
||||
Empty conversation → no request, live-region "Nothing to save
|
||||
yet."; success → live-region "Conversation saved." (status text
|
||||
only, no banner); 403/5xx/network → the error banner."""
|
||||
js = _js()
|
||||
body = _fn(js, "saveCurrentChat")
|
||||
# No-op first: nothing to save → live-region line, no fetch.
|
||||
noop = body.find('sendStatus.textContent = "Nothing to save yet."')
|
||||
first_fetch = body.find("await 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.
|
||||
assert "if (currentChatId)" in body
|
||||
assert '`/api/chats/${currentChatId}`' in body
|
||||
assert 'method: "PUT"' in body
|
||||
assert 'fetch("/api/chats"' in body
|
||||
assert 'method: "POST"' in body
|
||||
put_idx = body.find('method: "PUT"')
|
||||
post_idx = body.find('method: "POST"')
|
||||
assert -1 < put_idx < post_idx, "the PUT (linked) branch precedes the POST fallback"
|
||||
assert '{ messages: conversation }' in body, "the messages payload — no title (keep current)"
|
||||
# The 404→recreate fallback: unlink, then POST again.
|
||||
notfound_idx = body.find("res.status === 404")
|
||||
assert notfound_idx != -1, "the PUT 404 must be handled"
|
||||
fallback = body[notfound_idx:post_idx]
|
||||
assert "currentChatId = null" in fallback, "the stale link is dropped"
|
||||
# Success is status text only — the live region, never stale — and
|
||||
# nothing between the 201 link and the success line may raise a
|
||||
# banner (the !res.ok branch returns before either).
|
||||
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)]
|
||||
assert "showErrorBanner" not in between, "no banner on the success path"
|
||||
# Failures raise an actionable banner (non-ok HTTP + network).
|
||||
assert 'showErrorBanner("Couldn\'t save the conversation — is the app reachable?")' in body
|
||||
assert "check you're still signed in and try again" in body, "403/5xx: actionable line"
|
||||
# The double-click guard releases on EVERY outcome.
|
||||
finally_idx = body.rfind("finally")
|
||||
assert finally_idx != -1 and "saveBtn.disabled = false" in body[finally_idx:], (
|
||||
"the button is re-enabled in the finally — never stale"
|
||||
)
|
||||
|
||||
|
||||
# ---------- boot-load precedence ----------
|
||||
|
||||
|
||||
def test_boot_load_precedence_saved_chat_over_local_restore() -> None:
|
||||
"""Inside the boot IIFE: after fetchIsAdmin() + the reveal gate,
|
||||
restoreSavedChatFromUrl() runs; only when it returns false does the
|
||||
phase-14 local restore run. Header init stays first (shared-module
|
||||
contract)."""
|
||||
js = _js()
|
||||
boot_start = js.find("(async () => {")
|
||||
assert boot_start != -1, "the boot IIFE must exist"
|
||||
boot = js[boot_start:]
|
||||
init_i = boot.find("await initSharedHeader();")
|
||||
admin_i = boot.find("isAdmin = await fetchIsAdmin();")
|
||||
reveal_i = boot.find("saveBtn.hidden = !isAdmin")
|
||||
saved_i = boot.find("await restoreSavedChatFromUrl();")
|
||||
local_i = boot.find("restoreConversation();")
|
||||
assert -1 < init_i < admin_i < reveal_i < saved_i < local_i, (
|
||||
"boot order: header init → whoami → Save reveal → ?chat= load → local fallback"
|
||||
)
|
||||
assert "if (!openedSaved) restoreConversation();" in boot, (
|
||||
"the local restore runs ONLY when the saved-chat load did not open"
|
||||
)
|
||||
|
||||
|
||||
def test_boot_load_gates_valid_uuid_and_admin_only() -> None:
|
||||
"""restoreSavedChatFromUrl: a VALID uuid + admin is the ONLY
|
||||
fetch path — invalid/absent ?chat= and anonymous short-circuit to
|
||||
false (no request: the gate would 403). On 200 the messages
|
||||
REPLACE the local conversation, render through the SAME
|
||||
renderStoredMessage loop (pixel-identical restore), link
|
||||
currentChatId, and mirror to localStorage. 404/network/malformed/
|
||||
empty → banner + false (the local restore then runs)."""
|
||||
js = _js()
|
||||
body = _fn(js, "restoreSavedChatFromUrl")
|
||||
# The gates, in order: param present → valid uuid → admin.
|
||||
assert '.get("chat")' in body, "the ?chat= param"
|
||||
assert "UUID_RE.test(chatId)" in body, "a valid uuid only"
|
||||
assert "!isAdmin" in body, "admin only (no fetch for anonymous)"
|
||||
gate = body.find("!isAdmin")
|
||||
fetch_i = body.find('fetch(`/api/chats/${chatId}`)')
|
||||
assert -1 < gate < fetch_i, "the gates short-circuit BEFORE the fetch"
|
||||
assert "const UUID_RE" in js, "the uuid pattern is module-level"
|
||||
# On success: replace → render through the SAME loop → link → mirror.
|
||||
assert "conversation = messages" in body, "the saved messages REPLACE the local conversation"
|
||||
assert "renderStoredMessage(m)" in body, "the SAME renderStoredMessage path as local restore"
|
||||
assert "markLastRetryable()" in body, "parity with local restore: Retry on the last bubble"
|
||||
save_mir = body.find("saveConversation()")
|
||||
link_i = body.find("currentChatId = chatId")
|
||||
assert -1 < link_i < save_mir, "link first, then mirror to localStorage"
|
||||
# Failure: the exact banner line, then false (→ local restore). The
|
||||
# gate line returns false directly; the 404/network, malformed-body
|
||||
# and empty-payload paths all route through the banner helper.
|
||||
banner_line = 'showErrorBanner("That saved chat isn\'t available — it may have been deleted.")'
|
||||
assert banner_line in body
|
||||
assert "return false" in body, "invalid/absent param or anonymous → no fetch, local restore"
|
||||
assert body.count("return unavailable()") == 4, (
|
||||
"network, non-ok (404/403/5xx), malformed body and empty payload all fall back"
|
||||
)
|
||||
# The ?chat= param is a one-shot boot instruction: the success path
|
||||
# normalizes the URL back to / so a later refresh (or "New chat" +
|
||||
# refresh) restores the LOCAL session instead of re-opening the row.
|
||||
assert 'history.replaceState(null, "", "/")' in body, (
|
||||
"a consumed ?chat= must not linger in the URL"
|
||||
)
|
||||
# The defensive filter keeps a corrupted stored row from poisoning the
|
||||
# restore (same shape check as loadStoredConversation).
|
||||
assert 'm.who === "user" || m.who === "brain"' in body
|
||||
assert 'typeof m.text === "string"' in body
|
||||
|
||||
|
||||
# ---------- the reveal gate ----------
|
||||
|
||||
|
||||
def test_save_button_revealed_only_for_admin() -> None:
|
||||
"""The ship-hidden/reveal-for-admin contract: app.js queries
|
||||
#save-chat-btn, binds the click to saveCurrentChat, and the boot
|
||||
IIFE sets saveBtn.hidden = !isAdmin (phase 16 absent-not-hidden —
|
||||
hidden is display:none, no trace for anonymous)."""
|
||||
js = _js()
|
||||
assert 'document.querySelector("#save-chat-btn")' in js
|
||||
assert 'saveBtn?.addEventListener("click", saveCurrentChat)' in js
|
||||
assert "saveBtn.hidden = !isAdmin" in js, "revealed for admin only, at boot"
|
||||
# The reveal happens in the boot IIFE (after whoami), not at module
|
||||
# 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:
|
||||
"""The localStorage accesses stay EXACTLY the phase-14 three
|
||||
(loadStoredConversation / saveConversation / clearStoredConversation)
|
||||
— the saved-chat mirror goes through saveConversation(), so the
|
||||
house failure-safety pin (exactly 3, all try-wrapped) holds."""
|
||||
js = _js()
|
||||
accesses = list(re.finditer(r"localStorage\.(?:getItem|setItem|removeItem)", js))
|
||||
assert len(accesses) == 3, f"expected exactly 3 localStorage accesses, got {len(accesses)}"
|
||||
|
||||
|
||||
def test_no_cdn_added() -> None:
|
||||
"""AGENTS.md rule 6: the Save button adds no external script/link."""
|
||||
index = _index()
|
||||
assert 'src="http' not in index and 'href="http' not in index
|
||||
@@ -22,8 +22,8 @@ ASSETS = FRONTEND / "assets"
|
||||
|
||||
HEADER_JS = ASSETS / "header.js"
|
||||
|
||||
#: All six pages carry the shared header block (phase 34's five pages +
|
||||
#: phase 35's git-sources page).
|
||||
#: All seven pages carry the shared header block (phase 34's five pages
|
||||
#: + phase 35's git-sources page + phase 50's History page).
|
||||
PAGES = (
|
||||
FRONTEND / "index.html",
|
||||
FRONTEND / "sources.html",
|
||||
@@ -31,6 +31,7 @@ PAGES = (
|
||||
FRONTEND / "git-sources.html",
|
||||
FRONTEND / "login.html",
|
||||
FRONTEND / "tuning.html",
|
||||
FRONTEND / "history.html",
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user