feat(ui): explicit chat state machine — typing indicator, streaming progress, timeout and error recovery

This commit is contained in:
2026-08-21 18:45:27 -04:00
parent 2364e1ee7d
commit e5810b0bcf
8 changed files with 642 additions and 40 deletions
+146 -27
View File
@@ -2,13 +2,25 @@
*
* Renders suggestions (onboarding chips + "Maybe try" deflection chips —
* one shared .suggestion-chip component, renderChips below), shows KB
* health, and runs chat turns against POST /api/chat (SSE, PLAN §4):
* deltas render live into the Brain bubble, the done event appends source
* chips (and "Maybe try" chips when the turn was deflected — honesty gate,
* phase 04), errors surface as a red banner. The full feedback state
* machine lands with the loading-feedback story; this keeps the "never
* stale" contract: the button is busy for the whole turn and is always
* re-enabled at the end. All DOM ids match frontend/index.html.
* health, and runs chat turns against POST /api/chat (SSE, PLAN §4).
*
* Loading feedback (PLAN §7.4 "never stale" contract, loading-feedback
* story) is one explicit state machine with a single entry point —
* setUiState(state) — driving the typing indicator, the send button
* (disabled/spinner/label), and the #send-status live region:
*
* idle → thinking → streaming → done | error → idle
*
* • thinking — pre-token: typing dots + disabled "Thinking…" button;
* after 10s the indicator's aria-label shows elapsed
* seconds so screen-reader users are never left guessing.
* • streaming — the first delta removes the dots and appends live into
* the answer bubble; the button stays busy until `done`.
* • error — red banner (role="alert") with an actionable retry hint;
* the 120s guard (TURN_TIMEOUT_MS) catches hung pre-token
* streams, so the button can never sit zombified.
*
* All DOM ids match frontend/index.html.
*/
const messagesEl = document.querySelector("#messages");
@@ -23,6 +35,36 @@ const banner = document.querySelector("#kb-banner");
const bannerText = document.querySelector("#kb-banner-text");
const versionEl = document.querySelector("#app-version");
/* ---------- loading-feedback contract (PLAN §7.4) ----------
* Client-side guard: a pre-token stream that produces no delta within
* TURN_TIMEOUT_MS is treated as hung → error state + banner. It is
* cleared on the first delta (entering "streaming") and on every
* terminal transition. Exported so the constant is testable (tests/unit/
* test_frontend_feedback.py). */
export const TURN_TIMEOUT_MS = 120_000;
const UI_STATE = Object.freeze({
idle: "idle",
thinking: "thinking",
streaming: "streaming",
error: "error",
});
const SEND_STATUS = Object.freeze({
[UI_STATE.idle]: "",
[UI_STATE.thinking]: "Brain of Reese is thinking",
[UI_STATE.streaming]: "Brain of Reese is answering",
[UI_STATE.error]: "The last question failed — try again",
});
const TYPING_LABEL = "Brain of Reese is thinking";
const ERROR_HINT = "Try again — if this persists, check the LLM is reachable.";
/* Calm, don't remove: smooth scrolling is the one motion JS controls. */
const reducedMotion =
typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
const SCROLL = reducedMotion ? "auto" : "smooth";
/* ---------- tiny, safe markdown renderer (no external libs, no CDN) ---------- */
export function escapeHtml(s) {
return s.replace(/[&<>"']/g, (c) => ({
@@ -76,11 +118,12 @@ function addMessage(who, html) {
<div class="bubble">${html}</div>
</div>`;
messagesEl.appendChild(wrap);
wrap.scrollIntoView({ behavior: "smooth", block: "end" });
wrap.scrollIntoView({ behavior: SCROLL, block: "end" });
return wrap;
}
function addTyping() {
removeTyping(); // idempotent: at most one indicator at a time
if (emptyState) emptyState.hidden = true;
const wrap = document.createElement("div");
wrap.className = "msg brain";
@@ -88,12 +131,12 @@ function addTyping() {
wrap.innerHTML = `
<span class="avatar" aria-hidden="true">🧠</span>
<div class="msg-body">
<div class="bubble typing" role="status" aria-label="Brain of Reese is thinking">
<div class="bubble typing" role="status" aria-label="${TYPING_LABEL}">
<span></span><span></span><span></span>
</div>
</div>`;
messagesEl.appendChild(wrap);
wrap.scrollIntoView({ behavior: "smooth", block: "end" });
wrap.scrollIntoView({ behavior: SCROLL, block: "end" });
}
function removeTyping() {
@@ -164,12 +207,68 @@ async function loadHealth() {
}
}
/* ---------- composer ---------- */
function setBusy(busy) {
sendBtn.disabled = busy;
sendBtn.querySelector(".spinner").hidden = !busy;
sendLabel.textContent = busy ? "Thinking…" : "Send";
sendStatus.textContent = busy ? "Brain of Reese is working" : "";
/* ---------- chat feedback state machine (PLAN §7.4) ----------
*
* Timers belong to the state machine, not to the turn handler: every
* transition stops/clears them, which is what makes a stuck button
* impossible.
*/
let uiState = UI_STATE.idle;
let thinkingClock = 0; // setInterval id — elapsed-seconds hint
let thinkingStart = 0; // Date.now() when "thinking" began
let turnTimeout = 0; // setTimeout id — 120s pre-token guard
function stopThinkingClock() {
if (thinkingClock) {
clearInterval(thinkingClock);
thinkingClock = 0;
}
}
function startThinkingClock() {
thinkingStart = Date.now();
thinkingClock = setInterval(() => {
const secs = Math.round((Date.now() - thinkingStart) / 1000);
if (secs < 10) return; // hint only after 10s of pre-token silence
const bubble = document.querySelector("#typing-indicator .bubble");
if (bubble) {
bubble.setAttribute("aria-label", `Brain of Reese is still thinking (${secs}s)`);
}
}, 1000);
}
function armTurnTimeout(onTimeout) {
clearTurnTimeout();
turnTimeout = setTimeout(onTimeout, TURN_TIMEOUT_MS);
}
function clearTurnTimeout() {
if (turnTimeout) {
clearTimeout(turnTimeout);
turnTimeout = 0;
}
}
/* The single entry point for chat feedback. Every in-flight state has a
* visible indicator; every terminal state re-enables the button. */
export function setUiState(state, errorDetail = "") {
uiState = state;
stopThinkingClock();
clearTurnTimeout(); // the guard only owns the pre-token window
const inFlight = state === UI_STATE.thinking || state === UI_STATE.streaming;
sendBtn.disabled = inFlight;
sendBtn.querySelector(".spinner").hidden = !inFlight;
sendLabel.textContent = inFlight ? "Thinking…" : "Send";
sendStatus.textContent = SEND_STATUS[state] ?? "";
if (state === UI_STATE.thinking) {
addTyping();
startThinkingClock();
} else {
removeTyping();
}
if (state === UI_STATE.error) showErrorBanner(errorDetail);
}
function autoGrow() {
@@ -247,7 +346,7 @@ function showErrorBanner(detail) {
banner.hidden = false;
banner.classList.add("is-error");
banner.setAttribute("role", "alert");
bannerText.textContent = `${detail} Try your question again — I'm ready.`;
bannerText.textContent = detail ? `${detail} ${ERROR_HINT}` : ERROR_HINT;
}
function clearErrorBanner() {
@@ -268,13 +367,23 @@ async function handleSend(e) {
input.value = "";
autoGrow();
clearErrorBanner();
setBusy(true);
addTyping();
let wrap = null;
let acc = "";
let res = null;
let aborted = false; // the 120s guard already took the turn to error
try {
// thinking = pre-token: dots + busy button. The guard is armed so a
// hung stream can never leave the button zombified; it clears on the
// first delta (entering "streaming") and on every terminal transition.
setUiState(UI_STATE.thinking);
armTurnTimeout(() => {
aborted = true;
try { res?.body?.cancel(); } catch { /* already closed */ }
setUiState(UI_STATE.error, "That's taking a long time — the answer may be stuck.");
});
res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -289,17 +398,19 @@ async function handleSend(e) {
throw new Error(detail);
}
await readSSE(res, (ev) => {
if (aborted) return;
if (ev.type === "delta") {
acc += ev.text || "";
if (!wrap) {
removeTyping();
// First token: dots out, live bubble in; the button stays busy.
setUiState(UI_STATE.streaming);
wrap = addMessage("brain", "");
}
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
wrap.scrollIntoView({ behavior: "smooth", block: "end" });
wrap.scrollIntoView({ behavior: SCROLL, block: "end" });
} else if (ev.type === "done") {
if (!wrap) {
removeTyping();
setUiState(UI_STATE.streaming);
wrap = addMessage("brain", "…");
}
if (ev.deflected) {
@@ -311,16 +422,24 @@ async function handleSend(e) {
throw new Error(ev.detail || "Something went wrong on my side.");
}
});
if (!wrap) {
removeTyping();
if (!aborted && !wrap) {
addMessage("brain", "Hmm — that came back empty. Ask me again?");
}
} catch (err) {
removeTyping();
showErrorBanner(err.message || "Something went wrong on my side.");
if (!aborted) {
const detail =
err instanceof Error && err.message
? err.message
: "Something went wrong on my side.";
setUiState(UI_STATE.error, detail);
}
} finally {
// done | error → idle: always settle, always focus back. State is
// turn-local, so a page reload mid-stream leaves a usable composer.
clearTurnTimeout();
stopThinkingClock();
try { res?.body?.cancel(); } catch { /* stream already closed */ }
setBusy(false);
if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle);
input.focus();
}
}