feat(ui): explicit chat state machine — typing indicator, streaming progress, timeout and error recovery
This commit is contained in:
+146
-27
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user