Files
brain-of-reese/frontend/assets/app.js
T
ducoterra c851d1a1c5
Build and Push Containers / build-and-push-app (push) Successful in 2m2s
Build and Push Containers / build-and-push-db (push) Successful in 12s
phase: 117_tool_call_compact
All verification complete — every gate green. Final report:

**Phase 117 final verification pass — all criteria verified** (tasks 01–04 were already in `complete/`; I re-verified the full gate myself, no code changes needed)
- **Verified:** `appendToolLine` wraps lines in `details.tool-calls-disclosure` (open live, `closeToolCalls` at delta/done/stop + restore-closed, count via textContent); `addToolLines` parity (closed); `.tool-call` deboxed / `code` dechipped; new disclosure/summary CSS; only the one pinned CSS assert updated
- `uv run pytest` → **2466 passed**
- `uv run pytest --cov=app --cov-report=term-missing` → **99%** on `app/` (>90% floor)
- E2E in isolation: `test_tool_call_compact.py` **4 passed** (fold/expand/deboxed-flow/restored-folded); `test_agent_document_tools.py` **4 passed**; `test_big_read_progress.py` **4 passed** (live disclosure open + `.tool-elapsed` visible); `test_thinking_display.py` **5 passed**; `test_smoke.py` **3 passed**
- `uv run ruff check . && uv run pyright` → clean (pyright 0 errors, 0 warnings)
- Completion criteria: pins 1–4 ✅ (E2E above); live "calling tool" state byte-identical ✅ (`test_frontend_tool_states.py` 10 passed incl. `test_calling_tool_label_strings`); scope ✅ — `git diff` limited to the 3 frontend assets + 2 unit tests + E2E story + phase files, **0 changes in `app/`**
- Notable: `TODO.md` carries a **pre-existing uncommitted owner edit** (new retrieval-context idea) already in the tree before this pass — left untouched, will ride along in the harness commit; pre-existing committed quirk: the brain-wave commit's styles.css comment mislabels itself "Phase 117" (cosmetic, out of this diff)
- No commit made (harness commits + moves the phase); **next pending phase: none** — `todo/` holds only phase 117
2026-09-15 18:26:38 -04:00

