phase: 111_chat_banner_retry
Build and Push Containers / build-and-push-app (push) Successful in 2m24s
Build and Push Containers / build-and-push-db (push) Successful in 14s

## Phase 111 Completion Report

**Implemented/Verified:**
- `#kb-banner` contains a `<button type="button" class="banner-retry" id="banner-retry">` (hidden by default, Retry label + SVG)
- `showErrorBanner(detail, opts)` reveals the button only when `opts.retryable` is true AND `lastBrainWrap` exists
- Turn-error path passes `{ retryable: true }`; all non-turn callers (share, save-doc, stale) remain text-only
- `clearErrorBanner()` re-hides the button
- `ERROR_HINT` changed from "Try again — …" to "If this persists, check the LLM is reachable."
- `.banner-retry` CSS styled as a pill (matching `.stale-regenerate` family)
- 12 source-assertion unit tests in `tests/unit/test_frontend_banner_retry.py`

**Test / Lint / Coverage:**
- `uv run pytest tests/unit/test_frontend_banner_retry.py -v --no-cov` → 12 passed
- `uv run pytest --cov=app --cov-report=term-missing` → 2362 passed, 99% coverage
- `uv run ruff check .` → All checks passed
- `uv run pyright` → 0 errors
- `tests/e2e/test_llm_retry.py` → 4 passed (in isolation)
- `tests/e2e/test_smoke.py` → 3 passed (in isolation)

**Completion Criteria:**
- ✅ Retry button visible after failed chat turn, re-runs last question
- ✅ Non-turn callers show text-only banner (no button)
- ✅ pytest green, coverage >90%, ruff + pyright clean
- ✅ Phase dir to be moved by pipeline gate

**Next pending phase:** `112_honesty_gate_weak_hits`
This commit is contained in:
2026-09-14 23:07:56 -04:00
parent f37c517590
commit 2683128876
17 changed files with 697 additions and 4 deletions
+17 -3
View File
@@ -359,7 +359,7 @@ const SEND_STATUS = Object.freeze({
/* The typing indicator's accessible label (the 10s elapsed-seconds
* hint updates it from this base) — built at call time (phase 39). */
const TYPING_LABEL = () => `${brand()} is thinking`;
const ERROR_HINT = "Try again — if this persists, check the LLM is reachable.";
const ERROR_HINT = "If this persists, check the LLM is reachable.";
/* A turn with no answer content (an empty stream, or reasoning that
exhausted max_tokens — phase 17) still renders a bubble, and this exact
text is what gets persisted: what the user saw is what is stored. */
@@ -1278,7 +1278,7 @@ export function setUiState(state, errorDetail = "") {
} else {
removeTyping();
}
if (state === UI_STATE.error) showErrorBanner(errorDetail);
if (state === UI_STATE.error) showErrorBanner(errorDetail, { retryable: true });
}
/* Phase 104 (owner 2026-09-12, A3/A4): the visible question-length cap.
@@ -2081,11 +2081,22 @@ function startNewChat() {
* its own in-flight-turn guard + list reset. */
window.addEventListener("bor:new-chat", startNewChat);
function showErrorBanner(detail) {
function showErrorBanner(detail, opts = {}) {
banner.hidden = false;
banner.classList.add("is-error");
banner.setAttribute("role", "alert");
bannerText.textContent = detail ? `${detail} ${ERROR_HINT}` : ERROR_HINT;
// Phase 111 (task 01): reveal the banner Retry button only for failed
// chat turns (opts.retryable) AND when a retryable bubble exists.
if (opts.retryable) {
const btn = document.querySelector("#banner-retry");
if (btn && lastBrainWrap) {
btn.hidden = false;
// Bind click once per reveal — the old listener is removed after
// the first click, so re-binding on every reveal is safe.
btn.addEventListener("click", () => retryLastTurn(lastBrainWrap));
}
}
}
function clearErrorBanner() {
@@ -2094,6 +2105,9 @@ function clearErrorBanner() {
banner.setAttribute("role", "status");
bannerText.textContent = "";
banner.hidden = true;
// Phase 111 (task 01): re-hide the banner Retry button.
const btn = document.querySelector("#banner-retry");
if (btn) btn.hidden = true;
}
}