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
+28
View File
@@ -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 <td> 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");
+61
View File
@@ -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; }
+6 -2
View File
@@ -135,7 +135,10 @@
<!-- Phase 50: the full-width table (AGENTS.md rule 5 — no skinny
list): Title (the Open link → /?chat=<id>) | Messages |
Updated | Share (phase 51: Create link / Copy / Unshare —
Updated | Stale (phase 53: the READ-ONLY staleness marker —
the rose pill when the row predates the last KB-changing
sync; the Regenerate action lives on the chat-page banner,
task 05) | Share (phase 51: Create link / Copy / Unshare —
the row's share_url comes from GET /api/chats itself, no
second fetch) | Actions (Delete, inline two-step confirm).
history.js fills #history-tbody; #history-empty-row ships
@@ -150,13 +153,14 @@
<th scope="col">Title</th>
<th scope="col">Messages</th>
<th scope="col">Updated</th>
<th scope="col">Stale</th>
<th scope="col">Share</th>
<th scope="col"><span class="visually-hidden">Actions</span></th>
</tr>
</thead>
<tbody id="history-tbody">
<tr class="history-empty-row" id="history-empty-row" hidden>
<td colspan="5">No saved chats yet — finish a conversation and press <strong>Save</strong> in the chat.</td>
<td colspan="6">No saved chats yet — finish a conversation and press <strong>Save</strong> in the chat.</td>
</tr>
</tbody>
</table>
+23
View File
@@ -89,6 +89,29 @@
<span id="kb-banner-text"></span>
</div>
<!-- Phase 53 (task 05): the stale-saved-chat banner. The /?chat=<id>
boot load reveals it ONLY when the fetched row reports
stale: true (the server computes it — the row's sources stamp
is behind the current generation, task 03; the client never
does staleness math). Regenerate (the exact brand-pill family
of the Save/Share pair; the redo glyph is the phase-49 Retry
asset) re-asks the last question against the new index via
retryLastTurn and re-saves the linked row (the server
re-stamps sources_version → stale: false), clearing the
banner. A stale chat with no brain answer is revealed
text-only — app.js removes the button, so retryLastTurn is
never called. Stacks directly below #kb-banner when both are
visible (kb-banner keeps the top slot; the .chat-shell flex
gap spaces them). -->
<section class="kb-banner stale-banner" id="stale-banner" role="status" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
<span>The sources have been updated since this chat was saved.</span>
<button type="button" class="stale-regenerate" id="stale-regenerate">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
<span>Regenerate</span>
</button>
</section>
<!-- Phase 15 (now every page — phase 34, owner confirmation
2026-08-26): the tuning-notes panel (stored notes, newest
first) — rendered + driven by assets/header.js (shared), not