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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user