Files
brain-of-reese/frontend/assets/app.js
T
ducoterra 7c6763319b fix(chat): stop autoscrolling while a reply streams (owner direction)
TODO.md L5: "Get rid of the chat reply autoscroll, it's breaking things
like making it impossible for the user to scroll while a reply
generates." Owner direction 2026-08-27 (roadmap A1) revises the
phase-18 follow-the-bottom choice: the page NEVER auto-scrolls while a
turn streams. Kept (owner decision): the submit reveal (the user's own
message) and the one-shot phase-14 restore landing.

- frontend/assets/app.js: delete NEAR_BOTTOM_PX + isNearBottom;
  scrollReveal becomes the one unconditional scrollIntoView (still
  smooth, still "auto" under prefers-reduced-motion via SCROLL);
  addMessage(who, html, scroll = false) carries an explicit scroll
  intent — only the submit (", true") and the two restore landings
  scroll. The thinking/tool/delta handlers and the typing indicator
  drop their page-scroll calls; the thinking block's INTERNAL
  bottom-pin (textEl.scrollTop, phase 17 — reworked separately in
  phase 43) and the turn-end focus({ preventScroll: true }) survive.
- tests/unit/test_frontend_scroll.py: rewritten pin for the new
  contract — phase-18 gate absent, helper unconditional, explicit
  intent at submit/restore, no page-scroll call in the streaming
  handlers, typing bubble scroll-free, SCROLL reduced-motion intact.
- tests/unit/test_chat_persistence.py: restore-landing pin updated to
  the new signature (the old forced "auto" is gone; the landing
  rides the default SCROLL — noted at the call site).
- tests/e2e/test_no_reply_autoscroll.py (new, replaces the deleted
  test_follow_bottom_scroll.py): no autoscroll across >=10 samples
  (1px tolerance) during a long answer and during the thinking stream;
  submit-from-the-top still reveals the user message; the restore
  landing lands one-shot on the latest message and stays; long answer
  + sources and the collapsed thinking block persist and restore.

E2E (isolation): test_no_reply_autoscroll.py 5/5; regressions
test_chat_rag 3/3, test_thinking_display 5/5,
test_chat_persistence 4/4, test_long_answers 2/2, test_smoke 3/3;
unit+integration 723 passed, app/ coverage 99%; ruff + pyright clean.
2026-08-28 00:29:25 -04:00

1117 lines
48 KiB
JavaScript

