feat(chat): invalidate saved chats on sources sync — versioned stamps, stale marker, Regenerate against the new index
This commit is contained in:
+126
-2
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user