feat(chat): retry the last answer — redo-in-place Retry button on the latest brain bubble

This commit is contained in:
2026-08-29 18:32:15 -04:00
parent 1a60ecbd8b
commit 6832957ab0
7 changed files with 891 additions and 15 deletions
+155 -6
View File
@@ -110,6 +110,20 @@
* below — the header.js single-evaluation design (no direct <script>
* tag; esbuild inlines it into the page bundle).
*
* Retry the last answer (phase 49, owner-locked 2026-08-29, TODO.md L4):
* a "Retry" button in the meta row of the LAST brain bubble re-asks the
* preceding question IN PLACE — the old answer is removed from the DOM
* and from the persisted record (saved immediately after the pop, so a
* crash between the pop and the fresh `done` can never resurrect the
* replaced answer; the question stays), and the fresh answer streams
* into its place without re-adding the question. This is what
* `runTurn(text, { reask })` is for: the turn extracted from handleSend
* skips the user append + persistence save point 1 when `reask` is set.
* Only the last brain bubble carries the button (markLastRetryable), it
* is NOT admin-gated (chat is public — unlike Tune), and it is inert
* while a turn is in flight. No banner, no scroll (phase 42): the fresh
* bubble lands where the old one was.
*
* All DOM ids match frontend/index.html.
*/
@@ -315,6 +329,74 @@ function appendStoppedNote(wrap) {
meta.appendChild(note);
}
/* Phase 49 (owner-locked 2026-08-29, TODO.md L4): the rendered wrap of
* the CURRENT last brain record. Set wherever a brain bubble becomes the
* latest persisted answer (the `done` branch, the empty-answer fallback,
* the stop finalize) and on the phase-14 restore (the LAST restored
* brain bubble wins); cleared when the retry redo pops it. Both
* retryLastTurn's stale-click guard and markLastRetryable's targeting
* key off it. */
let lastBrainWrap = null;
/* The Retry button's redo glyph (aria-hidden decoration — the "Retry"
* text carries the accessible name), currentColor so the CSS themes the
* stroke (ink-soft → ink on hover, the phase-08 palette). */
const RETRY_ICON =
'<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>';
/* "Retry" button in the meta row of the last brain bubble — the redo
* action of phase 49 (owner-locked 2026-08-29, TODO.md L4). The house
* appendTuneButton pattern: reuses the .msg-meta row when it exists
* (role=list → the button joins as a listitem so ARIA stays valid),
* otherwise creates a plain meta row; one button per bubble. NOT
* admin-gated, unlike appendTuneButton — chat is public, so every
* visitor gets the redo (the meta-row actions read as a pair: Tune for
* the admin, Retry for everyone). Click → retryLastTurn(wrap). */
function appendRetryButton(wrap) {
const body = wrap.querySelector(".msg-body");
if (!body) return;
let meta = body.querySelector(".msg-meta");
if (!meta) {
meta = document.createElement("div");
meta.className = "msg-meta";
body.appendChild(meta);
}
if (meta.querySelector(".retry-btn")) return; // one per bubble
const btn = document.createElement("button");
btn.type = "button";
btn.className = "retry-btn";
if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");
btn.innerHTML = RETRY_ICON + "<span>Retry</span>";
btn.addEventListener("click", () => retryLastTurn(wrap));
meta.appendChild(btn);
}
/* Last-bubble-only management (owner-locked 2026-08-29): the Retry
* button lives on exactly ONE bubble — the last brain answer. Remove
* every rendered .retry-btn FIRST (an earlier bubble's button is stale
* the moment a newer answer lands), then re-append it to the last brain
* bubble — but only when that record has its preceding user record to
* re-ask (the invariant holds in practice: every brain record follows
* its user record). Call sites: on `done`, on the empty-answer
* fallback, on the stop finalize (a stopped partial is the prime retry
* candidate), and once at the end of the phase-14 restore.
* startNewChat needs no call: its list reset removes the buttons along
* with the list. */
function markLastRetryable() {
messagesEl.querySelectorAll(".retry-btn").forEach((b) => b.remove());
if (!lastBrainWrap) return;
let lastIdx = -1;
for (let i = conversation.length - 1; i >= 0; i -= 1) {
if (conversation[i].who === "brain") {
lastIdx = i;
break;
}
}
const prev = lastIdx > 0 ? conversation[lastIdx - 1] : null;
if (!prev || prev.who !== "user") return;
appendRetryButton(lastBrainWrap);
}
/* Inline tuning form under the bubble: labeled textarea (maxlength 2000)
+ Save / Cancel. Success replaces the form with the .tune-saved status
(role=status); failure keeps the form and shows an inline error
@@ -887,6 +969,7 @@ function renderStoredMessage(m) {
appendSources(wrap, m.sources);
appendTuneButton(wrap); // restored brain answers are tunable too
if (m.stopped) appendStoppedNote(wrap); // phase 48: the stop marker restores
lastBrainWrap = wrap; // phase 49: the LAST restored brain bubble wins
}
/* On load: re-render the stored conversation (markdown, source chips,
@@ -895,6 +978,7 @@ function renderStoredMessage(m) {
function restoreConversation() {
conversation = loadStoredConversation();
for (const m of conversation) renderStoredMessage(m);
markLastRetryable(); // phase 49: the restored last brain bubble is retryable
}
/* Brain message save point (on `done`): raw accumulated text + metadata.
@@ -994,6 +1078,46 @@ function stopTurn() {
turnAbort?.abort();
}
/* Phase 49 (owner-locked 2026-08-29, TODO.md L4): the redo-in-place
* retry — the click handler of the Retry button, which only ever sits
* on the LAST brain bubble. It re-asks the question preceding that
* bubble: the old answer is replaced in the DOM AND in the persisted
* record (the pop is saved immediately — a crash between the pop and
* the fresh `done` must never resurrect the replaced answer; the
* question remains), and the fresh answer streams into its place via
* runTurn(text, { reask: true }) — no user append, no push, no banner,
* 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. */
function retryLastTurn(wrap) {
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
if (wrap !== lastBrainWrap) return; // stale click — the button moved on
let lastIdx = -1;
for (let i = conversation.length - 1; i >= 0; i -= 1) {
if (conversation[i].who === "brain") {
lastIdx = i;
break;
}
}
if (lastIdx === -1) return;
// Invariant: every brain record follows its user record — the
// question to re-ask is the record immediately before the popped one.
const prev = conversation[lastIdx - 1];
if (!prev || prev.who !== "user") return;
const text = prev.text;
conversation.splice(lastIdx, 1); // redo in place: the old answer is gone
// Save BEFORE the rerun: what the user saw — the removed answer — is
// what is stored from this point on (the question stays, the replaced
// answer never comes back).
saveConversation();
wrap.remove();
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 });
}
async function handleSend(e) {
e.preventDefault();
// Phase 48: while a turn is in flight the Send button IS the Stop
@@ -1005,15 +1129,34 @@ async function handleSend(e) {
}
const text = input.value.trim();
if (!text || sendBtn.disabled) return;
addMessage("user", renderMarkdown(text), true); // reveal my message (owner-kept)
// Persistence save point 1: the question is stored the moment it is
// sent, so a failed/interrupted turn never loses it.
conversation.push({ who: "user", text });
saveConversation();
// Phase 49: the user append + persistence save point 1 moved into
// runTurn with the rest of the turn — the `reask` flag skips them on
// the redo-in-place retry path (the question is already in the DOM +
// conversation); handleSend keeps only the form-level pre-work.
input.value = "";
autoGrow();
clearErrorBanner();
await runTurn(text, { reask: false });
}
/* Phase 49 (owner-locked 2026-08-29, TODO.md L4): the chat turn —
* extracted from handleSend so the retry redo can re-run a question
* without re-adding it. `reask` skips (a) the user-bubble append and
* (b) persistence save point 1 (the conversation push + save) — the
* question is already in the DOM and in `conversation`. A plain send
* (`reask = false`) is byte-identical to the pre-extraction path:
* everything from setUiState(thinking) / armTurnTimeout through the
* finally settle moved here verbatim, and the turn-local resets (acc,
* thinkingAcc, sawThinking, sawDone, toolAcc, stoppedByUser, turnAbort)
* stay turn-scoped exactly as phase 48 left them. */
async function runTurn(text, { reask = false } = {}) {
if (!reask) {
addMessage("user", renderMarkdown(text), true); // reveal my message (owner-kept)
// Persistence save point 1: the question is stored the moment it is
// sent, so a failed/interrupted turn never loses it.
conversation.push({ who: "user", text });
saveConversation();
}
let wrap = null;
let res = null;
@@ -1160,6 +1303,8 @@ async function handleSend(e) {
sources: ev.sources,
suggestions: ev.suggestions,
});
lastBrainWrap = wrap; // this bubble is now the last brain answer
markLastRetryable(); // phase 49: the Retry button is last-bubble-only
} else if (ev.type === "error") {
throw new Error(ev.detail || "Something went wrong on my side.");
}
@@ -1179,6 +1324,8 @@ async function handleSend(e) {
const fwrap = addMessage("brain", fallback);
appendTuneButton(fwrap);
rememberBrainTurn(fallback, {}); // persist what the user actually saw
lastBrainWrap = fwrap;
markLastRetryable(); // phase 49: the fallback bubble is retryable too
}
} catch (err) {
if (aborted) {
@@ -1201,6 +1348,8 @@ async function handleSend(e) {
tools: toolAcc.length ? toolAcc : undefined,
stopped: true,
});
lastBrainWrap = wrap; // the stopped partial is the prime retry candidate
markLastRetryable(); // phase 49: Retry on the stopped partial
}
// Pre-token / thinking-only stop: persist NOTHING brain-side
// (phase-20 convention — the question is already saved on send).