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
+239
View File
@@ -366,3 +366,242 @@ def test_composer_form_is_novalidate() -> None:
assert not re.search(r"\brequired\b", textarea), (
"the composer textarea must not carry `required` (see novalidate)"
)
# ---------- retry answer (phase 49, task 01) ----------
def test_run_turn_is_the_extracted_turn_handler() -> None:
"""Phase 49 (owner-locked 2026-08-29, TODO.md L4): the turn
machinery is extracted from handleSend into
`runTurn(text, { reask = false })`. handleSend keeps only the
form-level pre-work (the in-flight stop guard, the !text guard, the
composer pre-work) and delegates; the user append + persistence
save point 1 (push + save) sit in runTurn's `!reask` block — the
redo-in-place retry path skips both, because the question is
already in the DOM and in `conversation`."""
js = _js()
assert re.search(r"async function runTurn\(text, \{ reask = false \} = \{\}\)", js), (
"runTurn(text, { reask = false }) must be the extracted turn handler"
)
handle = js.find("async function handleSend")
turn = js.find("async function runTurn")
assert -1 < handle < turn, "runTurn follows handleSend (the extracted body)"
handle_body = js[handle:turn]
assert "stopTurn();" in handle_body, "the in-flight guard stays in handleSend"
assert "const text = input.value.trim()" in handle_body, "the !text guard stays"
assert 'input.value = ""' in handle_body
assert "autoGrow()" in handle_body
assert "clearErrorBanner()" in handle_body
assert "runTurn(text, { reask: false })" in handle_body, ("handleSend delegates the turn")
assert 'addMessage("user"' not in handle_body, (
"the user append moved with the turn into runTurn"
)
assert "conversation.push" not in handle_body, (
"persistence save point 1 moved with the turn into runTurn"
)
reask_idx = js.find("if (!reask) {", turn)
wrap_idx = js.find("let wrap = null", turn)
assert -1 < reask_idx < wrap_idx, (
"the reask gate must precede the turn machinery (the append happens "
"before the turn starts, exactly like the pre-extraction order)"
)
turn_top = js[turn:wrap_idx]
assert "if (!reask) {" in turn_top, "the reask gate guards the append + push"
assert 'addMessage("user", renderMarkdown(text), true)' in turn_top
assert 'conversation.push({ who: "user", text })' in turn_top
assert "saveConversation()" in turn_top
def test_retry_button_is_not_admin_gated_and_one_per_bubble() -> None:
"""appendRetryButton: the house appendTuneButton pattern — reuses the
.msg-meta row when it exists (role=list → the button joins as a
listitem so ARIA stays valid), creates it otherwise, one .retry-btn
per bubble, the aria-hidden redo glyph + the "Retry" text (the
accessible name). NOT admin-gated — unlike appendTuneButton, chat is
public and every visitor gets the redo (owner-locked)."""
js = _js()
fn = js.find("function appendRetryButton")
assert fn != -1, "appendRetryButton must exist"
body = js[fn: js.find("\n}\n", fn)]
assert "isAdmin" not in body, "Retry is NOT admin-gated (owner-locked: all visitors)"
assert 'querySelector(".msg-meta")' in body, "reuses the meta row when it exists"
assert 'className = "msg-meta"' in body, "creates it otherwise"
assert 'className = "retry-btn"' in body
assert 'querySelector(".retry-btn")' in body, "one Retry button per bubble"
assert 'btn.setAttribute("role", "listitem")' in body, ("role=list → listitem")
assert "<span>Retry</span>" in body, "the text carries the accessible name"
assert "RETRY_ICON" in body, "the button leads with the redo glyph"
icon = js[js.find("const RETRY_ICON") : js.find(";", js.find("const RETRY_ICON"))]
assert 'aria-hidden="true"' in icon, "the redo glyph is decoration"
assert "retryLastTurn(wrap)" in body, "the click handler re-asks in place"
def test_mark_last_retryable_is_remove_then_append() -> None:
"""markLastRetryable: last-bubble-only management — remove every
rendered .retry-btn FIRST (an earlier bubble's button is stale the
moment a newer answer lands), then append the button to the last
brain bubble (only when its preceding user record exists to re-ask
— the invariant: every brain record follows its user record)."""
js = _js()
fn = js.find("function markLastRetryable")
assert fn != -1, "markLastRetryable must exist"
body = js[fn: js.find("\n}\n", fn)]
assert 'querySelectorAll(".retry-btn")' in body, "finds every rendered Retry button"
assert ".remove()" in body
assert "appendRetryButton(lastBrainWrap)" in body
assert body.index(".remove()") < body.index("appendRetryButton(lastBrainWrap)"), (
"the existing buttons must be removed before the new one is appended"
)
assert 'who !== "user"' in body, "needs the preceding user record to re-ask"
def test_mark_last_retryable_call_sites() -> None:
"""The four call sites (owner-locked): on `done` (after the Tune
append), the empty-answer fallback, the stop finalize (the stopped
partial is the prime retry candidate — after the partial is
persisted), and once at the end of the phase-14 restore.
startNewChat needs none: its list reset removes the buttons along
with the list."""
js = _js()
# done branch: after appendTuneButton.
done = js.find('ev.type === "done"')
done_branch = js[done: js.find('ev.type === "error"', done)]
assert "appendTuneButton(wrap)" in done_branch
assert "markLastRetryable()" in done_branch
assert done_branch.index("appendTuneButton(wrap)") < done_branch.index(
"markLastRetryable()"
), "the Retry append rides the done save point, after Tune"
# empty-answer fallback.
fallback = js.find("!aborted && !wrap")
fallback_block = js[fallback: js.find(")} catch (err) {", fallback)]
assert "appendTuneButton(fwrap)" in fallback_block
assert "markLastRetryable()" in fallback_block
# stop finalize: after the stopped partial is persisted.
catch = js.find("} catch (err) {")
stop_idx = js.find('stoppedByUser || err?.name === "AbortError"', catch)
stop_branch = js[stop_idx: js.find("} else {", stop_idx)]
assert "markLastRetryable()" in stop_branch
assert stop_branch.index("rememberBrainTurn") < stop_branch.index("markLastRetryable()"), (
"the Retry button lands only after the stopped partial is persisted"
)
# restore: once, at the end.
rfn = js.find("function restoreConversation")
rbody = js[rfn: js.find("\n}\n", rfn)]
assert rbody.count("markLastRetryable()") == 1
# startNewChat: no call — the list reset removes the buttons anyway.
nfn = js.find("function startNewChat")
nbody = js[nfn: js.find("\n}\n", nfn)]
assert "markLastRetryable" not in nbody
def test_retry_last_turn_redo_in_place_order() -> None:
"""retryLastTurn: the in-flight no-op (one turn at a time), the
stale-click guard (the click's wrap must still be the last brain
bubble's rendered wrap), and the redo-in-place order — pop the brain
record → saveConversation() BEFORE the rerun (a crash between the
pop and the fresh `done` never resurrects the replaced answer; the
question remains) → remove the wrap → runTurn(text, { reask: true }).
No banner, no scroll (phase 42)."""
js = _js()
fn = js.find("function retryLastTurn")
assert fn != -1, "retryLastTurn must exist"
body = js[fn: js.find("\n}\n", fn)]
assert "uiState === UI_STATE.thinking" in body, "in-flight no-op (thinking)"
assert "uiState === UI_STATE.streaming" in body, "in-flight no-op (streaming)"
assert "wrap !== lastBrainWrap" in body, "the stale-click guard"
assert "conversation.splice" in body, "the brain record is popped in place"
assert "saveConversation()" in body, "the pop is saved immediately"
assert "wrap.remove()" in body, "the old bubble leaves the DOM"
assert "runTurn(text, { reask: true })" in body, "re-ask without re-adding"
i_splice = body.index("conversation.splice")
i_save = body.index("saveConversation()")
i_remove = body.index("wrap.remove()")
i_rerun = body.index("runTurn(text, { reask: true })")
assert i_splice < i_save < i_remove < i_rerun, (
"pop → save → remove → rerun — the save must precede the rerun"
)
assert "showErrorBanner" not in body, "no error banner on the retry path"
assert "scrollReveal" not in body, "no scroll (phase 42: the bubble lands in place)"
def test_last_brain_wrap_tracking_and_restore() -> None:
"""lastBrainWrap is the rendered wrap of the current last brain
record: set on the `done` save point, the empty-answer fallback, and
the stop finalize (each before markLastRetryable), set for every
restored brain bubble (the LAST one wins), cleared by the retry
pop. The restore path marks the restored last brain bubble
retryable at the end."""
js = _js()
assert "let lastBrainWrap = null" in js, "module-scope last-brain wrap"
done = js.find('ev.type === "done"')
done_branch = js[done: js.find('ev.type === "error"', done)]
assert "lastBrainWrap = wrap" in done_branch
assert done_branch.index("lastBrainWrap = wrap") < done_branch.index("markLastRetryable()")
fallback = js.find("!aborted && !wrap")
fallback_block = js[fallback: js.find(")} catch (err) {", fallback)]
assert "lastBrainWrap = fwrap" in fallback_block
catch = js.find("} catch (err) {")
stop_idx = js.find('stoppedByUser || err?.name === "AbortError"', catch)
stop_branch = js[stop_idx: js.find("} else {", stop_idx)]
assert "lastBrainWrap = wrap" in stop_branch
rfn = js.find("function renderStoredMessage")
rbody = js[rfn: js.find("\n}\n", rfn)]
assert "lastBrainWrap = wrap" in rbody, "every restored brain bubble updates it"
fn = js.find("function retryLastTurn")
body = js[fn: js.find("\n}\n", fn)]
assert "lastBrainWrap = null" in body, "the retry pop clears it"
def test_retry_btn_css_is_the_tune_family() -> None:
"""Phase 49 styling: the .retry-btn pill is the exact .tune-btn
visual family (same size/spacing/min-height, right-aligned after
the source chips, 14px glyph) so the two meta actions read as a
pair — with the neutral ink-soft → ink hover (Tune keeps the brand
pair). Contrast: ink-soft on --bg ~8.6:1, ink ~15:1, hover ink on
--brand-soft ~15.7:1 — all AA. The mobile squeeze keeps the >=44px
floor."""
css = _css()
block = re.search(r"\.retry-btn \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .retry-btn"
body = block.group(1)
for prop in (
"display: inline-flex",
"min-height: 44px",
"margin-left: auto",
"padding: 0.35rem 0.8rem",
"border-radius: 999px",
"border: 1px solid var(--line)",
"color: var(--ink-soft)",
"font-size: 0.82rem",
"cursor: pointer",
):
assert prop in body, f".retry-btn must keep the .tune-btn family ({prop})"
assert re.search(r"\.retry-btn svg \{ width: 14px; height: 14px", css), (
"the redo glyph rides the 14px meta-row size"
)
hover = re.search(r"\.retry-btn:hover \{([\s\S]*?)\n\}", css)
assert hover, ".retry-btn must have the hover step"
assert "color: var(--ink)" in hover.group(1), "ink-soft → ink on hover (neutral)"
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
assert mobile, "mobile media query missing"
assert ".retry-btn { min-height: 44px; }" in mobile.group(1), (
"the >=44px touch floor holds in the mobile squeeze"
)
def test_index_messages_comment_documents_the_meta_actions() -> None:
"""The #messages section comment documents the JS-injected meta-row
actions: Tune (admin only) and Retry (every visitor, last brain
bubble only) — no static markup for either."""
html = _html()
section = html.find('<section class="messages"')
assert section != -1, "index.html must contain the #messages section"
comment = html[max(0, section - 900):section]
assert "Retry" in comment and "Tune" in comment, (
"the messages-section comment must mention the meta-row actions"
)
assert "admin" in comment, "Tune is documented as admin-only"
assert "every visitor" in comment.lower() or "everyone" in comment.lower(), (
"Retry is documented as available to all visitors"
)