feat(chat): save and view chat history — admin-only saved_chats, History page, open-a-chat return

This commit is contained in:
2026-08-29 21:22:25 -04:00
parent 6832957ab0
commit ece93a7c8f
29 changed files with 3099 additions and 20 deletions
+2 -1
View File
@@ -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
+57
View File
@@ -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")
+183
View File
@@ -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
View File
@@ -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) —
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
})();
+6
View File
@@ -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
+228
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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>
+5
View File
@@ -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>
+180
View File
@@ -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>
+25
View File
@@ -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
+5
View File
@@ -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>
+5
View File
@@ -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>
+5
View File
@@ -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>
+5 -3
View File
@@ -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:
+434
View File
@@ -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
+15 -4
View File
@@ -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"
)
+3 -1
View File
@@ -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
+446
View File
@@ -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
+255
View File
@@ -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"
)
+24
View File
@@ -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)
# ---------------------------------------------------------------------------
+1
View File
@@ -28,6 +28,7 @@ HTML_PAGES = (
"document.html",
"login.html",
"git-sources.html",
"history.html", # phase 50: the admin saved-chats page
)
+428
View File
@@ -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
+293
View File
@@ -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
+3 -2
View File
@@ -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",
)