diff --git a/Containerfile b/Containerfile index 90352ec..4291abc 100644 --- a/Containerfile +++ b/Containerfile @@ -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 diff --git a/alembic/versions/0008_saved_chats.py b/alembic/versions/0008_saved_chats.py new file mode 100644 index 0000000..5d92d7e --- /dev/null +++ b/alembic/versions/0008_saved_chats.py @@ -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") diff --git a/app/api/chats.py b/app/api/chats.py new file mode 100644 index 0000000..ffd23e4 --- /dev/null +++ b/app/api/chats.py @@ -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 "``). + """ + 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 "``. + """ + 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=`` 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) diff --git a/app/core/caching.py b/app/core/caching.py index d76c055..f2ad75b 100644 --- a/app/core/caching.py +++ b/app/core/caching.py @@ -5,9 +5,10 @@ Two layers, one module: * **Version token** (``asset_version()``) — the value the HTML pages append to their asset URLs (``?v=``). * **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=``; ``/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=``; + ``/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=``. Everything else — all of ``/api/*`` (including the SSE chat stream) — diff --git a/app/main.py b/app/main.py index d4c75cf..487a282 100644 --- a/app/main.py +++ b/app/main.py @@ -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= asset refs; /assets/* becomes immutable for a year. diff --git a/app/models.py b/app/models.py index af80737..8d9c746 100644 --- a/app/models.py +++ b/app/models.py @@ -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() + ) diff --git a/app/schemas.py b/app/schemas.py index b7a8fb9..ac47aa1 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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 ````-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] diff --git a/frontend/assets/app.js b/frontend/assets/app.js index 085210b..f1e5a3e 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -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= 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= 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= 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=, 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/ (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= (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(); })(); diff --git a/frontend/assets/header.js b/frontend/assets/header.js index f706ac9..dd461c2 100644 --- a/frontend/assets/header.js +++ b/frontend/assets/header.js @@ -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 diff --git a/frontend/assets/history.js b/frontend/assets/history.js new file mode 100644 index 0000000..46bf23f --- /dev/null +++ b/frontend/assets/history.js @@ -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/` + * endpoints (phase 50 task 02) into the page's full-width table: + * + * • Title — an ``: 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= — 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/ + → 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/ → the row is removed + (+ the empty-state row reappears when it was the last one) and the + live region gets `Deleted "".` 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(); +})(); diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 08357f5..43edff0 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -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 diff --git a/frontend/document.html b/frontend/document.html index ede9935..a33edb5 100644 --- a/frontend/document.html +++ b/frontend/document.html @@ -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> diff --git a/frontend/git-sources.html b/frontend/git-sources.html index 4fe000f..62ee6b9 100644 --- a/frontend/git-sources.html +++ b/frontend/git-sources.html @@ -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> diff --git a/frontend/history.html b/frontend/history.html new file mode 100644 index 0000000..7c8a37a --- /dev/null +++ b/frontend/history.html @@ -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 + + + + + + +
+
+ + + Brain of Reese + + + + + + + + +
+
+ +
+ + +

+
+
+

Saved chats

+

+ Every conversation you pressed Save on — + newest activity first. Click a title to return to that chat. +

+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Saved chats — click a title to return to that conversation
TitleMessagesUpdatedActions
+
+
+
+ +
+ +
+ + + + + + diff --git a/frontend/index.html b/frontend/index.html index 67e56dc..8c00b0b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -43,6 +43,11 @@ reveals it once whoami says admin, exactly like the Sources link above. --> + + + + + + + +