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;
}
}
+27
View File
@@ -1516,6 +1516,32 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.stale-regenerate:disabled { opacity: 0.6; cursor: wait; }
.stale-regenerate svg { width: 16px; height: 16px; display: block; }
/* Phase 111 (task 01): the banner Retry button — the same pill treatment
as .stale-regenerate (brand fill, ≥44px, 999px radius, redo glyph) so
the banner's action reads as the same component family. The kb-banner
flex row + gap already handles inline spacing after #kb-banner-text. */
.banner-retry {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 44px;
margin-left: auto;
padding: 0.5rem 0.9rem;
border-radius: 999px;
border: 0;
background: var(--brand);
color: var(--bg);
font: inherit;
font-weight: 700;
font-size: 0.95rem;
white-space: nowrap;
cursor: pointer;
}
.banner-retry:hover { background: var(--brand-hover); color: var(--bg); }
.banner-retry:disabled { opacity: 0.6; cursor: wait; }
.banner-retry svg { width: 16px; height: 16px; display: block; }
/* Phase 55 (task 04): the share-success toast (TODO.md L5). Top-right,
just under the sticky header (--header-h + a small offset — the
variable already steps 64px → 58px at ≤640px), z-index 1000 (the
@@ -4614,6 +4640,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
above, action below) — the pill takes a full-width comfortable
row instead of squeezing into the text. */
.stale-regenerate { margin-left: 0; width: 100%; }
.banner-retry { margin-left: 0; width: 100%; }
/* Phase 16: the auth pill goes icon-only like New chat — brand text
ellipsizes as the designated squeeze target, no bar overflow. */
.auth-link { padding: 0.4rem 0.3rem; }