feat(chat): retry the last answer — redo-in-place Retry button on the latest brain bubble
This commit is contained in:
+155
-6
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user