# Task 01 — The Retry button (redo in place) **Phase:** `49_retry_answer` · **Source:** `TODO.md:4` — "Need a retry button to retry the last answer, like a redo button" **Story:** n/a (TODO-derived) ## Objective `handleSend` is split so a question can be re-run without re-adding it, and a Retry button on the last brain bubble performs that redo: old answer gone (DOM + storage), fresh answer streaming into its place. ## Work 1. `frontend/assets/app.js`: - **Extract `runTurn(text, { reask = false })`** from `handleSend`: everything from the `setUiState(thinking)` / `armTurnTimeout` point through the `finally` settle moves into `runTurn`; the `reask` flag skips (a) the `addMessage("user", …)` append and (b) the `conversation.push({ who: "user", … })` + `saveConversation()` (the question is already in both). `handleSend` keeps the form-level pre-work — the `!text` guard, the **in-flight guard that calls `stopTurn()`** (phase 48), `clearErrorBanner()`, the input clear + autoGrow — then appends + persists the user message and calls `runTurn(text, { reask })`. The turn-local resets (`acc`, `thinkingAcc`, `sawThinking`, `sawDone`, `toolAcc`, `stoppedByUser`, `turnAbort`) stay turn-scoped exactly as phase 48 left them. **No behavior drift for the normal send path**: a plain send must produce byte-identical DOM/SSE/persistence behavior to today (the regression suite is the proof). - **`appendRetryButton(wrap)`** — the house `appendTuneButton` pattern for the meta row (reuse the `.msg-meta` row; `role=list` → `role=listitem` where the row is a list; one per bubble; an inline redo-glyph SVG + "Retry" text — the text is the accessible name). **Not** admin-gated (owner-locked: all visitors). Click handler `retryLastTurn(wrap)`: - in-flight guard: if `uiState` is thinking/streaming → no-op (owner-locked). - find the record: the last brain record in `conversation` (and that `wrap` is the rendered wrap of that record — the button only ever sits on the last bubble, but the guard keeps a stale click harmless). - **redo in place:** pop the brain record from `conversation`; `saveConversation()` immediately (a crash between the pop and the fresh `done` must never resurrect the replaced answer — what the user saw, the removed answer, is what is stored; the question remains); remove `wrap` from the DOM; locate the preceding **user** record's `text` (the record immediately before the popped one — invariant: every brain record follows its user record); call `runTurn(text, { reask: true })`. No `sendStatus` banner; no scroll (phase-42 contract — no auto-scroll; the fresh bubble lands where the old one was). - **Last-bubble-only management:** a helper `markLastRetryable()` — removes any existing `.retry-btn` from every rendered `.msg-meta`, then appends the Retry button to the last brain bubble (only when it has a preceding user record — always true in practice). Call sites: on `done` (after `appendTuneButton`), on the empty-answer fallback path, on the **stop finalize** path (phase 48 — the stopped partial is the prime retry candidate), and once at the end of `restoreConversation()` (after all records are rendered). `startNewChat`'s list reset removes everything anyway — no change there. - **Header comment:** note the Retry contract (2026-08-29, `TODO.md` L4). 2. `frontend/assets/styles.css` — `.retry-btn`: the exact visual family of `.tune-btn` (same size/spacing/focus-visible/hover; ≥44px comfortable via the meta-row padding, as Tune has) with the redo glyph in the phase-08 palette (ink-soft → ink on hover), so the two meta actions read as a pair. 3. `frontend/index.html` — no markup change (the button is JS-injected like Tune); update the messages-section comment to mention the meta-row actions (Tune — admin; Retry — everyone). 4. Frontend source pins (house pattern, extend `tests/unit/test_frontend_feedback.py` or a sibling): the `runTurn` signature + the `reask` skips (no user append/push when reask); `appendRetryButton`'s no-admin-gate + one-per-bubble; `markLastRetryable`'s remove-then-append; the pop → save → rerun order in `retryLastTurn`; the in-flight no-op. - ASSUMPTION (owner-locked 2026-08-29): redo-in-place — the old answer is replaced (DOM + persisted record), the question is re-sent without duplication; only the last brain bubble carries the button; available to all visitors; inert while a turn is in flight. ## Testing & Quality - Unit: source pins as above; full suite green. - Coverage: **>90%** on `app/` (unchanged — frontend-only). ## Completion Criteria - [ ] The plain-send path is byte-identical to pre-task behavior (regression: `test_chat_rag.py` E2E green in isolation). - [ ] Retry on a completed / deflected / stopped answer redoes in place (unit-pinned now, E2E in task 02). - [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.