feat(chat): invalidate saved chats on sources sync — versioned stamps, stale marker, Regenerate against the new index

This commit is contained in:
2026-08-30 23:39:15 -04:00
parent ea8e041189
commit 32b7bfd4b3
26 changed files with 2145 additions and 63 deletions
+126 -2
View File
@@ -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/<id> —
* the client never does staleness math). The /?chat=<id> 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/<id> (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=<id>
* 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/<id> (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=<id> 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