phase: 111_chat_banner_retry
## 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:
+17
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user