/* 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. * Phase 17: while the model streams reasoning (`thinking` * SSE events), the live collapsible Thinking block IS the * visible feedback (it replaces the typing dots; the UI * state stays "thinking" — button still disabled, * "Thinking…") and the 120s guard clears on the first * thinking *or* delta event. * • streaming — the first delta removes the dots and appends live into * the answer bubble (auto-collapsing the Thinking block, * phase 17); 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 and the sawDone guard (phase 17) catches a * stream that dies after frames but before `done`, so the * button can never sit zombified. * * Conversation persistence (phase 14) makes the chat a durable LOCAL * session: the message list (raw text + turn metadata) lives in * localStorage under the versioned key `bor.chat.v1` and is re-rendered on * load — refresh, tab close, and a trip to Sources never lose it. Phase * 17: a brain record may carry an optional `thinking` field — the * collapsed Thinking block is restored with it; records without it (old * sessions) restore exactly as before, so no version bump. A10 is * untouched: the API stays stateless, nothing is stored server-side. * "New chat" (#new-chat-btn) clears the key + the list back to the empty * state. * * Steering notes (phase 15) let the owner tune how Brain answers: a * "Tune" button under every completed brain bubble (deflected included) * opens an inline form → POST /api/steering → the note is stored in * Postgres and injected into the system prompt of every subsequent turn * (the section). Notes are listed newest-first in the header * "Tuning" panel (#steering-panel), where each can be deleted. Note text * is always rendered with textContent (XSS-safe), save/delete are * announced through a polite live region (#steering-announcer), and the * panel + count badge update on every change. * * 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."; /* 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. */ const EMPTY_ANSWER_FALLBACK = "Hmm — that came back empty. Ask me again?"; /* 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"; /* ---------- document viewer link (phase 10; phase 13 adds `back`) ---------- * Every cited document opens in the viewer, in a NEW tab. All query * values are percent-encoded: real paths contain slashes and sometimes * spaces, which would otherwise corrupt the query string. `back` tells the * viewer which page to return to when its back button is clicked — the * chips live in the chat, so chat passes "/" (the viewer validates it: * only same-origin relative URLs are honored; Sources links omit it and * get the viewer's /sources.html default). (The renderer * renderMarkdown/escapeHtml now lives in assets/markdown.js — a classic * script loaded by index.html and document.html before these modules.) */ export function documentUrl(source, path, back = "/") { let url = "/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path); if (back) url += "&back=" + encodeURIComponent(back); return url; } /* ---------- steering notes (phase 15) ---------- * * The owner's tuning notes steer every future answer: they live in * Postgres (stateless API, A10) and the chat turn reads them into the * system prompt. UI contract: Tune button → inline form → save → * confirmation (or inline error, form kept); the header panel lists the * notes (newest first) with per-note delete. */ const steeringToggle = document.querySelector("#steering-toggle"); const steeringCount = document.querySelector("#steering-count"); const steeringPanel = document.querySelector("#steering-panel"); const steeringList = document.querySelector("#steering-list"); const steeringEmpty = document.querySelector("#steering-empty"); const steeringAnnouncer = document.querySelector("#steering-announcer"); const TUNE_ICON = ''; let tuneSeq = 0; // unique ids for one open tune form's inputs function announceSteering(message) { if (steeringAnnouncer) steeringAnnouncer.textContent = message; } /* "Tune" button in the meta row of a completed brain bubble. Reuses the sources' .msg-meta row when it exists (role=list → the button joins as a listitem so ARIA stays valid); otherwise creates a plain meta row. Phase 16: anonymous visitors never get the button — this single guard covers both fresh turns and the phase-14 restore path. */ function appendTuneButton(wrap) { if (!isAdmin) return; // phase 16: tuning is admin-only const body = wrap.querySelector(".msg-body"); if (!body) return; let meta = body.querySelector(".msg-meta"); if (!meta) { meta = document.createElement("div"); meta.className = "msg-meta"; body.appendChild(meta); } if (meta.querySelector(".tune-btn")) return; // one per bubble const btn = document.createElement("button"); btn.type = "button"; btn.className = "tune-btn"; if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem"); btn.innerHTML = TUNE_ICON + "Tune"; btn.addEventListener("click", () => openTuneForm(wrap, btn)); meta.appendChild(btn); } /* Inline tuning form under the bubble: labeled textarea (maxlength 2000) + Save / Cancel. Success replaces the form with the .tune-saved status (role=status); failure keeps the form and shows an inline error (role=alert) — the note is never lost on a failed save. */ function openTuneForm(wrap, toggleBtn) { document.querySelectorAll(".tune-form").forEach((f) => f.remove()); // one at a time const body = wrap.querySelector(".msg-body"); if (!body) return; tuneSeq += 1; const inputId = `tune-input-${tuneSeq}`; const form = document.createElement("form"); form.className = "tune-form"; form.noValidate = true; form.innerHTML = `` + ``; const actions = document.createElement("div"); actions.className = "tune-form-actions"; const saveBtn = document.createElement("button"); saveBtn.type = "submit"; saveBtn.className = "tune-save"; saveBtn.textContent = "Save"; const cancelBtn = document.createElement("button"); cancelBtn.type = "button"; cancelBtn.className = "tune-cancel"; cancelBtn.textContent = "Cancel"; actions.append(saveBtn, cancelBtn); form.appendChild(actions); const status = document.createElement("p"); status.className = "tune-error"; status.setAttribute("role", "alert"); status.hidden = true; form.appendChild(status); form.addEventListener("submit", async (e) => { e.preventDefault(); saveBtn.disabled = true; status.hidden = true; try { const r = await fetch("/api/steering", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ note: form.querySelector("textarea").value }), }); if (!r.ok) { let detail = "Could not save the note — try again."; try { const data = await r.json(); if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) { detail = String(data.detail[0].msg); } else if (typeof data.detail === "string" && data.detail) { detail = data.detail; } } catch { /* non-JSON error body */ } status.textContent = detail; status.hidden = false; saveBtn.disabled = false; return; // form kept on failure — the instruction survives } const saved = document.createElement("p"); saved.className = "tune-saved"; saved.setAttribute("role", "status"); saved.textContent = "Saved — future answers will follow this."; form.replaceWith(saved); announceSteering("Tuning note saved. Future answers will follow it."); await loadSteering(); // panel + count badge update } catch { status.textContent = "Could not save the note — is the app reachable?"; status.hidden = false; saveBtn.disabled = false; } }); cancelBtn.addEventListener("click", () => { form.remove(); toggleBtn.focus(); }); body.appendChild(form); form.querySelector("textarea").focus(); } /* Panel: newest-first list (textContent — XSS-safe), per-note delete, empty text, and the header count badge. */ async function loadSteering() { let notes = []; try { const r = await fetch("/api/steering"); if (r.ok) notes = (await r.json()).notes || []; } catch { /* API unreachable: keep the last rendered list */ } renderSteeringPanel(notes); return notes; } function renderSteeringPanel(notes) { if (!steeringList) return; steeringList.textContent = ""; for (const n of notes) { const li = document.createElement("li"); li.className = "steering-note"; const text = document.createElement("span"); text.className = "steering-note-text"; text.textContent = n.note; // rendered as text, never as HTML li.appendChild(text); const del = document.createElement("button"); del.type = "button"; del.className = "steering-delete"; del.setAttribute("aria-label", `Delete tuning note: ${n.note}`); del.innerHTML = ''; del.addEventListener("click", () => deleteSteeringNote(n.id, del)); li.appendChild(del); steeringList.appendChild(li); } if (steeringEmpty) steeringEmpty.hidden = notes.length > 0; if (steeringCount) steeringCount.textContent = String(notes.length); } async function deleteSteeringNote(id, btn) { btn.disabled = true; try { const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" }); if (r.status === 404) { announceSteering("That note was already removed."); await loadSteering(); return; } if (!r.ok) { announceSteering("Could not delete the note — try again."); btn.disabled = false; return; } await loadSteering(); announceSteering("Tuning note deleted."); } catch { announceSteering("Could not delete the note — is the app reachable?"); btn.disabled = false; } } function setSteeringPanel(open) { if (!steeringPanel || !steeringToggle) return; steeringPanel.hidden = !open; steeringToggle.setAttribute("aria-expanded", open ? "true" : "false"); } if (steeringToggle && steeringPanel) { steeringToggle.addEventListener("click", () => { setSteeringPanel(steeringPanel.hidden); if (!steeringPanel.hidden) loadSteering(); // refresh when (re)opened }); } /* ---------- avatar glyphs (phase 08: emoji-free chrome) ---------- * Inline SVG as string constants so the message renderer and the typing * indicator share exactly the same marks. currentColor lets the CSS theme * the stroke (brand-ink for Brain, ink-soft for the user — see styles.css). */ const BRAIN_AVATAR = ''; const USER_AVATAR = ''; /* ---------- messages ---------- */ function addMessage(who, html, scrollBehavior = SCROLL) { if (emptyState) emptyState.hidden = true; const wrap = document.createElement("div"); wrap.className = `msg ${who}`; wrap.innerHTML = `
${html}
`; messagesEl.appendChild(wrap); wrap.scrollIntoView({ behavior: scrollBehavior, 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(); } /* ---------- thinking block (phase 17) ---------- * The model's reasoning streams into a collapsible
block ABOVE * the answer bubble: created OPEN on the first `thinking` event, * auto-collapsed when the first answer token lands, and user-toggleable * afterwards (native
/ — a real focusable control). * ensureThinkingBlock is idempotent (returns the existing block if any); * closeThinkingBlock never reopens a block once the answer has started, * so a late/interleaved `thinking` event only appends to the closed text. */ function ensureThinkingBlock(wrap) { let block = wrap.querySelector(".thinking"); if (!block) { block = document.createElement("details"); block.className = "thinking"; block.open = true; block.innerHTML = `Thinking
`; const body = wrap.querySelector(".msg-body"); body.insertBefore(block, body.querySelector(".bubble")); } return block; } function closeThinkingBlock(wrap) { const block = wrap?.querySelector?.(".thinking"); if (block) block.open = false; // idempotent; no-op without a block } /* ---------- 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