/* 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 — bound by the shared header module,
* phase 34 task 02) clears the key + the list back to the empty state.
*
* Agent tool calls (phase 37, PLAN §4 extension): a grounded turn may
* call the two server-side document tools (list_documents /
* read_document, budgeted server-side). Each call streams a `tool` SSE
* frame, and the UI shows the "calling tool" state IN ADDITION to
* "thinking": the UI state itself stays "thinking" (button stays
* disabled — never stale, PLAN §7.4) while the LABELS change — the
* button says "Calling tool…", the #send-status + typing-indicator
* labels say what Brain is doing ("…is listing documents" /
* "…is reading source/path" — the name prefix resolves from
* window.BOR_BRAND at call time, phase 39), and a visible `.tool-call`
* line (own icon + accent color, distinct from the brand-ink Thinking
* block) is appended above the answer, one per call, in order.
* Append-only like thinking: frames are tolerated in any interleaving
* (a frame after the first delta just appends — the agent loop never
* emits one, but it must not crash). The turn record persists an
* optional `tools: [{name, argument}]` array next to `thinking` and
* restore re-renders the lines (phase 14 convention).
*
* 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 <tuning> section). The header "Tuning" panel (#steering-panel)
* — toggle, list, per-note delete, count badge, announcer — is owned by
* the shared header module (assets/header.js, phase 34); this file keeps
* only the chat-specific per-bubble Tune button + inline form, whose
* success path refreshes the panel (refreshSteering()) and announces
* (announceSteering()) through the module. Note text is always rendered
* with textContent (XSS-safe) in both places.
*
* No reply autoscroll (owner direction 2026-08-27, TODO.md L5 —
* revising the phase 18 follow-the-bottom choice): the page NEVER
* auto-scrolls while a turn streams — no thinking, tool, or delta frame
* moves the viewport, so scrolling up to read earlier content holds for
* the rest of the turn. The only scroll call sites are user intent: the
* submit (your own message is revealed) and the phase-14 restore landing
* (one-shot, load-time). scrollReveal(wrap) is the one scrollIntoView in
* this file; addMessage(who, html, scroll) carries the intent. The
* thinking block's internal bottom-pin (textEl.scrollTop, phase 17 —
* reworked separately in phase 43) pins the block's own clip, not the
* page, and is untouched here.
*
* Document modal (phase 26): a source chip opens the cited document in
* the almost-fullscreen modal overlay (assets/document-modal.js) on the
* SAME page — no new tab, no navigation. The chip keeps its
* /document.html href as the no-JS / context-menu escape hatch;
* left-clicks are intercepted (preventDefault) and routed to
* openDocumentModal. The module is loaded through the relative import
* below — the header.js single-evaluation design (no direct <script>
* tag; esbuild inlines it into the page bundle).
*
* All DOM ids match frontend/index.html.
*/
import {
fetchIsAdmin,
initSharedHeader,
refreshSteering,
announceSteering,
} from "./header.js";
import { openDocumentModal } from "./document-modal.js"; // phase 26: chips open the same-page modal
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");
/* Phase 39: the display name resolves from one place — window.BOR_BRAND
* (the classic assets/brand.js sets it at parse time; its /api/config
* fetch refreshes it). Read LAZILY (a function, not a const string):
* a label set after the fetch lands carries the configured name; the
* literal below is only the no-config fallback — with the default name
* every label renders the pre-phase-39 bytes. */
const brand = () => window.BOR_BRAND || "Brain of Reese";
/* ---------- 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",
});
/* The #send-status live-region text per UI state (PLAN §7.4). The
* values are builders (phase 39): the brand entries resolve brand() at
* call time, never at module evaluation, so a label set after the
* /api/config fetch lands carries the configured name. */
const SEND_STATUS = Object.freeze({
[UI_STATE.idle]: () => "",
[UI_STATE.thinking]: () => `${brand()} is thinking`,
[UI_STATE.streaming]: () => `${brand()} is answering`,
[UI_STATE.error]: () => "The last question failed — try again",
});
/* 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.";
/* 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";
/* No reply autoscroll (owner direction 2026-08-27, TODO.md L5 —
* revising the phase 18 follow-the-bottom choice): the page never
* auto-scrolls while a turn streams. The only scroll call sites are
* the user submit (reveal my message) and the phase-14 restore
* landing (one-shot, load-time). */
/* The ONE scrollIntoView in this file — unconditional (unit-pinned):
* scrollReveal scrolls whenever it is called, so a page scroll can only
* ever happen from those two user-intent call sites. */
function scrollReveal(wrap, behavior = SCROLL) {
wrap.scrollIntoView({ behavior, block: "end" });
}
/* ---------- document viewer link (phase 10; phase 13 adds `back`) ----------
* The href a source chip carries: the dedicated viewer (no-JS /
* context-menu escape hatch). Phase 26: the chip's left-click is
* intercepted and the document opens in the same-page modal instead
* (document-modal.js) — this URL is also what the modal's "Full page"
* link points at. 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; panel module-owned from phase 34) ----------
*
* 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 — toggle,
* list, per-note delete, count badge, announcer — is owned by the shared
* header module (assets/header.js, phase 34 task 01); this page keeps
* only the chat-specific part: the Tune button under every completed
* brain bubble and its inline form. Save success refreshes the panel
* through refreshSteering() and announces through announceSteering() —
* both imported from header.js.
*/
const TUNE_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15.5" cy="7" r="2.2"/><circle cx="9.5" cy="17" r="2.2"/></svg>';
let tuneSeq = 0; // unique ids for one open tune form's inputs
/* "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 + "<span>Tune</span>";
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 =
`<label for="${inputId}">Tuning note — how should Brain answer from now on?</label>` +
`<textarea id="${inputId}" name="note" rows="2" maxlength="2000"
placeholder="e.g. be more concise — or: assume I'm on NixOS"></textarea>`;
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 refreshSteering(); // header.js: 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();
}
/* ---------- 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 =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6.5" y="6.5" width="11" height="11" rx="2.5"/><circle cx="12" cy="12" r="1.9" fill="currentColor" stroke="none"/><path d="M9.5 6.5V3.8M14.5 6.5V3.8M9.5 20.2v-2.7M14.5 20.2v-2.7M6.5 9.5H3.8M6.5 14.5H3.8M20.2 9.5h-2.7M20.2 14.5h-2.7"/></svg>';
const USER_AVATAR =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="8" r="3.6"/><path d="M4.8 20.2c.9-3.9 3.8-6 7.2-6s6.3 2.1 7.2 6"/></svg>';
/* ---------- messages ----------
* Scroll is explicit intent (phase 42, no reply autoscroll): addMessage
* scrolls only when the caller passes `scroll = true` — the user submit
* (reveal my message) and the phase-14 restore landing. The streaming
* path (thinking / tool / delta) creates bubbles with the default
* (scroll = false): the page never follows a turn. */
function addMessage(who, html, scroll = false) {
if (emptyState) emptyState.hidden = true;
const wrap = document.createElement("div");
wrap.className = `msg ${who}`;
wrap.innerHTML = `
<span class="avatar" aria-hidden="true">${who === "brain" ? BRAIN_AVATAR : USER_AVATAR}</span>
<div class="msg-body">
<div class="bubble">${html}</div>
</div>`;
messagesEl.appendChild(wrap);
if (scroll) scrollReveal(wrap);
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 = `
<span class="avatar" aria-hidden="true">${BRAIN_AVATAR}</span>
<div class="msg-body">
<div class="bubble typing" role="status" aria-label="${TYPING_LABEL()}">
<span></span><span></span><span></span>
</div>
</div>`;
messagesEl.appendChild(wrap);
// No page scroll (phase 42): a typing bubble must not yank the viewport.
}
function removeTyping() {
document.querySelector("#typing-indicator")?.remove();
}
/* ---------- thinking block (phase 17) ----------
* The model's reasoning streams into a collapsible <details> 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 <details>/<summary> — 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 =
`<summary>Thinking</summary><div class="thinking-text"></div>`;
const body = wrap.querySelector(".msg-body");
// Phase 37: the scratchpad stays the TOP row of the wrap — if tool
// lines are already there (a `tool` frame preceded the first
// `thinking` frame), the block lands above them, not below.
const anchor =
body.querySelector(".tool-calls") ?? body.querySelector(".bubble");
body.insertBefore(block, anchor);
}
return block;
}
function closeThinkingBlock(wrap) {
const block = wrap?.querySelector?.(".thinking");
if (block) block.open = false; // idempotent; no-op without a block
}
/* ---------- tool-call lines (phase 37, PLAN §4 extension) ----------
* One visible "calling tool" row per `tool` SSE frame, in the same wrap
* the Thinking block uses — above the answer, below the Thinking
* summary (ensureThinkingBlock keeps the scratchpad on top). The first
* frame creates the .tool-calls list; later frames — any tool, any
* interleaving with thinking frames, even after the first delta (the
* agent loop never emits one, but a late frame must not crash) — just
* append another line, in order. The SAME helper re-renders the
* persisted lines on restore (phase 14 convention): the path argument
* goes through textContent, so nothing HTML-shaped can come from
* storage. Lines are not interactive (no focus targets). */
function appendToolLine(wrap, name, argument) {
const body = wrap?.querySelector?.(".msg-body");
if (!body) return;
let container = body.querySelector(".tool-calls");
if (!container) {
container = document.createElement("div");
container.className = "tool-calls";
container.setAttribute("role", "list");
container.setAttribute("aria-label", "Tool calls");
// Before the bubble; below an existing Thinking block (both insert
// before the bubble, so document order is preserved).
body.insertBefore(container, body.querySelector(".bubble"));
}
const line = document.createElement("span");
line.className = "tool-call";
line.setAttribute("role", "listitem");
if (name === "read_document" && argument) {
line.textContent = "📄 Reading ";
const code = document.createElement("code");
code.textContent = argument; // the path is data, never markup
line.appendChild(code);
} else {
line.textContent = "🔎 Listing documents";
}
container.appendChild(line);
}
/* ---------- 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 <button type="button"> 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
/* Turn accumulators + the navigate-away flag (phase 20, owner choice
* 2026-08-24 A1): module scope because the `pagehide` handler reads them
* while a turn is still in flight; reset per turn at the top of
* handleSend, so they stay turn-scoped like the rest of the turn locals. */
let acc = ""; // accumulated answer text this turn
let thinkingAcc = ""; // accumulated thinking text (persisted with the turn)
let persistedOnLeave = false; // pagehide partial-persist at most once
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", `${brand()} 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) ---------- */
/* Cancel a response body without leaking an unhandled rejection:
* while readSSE's reader is still attached, cancel() on a LOCKED stream
* REJECTS (a rejected promise — a try/catch around the call cannot see
* it), which surfaced as a "Cannot cancel a locked stream" page error on
* every completed turn. Both outcomes are fine here: the stream is dead
* or dying. */
function cancelStream(res) {
try {
res?.body?.cancel().catch(() => {});
} catch {
/* body already consumed/closed */
}
}
/* Parse an SSE response body into JSON events. */
async function readSSE(response, onEvent) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buf = "";
try {
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));
}
}
} finally {
// Release the reader's lock: with it held, the turn-end
// cancelStream(res) below rejects (locked stream — unhandled
// promise rejection). Released, the finished stream is closed and
// cancel() settles quietly.
reader.releaseLock();
}
}
/* Source chips (mono, source/path) under a Brain bubble. Phase 26:
* clicking a chip opens the document in the same-page modal (no new
* tab) — the /document.html href stays as the no-JS / context-menu
* escape hatch. */
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 = documentUrl(s.source, s.path, "/"); // back → the chat page
chip.addEventListener("click", (e) => {
e.preventDefault(); // no new tab (phase 26) — the modal takes over
e.stopPropagation();
openDocumentModal(s.source, s.path, chip);
});
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);
}
/* ---------- conversation persistence (phase 14) ----------
*
* A durable LOCAL session (A10 unchanged: the API stays stateless — the
* server stores nothing about the conversation). The whole conversation
* lives in localStorage under a versioned key; a format bump = clean start:
*
* bor.chat.v1 → { v: 1, messages: [{ who: "user"|"brain", text,
* sources?, deflected?, suggestions?,
* thinking?, tools? }] }
*
* Only RAW TEXT is stored — restore re-renders it through the escape-first
* markdown renderer, so no HTML is ever persisted. Save points: the user
* message on send (a failed turn keeps the question), the brain message on
* `done` (with sources/deflected/suggestions), and the PARTIAL brain
* message on navigate-away (`pagehide`, phase 20 — an in-flight turn keeps
* whatever had already streamed; thinking-only turns persist nothing
* brain-side). Every localStorage access
* is try/catch'd — private mode or quota exhaustion degrades silently to
* in-memory-only chat. If the serialized state outgrows the budget (~700k
* chars, far under the ~5MB quota) the oldest messages are dropped first.
*/
const STORAGE_KEY = "bor.chat.v1";
export const STORAGE_VERSION = 1;
export const STORAGE_BUDGET_CHARS = 700_000;
let conversation = []; // in-memory copy of the persisted messages
function loadStoredConversation() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const data = JSON.parse(raw);
if (!data || data.v !== STORAGE_VERSION || !Array.isArray(data.messages)) return [];
// Legacy/corrupt shape → clean start; keep only well-formed raw-text
// messages (nothing HTML-shaped can survive this filter).
return data.messages.filter(
(m) =>
m &&
(m.who === "user" || m.who === "brain") &&
typeof m.text === "string" &&
m.text.length > 0
);
} catch {
return []; // unreadable storage: start clean, never throw
}
}
function trimToBudget(messages) {
let out = messages.slice();
for (;;) {
let size = Infinity;
try {
size = JSON.stringify({ v: STORAGE_VERSION, messages: out }).length;
} catch {
break; // even one message cannot serialize — keep it in memory only
}
if (size <= STORAGE_BUDGET_CHARS || out.length <= 1) return out;
out = out.slice(1); // drop the oldest until it fits
}
return out;
}
function saveConversation() {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ v: STORAGE_VERSION, messages: trimToBudget(conversation) })
);
} catch {
/* quota/private mode: chat keeps working with in-memory state only */
}
}
export function clearStoredConversation() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* nothing was stored */
}
}
function renderStoredMessage(m) {
// Phase-14 restore landing (kept by the phase-42 direction): the
// one-shot load-time scroll — scroll=true so a restored conversation
// lands on its latest message. The new addMessage(who, html, scroll)
// signature has no per-call behavior override, so the landing rides
// the default SCROLL (smooth; "auto" under prefers-reduced-motion)
// instead of the old forced "auto" — noted per the phase-42 task.
if (m.who === "user") {
addMessage("user", renderMarkdown(m.text), true);
return;
}
const wrap = addMessage("brain", renderMarkdown(m.text), true);
if (m.thinking) {
// Phase 17: restore the thinking block COLLAPSED above the bubble.
const block = ensureThinkingBlock(wrap);
block.open = false;
block.querySelector(".thinking-text").innerHTML = renderMarkdown(m.thinking);
}
if (Array.isArray(m.tools)) {
// Phase 37: restore the tool lines in saved order through the SAME
// append helper as the live frames (no HTML from storage, ever).
for (const t of m.tools) {
if (!t || typeof t.name !== "string") continue;
const arg =
typeof t.argument === "string" && t.argument ? t.argument : null;
appendToolLine(wrap, t.name, arg);
}
}
if (m.deflected) {
wrap.classList.add("is-deflected");
appendMaybeTry(wrap, m.suggestions);
}
appendSources(wrap, m.sources);
appendTuneButton(wrap); // restored brain answers are tunable too
}
/* On load: re-render the stored conversation (markdown, source chips,
deflected styling, maybe-try chips). addMessage hides the empty state,
so a restored conversation starts right where it was left. */
function restoreConversation() {
conversation = loadStoredConversation();
for (const m of conversation) renderStoredMessage(m);
}
/* Brain message save point (on `done`): raw accumulated text + metadata.
Phase 17: meta.thinking and phase 37: meta.tools are optional —
`undefined` drops the key from the JSON, so turns without them persist
exactly as before. An empty answer keeps the fallback/"…" text that
was actually rendered — what the user saw is what is stored. */
function rememberBrainTurn(rawText, meta) {
conversation.push({ who: "brain", text: rawText || "…", ...meta });
saveConversation();
}
/* ---------- new chat (phase 14) ----------
* Clears the stored conversation + the rendered list and returns to the
* empty state (suggestions included). Ignored while a turn is in flight —
* a live stream must not be hijacked. Confirmation reuses the existing
* #send-status live region (aria-live=polite). */
/* ---------- single-admin auth (phase 16, A10 revised) ----------
*
* /api/whoami decides the header: anonymous → the Sign in link and NO
* tuning surface at all — the Tuning toggle + panel are removed from the
* DOM (the story says "absent", not just hidden), /api/steering is never
* fetched, and appendTuneButton injects nothing (new or restored
* messages). Admin → Sign out + the full phase-15 UI. Whoami is awaited
* BEFORE the phase-14 restore, so restored brain bubbles never flash a
* Tune button that should not be there.
*
* Phase 19: the whoami fetch, the Sign in / Sign out / Sources-nav
* toggling, and the #sign-out-btn click binding (POST /api/logout +
* reload) all moved to the shared header module (assets/header.js) —
* initSharedHeader() does the header toggling on every page, and
* fetchIsAdmin() is the single cached whoami, so this page still makes
* exactly one request per load.
*
* Phase 34: removing the tuning surface for anonymous visitors (toggle
* + panel, "absent not hidden") also moved into initSharedHeader() —
* the module owns the controls, so the module gates them. applyAuthState
* keeps only the auth-pair toggling — idempotent alongside the header
* module's own link/button toggling.
*/
const signInLink = document.querySelector("#sign-in-link");
const signOutBtn = document.querySelector("#sign-out-btn");
let isAdmin = false;
function applyAuthState() {
if (signInLink) signInLink.hidden = isAdmin;
if (signOutBtn) signOutBtn.hidden = !isAdmin;
}
function startNewChat() {
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
conversation = [];
clearStoredConversation();
removeTyping();
messagesEl.querySelectorAll(".msg").forEach((el) => el.remove());
if (emptyState) emptyState.hidden = false;
clearErrorBanner();
setUiState(UI_STATE.idle);
input.value = "";
autoGrow();
input.focus();
sendStatus.textContent = "New chat started — previous conversation cleared.";
}
/* Phase 34 task 02: the #new-chat-btn binding is module-owned
* (header.js, the SINGLE New chat binding) — on the chat page the
* module dispatches window "bor:new-chat" and this page acts through
* its own in-flight-turn guard + list reset. */
window.addEventListener("bor:new-chat", startNewChat);
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), true); // reveal my message (owner-kept)
// Persistence save point 1: the question is stored the moment it is
// sent, so a failed/interrupted turn never loses it.
conversation.push({ who: "user", text });
saveConversation();
input.value = "";
autoGrow();
clearErrorBanner();
let wrap = null;
let res = null;
let aborted = false; // the 120s guard already took the turn to error
// Phase 20: acc / thinkingAcc / persistedOnLeave live at module scope
// (the pagehide handler reads them) but reset here, so they stay
// turn-scoped exactly like the other turn locals.
acc = "";
thinkingAcc = "";
persistedOnLeave = false;
let sawThinking = false; // did any `thinking` frame arrive this turn?
let sawDone = false; // did the stream end with a `done` event?
let toolAcc = []; // phase 37: {name, argument} per `tool` frame —
// persisted with the turn (optional `tools` key)
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 thinking OR delta event (phase 17) and on every terminal
// transition.
setUiState(UI_STATE.thinking);
armTurnTimeout(() => {
aborted = true;
cancelStream(res); // best-effort: the reader may still hold the lock
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 === "thinking") {
// Phase 17: model reasoning — stream it live into the collapsible
// Thinking block. No setUiState here: the UI state stays
// "thinking" (button still disabled with "Thinking…", #send-status
// unchanged) — the live block simply replaces the typing dots as
// the visible feedback.
thinkingAcc += ev.text || "";
sawThinking = true;
clearTurnTimeout(); // the stream is alive — as the first delta says
if (!wrap) wrap = addMessage("brain", "");
removeTyping(); // the live block replaces the dots as feedback
const block = ensureThinkingBlock(wrap);
const textEl = block.querySelector(".thinking-text");
textEl.innerHTML = renderMarkdown(thinkingAcc); // escape-first, XSS-safe
if (block.open) {
// Pin the block's OWN stream (phase 17 — reworked in phase 43);
// the page never follows (phase 42, no reply autoscroll).
textEl.scrollTop = textEl.scrollHeight;
}
} else if (ev.type === "tool") {
// Phase 37 (PLAN §4 extension): an agent tool call. The UI
// state stays "thinking" — the button remains disabled (never
// stale, PLAN §7.4); what changes are the LABELS: the button
// carries the "calling tool" text, #send-status + the typing
// indicator (if still visible) say what Brain is doing, and a
// .tool-call line lands above the answer (append-only, in
// order). The elapsed-seconds hint (thinkingClock) keeps
// running through tool frames — no clock changes here.
const name = typeof ev.name === "string" ? ev.name : "";
const argument =
typeof ev.argument === "string" && ev.argument ? ev.argument : null;
toolAcc.push({ name, argument });
clearTurnTimeout(); // the stream is alive — a frame arrived
if (!wrap) wrap = addMessage("brain", "");
const toolStatus =
name === "read_document" && argument
? `${brand()} is reading ${argument}`
: `${brand()} is listing documents`;
if (uiState === UI_STATE.thinking) {
sendLabel.textContent = "Calling tool…";
sendStatus.textContent = toolStatus;
document
.querySelector("#typing-indicator .bubble")
?.setAttribute("aria-label", toolStatus);
}
appendToolLine(wrap, name, argument);
// No page scroll (phase 42): tool lines never yank the viewport.
} else if (ev.type === "delta") {
acc += ev.text || "";
if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming);
if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in
closeThinkingBlock(wrap); // auto-collapse; idempotent, never reopens
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
// No page scroll (phase 42): the answer never follows the viewport.
} else if (ev.type === "done") {
sawDone = true;
closeThinkingBlock(wrap); // the turn is over: settle the block closed
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);
appendTuneButton(wrap); // every completed brain bubble is tunable
// Thinking-without-answer (reasoning can exhaust max_tokens): the
// bubble gets the empty-answer fallback — what the user saw is
// what gets persisted.
const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : "");
if (!acc && sawThinking) {
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText);
}
// Persistence save point 2: the answer lands only when the turn is
// complete (raw text + the done metadata; phase 17: + optional
// thinking, phase 37: + optional tools — `undefined` drops the
// key from the JSON).
rememberBrainTurn(finalText || acc, {
thinking: thinkingAcc || undefined,
tools: toolAcc.length ? toolAcc : undefined,
deflected: !!ev.deflected,
sources: ev.sources,
suggestions: ev.suggestions,
});
} else if (ev.type === "error") {
throw new Error(ev.detail || "Something went wrong on my side.");
}
});
// Stream-drop guard (phase 17): frames arrived but no `done` event —
// the connection died mid-turn. Say so; never settle silently into
// idle with a half bubble. The zero-frame case falls through to the
// existing empty-answer fallback below.
if (!sawDone && !aborted && (acc || thinkingAcc)) {
setUiState(
UI_STATE.error,
"The stream ended before my answer finished — try again?"
);
}
if (!aborted && !wrap) {
const fallback = EMPTY_ANSWER_FALLBACK;
const fwrap = addMessage("brain", fallback);
appendTuneButton(fwrap);
rememberBrainTurn(fallback, {}); // persist what the user actually saw
}
} 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();
cancelStream(res); // the reader lock is released — no unhandled rejection
if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle);
// Focus back for the next question, but never move the viewport —
// the page never auto-scrolls (phase 42), so a user reading earlier
// content stays where they are.
input.focus({ preventScroll: true });
}
}
input.addEventListener("input", autoGrow);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
composer.requestSubmit();
}
});
composer.addEventListener("submit", handleSend);
/* Navigate-away save point (phase 20, owner choice 2026-08-24 A1):
* leaving the chat mid-turn would otherwise drop the in-flight
* answer — the brain message persists only on `done`, and
* navigation aborts the stream. On `pagehide`, if a turn is in
* flight and answer text has streamed, persist the partial raw text
* (reusing the save-point helper, so restore re-renders it exactly
* like a completed answer — no "(partial)" marker, no sources).
* Thinking-only (no answer tokens yet) persists nothing brain-side:
* the question is already saved on send and the user can re-ask.
* `persistedOnLeave` makes this idempotent across pagehide/bfcache
* churn. */
window.addEventListener("pagehide", () => {
if (persistedOnLeave) return;
if (uiState !== UI_STATE.thinking && uiState !== UI_STATE.streaming)
return;
if (!acc) return; // nothing brain-side to save yet
persistedOnLeave = true;
rememberBrainTurn(acc, { thinking: thinkingAcc || undefined });
});
/* Boot: auth state FIRST — it decides whether the restored conversation
gets Tune buttons and whether the steering UI exists at all (phase 16).
Phase 14: the conversation then comes back exactly as left. Phase 19:
the shared header module runs the whoami (cached — exactly one
request per page load) and toggles the Sign in/out pair + the Sources
nav link; applyAuthState() then applies the chat-page-only gating.
Phase 34: the steering panel's admin boot refresh (count badge) and
the anonymous removal of the tuning surface both happen inside
initSharedHeader() now. */
(async () => {
await initSharedHeader(); // header.js: whoami + Sign in/out + steering gate
isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami
applyAuthState(); // chat page: the auth pair (idempotent with header.js)
restoreConversation();
loadSuggestions();
loadHealth();
})();