/* Brain of Reese — chat shell.
*
* 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).
*
* 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");
const emptyState = document.querySelector("#empty-state");
const suggestionsEl = document.querySelector("#suggestions");
const composer = document.querySelector("#composer");
const input = document.querySelector("#message-input");
const sendBtn = document.querySelector("#send-btn");
const sendLabel = document.querySelector("#send-label");
const sendStatus = document.querySelector("#send-status");
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) => ({
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
}[c]));
}
export function renderMarkdown(md) {
// 1. Protect fenced code blocks.
const codeBlocks = [];
let text = md.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
codeBlocks.push(`
${escapeHtml(code.replace(/\n$/, ""))} `);
return `\u0000CODE${codeBlocks.length - 1}\u0000`;
});
// 2. Escape everything else, then apply inline + block transforms.
text = escapeHtml(text)
.replace(/`([^`\n]+)`/g, "$1")
.replace(/\*\*([^*]+)\*\*/g, "$1 ")
.replace(/(^|[\s(])\*([^*\n]+)\*/g, "$1$2 ")
.replace(/^### (.*)$/gm, "$1 ")
.replace(/^## (.*)$/gm, "$1 ")
.replace(/^# (.*)$/gm, "$1 ")
.replace(/^\s*[-*] (.*)$/gm, "$1 ")
.replace(/([\s\S]*?<\/li>)(?!\s* )/g, "")
.replace(/^\d+\. (.*)$/gm, " $1 ");
// 3. Paragraphs (double newline separated).
text = text
.split(/\n{2,}/)
.map((block) => {
const b = block.trim();
if (!b) return "";
if (/^<(h\d|ul|ol|pre|li)/.test(b)) return b;
return `${b.replace(/\n/g, " ")}
`;
})
.join("");
// 4. Restore code blocks.
return text.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => codeBlocks[Number(i)]);
}
/* ---------- messages ---------- */
function addMessage(who, html) {
if (emptyState) emptyState.hidden = true;
const wrap = document.createElement("div");
wrap.className = `msg ${who}`;
wrap.innerHTML = `
${who === "brain" ? "🧠" : "🧑"}
`;
messagesEl.appendChild(wrap);
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";
wrap.id = "typing-indicator";
wrap.innerHTML = `
🧠
`;
messagesEl.appendChild(wrap);
wrap.scrollIntoView({ behavior: SCROLL, block: "end" });
}
function removeTyping() {
document.querySelector("#typing-indicator")?.remove();
}
/* ---------- suggestions (shared chip component, phase 05) ----------
*
* One component, two homes: the onboarding row in the empty state and the
* "Maybe try" row under a deflected answer. The container must be
* role="list" with an accessible name ("Suggested questions" / "Maybe
* try"); each chip is a real with role="listitem".
* Clicking a chip is one tap → question: it fills the composer, focuses it,
* and submits — the same behavior everywhere (submitSuggestion).
*/
function submitSuggestion(text) {
input.value = text;
autoGrow();
input.focus();
composer.requestSubmit();
}
function renderChips(container, items, { onSelect } = {}) {
// Replace only previous chips; keep any other children (e.g. the
// visually-hidden group label inside a "maybe-try" row).
container.querySelectorAll(".suggestion-chip").forEach((c) => c.remove());
for (const item of items || []) {
const text = String(item || "").trim();
if (!text) continue;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "suggestion-chip";
btn.setAttribute("role", "listitem");
btn.textContent = text;
btn.addEventListener("click", () => {
submitSuggestion(text);
if (onSelect) onSelect(text, btn);
});
container.appendChild(btn);
}
return container;
}
async function loadSuggestions() {
try {
const r = await fetch("/api/suggestions");
if (!r.ok) return;
const { suggestions } = await r.json();
renderChips(suggestionsEl, suggestions);
} catch {
/* suggestions are progressive enhancement: no chips, no error spam */
}
}
/* ---------- health / version ---------- */
async function loadHealth() {
try {
const r = await fetch("/api/health");
const body = await r.json();
versionEl.textContent = `v${body.version}`;
if (body.db === "down") {
bannerText.textContent =
"Knowledge base is offline — start Postgres with `podman compose up -d db`.";
banner.hidden = false;
}
} catch {
/* API unreachable: page still renders, composer will explain on send */
}
}
/* ---------- 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() {
input.style.height = "auto";
input.style.height = `${Math.min(input.scrollHeight, 192)}px`;
}
/* ---------- chat turn (SSE streaming, PLAN §4) ---------- */
/* Parse an SSE response body into JSON events. */
async function readSSE(response, onEvent) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buf = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let sep;
while ((sep = buf.indexOf("\n\n")) !== -1) {
const frame = buf.slice(0, sep).trim();
buf = buf.slice(sep + 2);
if (!frame.startsWith("data:")) continue;
const payload = frame.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
onEvent(JSON.parse(payload));
}
}
}
/* Source chips (mono, source/path) under a Brain bubble. */
function appendSources(wrap, sources) {
if (!sources || !sources.length) return;
const body = wrap.querySelector(".msg-body");
const meta = document.createElement("div");
meta.className = "msg-meta";
meta.setAttribute("role", "list");
meta.setAttribute("aria-label", "Sources");
for (const s of sources) {
const label = `${s.source}/${s.path}`;
const chip = document.createElement("a");
chip.className = "source-chip";
chip.setAttribute("role", "listitem");
chip.href = "/sources.html";
chip.textContent = label;
chip.title = label;
meta.appendChild(chip);
}
body.appendChild(meta);
// Accessible full path whenever the pill visually truncates.
for (const chip of meta.children) {
if (chip.scrollWidth > chip.clientWidth) chip.setAttribute("aria-label", chip.title);
}
}
/* "Maybe try:" chips under a deflected bubble (honesty gate, phase 04,
shared component + one-tap submit, phase 05). The group is accessible
(role=list + aria-label) and wraps cleanly at every width. */
function appendMaybeTry(wrap, suggestions) {
if (!suggestions || !suggestions.length) return;
const body = wrap.querySelector(".msg-body");
const group = document.createElement("div");
group.className = "maybe-try";
group.setAttribute("role", "list");
group.setAttribute("aria-label", "Maybe try");
const label = document.createElement("span");
label.className = "visually-hidden";
label.textContent = "Maybe try:";
group.appendChild(label);
renderChips(group, suggestions);
body.appendChild(group);
}
function showErrorBanner(detail) {
banner.hidden = false;
banner.classList.add("is-error");
banner.setAttribute("role", "alert");
bannerText.textContent = detail ? `${detail} ${ERROR_HINT}` : ERROR_HINT;
}
function clearErrorBanner() {
if (banner.classList.contains("is-error")) {
banner.classList.remove("is-error");
banner.setAttribute("role", "status");
bannerText.textContent = "";
banner.hidden = true;
}
}
async function handleSend(e) {
e.preventDefault();
const text = input.value.trim();
if (!text || sendBtn.disabled) return;
addMessage("user", renderMarkdown(text));
input.value = "";
autoGrow();
clearErrorBanner();
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" },
body: JSON.stringify({ message: text }),
});
if (!res.ok || !res.body) {
let detail = `Brain's API answered with HTTP ${res.status}.`;
try {
const body = await res.json();
if (body.detail) detail = body.detail;
} catch { /* non-JSON error body */ }
throw new Error(detail);
}
await readSSE(res, (ev) => {
if (aborted) return;
if (ev.type === "delta") {
acc += ev.text || "";
if (!wrap) {
// 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: SCROLL, block: "end" });
} else if (ev.type === "done") {
if (!wrap) {
setUiState(UI_STATE.streaming);
wrap = addMessage("brain", "…");
}
if (ev.deflected) {
wrap.classList.add("is-deflected");
appendMaybeTry(wrap, ev.suggestions);
}
appendSources(wrap, ev.sources);
} else if (ev.type === "error") {
throw new Error(ev.detail || "Something went wrong on my side.");
}
});
if (!aborted && !wrap) {
addMessage("brain", "Hmm — that came back empty. Ask me again?");
}
} catch (err) {
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 */ }
if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle);
input.focus();
}
}
input.addEventListener("input", autoGrow);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
composer.requestSubmit();
}
});
composer.addEventListener("submit", handleSend);
loadSuggestions();
loadHealth();