diff --git a/.agent/phases/todo/53_stale_saved_chats/01_sources_version_and_migration.md b/.agent/phases/complete/53_stale_saved_chats/01_sources_version_and_migration.md similarity index 100% rename from .agent/phases/todo/53_stale_saved_chats/01_sources_version_and_migration.md rename to .agent/phases/complete/53_stale_saved_chats/01_sources_version_and_migration.md diff --git a/.agent/phases/todo/53_stale_saved_chats/02_sync_version_bump.md b/.agent/phases/complete/53_stale_saved_chats/02_sync_version_bump.md similarity index 100% rename from .agent/phases/todo/53_stale_saved_chats/02_sync_version_bump.md rename to .agent/phases/complete/53_stale_saved_chats/02_sync_version_bump.md diff --git a/.agent/phases/todo/53_stale_saved_chats/03_chats_api_staleness.md b/.agent/phases/complete/53_stale_saved_chats/03_chats_api_staleness.md similarity index 100% rename from .agent/phases/todo/53_stale_saved_chats/03_chats_api_staleness.md rename to .agent/phases/complete/53_stale_saved_chats/03_chats_api_staleness.md diff --git a/.agent/phases/todo/53_stale_saved_chats/04_history_stale_badge.md b/.agent/phases/complete/53_stale_saved_chats/04_history_stale_badge.md similarity index 100% rename from .agent/phases/todo/53_stale_saved_chats/04_history_stale_badge.md rename to .agent/phases/complete/53_stale_saved_chats/04_history_stale_badge.md diff --git a/.agent/phases/todo/53_stale_saved_chats/05_stale_banner_and_regenerate.md b/.agent/phases/complete/53_stale_saved_chats/05_stale_banner_and_regenerate.md similarity index 100% rename from .agent/phases/todo/53_stale_saved_chats/05_stale_banner_and_regenerate.md rename to .agent/phases/complete/53_stale_saved_chats/05_stale_banner_and_regenerate.md diff --git a/alembic/versions/0010_sources_version.py b/alembic/versions/0010_sources_version.py new file mode 100644 index 0000000..fa67600 --- /dev/null +++ b/alembic/versions/0010_sources_version.py @@ -0,0 +1,66 @@ +"""sources_meta + saved_chats.sources_version: the KB-generation stamp (phase 53) + +Revision ID: 0010 +Revises: 0009 +Create Date: 2026-08-30 + +Phase 53 (invalidate saved chats on sources sync, TODO-derived +2026-08-30): a saved answer can silently predate the current index, +because neither sync path (the admin Sync button, +``scripts/import_docs.py``) records *when the KB last changed*. ONE +migration carries the whole feature (A13 — one feature, one atomic, +reversible schema change): + +* ``sources_meta`` — a **single-row** counter (``id INTEGER PK + DEFAULT 1``, the ``kb_overview`` phase-31 precedent) holding the + current **generation** of the knowledge base. The seed row + (id 1, version 0) is inserted here — not lazily on first bump — so + ``app.rag.sources_meta.current_sources_version`` is a plain PK read. + ``version`` is bumped exactly once per KB-changing sync (task 02, + change-gated on ``added + updated + pruned > 0``); a failed sync + never bumps. +* ``saved_chats.sources_version`` — ``INTEGER NOT NULL DEFAULT 0``: + the generation the conversation was saved against, stamped by the + chats API at save time (task 03). Pre-0010 rows come back stamped 0 + (the pre-counter KB) and go stale on the first bump. +""" +from __future__ import annotations + +import sqlalchemy as sa + +from alembic import op + +revision = "0010" +down_revision = "0009" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "sources_meta", + sa.Column("id", sa.Integer(), primary_key=True, server_default=sa.text("1")), + sa.Column("version", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + ) + # Seed the single row (id 1, version 0): "the pre-counter KB". + op.execute( + "INSERT INTO sources_meta (id, version, updated_at) VALUES (1, 0, now())" + ) + op.add_column( + "saved_chats", + sa.Column( + "sources_version", sa.Integer(), nullable=False, server_default=sa.text("0") + ), + ) + + +def downgrade() -> None: + # Safe order: drop the stamp column first, then the counter table. + op.drop_column("saved_chats", "sources_version") + op.drop_table("sources_meta") diff --git a/app/api/chats.py b/app/api/chats.py index d7a43fe..2c2ceaf 100644 --- a/app/api/chats.py +++ b/app/api/chats.py @@ -15,16 +15,31 @@ payload is the exact ``bor.chat.v1`` localStorage record shape pixel-identical through the existing ``renderStoredMessage`` path. Routes: ``GET`` (list, latest activity first — no payloads, but the -phase-51 ``share_url`` so the History's Share column renders without a -second fetch), ``POST`` (create — auto-title from the first question +phase-51 ``share_url`` and the phase-53 ``stale`` flag so the +History's Share and Stale columns render without a second fetch), +``POST`` (create — auto-title from the first question when no ``title`` is supplied; (phase 51, task 02) ``share: true`` sets -the token in the SAME commit — the save-then-share contract), +the token in the SAME commit — the save-then-share contract; +(phase 53, task 03) stamps the row's ``sources_version`` on the +PENDING row so it ships in the same INSERT), ``GET /{chat_id}`` (full payload), ``PUT /{chat_id}`` (re-Save upsert — full ``messages`` replacement, ``title`` replaced -only when supplied), ``DELETE /{chat_id}``, and (phase 51, task 01) +only when supplied; re-stamps ``sources_version`` to the current +generation — a Re-Save is the owner affirming this content against +the current KB), ``DELETE /{chat_id}``, and (phase 51, task 01) ``POST /{chat_id}/share`` / ``POST /{chat_id}/unshare`` on this admin-gated router. +Staleness (phase 53, task 03): every saved row carries the +``sources_meta`` generation it was saved against (``sources_version``, +migration 0010). ``GET``/list/detail expose ``stale`` — true iff the +row's stamp is behind the current generation, computed server-side +from ONE ``current_sources_version`` read per request (the client +never does staleness math). Share/unshare stay version-immune (raw SQL +on ``share_token`` only — the version, like ``updated_at``, is +untouched); ``SharedChatOut`` (the public snapshot) carries no +staleness surface at all — it is frozen by design (phase 51). + Sharing (phase 51, owner-locked 2026-08-29): a saved chat's ``share_token`` (a 128-bit ``uuid4``, migration 0009) makes it publicly readable at ``/shared/`` — two more routers in this @@ -54,6 +69,7 @@ from app.config import get_settings from app.core.auth import require_admin from app.db import get_db from app.models import SavedChat +from app.rag.sources_meta import current_sources_version from app.schemas import ( ChatMessage, SavedChatCreate, @@ -100,8 +116,14 @@ def _share_url(row: SavedChat) -> str | None: return f"/shared/{row.share_token}" if row.share_token else None -def _to_out(row: SavedChat) -> SavedChatOut: - """The full-payload response shape (create/get/put).""" +def _to_out(row: SavedChat, current_version: int) -> SavedChatOut: + """The full-payload response shape (create/get/put). + + ``current_version`` is the caller's ONE per-request + ``current_sources_version`` read — the helpers stay pure (no session + argument, no second query); ``stale`` is true iff the row's stamp + is behind that generation. + """ return SavedChatOut( id=row.id, title=row.title, @@ -110,18 +132,22 @@ def _to_out(row: SavedChat) -> SavedChatOut: message_count=len(row.messages), messages=[ChatMessage.model_validate(m) for m in row.messages], share_url=_share_url(row), + stale=row.sources_version < current_version, ) -def _to_row(row: SavedChat) -> SavedChatRow: +def _to_row(row: SavedChat, current_version: int) -> SavedChatRow: """The list-page row shape (no payloads in the list; ``share_url`` - is not a payload — it is the History Share column's data).""" + and ``stale`` are not payloads — they are the History Share and + Stale columns' data). ``current_version`` is the caller's ONE + per-request read (see :func:`_to_out`).""" return SavedChatRow( id=row.id, title=row.title, updated_at=row.updated_at, message_count=len(row.messages), share_url=_share_url(row), + stale=row.sources_version < current_version, ) @@ -130,11 +156,15 @@ 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.""" + desc``) — the History page's table order. Each row carries the + phase-53 ``stale`` flag: the current generation is read ONCE per + request (one PK read of the seeded row) and compared against every + row's stamp in the serializer helpers — no per-row queries.""" + current_version = current_sources_version(db) 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]) + return SavedChatList(chats=[_to_row(row, current_version) for row in rows]) @router.post("", response_model=SavedChatOut, status_code=201) @@ -153,8 +183,15 @@ def create_chat( the fresh row carries ``share_token = uuid.uuid4()`` in the SAME INSERT/commit — one request saves AND shares, and the 201 body carries ``share_url`` (the chat page copies it in one action). + + ``sources_version`` (phase 53, task 03): stamped on the PENDING + row with the current generation — it ships in the SAME INSERT (the + ``share_token`` precedent), so a save can never be committed + unstamped, and the 201 body's ``stale`` flag (freshly stamped → + always false) is honest by construction. """ title = (payload.title or "").strip() or _auto_title(payload.messages) + current_version = current_sources_version(db) # once per request row = SavedChat( title=title, # Plain model_dump (no exclude_none): the stored JSONB keeps @@ -163,6 +200,9 @@ def create_chat( # 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], + # Phase 53: the KB generation this save is made against, set + # before db.add so it rides the same INSERT (see above). + sources_version=current_version, ) if payload.share: # Phase 51: set on the PENDING row, so the token ships in the @@ -174,7 +214,7 @@ def create_chat( row.title = f"Chat {row.id.hex[:8]}" db.commit() db.refresh(row) - return _to_out(row) + return _to_out(row, current_version) @router.get("/{chat_id}", response_model=SavedChatOut) @@ -183,11 +223,13 @@ def get_chat( db: Session = Depends(get_db), # noqa: B008 ) -> SavedChatOut: """One saved chat, full payload (the ``?chat=`` load); 404 when - the id is unknown.""" + the id is unknown. The ``stale`` flag (phase 53) tells the chat + page whether to reveal its stale banner (task 05) before the + messages render.""" row = db.get(SavedChat, chat_id) if row is None: raise HTTPException(status_code=404, detail="unknown chat") - return _to_out(row) + return _to_out(row, current_sources_version(db)) @router.put("/{chat_id}", response_model=SavedChatOut) @@ -203,17 +245,28 @@ def update_chat( 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. + + ``sources_version`` is re-stamped to the CURRENT generation + unconditionally (phase 53, task 03 — ASSUMPTION: a Re-Save ALWAYS + re-stamps, not "only when stale"): re-Saving is the owner + affirming this content against the current KB, which makes the + response's ``stale`` flag false and doubles as the manual escape + hatch for a false-positive stale row (one fewer code path than a + conditional re-stamp). """ row = db.get(SavedChat, chat_id) if row is None: raise HTTPException(status_code=404, detail="unknown chat") + current_version = current_sources_version(db) # once per request row.messages = [m.model_dump() for m in payload.messages] title = (payload.title or "").strip() if title: row.title = title + # Phase 53: the affirmation stamp — see the docstring above. + row.sources_version = current_version db.commit() db.refresh(row) - return _to_out(row) + return _to_out(row, current_version) @router.delete("/{chat_id}", status_code=204) diff --git a/app/api/sync.py b/app/api/sync.py index 73fb5ec..700e09c 100644 --- a/app/api/sync.py +++ b/app/api/sync.py @@ -39,7 +39,16 @@ decisions): CLI's no-prune default is unchanged); 5. when the import changed the KB (added + updated > 0), ``regenerate_overview`` refreshes the single ``kb_overview`` row - (phase 31 trigger, best-effort inside). + (phase 31 trigger, best-effort inside); +6. when the import changed the KB (added + updated + pruned > 0 — the + saved-chat invalidation gate, phase 53 task 02: a pruned document + can invalidate a saved answer that cited it, deliberately broader + than step 5's overview gate), the single-row ``sources_meta`` + version counter is bumped exactly once in a short-lived session and + the resulting generation lands in the status detail as + ``sources_version`` (an unchanged re-sync reports the current + generation without advancing it). A FAILED sync never bumps — the + run aborts in the ``failed`` state before this step. Status is in memory: a restart mid-sync loses the running state (accepted — the next click re-syncs idempotently). @@ -63,6 +72,7 @@ from app.rag.git_sources import effective_sources from app.rag.importer import ImportSummary, import_sources from app.rag.llm import LLMClient, check_models from app.rag.overview import regenerate_overview +from app.rag.sources_meta import bump_sources_version, current_sources_version from scripts.git_sync import GitSyncError, clone_or_pull from scripts.import_docs import repo_name @@ -202,6 +212,26 @@ async def _run_sync() -> None: overview = False if summary.added + summary.updated > 0: overview = await regenerate_overview(llm) + # Phase 53 (task 02): a sync that changed the KB advances the + # sources version exactly once — the saved-chat invalidation + # marker (task 03 stamps rows against it). The gate is + # deliberately broader than the overview's above: a pruned + # document can invalidate a saved answer that cited it, so + # ``pruned > 0`` bumps too. The bump commits in its own short + # session (the ``effective_sources`` pattern above), so it + # lands even if the best-effort overview then fails — the index + # really did change. An unchanged re-sync never bumps; it + # reports the current generation instead, so the detail always + # carries the generation the KB is now at. + db = SessionLocal() + try: + if summary.added + summary.updated + summary.pruned > 0: + sources_version = bump_sources_version(db) + db.commit() + else: + sources_version = current_sources_version(db) + finally: + db.close() _status.state = "success" _status.finished_at = datetime.now(UTC) _status.detail = { @@ -215,6 +245,7 @@ async def _run_sync() -> None: "summaries": summary.summaries, "summary_errors": summary.summary_errors, "overview": overview, + "sources_version": sources_version, } logger.info("sync: done detail=%s", _status.detail) except Exception as e: # noqa: BLE001 — a background task dies in state, see above diff --git a/app/models.py b/app/models.py index 2d7c560..fb75d40 100644 --- a/app/models.py +++ b/app/models.py @@ -23,7 +23,14 @@ Data model — see ``.agent/PLAN.md`` §Data Model: 14 shape) — phase 50; ``/api/chat`` stays stateless; ``share_token`` (NULL = private, ``uuid4`` = publicly readable at - ``/shared/``) — phase 51. + ``/shared/``) — phase 51; ``sources_version`` + (the KB generation the conversation was saved + against; 0 = the pre-counter KB, stale on the + first bump) — phase 53. +* ``sources_meta`` — single-row sources-version counter: which + generation of the knowledge base is current, + bumped exactly once per KB-changing sync so saved + chats can be marked stale (phase 53). """ from __future__ import annotations @@ -146,6 +153,33 @@ class KbOverview(Base): ) +class SourcesMeta(Base): + """Single-row sources-version counter (phase 53). + + Exactly one row (``id = 1``, seeded by migration 0010 — the + ``kb_overview`` id=1 precedent) holds the current **generation** of + the knowledge base. ``version`` is bumped exactly once per sync that + actually changed the KB (phase 53, task 02 — change-gated on + ``added + updated + pruned > 0``), so it doubles as the invalidation + marker for saved chats: a ``saved_chats`` row stamped with an older + generation is *stale* — its answers predate the current index and + may be Regenerated against it (phase 53, tasks 03/05). The row is + seeded by the migration (not lazily on first bump), so + :func:`app.rag.sources_meta.current_sources_version` is a plain PK + read. + """ + + __tablename__ = "sources_meta" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, server_default="1") + #: The KB generation. 0 = the pre-counter KB (everything indexed + #: before phase 53); incremented by one per KB-changing sync. + version: Mapped[int] = mapped_column(Integer, server_default="0") + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + class GitSource(Base): """One admin-managed source (phase 35; kind discriminator, phase 38). @@ -203,6 +237,13 @@ class SavedChat(Base): share_token: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), unique=True, nullable=True ) + #: The sources version (KB generation) the conversation was saved + #: against (phase 53): stamped by the API at save time. Existing + #: rows (saved before the counter existed) stamp ``0`` — "the + #: pre-counter KB" — and become stale on the first KB-changing sync + #: (stale = ``sources_version < current``, computed server-side by + #: the chats API, phase 53 task 03). + sources_version: Mapped[int] = mapped_column(Integer, server_default="0") 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/rag/sources_meta.py b/app/rag/sources_meta.py new file mode 100644 index 0000000..c48fa73 --- /dev/null +++ b/app/rag/sources_meta.py @@ -0,0 +1,53 @@ +"""Sources-version counter (phase 53, task 01). + +The single-row ``sources_meta`` table (migration 0010, the +``kb_overview`` id=1 precedent) records **which generation of the +knowledge base** is current. ``version`` is bumped exactly once per +sync that actually changed the KB (task 02 — change-gated on +``added + updated + pruned > 0``; a failed sync never bumps), so it +doubles as the invalidation marker for saved chats: the chats API +stamps each saved row with the current version at save time (task 03) +and flags rows stamped with an older generation *stale* — their +answers predate the current index and may be Regenerated against it +(task 05). + +Session convention (phase 53): :func:`bump_sources_version` only +*flushes* — the **caller** commits. The two sync paths (the admin Sync +button, ``scripts/import_docs``) each own their session, and the bump +must land in the same transaction as the KB changes it records. +""" +from __future__ import annotations + +from sqlalchemy.orm import Session + +from app.models import SourcesMeta + + +def current_sources_version(db: Session) -> int: + """The current KB generation (one PK read of the seeded row). + + Returns ``0`` when the row is absent — defensive, never raises + (the migration seeds the row, so absence only happens if someone + deleted it out-of-band). + """ + row = db.get(SourcesMeta, 1) + if row is None: + return 0 + return row.version + + +def bump_sources_version(db: Session) -> int: + """Advance the KB generation by one; return the new version. + + Upserts the single row (creating it if it was ever deleted), + increments ``version`` by one, and ``flush``es — the **caller** + commits, because each of the two sync paths owns its session and + the bump must commit together with the KB changes it witnessed. + """ + row = db.get(SourcesMeta, 1) + if row is None: + row = SourcesMeta(id=1, version=0) + db.add(row) + row.version += 1 + db.flush() + return row.version diff --git a/app/schemas.py b/app/schemas.py index 9271800..33418dd 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -387,7 +387,7 @@ def _drop_absent_share_url(model: BaseModel, handler: SerializerFunctionWrapHand class SavedChatOut(BaseModel): """One saved chat, full payload (create/get/put response, phase 50; - ``share_url``, phase 51 task 02). + ``share_url``, phase 51 task 02; ``stale``, phase 53 task 03). ``messages`` round-trips the ``bor.chat.v1`` record list losslessly — the restore path is pixel-identical by construction. @@ -395,6 +395,13 @@ class SavedChatOut(BaseModel): ``share_url`` (phase 51): ``"/shared/"`` while the chat is shared, ABSENT from the JSON when unshared (``None`` → dropped by :func:`_drop_absent_share_url` — no ``null`` in the wire shape). + + ``stale`` (phase 53): true iff the row's ``sources_version`` stamp + is behind the current ``sources_meta`` generation — the answer + predates the latest KB-changing sync. Computed server-side (the + client never does staleness math); ``SharedChatOut`` deliberately + carries no staleness surface (the public snapshot is frozen by + design, phase 51). """ id: uuid.UUID @@ -404,6 +411,9 @@ class SavedChatOut(BaseModel): message_count: int messages: list[ChatMessage] share_url: str | None = None + #: Required (no default): the API must always compute staleness + #: server-side — there is no wire shape without the flag. + stale: bool @model_serializer(mode="wrap") def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any: @@ -418,7 +428,9 @@ class SavedChatRow(BaseModel): (Title, Messages count, Updated). ``share_url`` is populated here so the History page's Share column renders straight from ``GET /api/chats`` — no second fetch per row (``None`` → absent, the same - omission rule as :class:`SavedChatOut`). + omission rule as :class:`SavedChatOut`). ``stale`` (phase 53) feeds + the History page's Stale column the same way: one ``GET`` powers + every column. """ id: uuid.UUID @@ -426,6 +438,8 @@ class SavedChatRow(BaseModel): updated_at: datetime message_count: int share_url: str | None = None + #: Required (no default) — see :attr:`SavedChatOut.stale`. + stale: bool @model_serializer(mode="wrap") def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any: diff --git a/frontend/assets/app.js b/frontend/assets/app.js index 6893bdf..8829810 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -177,6 +177,32 @@ * in hint, like Save); a network failure → the "is the app reachable?" * banner. * + * Stale saved chats (phase 53, TODO.md L4): every sync that changes the + * knowledge base bumps the sources generation; a row saved against an + * older one is STALE (server-computed `stale` on GET /api/chats/ — + * the client never does staleness math). The /?chat= boot load + * reveals the #stale-banner (top of the column, directly below + * #kb-banner) when the fetched payload reports `stale: true`. A stale + * conversation with NO brain record is revealed text-only — the + * #stale-regenerate button is removed (retryLastTurn is never called in + * that state). Regenerate = the phase-49 redo-in-place of the LAST + * brain bubble ONLY: retryLastTurn(lastBrainWrap) re-asks the last + * question against the new index (full conversation context kept; earlier + * answers are not re-run), and retryLastTurn now RETURNS the + * runTurn promise so the handler can await the turn's completion + * (behavior-neutral for the existing Retry click, which ignores it). + * Only when the turn completes WITHOUT the error banner does the handler + * persist the linked row through the SAME upsert as Save — PUT + * /api/chats/ (the server re-stamps sources_version → stale: false); + * a 404 (row deleted from History meanwhile) unlinks and recreates + * (saveCurrentChat's stale-link rule). A regenerate that errors + * mid-stream leaves the row untouched (stale stays true); a regenerate + * STOPPED mid-stream (phase 48) persists the stopped partial. Success + * hides the banner and announces in the #send-status live region + * (PLAN §7.4 never-stale). The banner also clears on "New chat" and on + * a successful manual re-Save (both make the row/conversation no longer + * the one the banner describes). + * * All DOM ids match frontend/index.html. */ @@ -201,6 +227,8 @@ 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) const shareBtn = document.querySelector("#share-chat-btn"); // phase 51: admin-only Share pill (ships hidden) +const staleBanner = document.querySelector("#stale-banner"); // phase 53: the stale banner (ships hidden) +const staleRegenBtn = document.querySelector("#stale-regenerate"); // phase 53: the banner's Regenerate pill /* 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 @@ -1100,6 +1128,18 @@ async function restoreSavedChatFromUrl() { 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 + // Phase 53 (task 05): the `stale` flag is server-computed (task 03 — + // the row's sources stamp is behind the current generation; the + // client never does staleness math). Reveal the banner; when the + // conversation has NO brain record there is nothing to regenerate, + // so the button is removed first (text-only — retryLastTurn is never + // called in that state). + if (data.stale === true) { + if (!conversation.some((m) => m.who === "brain") && staleRegenBtn) { + staleRegenBtn.remove(); // no brain answer — nothing to regenerate + } + if (staleBanner) staleBanner.hidden = false; + } // 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. @@ -1151,6 +1191,9 @@ async function saveCurrentChat() { currentChatId = String(created.id); // fresh Save: link to the new row } sendStatus.textContent = "Conversation saved."; + // Phase 53: a re-Save re-stamps the row to the current generation + // (task 03) — the row is no longer stale, so the banner is done. + if (staleBanner) staleBanner.hidden = true; } catch { showErrorBanner("Couldn't save the conversation — is the app reachable?"); } finally { @@ -1268,6 +1311,75 @@ async function shareCurrentChat() { } } +/* Regenerate a stale saved chat — the #stale-regenerate handler + * (phase 53, task 05). The banner only ever shows on the /?chat= + * boot path (admin), so currentChatId is set whenever this runs. The + * redo: retryLastTurn(lastBrainWrap) — the phase-49 redo-in-place of + * the LAST brain bubble (its own guards — in-flight, wrap !== + * lastBrainWrap, no preceding user record — make a stale or superseded + * click a no-op that resolves nothing). The handler AWAITs the returned + * turn promise, and only when the turn completed WITHOUT the error + * banner persists the linked row through the SAME upsert as Save: + * PUT /api/chats/ (the server re-stamps sources_version → the row + * is fresh again); a 404 (the row was deleted from History meanwhile) + * follows saveCurrentChat's stale-link rule — unlink + recreate, so the + * owner is never left with an unsaved conversation. A regenerate that + * errors mid-stream leaves the row untouched (stale stays true — + * Regenerate stays available); a regenerate STOPPED mid-stream (phase + * 48) persists the stopped partial (the owner engaged with the new + * index). Success hides the banner and announces the outcome in the + * #send-status live region (PLAN §7.4 never-stale). */ +async function regenerateStaleChat() { + if (staleRegenBtn?.disabled) return; // one regenerate at a time (double-click guard) + staleRegenBtn.disabled = true; + try { + // Phase-49 targeting: the LAST brain bubble's rendered wrap. When a + // guard no-ops the redo (no brain bubble — the no-brain-record state + // that removed the button at reveal; in-flight turn; superseded + // wrap), retryLastTurn returns nothing and there is nothing to + // await or persist. + const turn = lastBrainWrap ? retryLastTurn(lastBrainWrap) : undefined; + if (!turn) return; + await turn; // the turn's completion — runTurn settles to idle always + // A regenerate that errored mid-stream (the error banner is up) leaves + // the linked row untouched — the row stays stale, the banner stays. + if (banner.classList.contains("is-error")) return; + // Persist the linked row through the SAME upsert as Save: PUT (the + // server re-stamps sources_version — the row is fresh again); a 404 + // (deleted from History meanwhile) unlinks and recreates. + const body = JSON.stringify({ messages: conversation }); + const headers = { "Content-Type": "application/json" }; + 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 regenerated answer — check you're still signed in and try again." + ); + return; + } + if (res.status === 201) { + const created = await res.json(); + currentChatId = String(created.id); // the recreate: link the new row + } + staleBanner.hidden = true; // fresh row — the banner is done + sendStatus.textContent = "Regenerated — the answer now reflects the current sources."; + } catch { + showErrorBanner("Couldn't save the regenerated answer — is the app reachable?"); + } finally { + if (staleRegenBtn) staleRegenBtn.disabled = false; // released on EVERY outcome + } +} + /* 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 @@ -1319,6 +1431,7 @@ function startNewChat() { if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return; conversation = []; currentChatId = null; // phase 50: a new conversation is unlinked until saved + if (staleBanner) staleBanner.hidden = true; // phase 53: the banner described the cleared conversation clearStoredConversation(); removeTyping(); messagesEl.querySelectorAll(".msg").forEach((el) => el.remove()); @@ -1377,7 +1490,12 @@ function stopTurn() { * no scroll (phase 42: the fresh bubble lands where the old one was). * Guards: inert while a turn is in flight (one turn at a time), and the * click's wrap must still be the last brain bubble's rendered wrap — a - * stale click on a superseded bubble is harmless by construction. */ + * stale click on a superseded bubble is harmless by construction. + * Phase 53 (task 05): RETURNS the runTurn promise when the redo runs + * (undefined when a guard no-ops it) — the stale banner's Regenerate + * path awaits the turn's completion to know when to persist the linked + * row. The existing Retry click handler ignores the return value, so + * phase-49 behavior is unchanged. */ function retryLastTurn(wrap) { if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return; if (wrap !== lastBrainWrap) return; // stale click — the button moved on @@ -1403,7 +1521,9 @@ function retryLastTurn(wrap) { lastBrainWrap = null; // Re-ask without re-adding: the reask turn skips the user append and // persistence save point 1 (the question is already in both). - void runTurn(text, { reask: true }); + // Phase 53: the promise is returned (the Regenerate await above); + // runTurn never rejects — a failure surfaces as the error banner. + return runTurn(text, { reask: true }); } async function handleSend(e) { @@ -1686,6 +1806,10 @@ saveBtn?.addEventListener("click", saveCurrentChat); /* Phase 51 (owner-locked 2026-08-29, TODO.md L6): the Share pill — * same ship-hidden/reveal contract as Save (the boot IIFE below). */ shareBtn?.addEventListener("click", shareCurrentChat); +/* Phase 53 (task 05): the stale banner's Regenerate pill. The binding + * is inert unless the banner is revealed — which only happens on the + * /?chat= boot path (admin, task-50 contract). */ +staleRegenBtn?.addEventListener("click", regenerateStaleChat); /* Navigate-away save point (phase 20, owner choice 2026-08-24 A1): * leaving the chat mid-turn would otherwise drop the in-flight diff --git a/frontend/assets/history.js b/frontend/assets/history.js index 8b82070..f2c1931 100644 --- a/frontend/assets/history.js +++ b/frontend/assets/history.js @@ -13,6 +13,12 @@ * 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; + * • Stale — phase 53 (task 04): the READ-ONLY staleness marker, + * rendered from the row's `stale` flag (the server computes it — + * the client never does staleness math): a rose "Stale" pill when + * the row was saved before the last KB-changing sync, an em-dash + * when fresh. The Regenerate action is NOT here — it lives on the + * chat-page banner (task 05); opening the row is the action; * • Share — phase 51 (owner-locked 2026-08-29, TODO.md L6): the * row's share state, rendered from the list's OWN share_url (the * GET /api/chats endpoint populates it — no second fetch per row). @@ -105,6 +111,28 @@ function makeRow(chat) { updatedTd.textContent = fmtDate(chat.updated_at); tr.appendChild(updatedTd); + // Phase 53 (task 04): the Stale cell (between Updated and Share) — + // the READ-ONLY staleness marker. `chat.stale` is computed server- + // side (task 03), so this branches on the flag, never on versions. + // Stale rows get the rose pill (the exact hover copy points at the + // Regenerate action on the chat page, task 05); fresh rows get a + // plain em-dash. The carries its own aria-label in BOTH states + // — the marker must be conveyed without the visual (WCAG 2.1 AA). + const staleTd = document.createElement("td"); + staleTd.className = "history-stale-cell"; + if (chat.stale) { + staleTd.setAttribute("aria-label", "Stale — sources have changed since this chat was saved"); + const pill = document.createElement("span"); + pill.className = "stale-pill"; + pill.title = "Sources have changed since this chat was saved — open the chat to Regenerate"; + pill.textContent = "Stale"; + staleTd.appendChild(pill); + } else { + staleTd.setAttribute("aria-label", "Current — saved against the latest sources"); + staleTd.textContent = "—"; // the em-dash: fresh rows' marker + } + tr.appendChild(staleTd); + // Phase 51: the Share cell (between Updated and Actions) — the // three-state share control (unshared / shared / confirming-unshare). const shareTd = document.createElement("td"); diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 1feaf65..8a1a36e 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -1267,6 +1267,42 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } } .kb-banner.is-error { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); } .kb-banner svg { width: 18px; height: 18px; flex: 0 0 auto; display: block; } +/* Phase 53 (task 05): the stale-saved-chat banner — the .kb-banner + FAMILY (the section carries both classes: same flex row, accent + tokens — accent-ink on accent-bg 9.5:1 — 18px glyph), but the + leading mark is the REDO glyph, distinct from the warning triangle. + It sits directly after #kb-banner in .chat-shell, so when both are + visible the empty-KB banner keeps the top slot and the stale banner + stacks directly below (the flex column's gap spaces them). The text + takes the row; the Regenerate pill right-aligns (margin-left: auto) + and the row wraps only when it must. The pill is the EXACT + brand-pill family of Save/Share: solid --brand, --bg text (5.2:1, + AA), borderless, 999px radius, ≥44px target, hover lightens the + brand fill; the 16px redo glyph is the phase-49 Retry asset. The + ≤640px block below makes the pill a full-width row. */ +.stale-banner { flex-wrap: wrap; } +.stale-banner > span { flex: 1 1 auto; } +.stale-regenerate { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + min-height: 44px; + margin-left: auto; + 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; +} +.stale-regenerate:hover { background: #f55a72; color: var(--bg); } +.stale-regenerate:disabled { opacity: 0.6; cursor: wait; } +.stale-regenerate svg { width: 16px; height: 16px; display: block; } /* ---------- Login page (phase 16) ---------- */ /* Centered card in the standard frame: one admin, one password. */ @@ -1930,6 +1966,27 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } /* 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; } +/* Stale marker (phase 53, task 04): the READ-ONLY staleness badge on + rows saved before the last KB-changing sync (the Regenerate action + lives on the chat-page banner — the marker is a pill, never a + control). The rose family, i.e. the Stop-treatment tokens, so + "stale" reads in the same visual language as the in-flight control: + err-ink on err-bg ≈9.3:1 (≥4.5:1 on --surface too), err-line + border — theme tokens, AA in the palette as a whole. Fresh rows' + em-dash rides the cell's ink-soft (5.1:1 on --surface). */ +.history-stale-cell { color: var(--ink-soft); white-space: nowrap; } +.stale-pill { + display: inline-block; + padding: 0.15rem 0.55rem; + border: 1px solid var(--err-line); + border-radius: 999px; + background: var(--err-bg); + color: var(--err-ink); + font-size: 0.75rem; + font-weight: 700; + line-height: 1.45; + 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; } @@ -2650,6 +2707,10 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } .chat-shell .save-chat-btn svg { display: none; } .chat-shell .share-chat-label { display: inline; } .chat-shell .share-chat-btn svg { display: none; } + /* Phase 53: the stale banner's row wraps at phone width (message + above, action below) — the pill takes a full-width comfortable + row instead of squeezing into the text. */ + .stale-regenerate { margin-left: 0; width: 100%; } /* 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; } diff --git a/frontend/history.html b/frontend/history.html index dab8054..7ca08ad 100644 --- a/frontend/history.html +++ b/frontend/history.html @@ -135,7 +135,10 @@ + +