2849 lines
140 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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
* (one button, two roles: "Send" when idle, the enabled "Stop" control
* while a turn is in flight — phase 48, 2026-08-29, TODO.md L3), and
* the #send-status live region:
*
* idle → thinking → streaming → done | error → idle
* stop → idle (no banner — phase 48)
*
* • thinking — pre-token: typing dots; the button is the enabled
* "Stop" control (it stays so until `done`). 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" — the button stays the "Stop"
* control) and the 300s 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 the "Stop" control until
* `done`.
* • stop — phase 48: clicking the in-flight button (or pressing
* Enter) aborts the fetch (AbortController, the task-01
* server teardown closes the model's stream on
* disconnect). The partial answer is kept on screen and
* persisted with the optional `stopped` marker (a
* pre-token stop persists nothing brain-side — phase-20
* convention), the live region confirms "Answer
* stopped.", and the turn settles to idle through the
* SAME finally path — no error banner, no scroll
* (phase 42).
* • error — red banner (role="alert") with an actionable retry hint;
* the 300s 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; phase 68 added
* search_documents; phase 70 remapped the surface to the harness
* names ls / read(path) / grep(pattern, path?)): a grounded turn may
* call the three server-side document tools, bounded only by the
* round cap (phases 45/68). 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" (the button stays the enabled
* "Stop" control — phase 48 — never stale, PLAN §7.4) while the STATUS
* LABELS change — the #send-status + typing-indicator labels say what
* Brain is doing ("…is listing documents" / "…is reading source/path"
* / "…is searching for pattern" — the name prefix resolves from
* window.BOR_BRAND at call time, phase 39) — the button no longer
* relabels to "Calling tool…" (phase 48, owner-locked: it stays
* "Stop" for the whole turn) — 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. Phase 70: the
* line/label branches key off the NEW names and still carry the legacy
* ones (list_documents / read_document / search_documents) — persisted
* turns from before the remap render exactly as before (no migration).
* 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).
*
* Truncated reads (phase 95, A15 extension — `tool_result` is the
* seventh, optional SSE event type; existing frames untouched, unknown
* types ignored): a `read` longer than BOR_READ_MAX_CHARS streams ONE
* `tool_result` frame after its `tool` frame, and the handler appends
* the " (truncated — showing N of M chars)" marker to that Reading
* line (appendTruncatedNote — DOM append, never a re-render) AND stamps
* the matching toolAcc entry (newest, same argument) with `truncated` /
* `chars_shown` / `chars_total` — the save payload carries the record
* with zero other change, and BOTH restore paths (the phase-14 local
* renderStoredMessage and the shared page's addToolLines) re-render the
* same marker from the stored record (pixel-identical, the phase-50
* restore contract). A non-truncated read streams no frame at all.
*
* LLM retry status (phase 67, TODO.md L3): if the endpoint dies BEFORE
* the first frame of an LLM request lands, the server restarts that
* request (up to BOR_LLM_RETRIES retries, BOR_LLM_RETRY_DELAY seconds
* apart — a flat delay, no backoff, owner-locked A3) and streams one
* `retry` SSE frame per wait. The handler treats it with the same
* status pattern as the `tool` frames: the 300s guard clears (a frame
* arrived) and the EXISTING #send-status live region + the
* typing-indicator aria-label read the owner-locked copy
* "Communication interrupted — retrying (n of N)…" (n = the attempt
* about to be tried, N = the configured total). Nothing else changes —
* no bubble, no tool line, no banner, no UI-state change: it is a
* transient status that the next thinking/tool/delta frame replaces
* through its own branch. A retry arrives only before the request's
* first piece (locked A2), so it never races a partial answer; the
* status gate still covers BOTH live states (thinking AND streaming)
* because a LATER agent round may restart while an earlier round
* already emitted content (the rare content+tool-call stream).
*
* 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 page scroll in
* this file — a document-BOTTOM landing, not a message-bottom alignment
* (its comment explains why block:"end" hopped the page up on submit);
* 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).
*
* Retry the last answer (phase 49, owner-locked 2026-08-29, TODO.md L4):
* a "Retry" button in the meta row of the LAST brain bubble re-asks the
* preceding question IN PLACE — the old answer is removed from the DOM
* and from the persisted record (saved immediately after the pop, so a
* crash between the pop and the fresh `done` can never resurrect the
* replaced answer; the question stays), and the fresh answer streams
* into its place without re-adding the question. This is what
* `runTurn(text, { reask })` is for: the turn extracted from handleSend
* skips the user append + persistence save point 1 when `reask` is set.
* Only the last brain bubble carries the button (markLastRetryable), it
* is NOT admin-gated (chat is public — unlike Tune), and it is inert
* while a turn is in flight. No banner, no scroll (phase 42): the fresh
* bubble lands where the old one was.
*
* Auto-save the conversation (phase 55, owner-locked A2, 2026-08-31 —
* the phase-50 Save pill is RETIRED, TODO.md L4 "Save shouldn't be a
* button"): every conversation upserts itself into Postgres
* (saved_chats, migration 0008) at the persistence save points — no
* button, no explicit action. The headless persistConversation() helper
* carries the phase-50 upsert semantics, keyed by `currentChatId`
* (module scope, string | null): a save while unlinked POSTs /api/chats
* (the server auto-titles from the first question, 120-char cap) and
* links the conversation to the created row's id; a save while linked
* PUTs the SAME row — the same conversation never spawns a second row;
* a 404 from that PUT (the row was deleted on the History page behind
* our back) unlinks and retries as a create, so a stale link can never
* wedge the conversation. The module-level `persisting` flag is the
* double-fire guard: the save points can overlap (pagehide during a
* stream), so a call while an upsert is in flight is a no-op — the
* next save point retries. The A2 quiet contract: a FAILED auto-save
* never blocks the conversation — a one-line #send-status note ("Couldn't
* save automatically — will try on the next message."), NO error banner;
* a SUCCESSFUL auto-save is silent (the History page is the visible
* proof — the toast is reserved for share). The row link survives
* reloads: the bor.chat.v1 record carries `chatId` (null when unlinked;
* a pre-55 record without the field reads as null — never throws), so a
* refresh restores the conversation AND its link. "New chat" unlinks
* (a fresh conversation creates a fresh row on its first message). Boot
* load: /?chat=<id> with a VALID uuid AND admin fetches the row and
* renders its messages through the SAME renderStoredMessage loop as the
* phase-14 local restore (sources / thinking / tools / stopped /
* deflection — pixel-identical), links currentChatId to the id, and
* mirrors the conversation to localStorage (a plain refresh returns to
* it the phase-14 way, link included). The ?chat= param is a ONE-SHOT
* boot instruction: the success path normalizes the URL back to /
* (history.replaceState), so a later refresh — or a "New chat" +
* refresh — restores the LOCAL session (the mirror) instead of
* re-opening the saved row and evicting whatever the owner typed since.
* Invalid/absent param, anonymous (no fetch — the gate would 403), 404,
* or network failure: the normal local restore runs instead (404/network
* also raise the error banner). Phase 14's local persistence is
* untouched: auto-save is an additional, automatic upsert.
*
* Share the conversation (phase 51, owner-locked 2026-08-29, TODO.md
* L6 — visible to EVERY visitor since phase 55 task 03: the pill is
* static, always-visible markup with no reveal step, and the write
* surface is public — task 01): the "Share" pill (#share-chat-btn)
* turns the CURRENT conversation into a public read-only link
* (/shared/<token>, a 128-bit uuid4 on the saved_chats row). The
* save-then-share contract: the same
* empty-conversation no-op guard as the auto-save (live region, no
* request); linked (currentChatId set) → POST
* /api/chats/<id>/share (idempotent — the existing token comes back
* unchanged); unlinked → POST /api/chats with { messages: conversation,
* share: true } and link currentChatId to the created id — one action
* saves AND shares (owner-locked). On success the ABSOLUTE share URL
* (share_url resolved against the page origin) is copied:
* navigator.clipboard.writeText in a try — a non-secure (http) homelab
* origin rejects the clipboard, so the failure path renders the inline
* fallback: a transient link field near the status line (an <a> styled
* input-like that selects its full URL on focus, .share-link-fallback)
* and the live region reads "Share link ready — copy it from the
* field." (the owner-locked fallback). Success (clipboard) reads
* "Share link copied." Phase 55 task 04 (owner-locked A4): BOTH
* success paths ALSO raise the visual-only share toast (showToast —
* top-right slide-down, auto-dismiss ~4s, single instance; the node is
* aria-hidden, so the #send-status live region remains the sole a11y
* announcer — no double screen-reader read); a failed share NEVER
* toasts (the error banner is the failure UI). 403/5xx → the
* actionable error banner (neutral "try again" — with a public write
* surface a 403 is no longer a sign-in problem for a guest, phase 55
* task 01); a network failure → the "is the app reachable?" banner.
*
* Stale saved chats (phase 53, TODO.md L4): every sync that changes the
* knowledge base bumps the sources generation; a row saved against an
* older one is STALE (server-computed `stale` on GET /api/chats/<id> —
* the client never does staleness math). The /?chat=<id> boot load
* reveals the #stale-banner (top of the column, directly below
* #kb-banner) when the fetched payload reports `stale: true`. A stale
* conversation with NO brain record is revealed text-only — the
* #stale-regenerate button is removed (retryLastTurn is never called in
* that state). Regenerate = the phase-49 redo-in-place of the LAST
* brain bubble ONLY: retryLastTurn(lastBrainWrap) re-asks the last
* question against the new index (full conversation context kept; earlier
* answers are not re-run), and retryLastTurn now RETURNS the
* runTurn promise so the handler can await the turn's completion
* (behavior-neutral for the existing Retry click, which ignores it).
* Only when the turn completes WITHOUT the error banner does the handler
* persist the linked row through the SAME upsert as the auto-save — PUT
* /api/chats/<id> (the server re-stamps sources_version → stale: false);
* a 404 (row deleted from History meanwhile) unlinks and recreates
* (persistConversation's stale-link rule). A regenerate that errors
* mid-stream leaves the row untouched (stale stays true); a regenerate
* STOPPED mid-stream (phase 48) persists the stopped partial. Success
* hides the banner and announces in the #send-status live region
* (PLAN §7.4 never-stale). The banner also clears on "New chat" and on
* a successful auto-save re-stamp (both make the row/conversation no
* longer the one the banner describes).
*
* A hidden tab never stops a turn (phase 73, TODO.md L3): a merely-HIDDEN
* tab (switched away from) keeps the stream filling the live bubble and
* the turn completes when the user returns — only tab close, real
* navigation, or the Stop button aborts (the phase-48 teardown contract,
* untouched). Two hardenings: (1) the pagehide partial-persist (phase 20)
* is CORRELATED with the turn's settle — `leavePartialIndex` records the
* index of the brain record the pagehide handler pushed for this turn,
* and the done/stop settle REPLACES that entry in place (identity-guarded
* on the record still being a brain record) instead of appending a second
* brain turn, so the saved record holds exactly one brain turn per
* question even on browsers that fire pagehide on a merely-hidden tab
* (e.g. Safari; Chromium fires only visibilitychange — task 01); a REAL
* navigation never runs a settle, so the leave-save is unchanged there.
* (2) The 300s pre-token guard counts only VISIBLE time: when the tab
* returns to visible with the guard still armed (it clears on the first
* thinking/delta/retry frame), it re-arms with a fresh TURN_TIMEOUT_MS —
* a slow first frame arriving while the tab was hidden past the deadline
* no longer errors the turn (task 01, C2 confirmed).
*
* All DOM ids match frontend/index.html.
*/
import {
bindSharedHeaderControls,
fetchWhoami,
initSharedHeader,
refreshSteering,
announceSteering,
} from "./header.js";
import { openDocumentModal } from "./document-modal.js"; // phase 26: chips open the same-page modal
import { mountGate } from "./token-gate.js"; // phase 79 (task 05): the in-app token gate
// The shared header's control bindings (sign-out / mobile hamburger /
// New chat) — EXPLICIT init, once per document. header.js is inlined
// into every bundle that imports it (Containerfile stage 1), so the
// bindings must not run at module import — the body marker in
// header.js makes any further copy a no-op (2026-09-08
// double-binding production fix).
bindSharedHeaderControls();
/* Phase 77 (task 02): the bor:view-refresh exclusion is deliberate — the in-flight SSE stream and the local conversation must survive every switch (phase 76), so the chat view never listens and never re-fetches on a show. */
const messagesEl = document.querySelector("#messages");
const emptyState = document.querySelector("#empty-state");
const suggestionsEl = document.querySelector("#suggestions");
const charCountEl = document.querySelector("#char-count"); // phase 104: the question-length counter (ships hidden)
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 turnLoader = document.querySelector("#turn-loader"); // phase 109 (D16): the persistent in-turn loader — ships hidden; setUiState is its sole visibility owner
const banner = document.querySelector("#kb-banner");
const bannerText = document.querySelector("#kb-banner-text");
const versionEl = document.querySelector("#app-version");
// Phase 55 (A2): the phase-50 #save-chat-btn query is GONE — there is no
// Save control; persistConversation() auto-saves headless at the save points.
const shareBtn = document.querySelector("#share-chat-btn"); // phase 55 task 03: Share pill — static markup, visible to every visitor
const staleBanner = document.querySelector("#stale-banner"); // phase 53: the stale banner (ships hidden)
const staleRegenBtn = document.querySelector("#stale-regenerate"); // phase 53: the banner's Regenerate pill
/* 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 = 300_000;
/* Phase 87 (TODO.md L5): the latest tool line's visible "processing"
* threshold (A5): below 5s a frameless gap reads as normal latency;
* at/above it the latest line proves the turn is still processing.
* Pinned constant, not a magic number — the tool-line clock's tick
* gates on it. */
const TOOL_LINE_ELAPSED_AFTER_MS = 5_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 = "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";
/* Thinking-window follow-the-tail contract (owner direction
* 2026-08-27, `TODO.md` L7): the scratchpad autoscrolls to its live
* tail only while the user is pinned near the window's bottom —
* the 32px band is the "window bottom in view" threshold. Scrolling
* up pauses the follow; returning to the bottom resumes it (the
* check runs on every chunk — against the PRE-render geometry: a
* post-render reading measures the new chunk's height, not the user's
* position, and the follow died at the first \"\n\n\" paragraph break).
* Exported so the band is unit-pinned (same pattern as TURN_TIMEOUT_MS). */
export const THINKING_NEAR_BOTTOM_PX = 32;
function isThinkingNearBottom(textEl) {
return (
textEl.scrollHeight - textEl.scrollTop - textEl.clientHeight <=
THINKING_NEAR_BOTTOM_PX
);
}
/* 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 page scroll 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. It lands at the
* DOCUMENT BOTTOM, not on the message's own bottom edge: the composer
* and footer sit below the message in flow, so the old
* scrollIntoView({ block: "end" }) aligned the message's bottom to the
* viewport bottom — ABOVE the document bottom — and hopped the page UP
* by the composer+footer height on every submit (pushing the composer
* below the fold). At the document bottom the revealed message sits in
* view with the composer right under it. `wrap` is the revealed
* element, kept in the signature so the call sites read as intent. */
function scrollReveal(wrap, behavior = SCROLL) {
void wrap;
window.scrollTo({ top: document.documentElement.scrollHeight, behavior });
}
/* ---------- 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);
}
/* Phase 48 (owner-locked 2026-08-29): the "Stopped" note in the meta row
* of a user-stopped brain bubble. The live stop path (handleSend's stop
* branch) and the phase-14 restore path (a record with `m.stopped`) share
* this helper, so a restored bubble reads exactly like the stopped one.
* Reuses the .msg-meta row the way appendTuneButton does (role=list → the
* span joins as a listitem so ARIA stays valid). Non-interactive (no
* hover/focus — pointer-events: none in the CSS): the small filled-square
* stop glyph is aria-hidden decoration; the "Stopped" text carries the
* accessible meaning. */
function appendStoppedNote(wrap) {
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(".stopped-note")) return; // one per bubble
const note = document.createElement("span");
note.className = "stopped-note";
if (meta.getAttribute("role") === "list") note.setAttribute("role", "listitem");
note.innerHTML =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><rect x="6.5" y="6.5" width="11" height="11" rx="2"/></svg>';
const label = document.createElement("span");
label.textContent = "Stopped";
note.appendChild(label);
meta.appendChild(note);
}
/* Phase 49 (owner-locked 2026-08-29, TODO.md L4): the rendered wrap of
* the CURRENT last brain record. Set wherever a brain bubble becomes the
* latest persisted answer (the `done` branch, the empty-answer fallback,
* the stop finalize) and on the phase-14 restore (the LAST restored
* brain bubble wins); cleared when the retry redo pops it. Both
* retryLastTurn's stale-click guard and markLastRetryable's targeting
* key off it. */
let lastBrainWrap = null;
/* The Retry button's redo glyph (aria-hidden decoration — the "Retry"
* text carries the accessible name), currentColor so the CSS themes the
* stroke (ink-soft → ink on hover, the phase-08 palette). */
const RETRY_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>';
/* "Retry" button in the meta row of the last brain bubble — the redo
* action of phase 49 (owner-locked 2026-08-29, TODO.md L4). The house
* appendTuneButton pattern: reuses the .msg-meta row when it exists
* (role=list → the button joins as a listitem so ARIA stays valid),
* otherwise creates a plain meta row; one button per bubble. NOT
* admin-gated, unlike appendTuneButton — chat is public, so every
* visitor gets the redo (the meta-row actions read as a pair: Tune for
* the admin, Retry for everyone). Click → retryLastTurn(wrap). */
function appendRetryButton(wrap) {
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(".retry-btn")) return; // one per bubble
const btn = document.createElement("button");
btn.type = "button";
btn.className = "retry-btn";
if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");
btn.innerHTML = RETRY_ICON + "<span>Retry</span>";
btn.addEventListener("click", () => retryLastTurn(wrap));
meta.appendChild(btn);
}
/* Last-bubble-only management (owner-locked 2026-08-29): the Retry
* button lives on exactly ONE bubble — the last brain answer. Remove
* every rendered .retry-btn FIRST (an earlier bubble's button is stale
* the moment a newer answer lands), then re-append it to the last brain
* bubble — but only when that record has its preceding user record to
* re-ask (the invariant holds in practice: every brain record follows
* its user record). Call sites: on `done`, on the empty-answer
* fallback, on the stop finalize (a stopped partial is the prime retry
* candidate), and once at the end of the phase-14 restore.
* startNewChat needs no call: its list reset removes the buttons along
* with the list. */
function markLastRetryable() {
messagesEl.querySelectorAll(".retry-btn").forEach((b) => b.remove());
if (!lastBrainWrap) return;
let lastIdx = -1;
for (let i = conversation.length - 1; i >= 0; i -= 1) {
if (conversation[i].who === "brain") {
lastIdx = i;
break;
}
}
const prev = lastIdx > 0 ? conversation[lastIdx - 1] : null;
if (!prev || prev.who !== "user") return;
appendRetryButton(lastBrainWrap);
// Phase 59: "Save as doc" stays the meta row's rightmost action —
// when the Retry button lands on the SAME bubble, re-append the save
// button after it (the auto margins split the free space between the
// right-aligned buttons; DOM order decides the right edge).
const saveDocBtn = lastBrainWrap.querySelector(".save-as-doc-btn");
if (saveDocBtn && saveDocBtn.parentElement)
saveDocBtn.parentElement.appendChild(saveDocBtn);
}
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the bottom-right
* "Save as doc" action of EVERY completed brain bubble (deflected
* included — same scope as Tune; a stopped partial is a note, not an
* answer, so m.stopped records never get it — the restore call site
* gates on it). Gate: admin (the whoami gate Tune uses) AND a
* configured docs repo (docsRepoConfigured — /api/config, settled in
* the boot IIFE before any bubble renders). `markdown` — the RAW
* persisted answer text, m.text on the restore path, the
* done/fallback raw text on the live path, NEVER the rendered HTML —
* stays in the signature (the three call sites are unchanged) but no
* longer travels: phase 75 (TODO L4, A6) drafts the WHOLE conversation
* from the `conversation` record at click time (buildSessionTranscript).
* The .save-as-doc-btn's margin-inline-start: auto pushes it to the
* row's right edge (the TODO's "bottom right"); markLastRetryable
* keeps it rightmost when the last bubble also carries the Retry
* button.
*
* Click: default title (phase 115 — the QUESTION THE ANSWER
* ANSWERED: the saved bubble's paired user bubble, whitespace-
* collapsed, ≤120 chars, the phase-50 auto-title convention; the
* last-conversation-record rule survives as the no-pair fallback) +
* default in-repo path (docs/<slug>.md) + the
* FULL-SESSION transcript as the body (phase 75 A6) → POST
* /api/doc-drafts {title, path, body} → 201 →
* /doc-edit.html?draft=<token> (the edit screen, task 06, owns the
* rest). Failure → the neutral one-line banner (phase-55
* convention), the conversation unblocked, no navigation. */
const SAVE_AS_DOC_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H6a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 16h4"/></svg>';
const DOC_TITLE_MAX = 120; // the phase-50 auto-title cap (owner-locked)
/* The default doc title. Phase 115 (task 03 — the TODO L7 side
* observation): when the saved brain bubble's *wrap* is given, the
* title comes from the user bubble PAIRED with it — the nearest
* preceding .msg.user in the DOM conversation flow (#messages' direct
* children are the bubbles, in order; the walk is over the wrap's
* previousElementSibling chain). The pairing is DOM-structural, not
* index-based: redo-in-place (phase 49) reorders the DOM, and the
* structural pair IS the answer's question by construction — the
* last conversation record, after a retry, can be an unrelated
* trailing question (the junk-title edge case). The text is read
* from the bubble's .bubble (the rendered question — the meta rows
* with their button labels live on brain bubbles, never user ones).
* No wrap given, or no paired user bubble found (first-turn edge /
* DOM mismatch) → the pre-phase-115 fallback: the LAST user record
* in `conversation`. Whitespace-collapsed, truncated to 120 chars —
* the phase-50 auto-title convention (server-side:
* " ".join(text.split())[:120]); defensive "Note" when neither
* source yields a question (the UI cannot produce one).
* " ".join(split()) == replace(/\s+/g, " ").trim() for non-empty
* input; the trim keeps the leading/trailing-whitespace edge identical. */
function defaultDocTitle(wrap) {
let question = "";
if (wrap) {
for (
let el = wrap.previousElementSibling;
el !== null;
el = el.previousElementSibling
) {
if (el.classList.contains("msg") && el.classList.contains("user")) {
const bubble = el.querySelector(".bubble");
question = bubble ? (bubble.textContent ?? "") : "";
break;
}
}
}
if (!question) {
for (let i = conversation.length - 1; i >= 0; i -= 1) {
if (conversation[i].who === "user") {
question = conversation[i].text;
break;
}
}
}
return question.replace(/\s+/g, " ").trim().slice(0, DOC_TITLE_MAX) || "Note";
}
/* The default in-repo path slug (phase 59 locked assumption):
* lowercase, runs of non-alphanumerics → "-", trimmed, ≤60 chars,
* empty → "note". The 60-cut can land mid dash-run — the trailing
* trim again keeps the path from ending in a dangling "-". */
function docSlug(title) {
const slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60)
.replace(/-+$/g, "");
return slug || "note";
}
/* Phase 75 (TODO L4; A6, owner-confirmed 2026-09-08): the draft BODY
* is the WHOLE conversation — every user question and the brain
* answers that followed it, in order, as the RAW persisted text. The
* user edits out unwanted turns in the doc-edit body (A7 — the
* existing free-form body field; no new UI surface). The shape: a
* numbered section per USER turn —
*
* ## 1. <user question, raw text>
*
* <brain answer, raw text>
*
* ## 2. <user question, raw text>
*
* <brain answer, raw text>
*
* — 1-based per user turn (normally one answer per section; more
* answers join under the same heading), sections blank-line
* separated, ALL trailing whitespace collapsed to a single final
* newline. ONLY the raw text travels (m.who + m.text — nothing else
* off the record); a stopped/partial brain turn appears as-is (its
* text is what the user saw — A7); a brain record before the first
* user record (unproducible from the UI) is skipped, and a user turn
* whose brain record never landed is a heading-only section. */
function buildSessionTranscript() {
const sections = []; // { heading: "## N. <q>", answers: [raw text] }
let open = null; // answers of the current user section (null while
// no user record has opened one yet)
for (const m of conversation) {
if (m.who === "user") {
open = [];
sections.push({
heading: `## ${sections.length + 1}. ${m.text}`,
answers: open,
});
} else if (open) {
open.push(m.text);
}
}
const body = sections
.map((s) =>
s.answers.length ? `${s.heading}\n\n${s.answers.join("\n\n")}` : s.heading
)
.join("\n\n");
return body.replace(/\s+$/, "") + "\n";
}
/* The bottom-right "Save as doc" button — the appendTuneButton
* pattern: reuses the .msg-meta row when it exists (role=list → the
* button joins as a listitem so ARIA stays valid), otherwise creates
* a plain meta row; one button per bubble. */
function appendSaveAsDocButton(wrap, markdown) {
if (!isAdmin || !docsRepoConfigured) return; // phase 59: admin + configured
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(".save-as-doc-btn")) return; // one per bubble
const btn = document.createElement("button");
btn.type = "button";
btn.className = "save-as-doc-btn"; // margin-inline-start: auto → bottom-right
if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");
btn.innerHTML = SAVE_AS_DOC_ICON + "<span>Save as doc</span>";
btn.addEventListener("click", () => saveAsDoc(btn));
meta.appendChild(btn);
}
/* Create the draft from the FULL-SESSION transcript (phase 75 A6 —
* every Q/A up to the click, in order — replacing the phase-59
* single-bubble body; the title and path rules are unchanged) and
* hand off to the edit screen. Double-click guard: one save at a time
* (the button is disabled until the outcome — released in the
* finally, never stale, PLAN §7.4). */
async function saveAsDoc(btn) {
if (btn.disabled) return; // one save at a time (double-click guard)
btn.disabled = true;
try {
// Phase 115 (task 03): pass the button's OWN bubble (the .save-
// as-doc-btn lives in the bubble's .msg-meta row, so closest
// climbs button → meta → body → the .msg.brain wrap) — the title
// pairs the answer with ITS question (the redo-in-place fix).
const title = defaultDocTitle(btn.closest(".msg.brain"));
const path = `docs/${docSlug(title)}.md`;
const res = await fetch("/api/doc-drafts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, path, body: buildSessionTranscript() }),
});
if (!res.ok) {
// Neutral one-line copy (phase-55 convention) — the detail may
// be a guard-rail 422 or a server hiccup; neither is actionable
// here, and the conversation stays unblocked (no navigation).
showErrorBanner("Couldn't save the answer as a doc — try again.");
return;
}
const draft = await res.json();
// 201: the draft's uuid4 token IS the edit screen's credential.
location.assign("/doc-edit.html?draft=" + draft.token);
} catch {
showErrorBanner("Couldn't save the answer as a doc — is the app reachable?");
} finally {
btn.disabled = false; // released on EVERY outcome — never stale
}
}
/* 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 tuneSaveBtn = document.createElement("button");
tuneSaveBtn.type = "submit";
tuneSaveBtn.className = "tune-save";
tuneSaveBtn.textContent = "Save";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "tune-cancel";
cancelBtn.textContent = "Cancel";
actions.append(tuneSaveBtn, 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();
tuneSaveBtn.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;
tuneSaveBtn.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;
tuneSaveBtn.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; phase 109 makes it a toggle) ----------
* The model's reasoning streams into a collapsible <details> block ABOVE
* the answer bubble: created OPEN on the first `thinking` event, then
* user-toggleable (native <details>/<summary> — a real focusable
* control).
*
* Phase 109 (TODO.md L3, D15) — the block is a TOGGLE, not a one-way
* door: open-while-thinking, closed-while-answering. The `thinking` SSE
* handler re-opens it (`block.open = true`, idempotent) — a `thinking`
* frame after the answer has started (the next agent round: the model
* answered, called a tool, then thinks again) re-opens the collapsed
* scratchpad — and the `delta` handler closes it (closeThinkingBlock,
* idempotent). The block reflects the model's current activity in every
* agent round — the "frozen chat" repro from TODO.md L3 is gone. The
* phase-14 restore path (renderStoredMessage) still renders stored
* blocks COLLAPSED — untouched.
* ensureThinkingBlock is idempotent (returns the existing block if any);
* closeThinkingBlock is a no-op without a block. */
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.
// Phase 117: the anchor is the WHOLE disclosure (the bare .tool-calls
// list term stays as the pre-117 fallback).
const anchor =
body.querySelector(".tool-calls-disclosure") ??
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
}
/* Phase 117 (D3): the tool-calls disclosure settles FOLDED at rest —
* the same sites that settle the Thinking block: the answer began
* (`delta`), the turn ended (`done`), or the turn was stopped (the
* abort settle). The N-line record collapses to one compact
* "Tool call(s) (N)" line. Idempotent; a no-op without a disclosure
* (deflected answers, pre-tool turns, …). */
function closeToolCalls(wrap) {
const disc = wrap?.querySelector?.(".tool-calls-disclosure");
if (disc) disc.open = false; // idempotent; no-op without a disclosure
}
/* ---------- 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): every argument
* (path / pattern / source scope) goes through textContent, so nothing
* HTML-shaped can come from storage. Lines are not interactive (no
* focus targets).
*
* Phase 117 (owner report 2026-09-15): the list rides in a native
* <details> disclosure (details.tool-calls-disclosure + a plain-text
* "Tool call(s) (N)" summary) — OPEN while the turn is live, folded by
* closeToolCalls (delta/done/stop) and by the restore path (a restored
* turn is one compact line, not N stacked cards). The line bytes, the
* four label literals, and the <code> textContent arguments are
* untouched (phase-87 clock + emoji-guard pins stay green).
*
* Phase 70 (owner permission 2026-09-03): the server tools were remapped
* to the harness-aligned surface — ls / read(path) / grep(pattern,
* path?) — so the NEW names get their own lines (read → the Reading
* line, grep → the Searching-for line, ls → the Listing-documents line,
* a scoped ls → the Listing-documents-in-<scope> line), and the
* pre-phase-70 names (list_documents / read_document /
* search_documents) still render EXACTLY as before: persisted turns
* (phase 14) carry the old names, so both generations render — no
* migration. The content marks (the read/search/list glyphs) stay the
* exact tool-line template literals — the frontend emoji guard strips
* precisely those in this file. */
function appendToolLine(wrap, name, argument) {
const body = wrap?.querySelector?.(".msg-body");
if (!body) return;
// Phase 117 (D2): the lines ride in a native <details> disclosure —
// one compact "Tool call(s) (N)" line instead of N stacked cards.
let container = body.querySelector(".tool-calls-disclosure");
if (!container) {
container = document.createElement("details");
container.className = "tool-calls-disclosure";
container.open = true; // D3: open while the turn is live; closeToolCalls folds it
const summary = document.createElement("summary");
summary.className = "tool-calls-summary";
container.appendChild(summary);
const listEl = document.createElement("div");
listEl.className = "tool-calls";
listEl.setAttribute("role", "list");
listEl.setAttribute("aria-label", "Tool calls");
container.appendChild(listEl);
// 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 list = container.querySelector(".tool-calls");
const line = document.createElement("span");
line.className = "tool-call";
line.setAttribute("role", "listitem");
// Phase 70: new names first, legacy names kept — a restored turn saved
// before the remap (read_document / search_documents / list_documents)
// renders byte-identical to before (no migration).
if ((name === "read" || 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 if ((name === "grep" || name === "search_documents") && argument) {
line.textContent = "🔎 Searching for ";
const code = document.createElement("code");
code.textContent = argument; // the pattern is data, never markup
line.appendChild(code);
} else if (name === "ls" && argument) {
line.textContent = "🔎 Listing documents in ";
const code = document.createElement("code");
code.textContent = argument; // the source scope is data, never markup
line.appendChild(code);
} else {
line.textContent = "🔎 Listing documents";
}
list.appendChild(line);
// Phase 117 (D5): the count rides the summary — every append (live
// frames and the restore path) re-renders through here.
const n = list.children.length;
container.querySelector("summary").textContent =
`Tool call${n === 1 ? "" : "s"} (${n})`;
}
/* Phase 95 (task 02): the truncation marker on a Reading line. The
* `tool_result` frame's argument is the model's raw `source/path` — the
* SAME string the matching `tool` frame put in the line's `<code>` child
* (textContent carries data, never markup) — so the newest `.tool-call`
* line whose code child holds that argument is the target (one line per
* call, phase 37/48 — the marker APPENDS a span sibling, it never
* rewrites the line's pinned template text). createElement + textContent
* only — the house "this file never builds HTML" rule (no innerHTML).
* A frame for a line that is no longer in the DOM (New Chat mid-turn)
* is a silent no-op — the persisted record still carries the counts.
* Pinned marker copy (unit + E2E assertion target): " (truncated —
* showing N of M chars)" — plain integers, no thousands separators. */
function appendTruncatedNote(wrap, argument, charsShown, charsTotal) {
const calls = wrap?.querySelector?.(".tool-calls");
if (!calls) return;
const lines = calls.querySelectorAll(".tool-call");
for (let i = lines.length - 1; i >= 0; i -= 1) {
const code = lines[i].querySelector("code");
if (!code || code.textContent !== argument) continue;
const note = document.createElement("span");
note.className = "truncated-note";
note.textContent =
" (truncated — showing " + charsShown + " of " + charsTotal + " chars)";
lines[i].appendChild(note);
return;
}
}
/* ---------- 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();
updateCharCount(); // phase 104: the chip fill bypasses maxlength — count what landed
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;
// phase 104 (owner 2026-09-12): the single-line chip clips long
// questions — `title` is the hover reveal, `aria-label` the
// clipped-case accessible name (the source-chip pattern).
btn.title = text;
btn.addEventListener("click", () => {
submitSuggestion(text);
if (onSelect) onSelect(text, btn);
});
container.appendChild(btn);
if (btn.scrollWidth > btn.clientWidth) btn.setAttribute("aria-label", text);
}
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 — 300s pre-token guard
let turnTimeoutCb = null; // the guard's callback — lets visibilitychange re-arm it (phase 73)
/* Phase 87 (TODO.md L5): the per-tool-line elapsed clock — one clock per
* turn, re-armed per `tool` frame: the baseline (toolLineStart) resets on
* every new line, so each line counts its OWN frameless silence, and the
* "(Ns)" suffix targets that line's LATEST row only (older rows keep their
* permanent record, no timers). toolLineWrap is the message wrap the live
* lines render into — the tick's null-safe lookups make a New-Chat click
* mid-gap a no-op (no container → no suffix). Settle (next frame) and
* stop (every setUiState transition) own the teardown. */
let toolLineTimer = 0; // setInterval id — the latest tool line's "(Ns)" suffix
let toolLineStart = 0; // Date.now() when the latest tool line armed the clock
let toolLineWrap = null; // the live wrap the clock suffixes (null when stopped)
/* 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
/* Phase 73 (task 02, TODO.md L3): the pagehide partial is CORRELATED with
* the turn's settle. `leavePartialIndex` is the index in `conversation`
* of the brain record the `pagehide` handler pushed for THIS turn (else
* -1): when the turn later settles (done | stop), its final record
* REPLACES that entry in place (rememberBrainTurn's in-place mode) instead
* of appending a second brain record — one brain turn per question, even
* on browsers that fire pagehide on a merely-hidden tab (C1, task 01).
* Turn-local: reset at the top of runTurn, written only by the pagehide
* handler. The replace is identity-guarded (the index must still point at
* a brain record) — a New-Chat click or restore between pagehide and
* settle falls back to the append. */
let leavePartialIndex = -1; // index of this turn's pagehide partial (-1 = none)
/* Phase 48 (2026-08-29, TODO.md L3): the user-stop machinery.
* `turnAbort` owns the in-flight fetch (the Stop button aborts it; the
* 300s guard aborts the same controller as its backstop); `stoppedByUser`
* marks a turn the Stop button took, so the catch's AbortError can tell a
* user stop from the guard's own abort (the guard sets `aborted` first).
* Both are turn-scoped: created/reset at the top of handleSend, cleared
* in its finally. */
let turnAbort = null; // AbortController of the in-flight turn (null idle)
let stoppedByUser = false; // the Stop button took this turn (not the 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", `${brand()} is still thinking (${secs}s)`);
// Phase 87 (TODO.md L5): the 10s hint was aria-label-only — invisible
// to sighted users, who saw frozen dots on a big read. Now it ALSO
// renders a visible mono "Ns" suffix as the bubble's last child (after
// the three dot spans). The aria channel is kept byte-identical —
// both users, same clock. textContent only: the bubble is
// role="status", so the change is announced.
let el = bubble.querySelector(".typing-elapsed");
if (!el) {
el = document.createElement("span");
el.className = "typing-elapsed";
bubble.appendChild(el);
}
el.textContent = secs + "s";
}
}, 1000);
}
function armTurnTimeout(onTimeout) {
clearTurnTimeout();
turnTimeoutCb = onTimeout;
turnTimeout = setTimeout(onTimeout, TURN_TIMEOUT_MS);
}
function clearTurnTimeout() {
if (turnTimeout) {
clearTimeout(turnTimeout);
turnTimeout = 0;
}
turnTimeoutCb = null;
}
/* Phase 87 (TODO.md L5): the per-tool-line elapsed clock — the latest
* tool line's visible "processing" suffix. The suffix is a SIBLING
* appended AFTER the line's existing children (the pinned template text
* + the <code> argument) — never a rewrite: appendToolLine's exact
* line.textContent literals stay byte-identical (the unit + emoji-guard
* pins), and the restore path (phase 14) re-renders lines with no clock
* at all (A6 — the indication is live-only). */
function armToolLineClock(wrap) {
toolLineWrap = wrap;
toolLineStart = Date.now();
if (!toolLineTimer) {
toolLineTimer = setInterval(() => {
const secs = Math.round((Date.now() - toolLineStart) / 1000);
if (secs * 1000 < TOOL_LINE_ELAPSED_AFTER_MS) return; // A5: below 5s the gap reads as normal latency
const line =
toolLineWrap?.querySelector?.(".tool-calls .tool-call:last-child");
if (!line) return; // the wrap was reset mid-gap (New Chat) — no-op
let el = line.querySelector(".tool-elapsed");
if (!el) {
el = document.createElement("span");
el.className = "tool-elapsed";
line.appendChild(el);
}
el.textContent = `(${secs}s)`;
}, 1000);
}
}
/* A frame arrived (thinking / retry / delta) or the turn is settling:
* the latest line is no longer "processing" — drop the interval and
* REMOVE the suffix (a frozen timestamp on a finished line is noise;
* the line itself stays the permanent record). */
function settleToolLine() {
if (toolLineTimer) {
clearInterval(toolLineTimer);
toolLineTimer = 0;
}
toolLineWrap?.querySelectorAll?.(".tool-elapsed").forEach((el) => el.remove());
}
/* The state-machine entry (next to stopThinkingClock / clearTurnTimeout
* in setUiState): settle + forget the wrap — no residue across turns. */
function stopToolLineClock() {
settleToolLine();
toolLineWrap = null;
}
/* Phase 73 (task 02; task 01 C2 — confirmed): a merely-HIDDEN tab must
* never stop a turn, but the 300s pre-token guard is a plain setTimeout,
* so hidden time counted toward it: a turn whose first frame lands while
* the tab is hidden past the 300s mark errored with the "stuck" copy
* (the user perceives "switching tabs killed the answer"). When the tab
* returns to VISIBLE with the guard still armed (it is only armed in the
* pre-token window — cleared on the first thinking/delta/retry frame and
* on every terminal transition), re-arm it with a FRESH TURN_TIMEOUT_MS:
* only visible pre-token time counts. While hidden the timer simply runs
* (and the stream itself keeps arriving at full rate — task 01 scenario
* A); real departures (close / navigation / Stop) still abort the fetch
* (phase 48, untouched). */
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible" && turnTimeout && turnTimeoutCb) {
armTurnTimeout(turnTimeoutCb);
}
});
/* The single entry point for chat feedback. Every in-flight state has a
* visible indicator; every terminal state returns the button to "Send".
* Phase 48 (owner-locked 2026-08-29): the button is ONE control with two
* roles — enabled "Send" when idle, the enabled "Stop" control while a
* turn is in flight (click or Enter aborts it). It is never disabled
* anymore, and the spinner never shows: the "Stop" label + the .is-stop
* treatment carry the in-flight state. */
export function setUiState(state, errorDetail = "", opts = {}) {
uiState = state;
stopThinkingClock();
clearTurnTimeout(); // the guard only owns the pre-token window
stopToolLineClock(); // phase 87: a stuck timer is impossible — every transition stops/clears it
const inFlight = state === UI_STATE.thinking || state === UI_STATE.streaming;
sendBtn.disabled = false; // enabled in every state — Stop is a control
sendBtn.classList.toggle("is-stop", inFlight);
// Phase 109 (TODO.md L3, D16): the persistent in-turn loader — this
// line is its SOLE visibility owner (unit-pinned single-owner check):
// shown iff a turn is in flight (thinking|streaming). Every terminal
// path funnels through setUiState (done → idle, error → error,
// stop/timeout → their error/idle landings), so it hides in every
// terminal state BY CONSTRUCTION — the §7.4 never-stale guarantee is
// structural, not per-handler cleanup (that is the point).
turnLoader.hidden = !inFlight;
sendBtn.querySelector(".spinner").hidden = true; // the Stop label carries it
sendLabel.textContent = inFlight ? "Stop" : "Send";
sendStatus.textContent = SEND_STATUS[state]?.() ?? "";
if (state === UI_STATE.thinking) {
addTyping();
startThinkingClock();
} else {
removeTyping();
}
// Phase 114 (TODO L6): turn-error opts (the SSE error frame's optional
// hint) merge into the banner call — the frame's hint replaces the
// default reachability hint when present (showErrorBanner's opts.hint).
if (state === UI_STATE.error)
showErrorBanner(errorDetail, { retryable: true, ...opts });
}
/* Phase 104 (owner 2026-09-12, A3/A4): the visible question-length cap.
MAX_QUESTION_CHARS mirrors ChatRequest.message max_length=4000
(app/schemas.py — the source of truth; the server 422s beyond it) and
must stay equal to the #message-input maxlength (cross-file pin in
tests/unit/test_chip_sizing_question_cap.py). The counter shows only
from 80% of the cap — no noise on normal use (owner A4). */
const MAX_QUESTION_CHARS = 4000;
const CHAR_COUNT_SHOW_AT = 3200; // 80% of the cap — visible only when it matters
function autoGrow() {
input.style.height = "auto";
input.style.height = `${Math.min(input.scrollHeight, 192)}px`;
}
/* Phase 104 (owner 2026-09-12, A4): the counter state — hidden below the
80% threshold, plain `len/4000` above it, and `len/4000 — character
limit` + the .is-max (–err-*) treatment at/over the cap. The over-cap
reading is the HONEST length (the programmatic chip-fill path can
exceed maxlength — e.g. `5123/4000 — character limit`). */
function updateCharCount() {
// RAW length (no trim): raw ≤ cap ⟹ trimmed ≤ cap, so the raw
// count is a safe superset of what the server validates.
const len = input.value.length;
if (len < CHAR_COUNT_SHOW_AT) {
charCountEl.hidden = true;
charCountEl.classList.remove("is-max");
return;
}
charCountEl.hidden = false;
const atMax = len >= MAX_QUESTION_CHARS;
charCountEl.classList.toggle("is-max", atMax);
charCountEl.textContent = atMax
? `${len}/${MAX_QUESTION_CHARS} — character limit`
: `${len}/${MAX_QUESTION_CHARS}`;
}
/* ---------- 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);
}
}
/* Phase 113 (task 02): the DE-EMPHASIZED related-docs row — the done
* frame's second tier (phase 113 task 01): documents that scored but
* did not clear the usefulness bar. It must never read as a citation:
* the links carry the .related-doc class (NOT .source-chip — the
* citation surface stays appendSources' alone) while behaving exactly
* like the chips — the same documentUrl href (the /document.html escape
* hatch) and the same left-click → same-page modal (phase 26). The
* labeled row ("Nearby docs, in case:") joins the bubble's .msg-meta
* family and stacks below the citation row in the .msg-body flex gap.
* app.js appends it LAST among the meta rows — after appendTuneButton /
* appendSaveAsDocButton / appendRetryButton claimed the FIRST
* .msg-meta row — so a deflected turn (no citation row) never lets a
* meta action join this row. Empty/absent input → no DOM at all
* (pre-phase saved chats carry no `related` — the row is simply absent).
*/
function appendRelated(wrap, related) {
if (!related || !related.length) return;
const body = wrap.querySelector(".msg-body");
const row = document.createElement("div");
row.className = "msg-meta related-docs";
row.setAttribute("role", "list");
row.setAttribute("aria-label", "Nearby docs, in case");
const label = document.createElement("span");
label.className = "related-docs-label";
label.textContent = "Nearby docs, in case:";
row.appendChild(label);
for (const s of related) {
const docLabel = `${s.source}/${s.path}`;
const link = document.createElement("a");
link.className = "related-doc";
link.setAttribute("role", "listitem");
link.href = documentUrl(s.source, s.path, "/"); // back → the chat page
link.addEventListener("click", (e) => {
e.preventDefault(); // no new tab (phase 26) — the modal takes over
e.stopPropagation();
openDocumentModal(s.source, s.path, link);
});
link.textContent = docLabel;
link.title = docLabel; // full path as the native tooltip (chip pattern)
link.setAttribute("aria-label", docLabel); // the accessible name is the full path
row.appendChild(link);
}
body.appendChild(row);
}
/* "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.
* Phase 55 (A2): the shape extends IN PLACE with `chatId` — the
* saved_chats row link, so a reload restores the conversation AND its
* link (the same conversation never spawns a second row). A pre-55
* record without the field reads as null (unlinked) — never throws:
*
* bor.chat.v1 → { v: 1, chatId: string | null,
* messages: [{ who: "user"|"brain", text,
* sources?, deflected?, suggestions?,
* thinking?, tools?, stopped? }] }
*
* 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), the PARTIAL brain message
* when the user stops the turn (phase 48 — the partial is kept, with the
* optional `stopped` marker; a pre-token stop persists nothing
* brain-side), 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). Phase 55 (A2): every
* save point ALSO auto-saves the row — persistConversation() (headless,
* quiet on failure, silent on success). 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
/* The stored record — { chatId, messages } or null. Phase 55 (A2): the
* row link rides the record so a reload restores it. `chatId` is
* OPTIONAL by contract (old-record safety): a pre-55 record without the
* field reads as null (unlinked) — never throws on the missing field. */
function loadStoredRecord() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const data = JSON.parse(raw);
if (!data || data.v !== STORAGE_VERSION || !Array.isArray(data.messages)) return null;
const chatId =
typeof data.chatId === "string" && data.chatId.length ? data.chatId : null;
// Legacy/corrupt shape → clean start; keep only well-formed raw-text
// messages (nothing HTML-shaped can survive this filter).
return {
chatId,
messages: data.messages.filter(
(m) =>
m &&
(m.who === "user" || m.who === "brain") &&
typeof m.text === "string" &&
m.text.length > 0
),
};
} catch {
return null; // unreadable storage: start clean, never throw
}
}
function trimToBudget(messages) {
let out = messages.slice();
for (;;) {
let size = Infinity;
try {
size = JSON.stringify({ v: STORAGE_VERSION, chatId: null, 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,
// Phase 55 (A2): the row link is persisted with the record (null
// = unlinked) — a reload restores it, so the next save point
// updates the SAME row instead of spawning a duplicate.
chatId: currentChatId,
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).
// Phase 95: a stored truncation record (truncated + the counts, the
// live tool_result frame's stamp) re-renders the SAME marker next to
// its Reading line — old records without the field render
// unchanged (t.truncated falsy → no marker).
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 (t.truncated && arg) {
appendTruncatedNote(wrap, arg, Number(t.chars_shown) || 0, Number(t.chars_total) || 0);
}
}
closeToolCalls(wrap); // phase 117 (D3): a restored turn renders folded
}
if (m.deflected) {
wrap.classList.add("is-deflected");
appendMaybeTry(wrap, m.suggestions);
}
appendSources(wrap, m.sources);
appendTuneButton(wrap); // restored brain answers are tunable too
// Phase 59: the RAW persisted markdown (m.text — HTML is never
// persisted). A stopped partial (m.stopped) is a note, not an answer
// — no button (the live stop path adds none either).
if (!m.stopped) appendSaveAsDocButton(wrap, m.text);
if (m.stopped) appendStoppedNote(wrap); // phase 48: the stop marker restores
// Phase 113 (task 02): the related tier restores with the bubble
// (LAST — after the meta-row claimers, exactly like the live done
// path). Pre-phase records carry no `related` → appendRelated
// no-ops and the row is simply absent (graceful).
appendRelated(wrap, m.related);
lastBrainWrap = wrap; // phase 49: the LAST restored brain bubble wins
}
/* 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. Phase 55
(A2): the row link is hydrated from the record here, at boot — before
any save point can run — so the next message updates the SAME row
(a pre-55 record restores unlinked, exactly as phase 14 did). */
function restoreConversation() {
const record = loadStoredRecord();
conversation = record ? record.messages : [];
currentChatId = record ? record.chatId : null; // phase 55: the link survives reloads
for (const m of conversation) renderStoredMessage(m);
markLastRetryable(); // phase 49: the restored last brain bubble is retryable
}
/* ---------- saved-chats row link (phase 50; auto-saved since phase 55) ----------
*
* `currentChatId` links the local conversation to a saved_chats row:
* set to the created row's id on a fresh auto-save (persistConversation
* — the first save point after "New chat"), set to the opened id on a
* successful /?chat=<id> boot load, hydrated from the bor.chat.v1
* record on the local restore (phase 55 — the link survives reloads),
* and cleared by "New chat" and by the 404-PUT fallback (the row
* vanished — recreate, never lose the save). null = unlinked (a plain
* local session, phase 14).
*/
let currentChatId = null; // string | null — the linked saved_chats row id
/* A uuid — for the ?chat=<id> param. The API's path param is uuid.UUID,
* so anything else would 422; the client gate keeps the no-fetch rule
* (invalid/absent param → no request, plain local restore). */
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/* Boot load (?chat=<id>, phase 50): when the URL carries a VALID uuid
* AND whoami says admin, GET the row and render it through the SAME
* renderStoredMessage loop as the local restore (pixel-identical), then
* link the conversation to the id and mirror it to localStorage (a plain
* refresh returns to it the phase-14 way). Returns true on success. Every
* other outcome — invalid or absent param, anonymous (no fetch: the gate
* would 403), 404 (deleted), network failure, or an unusable payload —
* returns false and the caller falls through to the normal local restore;
* the 404/network failures also raise the error banner. The ?chat= param
* is a one-shot boot instruction: on success the URL is normalized back
* to / (replaceState), so a later refresh or a "New chat" + refresh
* restores the LOCAL session (the mirror above) instead of re-opening
* the saved row. */
async function restoreSavedChatFromUrl() {
const chatId = new URLSearchParams(window.location.search).get("chat");
if (!chatId || !UUID_RE.test(chatId) || !isAdmin) return false;
const unavailable = () => {
showErrorBanner("That saved chat isn't available — it may have been deleted.");
return false;
};
let res;
try {
res = await fetch(`/api/chats/${chatId}`);
} catch {
return unavailable(); // network failure → banner + local restore
}
if (!res.ok) return unavailable(); // 404 (deleted) / 403 (signed out) / 5xx
let data = null;
try {
data = await res.json();
} catch {
return unavailable(); // malformed body — treat as unavailable
}
// The API schema guarantees the record shape; the same defensive filter
// as loadStoredRecord keeps a corrupted stored row from poisoning
// the restore (nothing HTML-shaped, ever).
const messages = (Array.isArray(data?.messages) ? data.messages : []).filter(
(m) =>
m &&
(m.who === "user" || m.who === "brain") &&
typeof m.text === "string" &&
m.text.length > 0
);
if (!messages.length) return unavailable();
conversation = messages; // REPLACES the local conversation (owner-locked)
for (const m of conversation) renderStoredMessage(m);
markLastRetryable(); // parity with the local restore: Retry on the last brain bubble
currentChatId = chatId; // linked: a subsequent Save updates THIS row
saveConversation(); // mirror to localStorage — a plain refresh returns here
// Phase 53 (task 05): the `stale` flag is server-computed (task 03 —
// the row's sources stamp is behind the current generation; the
// client never does staleness math). Reveal the banner; when the
// conversation has NO brain record there is nothing to regenerate,
// so the button is removed first (text-only — retryLastTurn is never
// called in that state).
if (data.stale === true) {
if (!conversation.some((m) => m.who === "brain") && staleRegenBtn) {
staleRegenBtn.remove(); // no brain answer — nothing to regenerate
}
if (staleBanner) staleBanner.hidden = false;
}
// The ?chat= param is a one-shot boot instruction: normalize the URL
// back to / so a later refresh / "New chat" + refresh restores the
// LOCAL session (the mirror above) instead of re-opening this row.
history.replaceState(null, "", "/");
return true;
}
/* Auto-save the current conversation (phase 55, owner-locked A2) — the
* headless replacement of the phase-50 #save-chat-btn handler (the Save
* pill is gone: no button to press, no UI to update). Called fire-and-
* forget from the persistence save points (the user message on send,
* every brain-done through rememberBrainTurn, the pagehide partial —
* which rides rememberBrainTurn, so no extra wiring). The phase-50
* upsert semantics, unchanged:
*
* • empty conversation → no-op (nothing to save, nothing to say);
* • linked (currentChatId set) → PUT /api/chats/<id> — the SAME row
* updates (no title in the body, so the row keeps its current one);
* a 404 from the PUT — the row was deleted on the History page —
* unlinks and retries as a create, so a stale link can never wedge
* the conversation;
* • unlinked → POST /api/chats (the server auto-titles) and link to
* the created id (201) — the first save point creates the row.
*
* The `persisting` flag is the double-fire guard: the save points can
* overlap (pagehide during a stream), so a call while an upsert is in
* flight is a no-op — the next save point retries. The A2 quiet contract
* on failure (non-ok HTTP or network): a one-line #send-status note,
* NO error banner, the turn never blocks. Success is SILENT (the History
* page is the visible proof — the toast is reserved for share), apart
* from clearing the phase-53 stale banner (a successful re-save
* re-stamps the row to the current generation — the row is no longer
* stale). */
let persisting = false; // phase 55: one upsert at a time (double-fire guard)
async function persistConversation() {
if (!conversation.length) return; // nothing to save
if (persisting) return; // an upsert is already in flight (double-fire guard)
persisting = true;
const body = JSON.stringify({ messages: conversation });
const headers = { "Content-Type": "application/json" };
try {
let res;
if (currentChatId) {
res = await fetch(`/api/chats/${currentChatId}`, { method: "PUT", headers, body });
if (res.status === 404) {
// Stale link: the row is gone (deleted from History) — unlink and
// retry as a create, so the save never silently dies.
currentChatId = null;
res = await fetch("/api/chats", { method: "POST", headers, body });
}
} else {
res = await fetch("/api/chats", { method: "POST", headers, body });
}
if (!res.ok) {
// A2 quiet contract: a failed auto-save never blocks the
// conversation — one status-line note, no error banner, and the
// next save point retries.
sendStatus.textContent =
"Couldn't save automatically — will try on the next message.";
return;
}
if (res.status === 201) {
const created = await res.json();
currentChatId = String(created.id); // first save: link to the new row
}
// Silent on success (A2) — but a re-save re-stamps the row to the
// current generation (phase 53, task 03): the row is no longer
// stale, so the banner is done.
if (staleBanner) staleBanner.hidden = true;
} catch {
// A2 quiet contract: a network failure is the same one-line note.
sendStatus.textContent =
"Couldn't save automatically — will try on the next message.";
} finally {
persisting = false; // released on EVERY outcome
}
}
/* ---------- share the conversation (phase 51, owner-locked 2026-08-29) ---------- */
/* The share link's ABSOLUTE URL: the API reports the PATH
* (/shared/<token>); the owner's own origin supplies the scheme/host —
* a homelab http origin stays http (never assume https). */
function absoluteShareUrl(shareUrl) {
return new URL(shareUrl, window.location.origin).toString();
}
/* Select every text node in an element — the link field's
* select-on-focus (an <a> has no .select(); a range does the job).
* Best-effort: selection failure only means the user copies by hand. */
function selectAllInField(el) {
try {
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
} catch {
/* selection is best-effort — the field still shows the full URL */
}
}
/* Clipboard copy with the owner-locked inline-link fallback: a
* non-secure (http) homelab origin rejects navigator.clipboard, so the
* failure path renders a TRANSIENT <a> link field near the status line
* (appended to the composer, beside the send button that carries
* #send-status) — input-like, it selects its full URL on focus (click
* or Tab, then Ctrl/Cmd+C). One field at a time (a new offer replaces
* the old). Returns true when the clipboard took it. */
async function copyShareLinkWithFallback(absoluteUrl) {
document.querySelectorAll(".share-link-fallback").forEach((el) => el.remove());
try {
await navigator.clipboard.writeText(absoluteUrl);
return true;
} catch {
const field = document.createElement("a");
field.className = "share-link-fallback";
field.href = absoluteUrl; // carries the full URL (copy link address works too)
field.textContent = absoluteUrl; // the URL is data — textContent, never innerHTML
field.title = "Share link — click, then copy (Ctrl/Cmd+C)";
field.addEventListener("focus", () => selectAllInField(field));
composer.appendChild(field); // near the status line (inside the send button)
field.focus({ preventScroll: true }); // selects the URL — ready to copy
return false;
}
}
/* Share-success toast (phase 55, task 04 — TODO.md L5): the VISIBLE
* confirmation that a share worked. A4 owner-locked: the node is
* aria-hidden (visual only) — the #send-status live region remains the
* a11y announcer, so there is no double screen-reader read. Top-right,
* slides down, auto-dismisses in ~4s. A SINGLE instance: the node is
* lazy-created ONCE and reused — a new toast replaces a pending one
* (clear the prior dismiss timer, re-run the entry) and toasts never
* stack. The text lands via textContent only (XSS-safe). Shown on
* BOTH share-success paths; NEVER on a failure (the error
* banner is the failure UI). Page-script-local by design — the toast
* is chat-page only for this phase (no cross-page module). */
let toastEl = null; // the single toast node — lazy-created, reused
let toastTimer = 0; // the pending auto-dismiss (replaced by a new toast)
function showToast(message) {
if (!toastEl) {
toastEl = document.createElement("div");
toastEl.className = "toast";
toastEl.setAttribute("aria-hidden", "true"); // A4: visual only — #send-status is the announcer
document.body.appendChild(toastEl);
}
toastEl.textContent = message; // XSS-safe text assignment
// Re-trigger the entry even when a toast is already up (a second
// share while the first is showing): clear the pending dismiss,
// drop the visible state, force a reflow (restarts the CSS
// transition), then show again.
clearTimeout(toastTimer);
toastEl.classList.remove("is-visible");
void toastEl.offsetWidth; // force reflow — the entry transition restarts
toastEl.classList.add("is-visible");
toastTimer = setTimeout(() => {
toastEl.classList.remove("is-visible"); // auto-dismiss ~4s
}, 4000);
}
/* Share the current conversation — the #share-chat-btn handler
* (phase 51, owner-locked 2026-08-29, TODO.md L6). No-op with a live-
* region line when there is nothing to share (the same guard as
* Save). The save-then-share branch: linked → POST
* /api/chats/<id>/share (idempotent token); unlinked → POST /api/chats
* with { messages, share: true } and link to the created id — one
* action saves AND shares (owner-locked). Success copies the absolute
* URL (clipboard → inline-field fallback); the live region reads
* "Share link copied." or "Share link ready — copy it from the
* field." Phase 55 task 04: BOTH success paths additionally raise the
* visual-only toast (showToast — aria-hidden; the #send-status line
* stays the a11y announcer; a failed share NEVER toasts — the error
* banner is the failure UI). 403/5xx → the actionable banner
* (neutral — the write surface is public, phase 55 task 01); a network
* failure → the reachable? banner. The double-click guard releases in
* the finally — never stale (PLAN §7.4). */
async function shareCurrentChat() {
if (!conversation.length) {
sendStatus.textContent = "Nothing to share yet.";
return;
}
if (shareBtn.disabled) return; // one share at a time (double-click guard)
shareBtn.disabled = true;
try {
let shareUrl;
if (currentChatId) {
// Linked (already saved): the idempotent share — an existing
// token comes back unchanged, a new one is minted.
const res = await fetch(`/api/chats/${currentChatId}/share`, { method: "POST" });
if (!res.ok) {
showErrorBanner("Couldn't share the conversation — try again.");
return;
}
shareUrl = (await res.json()).share_url;
} else {
// Unsaved: save AND share in ONE action (owner-locked) — the
// server sets the 128-bit uuid4 token in the same commit.
const res = await fetch("/api/chats", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: conversation, share: true }),
});
if (!res.ok) {
showErrorBanner("Couldn't share the conversation — try again.");
return;
}
const created = await res.json();
currentChatId = String(created.id); // one action saved AND shared: link
shareUrl = created.share_url;
}
const copied = await copyShareLinkWithFallback(absoluteShareUrl(shareUrl));
// The #send-status lines stay the a11y announcer (PLAN §7.4 never-
// stale) — the toast below is visual only (aria-hidden, task 04).
sendStatus.textContent = copied
? "Share link copied."
: "Share link ready — copy it from the field.";
// Task 04 (A4): the VISIBLE confirmation rides the same two
// success paths, each with its own text. A failed share never
// toasts — the error banner is the failure UI.
if (copied) {
showToast("Share link copied.");
} else {
showToast("Share link ready — copy it from the field.");
}
} catch {
showErrorBanner("Couldn't share the conversation — is the app reachable?");
} finally {
shareBtn.disabled = false; // released on EVERY outcome — never stale
}
}
/* Regenerate a stale saved chat — the #stale-regenerate handler
* (phase 53, task 05). The banner only ever shows on the /?chat=<id>
* boot path (admin), so currentChatId is set whenever this runs. The
* redo: retryLastTurn(lastBrainWrap) — the phase-49 redo-in-place of
* the LAST brain bubble (its own guards — in-flight, wrap !==
* lastBrainWrap, no preceding user record — make a stale or superseded
* click a no-op that resolves nothing). The handler AWAITs the returned
* turn promise, and only when the turn completed WITHOUT the error
* banner persists the linked row through the SAME upsert as the
* auto-save:
* PUT /api/chats/<id> (the server re-stamps sources_version → the row
* is fresh again); a 404 (the row was deleted from History meanwhile)
* follows persistConversation's stale-link rule — unlink + recreate,
* so the owner is never left with an unsaved conversation. A regenerate that
* errors mid-stream leaves the row untouched (stale stays true —
* Regenerate stays available); a regenerate STOPPED mid-stream (phase
* 48) persists the stopped partial (the owner engaged with the new
* index). Success hides the banner and announces the outcome in the
* #send-status live region (PLAN §7.4 never-stale). */
async function regenerateStaleChat() {
if (staleRegenBtn?.disabled) return; // one regenerate at a time (double-click guard)
staleRegenBtn.disabled = true;
try {
// Phase-49 targeting: the LAST brain bubble's rendered wrap. When a
// guard no-ops the redo (no brain bubble — the no-brain-record state
// that removed the button at reveal; in-flight turn; superseded
// wrap), retryLastTurn returns nothing and there is nothing to
// await or persist.
const turn = lastBrainWrap ? retryLastTurn(lastBrainWrap) : undefined;
if (!turn) return;
await turn; // the turn's completion — runTurn settles to idle always
// A regenerate that errored mid-stream (the error banner is up) leaves
// the linked row untouched — the row stays stale, the banner stays.
if (banner.classList.contains("is-error")) return;
// Persist the linked row through the SAME upsert as Save: PUT (the
// server re-stamps sources_version — the row is fresh again); a 404
// (deleted from History meanwhile) unlinks and recreates.
const body = JSON.stringify({ messages: conversation });
const headers = { "Content-Type": "application/json" };
let res;
if (currentChatId) {
res = await fetch(`/api/chats/${currentChatId}`, { method: "PUT", headers, body });
if (res.status === 404) {
// Stale link: the row is gone (deleted from History) — unlink
// and retry as a create, so the save never silently dies.
currentChatId = null;
res = await fetch("/api/chats", { method: "POST", headers, body });
}
} else {
res = await fetch("/api/chats", { method: "POST", headers, body });
}
if (!res.ok) {
showErrorBanner(
"Couldn't save the regenerated answer — check you're still signed in and try again."
);
return;
}
if (res.status === 201) {
const created = await res.json();
currentChatId = String(created.id); // the recreate: link the new row
}
staleBanner.hidden = true; // fresh row — the banner is done
sendStatus.textContent = "Regenerated — the answer now reflects the current sources.";
} catch {
showErrorBanner("Couldn't save the regenerated answer — is the app reachable?");
} finally {
if (staleRegenBtn) staleRegenBtn.disabled = false; // released on EVERY outcome
}
}
/* 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.
Phase 73 (task 02): optional in-place mode — `replaceIndex` >= 0
REPLACES the record at that index instead of appending, so a settle
that follows a pagehide partial (C1) keeps exactly one brain turn per
question. Identity-guarded: the replace only fires when the recorded
index STILL points at a brain record (a New-Chat click or restore
between pagehide and settle — impossible today, but the guard makes
the invariant explicit) — otherwise it falls back to the append.
Either way the record is written once and the save points below run
once (the auto-save refreshes the row exactly once). */
function rememberBrainTurn(rawText, meta, replaceIndex = -1) {
const rec = { who: "brain", text: rawText || "…", ...meta };
if (replaceIndex >= 0 && conversation[replaceIndex]?.who === "brain") {
conversation[replaceIndex] = rec;
} else {
conversation.push(rec);
}
saveConversation();
// Phase 55 (A2): the auto-save rides the brain save point — the row
// updates with the (possibly replaced) brain turn + metadata. The
// pagehide partial reuses this helper, so it rides the same path (no
// extra wiring).
persistConversation();
}
/* ---------- 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;
// Phase 79 (task 05): the authenticated role — admin OR token user.
// The auth PAIR keys off it (both get Sign out, neither sees Sign
// in); the admin-ONLY surfaces (Tune, Save as doc, ?chat= boot load)
// still key off isAdmin alone.
let signedIn = false;
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the docs-push gate
* — GET /api/config's ``docs_repo_configured`` (settings.docs_configured
* server-side), surfaced by brand.js as window.BOR_DOCS_REPO_CONFIGURED
* (the way app_name is: a window global, false until the boot fetch
* proves otherwise). Captured ONCE in the boot IIFE after the fetch
* settles, so the "Save as doc" buttons render exactly once: present
* for a configured admin, absent for everyone else — and while
* BOR_DOCS_REPO is empty the feature is inert (D3). */
let docsRepoConfigured = false;
function applyAuthState() {
// Phase 79 (task 05): the pair keys off the authenticated role — a
// token user (isAdmin false, signedIn true) gets Sign out like the
// admin and no Sign in link (idempotent with header.js's own
// toggling, which does the same from the shared whoami).
if (signInLink) signInLink.hidden = signedIn;
if (signOutBtn) signOutBtn.hidden = !signedIn;
}
function startNewChat() {
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
conversation = [];
currentChatId = null; // phase 55: unlinked — a fresh row on its first message
if (staleBanner) staleBanner.hidden = true; // phase 53: the banner described the cleared conversation
clearStoredConversation();
removeTyping();
messagesEl.querySelectorAll(".msg").forEach((el) => el.remove());
if (emptyState) emptyState.hidden = false;
// Phase 80 (task 03): the empty state is BACK — the onboarding row was
// fetched once at boot, and while the user was chatting the last-3
// state moved on. Refetch through the existing progressive-enhancement
// path (fetches /api/suggestions, re-renders #suggestions in place,
// swallows its own failures — a 401 for an anonymous visitor or a
// network drop just leaves the row as-is, no error spam). The
// in-flight-turn guard above means this only runs for a real new chat.
loadSuggestions();
clearErrorBanner();
setUiState(UI_STATE.idle);
input.value = "";
autoGrow();
updateCharCount(); // phase 104: the cleared composer hides the counter again
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, opts = {}) {
banner.hidden = false;
banner.classList.add("is-error");
banner.setAttribute("role", "alert");
// Phase 114 (TODO L6): a frame-carried hint (the "question too long"
// case — reachability is fine, only the length is the problem) replaces
// the default reachability hint when present.
bannerText.textContent = detail
? `${detail} ${opts.hint ?? ERROR_HINT}`
: (opts.hint ?? ERROR_HINT);
// Phase 111 (task 01): reveal the banner Retry button only for failed
// chat turns (opts.retryable) AND when a retryable bubble exists.
if (opts.retryable) {
const btn = document.querySelector("#banner-retry");
if (btn && lastBrainWrap) {
btn.hidden = false;
// Bind click once per reveal — the old listener is removed after
// the first click, so re-binding on every reveal is safe.
btn.addEventListener("click", () => retryLastTurn(lastBrainWrap));
}
}
}
function clearErrorBanner() {
if (banner.classList.contains("is-error")) {
banner.classList.remove("is-error");
banner.setAttribute("role", "status");
bannerText.textContent = "";
banner.hidden = true;
// Phase 111 (task 01): re-hide the banner Retry button.
const btn = document.querySelector("#banner-retry");
if (btn) btn.hidden = true;
}
}
/* Phase 48 (owner-locked 2026-08-29): the user stop. No-op unless a turn
* is in flight; marks the turn as user-stopped and aborts the fetch — the
* in-flight fetch / readSSE throw (AbortError) into handleSend's catch,
* where the stop finalizes (partial kept + persisted with `stopped: true`,
* the "Answer stopped." live-region confirmation, no error banner). A
* click on the in-flight button and an Enter-to-submit both land in
* handleSend's in-flight guard, which calls this — there is no separate
* click binding. */
function stopTurn() {
if (uiState !== UI_STATE.thinking && uiState !== UI_STATE.streaming) return;
stoppedByUser = true;
turnAbort?.abort();
}
/* Phase 49 (owner-locked 2026-08-29, TODO.md L4): the redo-in-place
* retry — the click handler of the Retry button, which only ever sits
* on the LAST brain bubble. It re-asks the question preceding that
* bubble: the old answer is replaced in the DOM AND in the persisted
* record (the pop is saved immediately — a crash between the pop and
* the fresh `done` must never resurrect the replaced answer; the
* question remains), and the fresh answer streams into its place via
* runTurn(text, { reask: true }) — no user append, no push, no banner,
* no scroll (phase 42: the fresh bubble lands where the old one was).
* Guards: inert while a turn is in flight (one turn at a time), and the
* click's wrap must still be the last brain bubble's rendered wrap — a
* stale click on a superseded bubble is harmless by construction.
* Phase 53 (task 05): RETURNS the runTurn promise when the redo runs
* (undefined when a guard no-ops it) — the stale banner's Regenerate
* path awaits the turn's completion to know when to persist the linked
* row. The existing Retry click handler ignores the return value, so
* phase-49 behavior is unchanged. */
function retryLastTurn(wrap) {
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
if (wrap !== lastBrainWrap) return; // stale click — the button moved on
let lastIdx = -1;
for (let i = conversation.length - 1; i >= 0; i -= 1) {
if (conversation[i].who === "brain") {
lastIdx = i;
break;
}
}
if (lastIdx === -1) return;
// Invariant: every brain record follows its user record — the
// question to re-ask is the record immediately before the popped one.
const prev = conversation[lastIdx - 1];
if (!prev || prev.who !== "user") return;
const text = prev.text;
conversation.splice(lastIdx, 1); // redo in place: the old answer is gone
// Save BEFORE the rerun: what the user saw — the removed answer — is
// what is stored from this point on (the question stays, the replaced
// answer never comes back).
saveConversation();
wrap.remove();
lastBrainWrap = null;
// Re-ask without re-adding: the reask turn skips the user append and
// persistence save point 1 (the question is already in both).
// Phase 53: the promise is returned (the Regenerate await above);
// runTurn never rejects — a failure surfaces as the error banner.
return runTurn(text, { reask: true });
}
async function handleSend(e) {
e.preventDefault();
// Phase 48: while a turn is in flight the Send button IS the Stop
// button (setUiState keeps it enabled) — a click or an Enter-to-submit
// aborts the in-flight turn instead of starting a new one.
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) {
stopTurn();
return;
}
const text = input.value.trim();
if (!text || sendBtn.disabled) return;
// Phase 104 (owner 2026-09-12, A5): maxlength caps typing + pastes, but
// a programmatic fill (the chip one-tap path) bypasses it — this guard
// is the never-stale backstop (PLAN §7.4): no turn, no clear, the user
// trims the kept text (out-of-turn banner, the saveAsDoc precedent).
if (text.length > MAX_QUESTION_CHARS) {
showErrorBanner("Questions are limited to 4,000 characters — trim the question and try again.");
return;
}
// Phase 49: the user append + persistence save point 1 moved into
// runTurn with the rest of the turn — the `reask` flag skips them on
// the redo-in-place retry path (the question is already in the DOM +
// conversation); handleSend keeps only the form-level pre-work.
input.value = "";
autoGrow();
updateCharCount(); // phase 104: the sent question clears the counter with the input
clearErrorBanner();
await runTurn(text, { reask: false });
}
/* Phase 49 (owner-locked 2026-08-29, TODO.md L4): the chat turn —
* extracted from handleSend so the retry redo can re-run a question
* without re-adding it. `reask` skips (a) the user-bubble append and
* (b) persistence save point 1 (the conversation push + save) — the
* question is already in the DOM and in `conversation`. A plain send
* (`reask = false`) is byte-identical to the pre-extraction path:
* everything from setUiState(thinking) / armTurnTimeout through the
* finally settle moved here verbatim, and the turn-local resets (acc,
* thinkingAcc, sawThinking, sawDone, toolAcc, stoppedByUser, turnAbort)
* stay turn-scoped exactly as phase 48 left them. */
async function runTurn(text, { reask = false } = {}) {
if (!reask) {
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();
// Phase 55 (A2): the auto-save rides the save point — an unlinked
// conversation creates its row here (auto-title, server-side), a
// linked one refreshes. Fire-and-forget: it never blocks the turn.
persistConversation();
}
let wrap = null;
let res = null;
let aborted = false; // the 300s 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. Phase 73:
// leavePartialIndex joins them — no pagehide partial exists for this
// turn yet (the pagehide handler records it if one lands).
acc = "";
thinkingAcc = "";
persistedOnLeave = false;
leavePartialIndex = -1;
// Phase 48: a fresh abort owner per turn (cleared in the finally); the
// stop flag resets with the rest of the turn locals.
turnAbort = new AbortController();
stoppedByUser = 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
turnAbort?.abort(); // phase 48: same outcome, one owner — aborted is
// set first, so the catch never reads the guard's
// abort as a user stop
setUiState(UI_STATE.error, "That's taking a long time — the answer may be stuck.");
});
// Phase 74 (TODO L4): the conversation so far travels WITH the
// question — the `conversation` record minus the current question.
// The invariant every caller holds at fetch time: a fresh send just
// pushed the question (save point 1 above); the phase-49 retry and
// the phase-53 stale-regen (both through retryLastTurn) popped the
// old answer and keep the question as the last entry — so slice(0,
// -1) is exactly the prior turns, oldest first, and the question is
// never duplicated into the history. `thinking` rides only brain
// records that actually streamed one (phase 17's optional key —
// `undefined` drops it from the JSON, the record's convention);
// user turns and old/restored records without thinking send none
// (the server maps those to plain assistant messages). No other
// record key (`sources`, `tools`, `deflected`, `suggestions`,
// `stopped`) travels in the body — the server schema (task 01)
// accepts exactly {who, text, thinking}; `tools` metadata is
// display-only and was never part of the LLM wire.
const history = conversation.slice(0, -1).map((m) => ({
who: m.who,
text: m.text,
thinking: m.who === "brain" ? m.thinking || undefined : undefined,
}));
res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text, history }),
signal: turnAbort.signal, // phase 48: the Stop button aborts the fetch
});
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 87 (TODO.md L5): a frame arrived — the latest tool line
// is no longer "processing"; settle its "(Ns)" suffix (the
// visible indication moves to the thinking block).
settleToolLine();
// Phase 17: model reasoning — stream it live into the collapsible
// Thinking block. No setUiState here: the UI state stays
// "thinking" (the button stays the enabled "Stop" control —
// phase 48 — #send-status unchanged) — the live block simply
// replaces the typing dots as the visible feedback.
// Phase 109 (TODO.md L3, D15): the block is a TOGGLE —
// open-while-thinking, closed-while-answering. The re-open
// assignment below (right after ensureThinkingBlock) re-opens a
// block a previous `delta` closed — the next agent round's
// thinking (the model answered, called a tool, then thinks
// again) no longer lands in an invisible collapsed scratchpad;
// while the block is already open (the pre-delta live flow) it
// is a no-op — that flow is unchanged.
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);
// Phase 109 (D15): open-while-thinking — idempotent: a no-op
// while already open (the pre-delta live flow), a re-open after
// a `delta` closed the block (the next agent round).
block.open = true;
const textEl = block.querySelector(".thinking-text");
// Pin state is measured BEFORE the re-render: a chunk taller
// than the 32px band — any paragraph break ("\n\n") or a few
// lines of text, which is exactly what the real model streams —
// grows the window's content below the old bottom, so measuring
// the distance AFTER the update reads the chunk's height, not
// the user's position, and the follow died at the first
// 2-newline gap. Pre-update, the distance is where the user
// actually is.
const pinned = block.open && isThinkingNearBottom(textEl);
textEl.innerHTML = renderMarkdown(thinkingAcc); // escape-first, XSS-safe
if (pinned) {
// Follow the live tail only while the user was pinned to the
// window bottom before this chunk (owner direction
// 2026-08-27); a scrolled-up reader is never re-pinned —
// returning to the bottom re-arms the pin on the next chunk.
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 stays the enabled "Stop"
// control (phase 48, owner-locked: it no longer relabels to
// "Calling tool…"); what changes are the STATUS LABELS:
// #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", "");
// Phase 70: the harness-aligned names (read/grep/ls) map to the
// same status copy as their legacy counterparts (read_document /
// search_documents) — a pre-remap frame keeps its label; the
// scoped ls mirrors the scoped tool line.
const toolStatus =
(name === "read" || name === "read_document") && argument
? `${brand()} is reading ${argument}`
: (name === "grep" || name === "search_documents") && argument
? `${brand()} is searching for ${argument}`
: name === "ls" && argument
? `${brand()} is listing documents in ${argument}`
: `${brand()} is listing documents`;
if (uiState === UI_STATE.thinking) {
sendStatus.textContent = toolStatus;
document
.querySelector("#typing-indicator .bubble")
?.setAttribute("aria-label", toolStatus);
}
appendToolLine(wrap, name, argument);
// Phase 87 (TODO.md L5): arm this line's elapsed clock — the
// baseline resets per line, so each line counts its OWN
// frameless silence. This live branch is the ONLY arm call site
// (A6): the phase-14 restore path re-renders lines without it.
armToolLineClock(wrap);
// No page scroll (phase 42): tool lines never yank the viewport.
} else if (ev.type === "retry") {
// Phase 87 (TODO.md L5): a frame arrived — settle the latest
// tool line's "(Ns)" suffix (the retry status line takes over as
// the visible feedback).
settleToolLine();
// Phase 67 (owner-locked A4): the server restarted the LLM
// request before ANY frame of it reached the client (locked
// A2) — say exactly what is happening on the existing status
// line. Transient status only: no bubble, no tool line, no
// banner, no UI-state change — the next thinking/tool/delta
// frame replaces it through its own branch. The gate covers
// BOTH live states: a LATER agent round may restart while the
// UI already streams (a content+tool-call stream from an
// earlier round).
clearTurnTimeout(); // the stream is alive — the server is restarting the LLM request
const attempt = Number(ev.attempt) || 1;
const max = Number(ev.max_attempts) || 1;
const retryStatus =
`Communication interrupted — retrying (${attempt} of ${max})…`;
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) {
sendStatus.textContent = retryStatus;
document
.querySelector("#typing-indicator .bubble")
?.setAttribute("aria-label", retryStatus);
}
} else if (ev.type === "delta") {
// Phase 87 (TODO.md L5): a frame arrived — settle the latest
// tool line's "(Ns)" suffix (the answer bubble takes over as the
// visible feedback).
settleToolLine();
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 — the next `thinking` frame re-opens it (phase 109, D15)
closeToolCalls(wrap); // the answer began — fold the tool record (phase 117, D3)
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
closeToolCalls(wrap); // the turn ended — settle the record folded (phase 117, D3)
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);
// Phase 113 (task 02): the cited tier stays the citation
// surface (the chips above); the related tier (scored docs
// under the usefulness bar — and the weak hits of a DEFLECTED
// turn, whose `ev.sources` is empty → zero chips) renders as
// the de-emphasized labeled row, appended LAST among the meta
// rows below (after the appendTuneButton/SaveAsDoc/Retry
// claimers took the first .msg-meta row, so an action never
// joins the related row — a deflected turn with related docs
// still gets its own meta row for the buttons).
// 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 : "");
appendTuneButton(wrap); // every completed brain bubble is tunable
// Phase 59: the RAW persisted markdown (never the rendered
// HTML) — exactly the string rememberBrainTurn stores below,
// so a reload (the restore path) offers the identical draft.
appendSaveAsDocButton(wrap, finalText || acc || "…");
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).
// Phase 73 (task 02): if a pagehide partial landed mid-turn, the
// final record REPLACES it in place (leavePartialIndex = -1 when
// there was none — the append, exactly as before).
rememberBrainTurn(finalText || acc, {
thinking: thinkingAcc || undefined,
tools: toolAcc.length ? toolAcc : undefined,
deflected: !!ev.deflected,
sources: ev.sources,
// Phase 113 (task 02): the related tier persists with the
// turn (undefined drops the key from the JSON — the house
// optional-meta pattern), so the restore path re-renders the
// row exactly as it looked live.
related: ev.related?.length ? ev.related : undefined,
suggestions: ev.suggestions,
}, leavePartialIndex);
lastBrainWrap = wrap; // this bubble is now the last brain answer
markLastRetryable(); // phase 49: the Retry button is last-bubble-only
appendRelated(wrap, ev.related); // phase 113 (task 02) — LAST meta row
} else if (ev.type === "tool_result") {
// Phase 95 (A15 extension, task 02): the truncation the LLM is
// told about is told to the USER. One frame per truncated read,
// always after its `tool` frame and before the next round — the
// Reading line is already on screen. Settle that line's
// elapsed clock like every other frame (the marker is the
// visible feedback now), then append the marker to the NEWEST
// line carrying this argument (appendTruncatedNote — a DOM
// append to the existing line: no new line, no re-render, the
// phase-37/48 tool-line lifecycle is untouched) and stamp the
// matching toolAcc entry so the save payload carries it (the
// `done` save point below needs zero other change). A frame
// whose line/toolAcc entry is gone (New Chat mid-turn) is a
// silent no-op; a non-truncated read never sends one.
settleToolLine();
const argument =
typeof ev.argument === "string" && ev.argument ? ev.argument : null;
const shown = Number(ev.chars_shown) || 0;
const total = Number(ev.chars_total) || 0;
if (argument && ev.truncated) {
appendTruncatedNote(wrap, argument, shown, total);
for (let i = toolAcc.length - 1; i >= 0; i -= 1) {
const t = toolAcc[i];
if (t && t.argument === argument) {
t.truncated = true;
t.chars_shown = shown;
t.chars_total = total;
break;
}
}
}
} else if (ev.type === "error") {
// Phase 114 (TODO L6): carry the frame's optional hint (the
// "question too long" frame has one) through the throw — the
// banner shows it in place of the default reachability hint.
const err = new Error(ev.detail || "Something went wrong on my side.");
err.hint = ev.hint;
throw err;
}
});
// 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);
appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path
// Phase 73: correlated like the other settles — unreachable when a
// pagehide partial exists (that needs acc, which means a wrap), but
// passed so EVERY settle write goes through the same correlation.
rememberBrainTurn(fallback, {}, leavePartialIndex); // persist what the user actually saw
lastBrainWrap = fwrap;
markLastRetryable(); // phase 49: the fallback bubble is retryable too
}
} catch (err) {
if (aborted) {
// The 300s guard already took the turn to the error state — its
// own abort surfaces here, and it is never read as a user stop.
} else if (stoppedByUser || err?.name === "AbortError") {
// Phase 48 (owner-locked): the STOP path — no error banner. When
// answer text streamed, keep the partial on screen and persist it
// with the optional `stopped` marker (phase-14 no-version-bump
// convention; no sources/suggestions — the turn never settled).
// The persistedOnLeave guard (phase 20) keeps a navigate-away —
// which aborts the same fetch — from saving the partial twice.
if (wrap && acc && !persistedOnLeave) {
closeThinkingBlock(wrap); // settle the block closed, like `done`
closeToolCalls(wrap); // the turn was stopped — fold the record (phase 117, D3)
persistedOnLeave = true;
appendTuneButton(wrap); // admin-only; parity with the restore path
appendStoppedNote(wrap);
// Phase 73 (task 02): the stopped partial REPLACES a pagehide
// partial (leavePartialIndex) in place — one brain turn for the
// question, marked stopped, never two records.
rememberBrainTurn(acc, {
thinking: thinkingAcc || undefined,
tools: toolAcc.length ? toolAcc : undefined,
stopped: true,
}, leavePartialIndex);
lastBrainWrap = wrap; // the stopped partial is the prime retry candidate
markLastRetryable(); // phase 49: Retry on the stopped partial
}
// Pre-token / thinking-only stop: persist NOTHING brain-side
// (phase-20 convention — the question is already saved on send).
// The "Answer stopped." confirmation is set in the finally, AFTER
// the single settle, so setUiState(idle) can't overwrite it.
} else {
const detail =
err instanceof Error && err.message
? err.message
: "Something went wrong on my side.";
// Phase 114 (TODO L6): the SSE error frame's optional hint flows to
// the banner (setUiState → showErrorBanner's opts.hint); the
// phase-111 Retry button rides along on the same turn-error path.
setUiState(UI_STATE.error, detail, err instanceof Error ? { hint: err.hint } : {});
}
} finally {
// done | error | stop → idle: always settle, always focus back.
// State is turn-local, so a page reload mid-stream leaves a usable
// composer. The stop path must not double-settle — this is the one
// settle, and it never scrolls (phase 42: no scrollReveal on stop).
clearTurnTimeout();
stopThinkingClock();
cancelStream(res); // the reader lock is released — no unhandled rejection
turnAbort = null; // phase 48: the turn's abort owner is spent
if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle);
if (stoppedByUser) sendStatus.textContent = "Answer stopped.";
// 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 });
}
}
// Phase 104: every input-path change (keystroke, paste — maxlength
// caps both at 4,000) re-runs the counter alongside the auto-grow.
input.addEventListener("input", () => {
autoGrow();
updateCharCount();
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
composer.requestSubmit();
}
});
composer.addEventListener("submit", handleSend);
/* Phase 55 (owner-locked A2, 2026-08-31): the phase-50 Save binding is
* GONE with the pill — there is no Save control; persistConversation()
* auto-saves headless at the save points (fire-and-forget, quiet on
* failure, silent on success).
* Phase 51 (owner-locked 2026-08-29, TODO.md L6; visible to every
* visitor since phase 55 task 03): the Share pill is static,
* always-visible markup (no reveal step) — only the click binding
* lives here. */
shareBtn?.addEventListener("click", shareCurrentChat);
/* Phase 53 (task 05): the stale banner's Regenerate pill. The binding
* is inert unless the banner is revealed — which only happens on the
* /?chat=<id> boot path (admin, task-50 contract). */
staleRegenBtn?.addEventListener("click", regenerateStaleChat);
/* 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.
* Phase 73 (task 02): the partial is CORRELATED with the turn's settle —
* after the push, `leavePartialIndex` records the record's index, so the
* done/stop settle REPLACES it in place (one brain turn per question,
* C1). A REAL navigation (the page actually unloads) never runs a settle,
* so the partial stays persisted exactly as before. */
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 });
leavePartialIndex = conversation.length - 1; // the record it just pushed
});
/* 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. Phase 55 (A2): the local restore hydrates
currentChatId from the record (restoreConversation), so the row link
survives a plain reload — no Save pill to reveal anymore.
Phase 79 (task 05): the token gate (mountGate) settles BEFORE the
header boots — a cached token's silent re-auth lands before the
first whoami fires, and the header + the chat-page gating read the
post-auth role (the auth pair off `authenticated`, the admin-only
surfaces off role === "admin"). */
(async () => {
// Phase 79 (task 05): the token gate settles FIRST — a cached
// bor.token is re-sent to /api/token-auth (silently) BEFORE the
// first whoami fires, so initSharedHeader below sees the
// POST-re-auth role deterministically (no stale "Sign in" for a
// returning token user; the gate and the header share the cached
// whoami promise — still exactly one /api/whoami per page load).
// onAuthed is a no-op in the shell: the lazy views mount on first
// show exactly as today (mount-once, hide-forever untouched), and
// the already-mounted views keep their state.
await mountGate(document.getElementById("main"), () => {});
await initSharedHeader(); // header.js: whoami + Sign in/out + steering gate
const who = await fetchWhoami(); // the same cached promise — one whoami
isAdmin = who.role === "admin"; // phase 79: admin-only surfaces key off role
signedIn = who.authenticated; // the auth pair keys off the authenticated role
// Phase 59: /api/config is settled BEFORE any bubble renders —
// brand.js's single boot fetch (window.BOR_CONFIG_PROMISE, never
// rejecting) has set window.BOR_DOCS_REPO_CONFIGURED (false until
// proven), so a restored conversation of a configured admin gets the
// "Save as doc" button exactly once: no flash, no re-render, no
// second fetch (the brand fetch IS the config fetch).
await (window.BOR_CONFIG_PROMISE ?? Promise.resolve());
docsRepoConfigured = window.BOR_DOCS_REPO_CONFIGURED === true;
applyAuthState(); // chat page: the auth pair (idempotent with header.js)
// Phase 55 (task 03): no Share-reveal step — the pill is static,
// always-visible markup (visible to every visitor, phase 51 contract).
// Phase 50: /?chat=<id> (valid uuid + admin) boots into the saved
// conversation; every other outcome falls through to the local restore
// (which hydrates the row link from the record — phase 55).
const openedSaved = await restoreSavedChatFromUrl();
if (!openedSaved) restoreConversation();
loadSuggestions();
loadHealth();
// Phase 88: the sticky cluster's compositor layer is born AFTER the
// boot paint — not in the first layout commit (see the styles.css
// gate). Two frames: frame 1 paints the settled boot (empty state or
// the restored conversation) with the cluster static; frame 2 pins it.
// A pre-settle throw leaves the cluster static — a degraded boot is
// already degraded (the gate/header above it), acceptable.
requestAnimationFrame(() =>
requestAnimationFrame(() => {
document.getElementById("view-chat")?.classList.add("chat-booted");
}),
);
})();