Files
brain-of-reese/frontend/assets/app.js
T
ducoterra 34d60efbc1
Build and Push Containers / build-and-push-app (push) Successful in 2m11s
Build and Push Containers / build-and-push-db (push) Successful in 29s
phase: 164_chat_error_honesty
All gates green. Report:

- **Verified (phase 164, all 4 tasks already in `complete/`)**: `HistoryTurn`/`ChatMessage`/`ChatThinkingSegment` caps → 600 000, `history`/saved-messages lists → 500, new `SavedChatCreate/Update` total-8 000 000-char validator; exported `errorDetailText` at the two previously unguarded chat paths (pre-stream `!res.ok` + SSE `error` frame); `FAILED_REQUEST_TEXT` + `bubbleText` threading + phase-114 banner hint; dedicated E2E `tests/e2e/test_chat_error_honesty.py`.
- **Final-pass additions**: `tests/integration/test_chat_api.py::test_history_rejects_one_char_over_the_text_cap` (endpoint-level >600k → 422 with the pydantic list-of-objects `msg` — the criterion's upper half was only unit-pinned); README env rows for `BOR_APP_VERSION` + `BOR_SESSION_COOKIE`; restored `test_document_viewer.py`'s tracked screenshot artifact dirtied by the E2E run (via read-only `git show`, no index/history touched).
- **Tests/lint (exact commands)**: `uv run pytest --cov=app --cov-report=term-missing` → **3837 passed**, `app/` coverage **99%** (>90%); `uv run pytest tests/e2e/test_chat_error_honesty.py -v --no-cov` → **4 passed in isolation**; per-file loop over **all 148** E2E files (`uv run pytest tests/e2e/<f> -q --no-cov`) → **148 × exit 0**, incl. `test_failed_turn_retry`, `test_chat_save_payload_limits`, `test_edit_and_resend`, `test_llm_history`, `test_smoke`; `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- **Completion criteria**: pre-stream 422 shows the distinct line + verbatim `msg`, `[object Object]` unrepresentable + no `input` in DOM — **met** (unit totality/source pins + E2E asserts 1–2); 525k history accepted (200 settles) / >600k → 422 / saved chat 201 + 8M bound — **met**; transport + SSE-frame settles keep `FAILED_TURN_TEXT`, stop/abort untouched, §7.4 re-enables — **met**; budget trim, `message` 4000, the 7 guarded detail-readers and all SSE shapes byte-identical (diff touches only caps + the two chat paths) — **met**; README/AGENTS full review + proxy deploy note — **met**; commit/`complete/` move = pipeline gate.
- **Docs**: README reviewed in full (env tables cross-checked programmatically: 74 `BOR_*` rows vs `Settings` defaults — no mismatches; every `.env.example` var documented; referenced scripts/pages/endpoints/`query_log` columns exist) — found & fixed the 2 undocumented real knobs above; the phase's payload-limits section, failed-turn copy and `client_max_body_size ≥ 16 MB` deploy note verified accurate. `AGENTS.md` verified consistent (rules, gitignore list, quick-reference commands, referenced paths all correct) — no changes needed.
- **Deviations**: `ChatThinkingSegment.text` raised 32 000 → 600 000 too (not itemized in A1, but its own docstring mirrors `HistoryTurn.thinking`; without it a long-thinking turn 422s its own auto-save — same mirror invariant). No live-infra changes made (the proxy limit lives in the separate deployment repo; README note only).
- **Next pending phase**: none — `164_chat_error_honesty` was the only entry in `todo/`.
2026-10-07 18:00:12 -04:00

4777 lines
245 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
// Phase 123 (task 02, TODO L6): the attach control family — the
// paperclip button (hidden until the boot /api/config says
// `images: true`), its hidden file-input backend, the preview strip
// (hidden until a pick), and the strip's remove button.
const attachBtn = document.querySelector("#attach-btn");
const attachFile = document.querySelector("#attach-file");
const attachPreview = document.querySelector("#attach-preview");
const attachRemove = document.querySelector("#attach-remove");
// Phase 132 (task 01, TODO L8): the strip's uploading status part —
// ships hidden; enterUploadState / exitUploadState (below) are its
// sole `hidden` writers (shown iff the image upload is in flight).
const attachStatus = document.querySelector(".attach-preview-status");
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?";
/* Phase 120 (TODO.md L3–4, locked A1): the fixed bubble text of a FAILED
turn that streamed ZERO frames (network error, pre-stream HTTP error) —
a short honest "my answer didn't make it" line. NOT the
EMPTY_ANSWER_FALLBACK: that constant is the zero-frame-but-COMPLETED
case's answer text (the stream settled; a failed turn didn't). The real
detail rides the in-bubble .failed-note (appendFailedNote) and the
persisted `error` key (capped at 500 — the ChatMessage bound). */
const FAILED_TURN_TEXT =
"My answer didn't make it — the connection dropped. Use Retry to ask again.";
/* Phase 164 (D3, locked A3): the PRE-STREAM HTTP-rejection settle — the
server ANSWERED with a 4xx/5xx (the request died BEFORE the model ran;
the 2026-10-07 live 422-after-a-long-answer is the finding). A distinct
honest line: FAILED_TURN_TEXT's "the connection dropped" is a LIE here
(the connection held — the API deliberately rejected the request). The
real detail rides the in-bubble .failed-note + the persisted `error`
key exactly as FAILED_TURN_TEXT's does (the phase-120 persistence
contract extends, unchanged). NOT for transport failures — a rejected
`fetch` is no answer at all, so FAILED_TURN_TEXT stays honest there. */
const FAILED_REQUEST_TEXT =
"I couldn't take that — my API rejected the request. Use Retry to ask again.";
/* Phase 164 (D2, locked A2): the SHARED serializer for an API error
* `detail` — the live failure of 2026-10-07: a FastAPI 422 carries a
* LIST OF OBJECTS ([{type, loc, msg, input}, …]) as `detail`, and the
* unguarded `new Error(body.detail)` on the chat path stringified the
* whole array to "[object Object]" inside the failed-turn note. TOTAL
* semantics — every input shape yields a human string or the named
* fallback: a non-empty string verbatim (every server-built frame —
* ChatErrorEvent — carries one); an ARRAY entry serializes as
* String(entry.msg) when the entry is an object with a non-empty msg,
* else String(entry) for a non-object entry (entries with neither
* drop), joined "; " (an all-empty result falls back); an OBJECT
* serializes as String(value.msg) when it has a non-empty msg, else
* falls back; nullish / empty / any other type falls back. NEVER
* JSON.stringify a raw detail and NEVER touch `input`/`loc`/`ctx`:
* the FastAPI 422 entries carry the full rejected input — the whole
* conversation — and only `msg` and plain strings may reach the DOM.
* Exported so the shape matrix is unit-pinned (same pattern as
* TURN_TIMEOUT_MS). */
export function errorDetailText(value, fallback) {
if (typeof value === "string") return value || fallback;
if (Array.isArray(value)) {
const parts = [];
for (const entry of value) {
if (entry && typeof entry === "object") {
if (typeof entry.msg === "string" && entry.msg) parts.push(String(entry.msg));
} else if (typeof entry === "string") {
if (entry) parts.push(entry);
} else if (entry !== null && entry !== undefined) {
parts.push(String(entry));
}
}
return parts.length ? parts.join("; ") : fallback;
}
if (value && typeof value === "object") {
return typeof value.msg === "string" && value.msg ? String(value.msg) : fallback;
}
return fallback;
}
/* 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";
/* Phase 143 (TODO L5): the coarse-pointer state of THIS device (touch
* phones/tablets) — the keyboard-pop gate for the turn-end focus-back. */
const COARSE_POINTER =
typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches;
/* 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
);
}
/* Phase 161 (D3): the reasoning container's ONE bounded live window
* (.reasoning-body, task 03's 320px) follows the SAME pre-update
* capture / post-append apply contract as the phase-17 per-block
* window: the pinned state is measured BEFORE the new chunk lands
* (the 32px band — the same threshold as THINKING_NEAR_BOTTOM_PX),
* and the scroll is applied AFTER the append. Inside the container
* the inner per-block window is disabled (task 03's max-height: none),
* so this is the single live scroll surface — the user watches the
* live tail move without the timeline pushing the answer off-screen.
* Exported so the band is unit-pinned (same pattern as TURN_TIMEOUT_MS). */
export const REASONING_NEAR_BOTTOM_PX = 32;
/* The container window's pre-update pin capture (the phase-17
* pre-measure convention — BEFORE the append grows the content; the
* post-render reading measures the new chunk's height, not the
* user's position). */
function isReasoningPinned(el) {
return (
el.scrollTop + el.clientHeight >= el.scrollHeight - REASONING_NEAR_BOTTOM_PX
);
}
/* The container window's post-append apply: follow the live tail
* (a scrolled-up reader is never re-pinned — returning to the bottom
* re-arms the pin on the next frame). */
function scrollReasoningToBottom(el) {
el.scrollTop = el.scrollHeight;
}
/* Phase 161 (D6): the live status setter — the span a frame fills
* ("Thinking…" / "Calling <name>…" / "N steps" at the settle sites);
* a no-op without a container (the plain-answer turn never creates
* one). The setter goes through a LOCAL reference: a direct
* `reasoningWrapEl?.…()?.textContent = x` left-hand side is a syntax
* error (an LHS allows at most one trailing `?.` member — not a
* call), which would kill the whole module at parse time. */
function setReasoningStatus(text) {
const el = reasoningWrapEl?.querySelector(".reasoning-status");
if (el) el.textContent = text;
}
/* 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) — phase 153 (task 01) made the chip's click
* pass THIS SAME `back` to openDocumentModal, so this URL is also
* what the modal's "Full page" link points at (the chat path:
* `&back=%2F` rides along; Sources links omit it on both sides, so
* their URLs stay byte-identical to pre-phase). 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
/* The bubble's ACTIONS ROW (phase 130 task 02, TODO L6) — get-or-
create the .msg-meta.msg-actions row: the ONE row the answer
actions (Tune, Retry, Save-as-doc) and the answer-status notes
(Stopped, Failed) join (ASSUMPTION A7 — the notes are answer-status
markers; the actions row is their home). Pre-phase-130 the buttons
and notes claimed the FIRST .msg-meta row of the bubble — usually
the source chips row — so chips, figures, and buttons pushed each
other around in one soup div; the dedicated row ends that. The row
is the bubble's LAST row (the call sites append the actions after
the source clusters and the related row — DOM order is visual
order) and the CSS right-aligns its contents (phase 130 task 03 —
the TODO's "bottom right" = the bubble's bottom row, right edge).
The house get-or-create pattern, scoped to the msg-actions class so
it NEVER grabs the chips row (or any other meta row): role=list +
the row's aria-label keep the ARIA valid, and each child joins as a
listitem (the appenders keep their role=listitem check — the row is
always role=list, so it always applies). Returns null without a
.msg-body (the appenders no-op, the pre-phase convention). */
function getActionsRow(wrap) {
const body = wrap?.querySelector?.(".msg-body");
if (!body) return null;
let row = body.querySelector(".msg-meta.msg-actions");
if (!row) {
row = document.createElement("div");
row.className = "msg-meta msg-actions";
row.setAttribute("role", "list");
row.setAttribute("aria-label", "Answer actions");
body.appendChild(row);
}
return row;
}
/* "Tune" button in the completed brain bubble's actions row
(getActionsRow — phase 130 task 02; the role=list row joins the
button as a listitem so ARIA stays valid). 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 meta = getActionsRow(wrap);
if (!meta) return;
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
* bubble's actions row (getActionsRow — phase 130 task 02, ASSUMPTION
* A7: the notes are answer-status markers, the actions row is their
* home; the role=list row joins the span as a listitem so ARIA stays
* valid). 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.
* 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 meta = getActionsRow(wrap);
if (!meta) return;
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 120 (TODO.md L3–4): the "Failed" note in the meta row of a failed
* brain bubble — the mirror of appendStoppedNote for the phase-120 failed
* turn (network error, SSE error frame, stream drop). The live failure
* paths (finalizeFailedTurn / the zero-frame fallback) and the phase-14
* restore path (a record with `m.failed`) share this helper, so a restored
* bubble reads exactly like the failed one. The banner keeps its
* role="alert" summary; this in-bubble note is the refresh-surviving
* copy (on restore it renders from the persisted `error` detail).
* Joins the bubble's actions row the way appendStoppedNote does
* (getActionsRow — phase 130 task 02, ASSUMPTION A7; role=list → the
* span joins as a listitem so ARIA stays valid); the triangle glyph is
* aria-hidden decoration — the "Failed" label + the detail text carry
* the accessible meaning (text + color, never color alone — B5).
* `detail` goes through textContent (no HTML from the error string,
* ever). */
const FAILED_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>';
function appendFailedNote(wrap, detail) {
const meta = getActionsRow(wrap);
if (!meta) return;
if (meta.querySelector(".failed-note")) return; // one per bubble
const note = document.createElement("span");
note.className = "failed-note";
if (meta.getAttribute("role") === "list") note.setAttribute("role", "listitem");
note.innerHTML = FAILED_ICON;
const label = document.createElement("span");
label.textContent = "Failed";
note.appendChild(label);
if (detail) {
const d = document.createElement("span");
d.className = "failed-detail";
d.textContent = detail; // the error string is NEVER innerHTML
note.appendChild(d);
}
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;
/* Phase 159 (task 01, TODO.md L3): the rendered wrap of the CURRENT
* last USER record — the exact mirror of lastBrainWrap (one `who`
* over). Assigned exactly where a user bubble becomes the newest
* question (runTurn's fresh-send addMessage — the reask path keeps
* the existing bubble, so no assignment there — and the phase-14
* restore's user branch, where the LAST restored user bubble wins),
* and cleared by startNewChat. Both markLastEditable's targeting and
* openEditor's stale-click guard key off it (the phase-49
* single-target discipline). Task 02 reuses the variable for the tail
* truncation. */
let lastUserWrap = 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>';
/* Phase 160 (task 04, TODO L4): the "Continue researching" button's
* forward glyph (aria-hidden decoration — the visible "Continue
* researching" label carries the accessible name). Same stroke family
* as RETRY_ICON: fill="none", currentColor stroke (the CSS themes it
* — ink-soft → ink on hover, the phase-08 palette), 24×24 viewBox,
* stroke-width 1.7. No icon font, no CDN (A11). */
const CONTINUE_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="m5 6 6 6-6 6"/><path d="m13 6 6 6-6 6"/></svg>';
/* Phase 159 (task 01, TODO.md L3): the Edit button's pencil glyph
* (aria-hidden decoration — the visible "Edit" label carries the
* accessible name). Same stroke family as RETRY_ICON: fill="none",
* currentColor stroke (the CSS themes it — ink-soft → ink on hover,
* the phase-08 palette), 24×24 viewBox, stroke-width 1.7. No icon
* font, no CDN (A11). */
const EDIT_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="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>';
/* "Retry" button in the last brain bubble's actions row — the redo
* action of phase 49 (owner-locked 2026-08-29, TODO.md L4). The house
* pattern through getActionsRow (phase 130 task 02 — the role=list
* row joins the button as a listitem so ARIA stays valid); one button
* per bubble. NOT admin-gated, unlike appendTuneButton — chat is
* public, so every visitor gets the redo (the actions cluster reads as
* a pair: Tune for the admin, Retry for everyone). Click →
* retryLastTurn(wrap). */
function appendRetryButton(wrap) {
const meta = getActionsRow(wrap);
if (!meta) return;
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);
}
/* Phase 160 (task 04, TODO L4): the "Continue researching" button —
* the one-click continuation of a turn that spent its whole tool
* budget (the `done` frame's additive `budget_exhausted` key, task
* 03 — ABSENT on every non-cap turn, so the button never appears
* elsewhere). The house pattern through getActionsRow (phase 130
* task 02 — the role=list row joins the button as a listitem so ARIA
* stays valid); one button per bubble. NOT admin-gated, like Retry —
* chat is public, so every visitor can keep a long research session
* going. On a capped answer the row order is Tune → Retry → Continue
* → Save-as-doc (the insertion order phase 130 relies on). Click →
* continueTurn() — a plain next turn: the budget is per-turn, so the
* new turn has a whole new round budget, and nothing server-side
* knows or cares that this is a continuation (A10). */
const CONTINUE_QUESTION = "continue";
function appendContinueButton(wrap) {
const meta = getActionsRow(wrap);
if (!meta) return;
if (meta.querySelector(".continue-btn")) return; // one per bubble
const btn = document.createElement("button");
btn.type = "button";
btn.className = "continue-btn";
if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");
btn.innerHTML = CONTINUE_ICON + "<span>Continue researching</span>";
btn.addEventListener("click", () => continueTurn());
meta.appendChild(btn);
}
/* "Edit" button in the last USER bubble's actions row (phase 159,
* task 01, TODO.md L3 — the trim-and-re-ask remedy: a "Question too
* long — trim it and re-ask" turn, or any mistyped / half-formed
* question, is fixed by editing, never by retyping). The house
* pattern through getActionsRow (phase 130 task 02 — the role=list
* row joins the button as a listitem so ARIA stays valid); one
* button per bubble. The visible "Edit" label carries the accessible
* name (no ellipsis clipping needed at this width). Click →
* openEditor(wrap, btn). NOT admin-gated, unlike appendTuneButton —
* chat is public, so every visitor who can chat can edit the last
* question (the phase-49 Retry precedent). */
function appendEditButton(wrap) {
const meta = getActionsRow(wrap);
if (!meta) return;
if (meta.querySelector(".edit-btn")) return; // one per bubble
const btn = document.createElement("button");
btn.type = "button";
btn.className = "edit-btn";
if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");
btn.innerHTML = EDIT_ICON + "<span>Edit</span>";
btn.addEventListener("click", () => openEditor(wrap, btn));
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). Phase 130 (task 02): the re-append
* lands in the bubble's .msg-actions row (appendRetryButton →
* getActionsRow) and the phase-59 save-button re-append hack is GONE —
* the row's row-level right-align (phase 130 task 03) right-aligns the
* WHOLE cluster, and the DOM order inside the row is the insertion
* order (the live call sites append Tune → Retry → Save-as-doc per
* their gates; the restore's per-message appends precede this
* after-the-loop call, so a restored last bubble reads Tune, Save,
* Retry). Call sites: on `done` (between the Tune and the Save-as-doc
* appends — the insertion order), 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 159 (task 01, TODO.md L3): the Edit button's single-target
* management — the sibling of markLastRetryable (the phase-49
* discipline, one `who` over): exactly ONE editable bubble, the
* NEWEST question (owner-locked 2026-10-05; earlier bubbles are
* never editable, so no mid-conversation branch exists and the
* "what happens to the turns after it" question never arises for an
* old message). Remove every rendered .edit-btn FIRST (an older
* bubble's button is stale the moment a newer question lands), then
* bail while a turn is in flight (the retryLastTurn guard — the
* button never appears or moves mid-turn; runTurn's idle settle
* re-runs this at rest), then scan `conversation` NEWEST-FIRST for
* `who === "user"` through lastUserRecordIndex() (ASSUMPTION: the
* index comes from the record scan — the markLastRetryable
* last-brain-index rule, one `who` over — NOT from a DOM↔record
* index map: the DOM and the record list are parallel today, but
* re-deriving the index from the record keeps the editor correct
* after the task-02 truncation), and bail on -1 or a missing
* lastUserWrap — the wrap is assigned exactly where a user bubble
* is created (runTurn's fresh send, the restore's user branch) and
* cleared by startNewChat, so a stale wrap can never carry the
* button. startNewChat needs no call: its list reset removes the
* buttons along with the list (the phase-49 reasoning). Task 02
* reuses lastUserRecordIndex() for the truncation. */
function lastUserRecordIndex() {
for (let i = conversation.length - 1; i >= 0; i -= 1) {
if (conversation[i].who === "user") return i;
}
return -1;
}
function markLastEditable() {
messagesEl.querySelectorAll(".edit-btn").forEach((b) => b.remove());
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
if (lastUserRecordIndex() === -1) return; // no user record — nothing to edit
if (!lastUserWrap) return; // no tracked bubble — a stale wrap never carries the button
appendEditButton(lastUserWrap);
}
/* 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).
* Phase 130 (task 02): the button joins the bubble's .msg-actions row
* (getActionsRow) — the row's row-level right-align (phase 130 task
* 03) makes the WHOLE cluster the TODO's "bottom right" (the
* per-button auto margins are gone with the markLastRetryable
* save-re-append hack they existed for); the call sites append it
* LAST in the row (Tune, Retry, Save-as-doc per their gates), so it
* stays the rightmost action.
*
* 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 house pattern through
* getActionsRow (phase 130 task 02: the role=list actions row joins
* the button as a listitem so ARIA stays valid); one button per
* bubble. */
function appendSaveAsDocButton(wrap, markdown) {
if (!isAdmin || !docsRepoConfigured) return; // phase 59: admin + configured
const meta = getActionsRow(wrap);
if (!meta) return;
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"; // bottom-right: the row's right-align (task 03)
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();
}
/* Phase 159 (task 01, TODO.md L3): the INLINE editor in the bubble —
* the openTuneForm family (owner-locked 2026-10-05: editing happens
* IN the bubble, not the composer — the composer keeps its own draft
* untouched by editing). A labeled textarea (rows 3, maxlength =
* MAX_QUESTION_CHARS — the 4000 cap stays single-sourced) prefilled
* from the record's text (through .value — never an HTML string, the
* house no-injection rule), Save (submit) + Cancel, and a
* role=alert .edit-error line. While open the rendered .bubble is
* hidden (.is-editing — the question appears ONCE); Cancel / Escape
* restore it and return focus to the toggle button (the openTuneForm
* cancel rule); Ctrl/Cmd+Enter submits. One editor at a time.
* Stale-click guard (the phase-49 discipline): the click's wrap must
* still be lastUserWrap — a superseded bubble's button is inert.
*
* Save (task 02 — the resend): guards first, in order — the
* in-flight no-op (a save can never start a second turn) and the
* empty trim (the .edit-error line, the form stays open —
* ChatRequest.message / ChatMessage.text both require min_length=1;
* the >4000 case is unreachable: the textarea carries the
* maxlength). Then: the trimmed text REPLACES the record in place
* (conversation[editIdx].text); the TAIL TRUNCATION drops every
* record the OLD question produced (conversation.splice(editIdx + 1)
* — stale by construction, never rewritten — the phase-114
* whole-turn rule), the matching DOM bubbles are removed (every
* .msg after lastUserWrap), and the turn bookkeeping resets
* (lastBrainWrap = null; leavePartialIndex cleared to -1 when it
* pointed past editIdx — a partial saved from the dropped turn
* must not be re-persisted into the new shape). The bubble text
* re-renders through renderMarkdown (the addMessage renderer — the
* markdown rules stay in one place); the attachment (if the
* question carried one) survives the re-render (the img is
* re-prepended — phase 123's one bubble-image position). The
* truncated shape persists — saveConversation() (localStorage,
* trimToBudget rides it unchanged) + persistConversation() (the
* phase-55 auto-save — fire-and-forget, never blocks the turn; a
* shortened messages array is already legal — no new field, no
* migration, A10 stays stateless). markLastRetryable() re-targets:
* no brain record exists after the drop, so the call simply clears
* every .retry-btn (the re-ask's settle re-appends it on the fresh
* bubble). The editor closes, the in-flight guard re-checks (the
* handler is ONE synchronous stretch — no await between the guard
* and the call), and runTurn(text, { reask: true }) re-asks: reask
* skips the user append + save point 1 (the edited question is the
* LAST record, so history = conversation.slice(0, -1) is exactly
* the prior turns and the question is never duplicated), and NO
* image is passed (the phase-49/123 locked A7 rule: a re-ask sends
* text only — the record keeps its image key, the bubble keeps the
* attachment). The promise is returned (the retryLastTurn
* convention) — runTurn never rejects; a failure lands in the
* phase-120 failed-turn path. The composer is never touched (the
* unsent draft survives the edit — input.value / autoGrow /
* updateCharCount stay untouched). The visible re-render is the
* feedback — no new live region (the #send-status announcer stays
* the sole a11y announcer, the phase-55 convention). */
let editSeq = 0; // unique ids for one open edit form's inputs
function openEditor(wrap, toggleBtn) {
if (wrap !== lastUserWrap) return; // stale click — the button moved on
document.querySelectorAll(".edit-form").forEach((f) => f.remove()); // one at a time
const body = wrap.querySelector(".msg-body");
if (!body) return;
const editIdx = lastUserRecordIndex();
if (editIdx === -1) return;
editSeq += 1;
const inputId = `edit-input-${editSeq}`;
const form = document.createElement("form");
form.className = "edit-form";
form.noValidate = true;
const label = document.createElement("label");
label.setAttribute("for", inputId);
label.textContent = "Edit your question";
const ta = document.createElement("textarea");
ta.id = inputId;
ta.name = "question";
ta.setAttribute("rows", "3");
ta.setAttribute("maxlength", String(MAX_QUESTION_CHARS));
ta.value = conversation[editIdx].text; // prefill through .value — never an HTML string
if (input && input.placeholder) ta.placeholder = input.placeholder; // the composer's placeholder, reused
form.append(label, ta);
const actions = document.createElement("div");
actions.className = "edit-form-actions";
const editSaveBtn = document.createElement("button");
editSaveBtn.type = "submit";
editSaveBtn.className = "edit-save";
editSaveBtn.textContent = "Save";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "edit-cancel";
cancelBtn.textContent = "Cancel";
actions.append(editSaveBtn, cancelBtn);
form.appendChild(actions);
const status = document.createElement("p");
status.className = "edit-error";
status.setAttribute("role", "alert");
status.hidden = true;
form.appendChild(status);
const bubble = wrap.querySelector(".bubble");
function closeEditor() {
form.remove();
if (bubble) bubble.classList.remove("is-editing");
toggleBtn.focus();
}
ta.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
e.preventDefault();
closeEditor(); // cancel — restore the bubble, return focus to the toggle
} else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
form.requestSubmit(); // Ctrl/Cmd+Enter submits
}
});
cancelBtn.addEventListener("click", closeEditor);
form.addEventListener("submit", (e) => {
e.preventDefault();
// In-flight re-check (the in-flight discipline): a save can never
// start a second turn — no-op while a turn is in flight (the
// form stays open; the user can Cancel or wait for the settle).
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
const text = ta.value.trim();
if (!text) {
// Empty guard: the question can't be empty (the schema's
// min_length=1, both sides) — refused in-form, the form stays
// open (the note is never lost, the openTuneForm failure way).
status.textContent = "Ask something first — the question can't be empty.";
status.hidden = false;
return;
}
// Replace in place (LOCKED): the edited text IS the record from
// this point on.
conversation[editIdx].text = text;
// Truncate the tail (LOCKED — the phase-49 redo-in-place rule,
// one level up: the question is REPLACED instead of kept
// verbatim): every record the OLD question produced is stale by
// construction and is dropped outright (never rewritten — the
// phase-114 whole-turn rule). The edited question is now the
// LAST record — runTurn's history = conversation.slice(0, -1)
// invariant holds unchanged, and the question is never
// duplicated into the history.
// Phase 159 (task 03): this truncation happens BEFORE the re-ask's turn starts, so the new turn's rememberBrainTurn index bookkeeping (the replaceIndex/leavePartialIndex correlation — reset to -1 when it points past editIdx, and again at runTurn's start; trimToBudget only drops HEAD-most records) sees the post-splice array and can never be confused by the drop.
conversation.splice(editIdx + 1);
// The matching DOM bubbles are removed: every .msg after the
// question's bubble (#messages' direct children are the bubbles,
// in order — the empty-state div is a leading child, so the
// trailing siblings are exactly the dropped tail).
let n = lastUserWrap.nextSibling;
while (n) {
const next = n.nextSibling;
n.remove();
n = next;
}
// Turn bookkeeping resets with the drop: the removed answer is
// no longer a retry target, and the phase-73 pagehide partial
// pointer must not survive the truncation — a partial saved from
// the dropped turn must not be re-persisted into the new shape
// (the settle's identity guard would fall back to an append).
// -1 = none (the safe value; the next runTurn also resets it
// turn-locally).
lastBrainWrap = null;
if (leavePartialIndex > editIdx) leavePartialIndex = -1;
// The bubble text re-renders through renderMarkdown (the
// addMessage renderer — the markdown rules stay in one place);
// the attachment (if any) survives the re-render (re-prepended —
// phase 123's one bubble-image position).
if (bubble) {
const img = bubble.querySelector(".msg-image");
bubble.innerHTML = renderMarkdown(text);
if (img) bubble.prepend(img);
}
// The truncated shape persists NOW (a crash never resurrects the
// pre-edit text or the stale tail): saveConversation() writes
// localStorage (trimToBudget rides it unchanged) +
// persistConversation() auto-saves the row (the phase-55 path —
// fire-and-forget, never blocks the turn; a shortened messages
// array is already legal — no new field, no migration, A10
// stays stateless).
saveConversation();
persistConversation();
// The Retry button re-targets the (now removed) tail: no brain
// record exists after the drop, so the call simply clears every
// .retry-btn (the re-ask's settle re-appends it on the fresh
// bubble).
markLastRetryable();
closeEditor();
// In-flight re-check (the double-check — earned by the handler
// being ONE synchronous stretch from the guard above to this
// call: there is no await in between, so uiState cannot change;
// the re-check keeps the invariant explicit): a save can never
// start a second turn.
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
// Re-ask (LOCKED): reask skips the user-bubble append + the
// persistence save point 1 (the question is already in the DOM
// and in the record — it is never duplicated), and NO image is
// passed (the phase-49/123 locked A7 rule: a re-ask sends text
// only — the record keeps its image key, the bubble keeps the
// attachment). The promise is returned (the retryLastTurn
// convention) — runTurn never rejects; a failure lands in the
// phase-120 failed-turn path. The composer was never touched
// (the unsent draft survives the edit).
return runTurn(text, { reask: true });
});
if (bubble) bubble.classList.add("is-editing");
body.appendChild(form);
ta.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.
*
* Phase 123 (task 02, TODO L6): the optional `image` argument —
* { src, alt } for a USER bubble carrying the question's attached
* image (attachedImage → the live data URL; task 03's restore → the
* stored path). The attachment is part of the question, so the img
* lands at the TOP of the bubble (above the text) through
* attachBubbleImage — ONE renderer for live + restore + shared. A
* null image (every text-only message, every brain message) leaves
* the bubble byte-identical to pre-phase. */
function addMessage(who, html, scroll = false, image = null) {
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>`;
if (who === "user" && image) {
attachBubbleImage(wrap.querySelector(".bubble"), image.src, image.alt);
}
messagesEl.appendChild(wrap);
if (scroll) scrollReveal(wrap);
return wrap;
}
/* Phase 123 (task 02, TODO L6): the question's image in a user bubble
* — the ONE renderer (task 03 reuses it for the restore and the shared
* page): the img is built createElement-style (no HTML strings, the
* house rule), capped height + full-width safe (a tall portrait must
* not blow the chat column — .msg-image in styles.css), lazy-loaded
* (the restore's stored paths re-fetch on demand), alt = the
* accessible name (the filename live, task 03's restore choice). The
* image PREPENDS the text: the attachment is part of the question. */
function attachBubbleImage(bubble, src, alt) {
const img = document.createElement("img");
img.className = "msg-image";
img.src = src;
img.alt = alt || "attached image";
img.loading = "lazy";
// Phase 123 (task 03, TODO L6): the load failure — the STORED file
// was deleted out-of-band (the record keeps its path, the render
// degrades): the img is replaced IN PLACE by the small "image
// unavailable" line (never a broken-image icon). In practice only
// the restore's stored path can 404 (a live data URL is inline); the
// shared page carries its own copy of the same degradation (the
// per-page duplication house style).
img.onerror = () => {
const note = document.createElement("span");
note.className = "msg-image-unavailable";
note.textContent = "image unavailable";
img.replaceWith(note);
};
bubble.prepend(img);
return img;
}
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();
}
/* ---------- the per-run timeline (phase 158, D1/D2/D5) ----------
* Phase 161 (D1, owner 2026-10-05): the ENTIRE per-run timeline
* streams into ONE details.reasoning container (createReasoningWrap /
* ensureReasoningWrap) per brain message — the container sits ABOVE
* the answer bubble, is OPEN from creation until the turn settles
* (done / stop / the failed-turn funnel — settleReasoning, D2), and
* is condensed in its ONE bounded .reasoning-body live window (320px
* task 03) with the pinned-tail follow (D3). The per-run blocks are
* plain divs — NOT collapsible (the D5 refinement, owner 2026-10-06:
* a nested fold inside the container's fold was the defect — a tool
* frame used to fold the live thinking block and the visible
* reasoning vanished mid-turn); they append into the container's
* .reasoning-body (the per-run bubble anchor is superseded), arrival
* order preserved. The container's summary carries the D6 status
* literals: "Reasoning" (static) + a live span ("Thinking…" /
* "Calling <name>…" / "N steps").
* A turn with no thinking and no tools creates NO container
* (byte-identical DOM — D1/D4).
*
* The model's reasoning streams into per-run blocks ABOVE
* the answer bubble, and the tool calls stream into per-run
* disclosures — one CONTIGUOUS thinking run gets its own
* details.thinking block and one CONTIGUOUS tool-call run gets its own
* details.tool-calls-disclosure, in arrival order (D1). The phase-109
* D15 "re-open the one shared block" layout is REPLACED (owner
* direction 2026-10-04): the turn renders as a chronological timeline —
* Thinking → Tool call (1) → Thinking → … → answer. All segments sit
* ABOVE the answer bubble, in arrival order (D2, owner-chosen Option B
* — including runs that start after the first `delta`, the mid-round
* reply case); the bubble stays ONE element, its content accumulating
* in arrival order.
*
* D5 (owner 2026-10-06 — the phase-161 D5 refinement): the per-run
* blocks inside the container are NOT collapsible — they are always
* visible (the container is the ONE disclosure; a nested fold inside
* a fold was the defect: a tool frame used to fold the live thinking
* block and the visible reasoning vanished mid-turn). The block
* renders as a plain div (createThinkingRunEl / createToolRunEl) —
* always shown, the "Thinking" / "Tool call(s) (N)" label rides a
* static (non-interactive) header row. The PRE-phase-161
* details-based blocks (createThinkingBlock / createToolDisclosure)
* stay for the LEGACY restore path (records WITHOUT `segments`
* render byte-identically — no container, the phase-17/117
* disclosures).
*
* The thinking block's STATIC structure is the phase-17 template (a
* static template with NO data — the plain `Thinking` literal, D3; the
* data flows through renderMarkdown / textContent exactly as today).
* The tool-calls disclosure keeps the phase-117 createElement/textContent
* shape (innerHTML ABSENT). The runs are tracked in segRuns (module
* scope — the pagehide partial reads it, like thinkingAcc); each entry
* is { kind, text, records } and rides the save payloads through
* buildSegments (D4). The per-tool-line elapsed clock (phase 87)
* targets the TRACKED line reference (armToolLineClock's second param)
* — the `:last-child` query breaks with multiple disclosures and is
* gone (the re-target, phase 158 D1). */
function createThinkingBlock() {
const block = document.createElement("details");
block.className = "thinking";
block.innerHTML =
`<summary>Thinking</summary><div class="thinking-text"></div>`;
return block;
}
/* The per-run thinking block INSIDE the reasoning container (owner
* 2026-10-06, the D5 refinement): a plain div — NOT collapsible
* (the container is the ONE disclosure). A static .thinking-summary
* label (the phase-17 `Thinking` literal — position distinguishes the
* runs) + the .thinking-text scratchpad (the SAME class the phase-17
* block uses — the renderMarkdown write is unchanged). Built with
* createElement only (innerHTML ABSENT — the house rule). */
function createThinkingRunEl() {
const block = document.createElement("div");
block.className = "thinking";
const label = document.createElement("span");
label.className = "thinking-summary";
label.textContent = "Thinking"; // the phase-17 literal (D3)
const textEl = document.createElement("div");
textEl.className = "thinking-text";
block.append(label, textEl);
return block;
}
/* Phase 161 (D1/D6, owner 2026-10-05): the ONE reasoning
* container's STATIC structure — details.reasoning wrapping
* summary.reasoning-summary (the "Reasoning" literal — D6's ONLY
* new static literal — + the EMPTY span.reasoning-status a frame
* fills: "Thinking…" / "Calling <name>…" / "N steps") and the
* bounded .reasoning-body live window (task 03: 320px + overflow-y).
* Built with createElement only (innerHTML ABSENT — the house rule;
* the "Reasoning" literal is a static text node, no data flows
* through it). The caller owns open/fold + insertion: the live path
* creates it OPEN (D1 — until the turn settles, D2); the
* restore/shared path renders it COLLAPSED around the per-run
* timeline (D5) with the "N steps" summary (D6). */
function createReasoningWrap() {
const cont = document.createElement("details");
cont.className = "reasoning";
const summary = document.createElement("summary");
summary.className = "reasoning-summary";
summary.textContent = "Reasoning"; // D6: the ONLY new static literal
const status = document.createElement("span");
status.className = "reasoning-status"; // empty until a frame sets it
summary.appendChild(status);
cont.appendChild(summary);
const body = document.createElement("div");
body.className = "reasoning-body";
cont.appendChild(body);
return cont;
}
/* The per-run tool disclosure's STATIC structure — the phase-117
* shape (summary.tool-calls-summary first + the .tool-calls list,
* role=list + aria-label "Tool calls"), built with createElement only
* (innerHTML ABSENT — the house rule). The caller owns open/fold (the
* live per-run path opens it, D5; the restore path creates it
* collapsed, phase 117 D3) and inserts it before the bubble. */
function createToolDisclosure() {
const container = document.createElement("details");
container.className = "tool-calls-disclosure";
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);
return container;
}
/* The per-run tool block INSIDE the reasoning container (owner
* 2026-10-06, the D5 refinement): a plain div — NOT collapsible (the
* container is the ONE disclosure). A static .tool-calls-summary
* label (a span — the count rides it on every append, the phase-117
* convention) + the SAME .tool-calls list (role=list + aria-label
* "Tool calls" — the appendToolLine shape is unchanged). Built with
* createElement only (innerHTML ABSENT). */
function createToolRunEl() {
const container = document.createElement("div");
container.className = "tool-calls-disclosure";
const summary = document.createElement("span");
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);
return container;
}
/* The turn's reasoning container — get-or-create (phase 161, D1):
* on the turn's FIRST thinking/tool frame it creates the container
* (createReasoningWrap), opens it (D1: open from creation until the
* turn settles), inserts it BEFORE the bubble (the ONE new anchor —
* the per-run blocks move from the bubble anchor into the
* container's .reasoning-body), and stores it in reasoningWrapEl
* (module scope — the pagehide partial reads the live container,
* like segRuns). A turn with no thinking and no tools never calls
* this (byte-identical DOM — D1). The container is created by the
* FIRST run's ensureReasoningWrap call — no other call site. */
function ensureReasoningWrap(wrap) {
if (reasoningWrapEl) return reasoningWrapEl;
const body = wrap.querySelector(".msg-body");
const cont = createReasoningWrap();
cont.open = true; // D1: open while the turn is live (D2: folds at settle)
body.insertBefore(cont, body.querySelector(".bubble"));
reasoningWrapEl = cont;
return cont;
}
/* The active thinking run's block — or, when the previous run is a
* different kind (or there is none): end it, create a NEW plain
* div.thinking (always visible — the D5 refinement: the container is
* the ONE disclosure, the inner runs never fold), append it into the
* phase-161 reasoning container's .reasoning-body (D1 — the
* container sits before the bubble; a new run appends after the
* previous run's block, so arrival order is preserved), and open a
* fresh run. The phase-109 D15 re-open of one shared block is
* superseded: a post-delta thinking frame lands in a NEW block, not
* a re-open. */
function ensureThinkingRun(wrap) {
if (currentRun?.kind === "thinking") return currentRun.el;
closeCurrentRun();
const block = createThinkingRunEl();
ensureReasoningWrap(wrap).querySelector(".reasoning-body").appendChild(block);
const run = { kind: "thinking", text: "", records: [] };
segRuns.push(run);
currentRun = { kind: "thinking", el: block, run };
return block;
}
/* The active tool run's disclosure — or, when the previous run is a
* different kind (or there is none): end it, create a NEW plain
* div.tool-calls-disclosure (always visible — the D5 refinement: the
* container is the ONE disclosure, the inner runs never fold),
* append it into the phase-161 reasoning container's .reasoning-body
* (D1 — the container sits before the bubble; arrival order
* preserved), and open a fresh run. One disclosure per CONTIGUOUS
* tool-call run (the phase-117 structure, now per run; the summary
* count is per run — "Tool call(s) (N)"). */
function ensureToolRun(wrap) {
if (currentRun?.kind === "tool") return currentRun.el;
closeCurrentRun();
const disc = createToolRunEl();
ensureReasoningWrap(wrap).querySelector(".reasoning-body").appendChild(disc);
const run = { kind: "tool", text: "", records: [] };
segRuns.push(run);
currentRun = { kind: "tool", el: disc, run };
return disc;
}
/* D5's end side: the ACTIVE run ends (idempotent — a no-op without a
* run). The D5 refinement (owner 2026-10-06): the inner runs are
* plain divs (NOT collapsible), so there is nothing to fold — the
* run simply stops accumulating frames (a frame of a new kind starts
* a NEW run below). The run itself stays in segRuns — the save
* payloads still carry every run. */
function closeCurrentRun() {
currentRun = null;
}
/* The turn's terminal settle (done + the stop/abort + the failed-turn
* funnel): the D5 refinement (owner 2026-10-06) made the inner runs
* non-collapsible divs, so there is nothing to fold inside the
* container (the container's own fold is settleReasoning's). The
* query is kept as the DEFENSIVE legacy guard: a pre-phase-161
* details-based block (the legacy restore path creates them closed)
* would fold here — by construction they are already closed, so this
* is a no-op in every live path. Idempotent; a no-op without a wrap. */
function closeAllRuns(wrap) {
wrap?.querySelectorAll?.(
"details.thinking, details.tool-calls-disclosure"
)?.forEach((el) => (el.open = false));
}
/* Phase 161 (D2): the container's FOLD — the turn's terminal
* settle (done / the stop/abort / the failed-turn funnel), called
* alongside closeAllRuns at the three settle sites. Idempotent;
* a no-op without a container (the plain-answer turn settles
* byte-identically). The mid-round delta does NOT fold the
* container (D2 — closeCurrentRun only ENDS the active run — the
* D5 refinement made the inner runs non-collapsible divs — and the
* container stays open until the final response has been returned
* and the session has stopped — the TODO's core contract). */
function settleReasoning(wrap) {
wrap?.querySelector("details.reasoning")?.removeAttribute("open");
}
/* D4: the record's `segments` array, built at save time from the turn's
* runs — in order. A thinking run stores its accumulated text ONLY when
* it is non-empty (a zero-char run — e.g. a BOR_STREAM_THINKING-
* suppressed turn — stores nothing); a tool run stores one entry per
* record, in call order (the record's exact shape: the phase-37
* {name, argument} pair, the phase-139 offset/limit, the phase-146
* path, and the phase-95 truncation stamp when present — the SAME
* objects toolAcc holds, so the tool_result stamp rides through with
* no extra wiring). A turn with no thinking and no tools returns [] —
* the save sites drop the key for an empty array (the
* undefined-drops-the-key house pattern). No re-validation on the
* client: the server's ChatMessage boundary validates. */
function buildSegments(runs) {
const out = [];
for (const r of runs) {
if (r.kind === "thinking") {
if (r.text) out.push({ kind: "thinking", text: r.text });
continue;
}
for (const rec of r.records) out.push({ kind: "tool", ...rec });
}
return out;
}
/* ---------- tool-call lines (phase 37, PLAN §4 extension) ----------
* One visible "calling tool" row per `tool` SSE frame. Phase 158
* (D1): the CALLER passes the run's disclosure (ensureToolRun — one
* details.tool-calls-disclosure per CONTIGUOUS tool-call run), which
* sits above the answer, below the Thinking blocks (both insert
* before the bubble, so document order is preserved). The first frame
* of a run creates the disclosure; 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). Phase 158 moved the disclosure's BUILD
* to createToolDisclosure (the caller — the live run builder or the
* restore path — owns open/fold + the before-the-bubble insertion),
* and the count rides the summary on every append — per run (one
* disclosure per contiguous run, D3). 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.
*
* Phase 146 (task 02): the grep arm gains the additive `scope` param
* (default null — the frame's `path` field / the saved record's
* `path`): a SCOPED grep's line shows WHERE the search runs — the
* pinned Searching label + the pattern <code>, then " in " + the scope
* in a second <code> — so repeated patterns with different scopes read
* as distinct searches (the "looks like the LLM is repeating itself"
* confusion, TODO.md L3). The scope is a
* .grep-scope span SIBLING after the pattern <code> (the phase-139
* read-range-suffix precedent), appended ONLY when it is a non-empty
* string: a bare grep's line stays byte-identical to pre-phase (the
* phase-70/87/117 pins and the frontend emoji guard stay green — no
* new emoji). */
function appendToolLine(container, name, argument, offset = null, limit = null, scope = null) {
if (!container) return;
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);
// Phase 139 (task 01): the requested line range — a span SIBLING
// after the <code> child (the phase-95 truncation-note precedent:
// append a span, never rewrite the pinned template text), appended
// ONLY when the suffix is non-empty — a plain read's line stays
// byte-identical to pre-phase (the phase-87/117 pins and the
// frontend emoji guard stay green; no new emoji).
const rangeSuffix = readRangeSuffix(offset, limit);
if (rangeSuffix) {
const range = document.createElement("span");
range.className = "read-range";
range.textContent = rangeSuffix; // the range is data, never markup
line.appendChild(range);
}
} 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);
// Phase 146 (task 02): the SCOPE of a scoped grep — the re-searched
// document's combined source/path — appended as a SIBLING after the
// pattern <code> (the phase-139 read-range-suffix precedent): a
// .grep-scope span holding the " in " text node + a second <code>,
// ONLY when the scope is a non-empty string. A bare grep's line
// stays byte-identical to pre-phase (no span, no extra bytes);
// the scope is data, never markup (no new emoji).
if (typeof scope === "string" && scope) {
const scopeSpan = document.createElement("span");
scopeSpan.className = "grep-scope";
scopeSpan.appendChild(document.createTextNode(" in "));
const scopeCode = document.createElement("code");
scopeCode.textContent = scope; // the scope is data, never markup
scopeSpan.appendChild(scopeCode);
line.appendChild(scopeSpan);
}
} 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. Phase 158
// (D3): the count is PER RUN (one disclosure per contiguous run —
// the per-run "Tool call(s) (N)"). The D5 refinement (owner
// 2026-10-06): the target is the .tool-calls-summary CLASS — the
// phase-117 `<summary>` and the in-container `<span>` both carry
// it, so ONE query serves both generations.
const n = list.children.length;
container.querySelector(".tool-calls-summary").textContent =
`Tool call${n === 1 ? "" : "s"} (${n})`;
return line; // phase 158: the caller arms the line's clock with this element
}
/* Phase 139 (task 01): the requested line range on the Reading line —
* the owner's TODO: repeated reads show "the same file over and over"
* with no way to tell which lines each read covered. A range read
* (read(path, offset, limit), phase 131) renders WHICH lines were
* requested, so two reads of one file are distinguishable. The single
* source of truth for the wording — the tool line and the status label
* both render it (ASSUMPTION A2). The well-formedness guard mirrors the
* server's _whole_line_number (app/api/chat.py): Number.isInteger(x) &&
* x >= 1 (bools are not integers in JS — the house guard, as on the
* server) — a malformed value renders no suffix. The range shown is the
* model's REQUESTED extent (ASSUMPTION A1): offset–(offset + limit − 1)
* with the en-dash per the pinned copy — the UI cannot know the document
* length, so a limit past EOF renders the requested extent. Neither
* field well-formed → "" — no span, no bytes added, the plain line stays
* byte-identical to pre-phase. */
function readRangeSuffix(offset, limit) {
const offOk = Number.isInteger(offset) && offset >= 1;
const limOk = Number.isInteger(limit) && limit >= 1;
if (offOk && limOk) return ` (lines ${offset}–${offset + limit - 1})`;
if (offOk) return ` (from line ${offset})`;
if (limOk) return ` (first ${limit} lines)`;
return "";
}
/* 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();
}
/* Phase 157 (owner-locked 2026-10-03, A1): the chip display cap — a
* chip's DISPLAYED text never exceeds CHIP_CAP characters (the `…`
* brings a capped chip to at most CAP+1). The cap is the PRIMARY
* length guard; the phase-104 single-line CSS clip stays the
* secondary. Display-only: the title / aria-label / click payload
* keep the FULL text (the phase-104 contract, extended). */
const CHIP_CAP = 90;
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.length > CHIP_CAP ? text.slice(0, CHIP_CAP).trimEnd() + "…" : 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);
// phase 157 (owner 2026-10-03, A1): the CHIP_CAP display cap is
// the PRIMARY guard — the CSS clip is the secondary.
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)
let toolLineEl = null; // phase 158: the TRACKED line the clock suffixes (the :last-child query is gone — multi-disclosure)
/* 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)
/* Phase 158 (D1/D4): the turn's CONTIGUOUS runs, in arrival order —
* { kind: "thinking", text, records: [] } / { kind: "tool", text,
* records } entries (a tool entry's records hold the SAME toolAcc
* record objects, so the phase-95 tool_result stamp flows into both).
* Module scope, like thinkingAcc: the pagehide partial reads them while
* a turn is in flight. Reset per turn at the top of runTurn; persisted
* through buildSegments at the save points (non-empty only). */
let segRuns = []; // this turn's runs, in arrival order ([] = nothing to store)
let currentRun = null; // the active run — { kind, el, run } (run = the segRuns entry); null when no run is open
/* Phase 161 (D1): this turn's reasoning container — created on the
* first thinking/tool frame (ensureReasoningWrap), open until the
* settle (settleReasoning). Module scope, like segRuns: the
* pagehide partial reads the live container; reset per turn in
* runTurn. */
let reasoningWrapEl = null;
let persistedOnLeave = false; // pagehide partial-persist at most once
/* Phase 120 verification (navigate-away regression): a pagehide has
* fired. A REAL departure tears the document down and the browser
* cancels the in-flight fetch — that teardown rejection (a TypeError,
* NOT an AbortError) must not be read as a FAILED turn by the error
* catch's else branch: the phase-20 convention is that a thinking-only
* navigate-away persists NOTHING brain-side (the question is already
* saved on send — the user can re-ask) and a partial navigate-away
* persists the pagehide partial, not a failed record. Set
* unconditionally on pagehide — a merely-hidden tab in some browsers
* also fires it, but there the stream keeps arriving (no rejection
* follows), so the flag stays inert — and reset per turn like the
* other turn locals. */
let leftThePage = false;
/* 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)
/* Phase 123 (task 02, TODO L6; locked A5): the composer's ATTACHED
* image — the { file, name, dataUrl } triple, held until send. The
* bytes NEVER touch the chat payload: the send flow uploads the File
* to POST /api/chat-images and the record + the request body carry the
* returned STORED PATH (A5: never base64). The data URL feeds two
* local things only — the preview thumbnail and the LIVE user bubble
* (no fetch needed); the restore (task 03) re-renders from the stored
* path instead. null = no attachment — the text-only path, byte-
* identical to pre-phase (request body, record, bubble). */
let attachedImage = null;
let attachUpload = false; // phase 123: one upload at a time (double-fire guard — the upload is the first await in handleSend; a second submit mid-upload is a no-op, the first owns the send)
/* The six extensions the upload endpoint accepts (phase 122's image
* set, one list — the server re-validates on the upload; this pre-check
* only keeps a bad pick from opening a state change + a wasted
* round-trip, and it checks the file NAME's extension: the accept
* attribute is advisory, and a drag-pasted or renamed file can carry
* any extension the server will 422 anyway). */
const ATTACHABLE_IMAGE_EXTENSIONS = ["bmp", "gif", "jpeg", "jpg", "png", "webp"];
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).
*
* Phase 158 (D1): the re-target — with per-run disclosures (one
* details.tool-calls-disclosure per contiguous tool-call run), the
* phase-87 `.tool-calls .tool-call:last-child` query would find the
* last line of the WRONG run's list. The suffix now targets the
* TRACKED line reference (this function's second param — the line the
* caller just appended; the re-arm resets it). The visible behavior is
* byte-identical (the suffix on the newest line; settle still removes
* every .tool-elapsed in the wrap). */
function armToolLineClock(wrap, lineEl) {
toolLineWrap = wrap;
toolLineEl = lineEl; // the NEW target line (the re-arm resets it)
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
if (!toolLineEl) return; // the wrap was reset mid-gap (New Chat) — no-op
let el = toolLineEl.querySelector(".tool-elapsed");
if (!el) {
el = document.createElement("span");
el.className = "tool-elapsed";
toolLineEl.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());
toolLineEl = null; // phase 158: forget the line target (stopToolLineClock resets via here)
}
/* 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.
*
* Phase 130 (task 01, TODO L6): the block is two CLUSTERS, never one
* interleaved row — the chips row holds the NON-image refs only (a
* ref carrying `image_url` is an image document, phase 122, and gets
* NO chip: its figure IS the citation), and a second images row
* (`.msg-meta.msg-images`) holds one `.source-image` figure per image
* ref, in ref order. Each row is present only when its refs are
* non-empty (an image-only citation gets its images row + the actions
* row and no chips row; a text-only turn renders the pre-phase-130
* single row). Row order in .msg-body: the answer figure (the caller
* appends it first, phase 124) → chips row → images row → the
* related row (the next caller, appendRelated) → the actions row
* (the action appenders after appendRelated — phase 130 task 02,
* the bubble's LAST row). */
function appendSources(wrap, sources) {
if (!sources || !sources.length) return;
const body = wrap.querySelector(".msg-body");
// The `image_url` presence is the existing image-doc signal
// (phase 122): image refs cite by figure, text refs by chip.
const imageRefs = sources.filter((s) => s.image_url);
const textRefs = sources.filter((s) => !s.image_url);
// Cluster 1 — the chips row (the pre-phase-130 row, text refs
// only), skipped entirely when there are no text refs.
if (textRefs.length) {
const meta = document.createElement("div");
meta.className = "msg-meta";
meta.setAttribute("role", "list");
meta.setAttribute("aria-label", "Sources");
for (const s of textRefs) {
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, "/"); // back → the chat page (phase 153)
});
chip.textContent = label;
chip.title = label;
meta.appendChild(chip);
}
body.appendChild(meta);
// Accessible full path whenever the pill visually truncates
// (the chips row only — the images row carries no chips).
for (const chip of meta.children) {
if (chip.scrollWidth > chip.clientWidth) chip.setAttribute("aria-label", chip.title);
}
}
// Cluster 2 — the images row: one figure per image ref, in ref
// order, no chips (absent when no image ref exists).
if (imageRefs.length) {
const images = document.createElement("div");
images.className = "msg-meta msg-images";
images.setAttribute("role", "list");
images.setAttribute("aria-label", "Source images");
for (const s of imageRefs) appendSourceImageFigure(images, s);
body.appendChild(images);
}
}
/* 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 rows in the .msg-body flex gap.
* app.js appends it BELOW the citation clusters and ABOVE the actions
* row (phase 130 task 02 — the .msg-actions row is the bubble's LAST
* row, and the getActionsRow scoping keeps the buttons out of this row
* either way: a deflected turn with related docs gets its OWN actions
* row, never a button in the related 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, "/"); // back → the chat page (phase 153)
});
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);
// Phase 122 (task 05): the related tier rides the same ref shape —
// an image doc's ref carries image_url and gets the same figure.
if (s.image_url) appendSourceImageFigure(row, s);
}
body.appendChild(row);
}
/* Phase 122 (task 05): the chat's sources block shows a retrieved
* IMAGE document "nicely" (TODO L6): the ref's chip gains a COMPACT
* INLINE FIGURE right after it. The chip keeps its text and its
* affordance — the figure is ADDITIVE, never a replacement — and the
* figure reuses the chip's navigation (the same documentUrl href, the
* /document.html escape hatch; the same left-click → same-page modal,
* phase 26). alt + the VISIBLE caption = the document's SUMMARY (the
* vision description — the WCAG alt contract): the frame carries no
* summary (image_url is the only new frame field), so the figure
* fetches the content endpoint the chip's modal already uses (the
* (source, path) pair is the same lookup key) and swaps the summary
* in; until it settles — and when it fails — the title stands in (a
* caption is ALWAYS visible). A FAILED IMAGE LOAD becomes the IN-PLACE
* CHIP FALLBACK (phase 130 task 01, ASSUMPTION A3): the figure is
* replaced, in place, with a plain `.source-chip` anchor for the same
* document — the citation survives a lost byte copy, never a
* broken-image icon (the row's bytes route 404s when the copy was
* lost). */
function appendSourceImageFigure(meta, s) {
const label = `${s.source}/${s.path}`;
const fig = document.createElement("a");
fig.className = "source-image";
fig.setAttribute("role", "listitem");
fig.href = documentUrl(s.source, s.path, "/"); // back → the chat page
fig.title = label; // full path as the native tooltip (chip pattern)
fig.setAttribute("aria-label", label); // the accessible name is the full path
fig.addEventListener("click", (e) => {
e.preventDefault(); // no new tab (phase 26) — the modal takes over
e.stopPropagation();
openDocumentModal(s.source, s.path, fig, "/"); // back → the chat page (phase 153)
});
const img = document.createElement("img");
img.className = "source-image-img";
img.src = s.image_url;
img.alt = s.title || label; // the summary arrives via the fetch below
img.addEventListener("error", () => {
// Phase 130 (task 01, ASSUMPTION A3): the IN-PLACE CHIP FALLBACK —
// there is no chip for image docs anymore (the figure is the
// citation), so the figure itself becomes one: replaced, in place,
// with a plain `.source-chip` anchor for the same document (the
// same documentUrl href, the same left-click → modal, the label as
// text) — the citation survives a lost byte copy, never a
// broken-image icon.
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, "/"); // back → the chat page (phase 153)
});
chip.textContent = label;
chip.title = label;
fig.replaceWith(chip);
});
const caption = document.createElement("span");
caption.className = "source-image-caption";
caption.textContent = s.title || label; // visible caption: the title first…
fig.append(img, caption);
meta.appendChild(fig);
// …and the document's summary once the content fetch settles.
fetchContentSummary(s.source, s.path).then((summary) => {
if (!fig.isConnected) return; // replaced by the chip fallback (img error) or bubble cleared
if (summary && summary.trim() !== "") {
img.alt = summary;
caption.textContent = summary;
}
});
}
/* The document content endpoint the chip's modal already boots
* against (the (source, path) lookup key the ref carries) — phase 122
* (task 05) asks it ONLY for the image figure's summary (alt +
* caption). Any failure (404, network, bad body) resolves to null —
* the title fallback stands and the figure is unaffected. */
function fetchContentSummary(source, path) {
const url =
"/api/documents/content?source=" +
encodeURIComponent(source) +
"&path=" +
encodeURIComponent(path);
return fetch(url)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null)
.then((doc) => (doc && typeof doc.summary === "string" ? doc.summary : null));
}
/* Phase 124 (task 06, TODO L3): the LLM's ANSWER IMAGE — the brain
* answer's first-class figure (the `done` frame's `answer_image` ref,
* task 04). A4: it sits in the .msg-body BELOW the answer text and
* ABOVE the source clusters — the callers append it right BEFORE
* appendSources builds the rows (phase 130 task 01: the chips row,
* then the images row), so it lands in between. The text bubble is
* untouched: the figure is ADDITIVE ("summaries should still be
* provided").
*
* The visual sibling of appendSourceImageFigure (the compact chip
* figure) but a different surface: the answer figure is the answer's
* own image, not a chip accessory — .answer-image scales it up (the
* .source-image language at answer size, styles.css). Same WCAG alt
* contract as the chip figure: alt + the VISIBLE caption = the
* document's SUMMARY (the vision description); the frame carries no
* summary (the ref is source/path/title/image_url), so the figure
* fetches the content endpoint the chip's modal already uses and
* swaps the summary in; until it settles — and when it fails — the
* title stands in (a caption is ALWAYS visible). A FAILED IMAGE LOAD
* REMOVES the whole figure (the collapse rule — the answer figure has
* no chip of its own to fall back to; the chip row below still cites
* the doc, the phase-124 auto-cite): an answer whose image bytes are
* gone renders as a normal answer, never a broken-image icon.
* Navigation reuses the chip affordance: the same documentUrl href
* (the /document.html escape hatch) + left-click → same-page modal
* (phase 26, the appendSources/appendSourceImageFigure pattern). */
function appendAnswerImage(wrap, ref) {
if (
!ref ||
typeof ref.source !== "string" ||
typeof ref.path !== "string" ||
typeof ref.image_url !== "string" ||
!ref.image_url
)
return; // no ref (or a malformed one) → no figure DOM at all
const body = wrap.querySelector(".msg-body");
const label = `${ref.source}/${ref.path}`;
const fig = document.createElement("a");
fig.className = "answer-image";
fig.href = documentUrl(ref.source, ref.path, "/"); // back → the chat page
fig.title = label; // full path as the native tooltip (chip pattern)
fig.setAttribute("aria-label", label); // the accessible name is the full path
fig.addEventListener("click", (e) => {
e.preventDefault(); // no new tab (phase 26) — the modal takes over
e.stopPropagation();
openDocumentModal(ref.source, ref.path, fig, "/"); // back → the chat page (phase 153)
});
const img = document.createElement("img");
img.className = "answer-image-img";
img.src = ref.image_url; // the public bytes route (task 04's ref)
img.loading = "lazy"; // A4: lazy-loaded
img.alt = ref.title || label; // the summary arrives via the fetch below
img.addEventListener("error", () => {
// Collapse to NOTHING (there is no chip inside the figure to fall
// back to) — the answer renders normally, the chip row below
// still cites the doc.
fig.remove();
});
const caption = document.createElement("span");
caption.className = "answer-image-caption";
caption.textContent = ref.title || label; // visible caption: the title first…
fig.append(img, caption);
body.appendChild(fig);
// …and the document's summary once the content fetch settles (the
// chip figure's SAME pattern — fetchContentSummary fails soft to
// null: the title stays).
fetchContentSummary(ref.source, ref.path).then((summary) => {
if (!fig.isConnected) return; // collapsed (img error) or bubble cleared
if (summary && summary.trim() !== "") {
img.alt = summary;
caption.textContent = summary;
caption.title = summary; // full text (the caption is line-clamped)
}
});
}
/* "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?,
* failed?, error? }] }
*
* 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 */
}
}
/* Phase 158 (task 03, D4/D5): the RESTORE of a record's `segments`
* array — the per-run timeline, COLLAPSED. A record WITH segments
* (the live per-run save — task 02's buildSegments) renders each
* contiguous thinking run as its own details.thinking block (the
* plain Thinking literal, D3 — position distinguishes the runs) and
* each CONTIGUOUS run of tool entries as ONE
* details.tool-calls-disclosure (the phase-117 structure via
* createToolDisclosure — the shared builder, innerHTML ABSENT), with
* the per-run "Tool call(s) (N)" count riding appendToolLine's
* summary update (the SAME container signature as the live path). All
* blocks are created CLOSED (D5 — the restore renders collapsed; the
* live path opens while its frames stream). Phase 161 (task 02,
* D5/D6): the per-run timeline renders inside ONE details.reasoning
* container — the SAME container as the live path (the shared
* createReasoningWrap builder, task 01), created COLLAPSED (the
* live open state is exclusively the live turn's; explicit
* `open = false` for the pin) and inserted ONCE before the bubble;
* every per-run block appends into the container's .reasoning-body
* (the per-run bubble anchor is superseded — arrival order
* preserved). The D5 refinement (owner 2026-10-06): the in-container
* runs are plain divs (createThinkingRunEl / createToolRunEl —
* NEVER collapsible; the container is the ONE disclosure — the
* per-run blocks are always visible while the container is open). The D6 at-rest summary is the step count, computed
* incrementally with the LIVE segRuns.length semantics — CONTIGUOUS
* runs, NOT entry count: each thinking entry is ONE run (a thinking
* entry after a tool group starts a new run; two adjacent thinking
* entries are TWO runs); the first entry of a new contiguous tool
* group is ONE run (a group of N entries is ONE run). The literal is
* uniform — `${runs} steps` (no singular/plural special-case). The
* tOffset/tLimit/tPath
* well-formedness guards (Number.isInteger(x) && x >= 1 / the string
* guard) and the phase-95 appendTruncatedNote call move VERBATIM from
* the legacy m.tools loop (the phase-139/146 restore precedent) — a
* malformed entry (no name / a non-string text) or an entry with an
* UNKNOWN `kind` is SKIPPED: never render from storage — the
* textContent-only house rule keeps a corrupted row from poisoning
* the render. The thinking text flows through the SAME escape-first
* renderMarkdown write the legacy path uses (XSS-safe; the structure
* is createElement/textContent — the only HTML write is that
* renderMarkdown into .thinking-text). A record WITHOUT segments
* never reaches here — the legacy path restores it byte-identically.
* The restore path NEVER arms the elapsed clock (A6 — the live
* frames only do that). */
function restoreTimeline(wrap, segments) {
const body = wrap?.querySelector?.(".msg-body");
if (!body) return;
const bubble = body.querySelector(".bubble");
// Phase 161 (task 02, D5/D6): the container — the shared task-01
// builder, created CLOSED (D5 — the restore renders collapsed; the
// live open state is exclusively the live turn's; explicit for the
// pin) and inserted ONCE before the bubble (the ONE anchor — the
// per-run blocks nest into its .reasoning-body, arrival order
// preserved).
const cont = createReasoningWrap();
cont.open = false; // D5: the restore renders collapsed (explicit for the pin)
const runsEl = cont.querySelector(".reasoning-body");
body.insertBefore(cont, bubble); // the container sits ABOVE the answer bubble
let toolDisc = null; // the CURRENT run of consecutive tool entries (one disclosure per run, D1)
let runs = 0; // D6: the at-rest step count — CONTIGUOUS runs, not entry count
for (const entry of segments || []) {
// a malformed entry (or one with an unknown `kind`) is SKIPPED —
// never render from storage (textContent only, the house rule).
if (!entry || typeof entry !== "object") continue;
if (entry.kind === "thinking") {
if (typeof entry.text !== "string") continue; // malformed: skipped
if (toolDisc) toolDisc = null; // the run is sealed — its count rode the last append
runs += 1; // D6: each thinking entry is ONE run (a thinking entry after a tool group starts a run; two adjacent thinking entries are TWO runs)
const block = createThinkingRunEl(); // D5 refinement (owner 2026-10-06): the in-container run is a plain div — never collapsible (the container is the ONE disclosure)
block.querySelector(".thinking-text").innerHTML = renderMarkdown(entry.text); // the existing restore convention — escape-first, XSS-safe
runsEl.appendChild(block); // Phase 161 D1/D5: inside the container, in arrival order
} else if (entry.kind === "tool") {
if (typeof entry.name !== "string") continue; // malformed: skipped
if (!toolDisc) {
toolDisc = createToolRunEl(); // D5 refinement: the in-container run is a plain div (never collapsible)
runs += 1; // D6: the first entry of a NEW contiguous group is ONE run (a group of N entries is ONE run)
runsEl.appendChild(toolDisc); // Phase 161 D1/D5: inside the container, in arrival order
}
const arg =
typeof entry.argument === "string" && entry.argument
? entry.argument
: null;
// Phase 139/146 restore guards — VERBATIM from the legacy loop
// (the phase-139/146 restore precedent): a record saved before
// the phase (no fields) renders byte-identically.
const tOffset =
Number.isInteger(entry.offset) && entry.offset >= 1 ? entry.offset : null;
const tLimit =
Number.isInteger(entry.limit) && entry.limit >= 1 ? entry.limit : null;
const tPath = typeof entry.path === "string" && entry.path ? entry.path : null;
appendToolLine(toolDisc, entry.name, arg, tOffset, tLimit, tPath);
// Phase 95: a stored truncation stamp re-renders the SAME marker
// next to its Reading line (moved verbatim inside the grouping;
// old records without the field render unchanged).
if (entry.truncated && arg) {
appendTruncatedNote(wrap, arg, Number(entry.chars_shown) || 0, Number(entry.chars_total) || 0);
}
}
// an entry with an unknown `kind` (or a malformed shape) is
// SKIPPED — never render from storage (textContent only, the
// house rule); a guard comment names the skip.
}
// Phase 161 (task 02, D6): the at-rest step count — the UNIFORM
// literal (no singular/plural special-case — `${runs} steps` is
// pinned exactly).
cont.querySelector(".reasoning-status").textContent = `${runs} steps`;
// the defensive close of the legacy details disclosure is GONE (the
// D5 refinement — the in-container runs are plain divs).
}
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") {
const wrap = addMessage("user", renderMarkdown(m.text), true);
lastUserWrap = wrap; // phase 159: the LAST restored user bubble wins (the mirror of the brain line below)
// Phase 123 (task 03, TODO L6): the restored record may carry the
// question's attached image — `m.image`, the STORED PATH (A5:
// never base64; a pre-phase / text-only record has no key at all,
// so it renders byte-identically — no img). It lands through the
// SAME one bubble-image renderer the live send uses
// (attachBubbleImage — the live bubble passed the data URL, the
// restore passes the stored path; the helper takes any src): the
// img at the top of the user bubble, above the text. A load
// failure degrades inside the helper (deleted out-of-band file →
// the small "image unavailable" line, never a broken icon). A
// re-ask (retryLastTurn) re-sends prev.text only (locked A7) —
// this bubble's restored attachment is untouched by the redo.
if (typeof m.image === "string" && m.image) {
attachBubbleImage(wrap.querySelector(".bubble"), m.image, m.text || "attached image");
}
return;
}
const wrap = addMessage("brain", renderMarkdown(m.text), true);
// Phase 158 (task 03, D4): a record WITH `segments` renders the
// per-run timeline through restoreTimeline (collapsed) — the runs
// carry the SAME data as `m.thinking` + `m.tools`, in order, so
// the legacy blocks are SKIPPED (rendering both would double the
// runs). Phase 161 (task 02, D5/D6): the segments path wraps the
// per-run timeline in the COLLAPSED details.reasoning container
// (restoreTimeline — the shared createReasoningWrap builder; the
// "N steps" summary). A record WITHOUT `segments` — every
// pre-phase record — restores BYTE-IDENTICALLY through the legacy
// path below (no container — D4); its folded-at-rest result is the
// same as the pre-phase code's (the pre-phase settle pair became
// closeAllRuns(wrap) — phase 158 task 02 — the legacy thinking
// block is created closed and the legacy disclosure opens during
// the loop and folds below, phase 117 D3).
if (Array.isArray(m.segments) && m.segments.length) {
restoreTimeline(wrap, m.segments);
} else {
if (m.thinking) {
// Phase 17: restore the thinking block COLLAPSED above the bubble.
// Phase 158 (task 02): the builder is SHARED with the live per-run
// path (createThinkingBlock) — the legacy path keeps ONE collapsed
// block (the full concatenation). A record WITH `segments` takes
// the restoreTimeline dispatch instead (task 03 — segments take
// precedence; this legacy path stays for pre-phase records).
const block = createThinkingBlock();
block.open = false;
wrap
.querySelector(".msg-body")
.insertBefore(block, wrap.querySelector(".bubble"));
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).
// Phase 158 (task 02): the single-container derivation moved out of
// appendToolLine (the caller passes the run's disclosure) — the
// legacy path keeps ONE disclosure per message (all m.tools),
// created with the live lifecycle (open during the loop, folded
// below) so a restored turn is one compact "Tool calls (N)" line
// (phase 117, D3), exactly as before.
let restoreDisc = null;
for (const t of m.tools) {
if (!t || typeof t.name !== "string") continue;
const arg =
typeof t.argument === "string" && t.argument ? t.argument : null;
// Phase 139 (task 02): a saved range read (the live toolAcc
// stamp — additive on the bor.chat.v1 record, the phase-95
// truncated-fields precedent) re-renders the SAME suffix through
// the SAME helper: the same Number.isInteger guard → a record
// saved before the phase (no fields) renders byte-identically
// (the phase-95 truncation note is untouched — it keys on the
// <code> argument, not the range).
const tOffset =
Number.isInteger(t.offset) && t.offset >= 1 ? t.offset : null;
const tLimit =
Number.isInteger(t.limit) && t.limit >= 1 ? t.limit : null;
// Phase 146 (task 02): a saved SCOPED grep (the live toolAcc
// stamp — the additive `path` key on the bor.chat.v1 record)
// re-renders the SAME scope through the SAME helper: the same
// string guard → a record saved before the phase (no `path`
// key) renders byte-identically (the phase-95 truncation note
// is untouched — it keys on the <code> argument, not the scope).
const tPath = typeof t.path === "string" && t.path ? t.path : null;
if (!restoreDisc) {
restoreDisc = createToolDisclosure();
restoreDisc.open = true; // the live lifecycle — folded below (D3)
wrap
.querySelector(".msg-body")
.insertBefore(restoreDisc, wrap.querySelector(".bubble"));
}
appendToolLine(restoreDisc, t.name, arg, tOffset, tLimit, tPath);
if (t.truncated && arg) {
appendTruncatedNote(wrap, arg, Number(t.chars_shown) || 0, Number(t.chars_total) || 0);
}
}
if (restoreDisc) restoreDisc.open = false; // phase 117 (D3): a restored turn renders folded
}
}
if (m.deflected) {
wrap.classList.add("is-deflected");
appendMaybeTry(wrap, m.suggestions);
}
// Phase 124 (task 06, TODO L3): the answer image restores with the
// bubble — the SAME helper + the SAME A4 position as the live done
// path (below the text, above the source clusters — phase 130:
// the chips row, then the images row). A record without the key
// renders byte-identically (pre-phase chats restore unchanged).
if (m.answer_image) appendAnswerImage(wrap, m.answer_image);
appendSources(wrap, m.sources);
// Phase 113 (task 02) / phase 130 (task 02): the related tier
// restores with the bubble — BELOW the citation clusters and ABOVE
// the actions row: the action appenders below (Tune / Save-as-doc /
// notes, plus the restore loop's after-the-loop markLastRetryable)
// get-or-create the .msg-actions row as the bubble's LAST row,
// 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);
// Phase 120: a failed turn (m.failed) is a note, not an answer — no
// Tune (the "note, not an answer" scope the m.stopped exclusion
// below shares; the live failure paths add no Tune either). Stopped
// partials keep their button — byte-identical to pre-phase-120.
if (!m.failed) 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) or a failed turn
// (m.failed) is a note, not an answer — no button (the live
// stop/failure paths add none either).
if (!m.stopped && !m.failed) appendSaveAsDocButton(wrap, m.text);
if (m.stopped) appendStoppedNote(wrap); // phase 48: the stop marker restores
// Phase 120: the failed marker restores — the in-bubble error note
// re-renders from the persisted `error` detail (the note is absent
// when it is null: the record's `text` already carries the detail).
// No other restore change: the lastBrainWrap assignment below + the
// restore loop's markLastRetryable() already land the Retry button
// on the LAST restored brain bubble — the failed one when it ends
// the conversation — so the refreshed page is the same retryable
// state the live error was (retryLastTurn re-asks the question that
// precedes the failed record, unchanged; a mid-conversation failed
// bubble gets no button — the phase-49 last-bubble-only rule).
if (m.failed && m.error) appendFailedNote(wrap, m.error);
// Phase 160 (task 04, TODO L4): a cap-reached answer keeps its
// Continue affordance on a page refresh — a restored `history` is
// exactly what the continuation turn needs (runTurn's plain send
// carries conversation.slice(0, -1)). The marker is a client
// restore hint only (the schema's budget_exhausted key, never a
// server behavior), so pre-phase / non-cap records restore
// byte-identically (no key → no button).
if (m.budget_exhausted) appendContinueButton(wrap);
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
markLastEditable(); // phase 159: the restored last user bubble is editable
}
/* ---------- 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
markLastEditable(); // phase 159: parity with the local restore: Edit on the last user 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
lastUserWrap = null; // phase 159: the editable wrap resets with the conversation (buttons die with the list reset below)
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
clearAttachedImage(); // phase 123: the attachment is composer draft state — it resets with the conversation
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 });
}
/* Phase 160 (task 04, TODO L4): the "Continue researching" click —
* the retryLastTurn discipline, one turn at a time. No-op while a
* turn is in flight (thinking / streaming — the runTurn guard; the
* button is never double-fired into two turns), then clear EVERY
* rendered .continue-btn FIRST (the markLastRetryable remove-first
* pattern: the moment the new turn's answer lands, a stale button on
* any earlier bubble is gone by construction; the one-per-bubble
* guard + the clear make a double-send impossible), then a PLAIN send
* (runTurn's `reask` defaults to false): the "continue" user bubble
* appears, and the record is pushed + saved through the ordinary save
* points (conversation.push + saveConversation + persistConversation).
* The request carries the FULL history (conversation.slice(0, -1)) —
* the model resumes with everything it already read and gets a fresh
* agent_max_rounds. ASSUMPTION: the continuation question is the
* literal word "continue" — what the model is taught to ask the user
* for (ROUND_CAP_WRAPUP_INSTRUCTION / the tool-budget-spent trailer,
* phase 160 tasks 01–02), so the visible bubble, the typed
* equivalent, and the model's own suggestion all agree. */
function continueTurn() {
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
messagesEl.querySelectorAll(".continue-btn").forEach((b) => b.remove());
return runTurn(CONTINUE_QUESTION);
}
/* Phase 123 (task 02, TODO L6): the send flow's UPLOAD STEP (locked
* A8) — the attached File goes to POST /api/chat-images as multipart
* (the session cookie rides the browser; the endpoint is user-gated
* like the turn it feeds) and the returned STORED path is what the
* record + the /api/chat body carry (A5: never base64). ANY failure —
* 413 over the cap, 422 a bad extension (a renamed file the client
* pre-check missed), 5xx, or a network drop — settles the
* phase-114-style OUT-OF-TURN banner with the server's detail and
* returns null: the send is BLOCKED (the question is never sent without
* the image the user attached — the typed text stays, the attachment
* stays for the retry). */
async function uploadAttachedImage(file) {
let res;
try {
const form = new FormData();
form.append("file", file);
res = await fetch("/api/chat-images", { method: "POST", body: form });
} catch {
// Network drop before the server answered — no detail to show.
showErrorBanner("Couldn't attach the image — try again.");
return null;
}
let detail = "";
let path = null;
try {
const body = await res.json();
if (typeof body?.detail === "string") detail = body.detail;
if (typeof body?.path === "string") path = body.path;
} catch { /* non-JSON error body — the status line stands in */ }
if (!res.ok || !path) {
showErrorBanner(
detail
? `Couldn't attach the image — ${detail}.`
: "Couldn't attach the image — try again."
);
return null;
}
return path;
}
/* Phase 123 (task 02, TODO L6): the preview strip — revealed with the
* attached image's data-URL thumbnail + filename (the thumbnail is
* decorative, alt="" in the static markup — the filename beside it is
* the readable label), and cleared with the attachment (the remove button,
* the send, or a New chat). The strip's markup is static (hidden by
* default); only the thumbnail's src + the name's textContent move
* here (the createElement/textContent house rule — no HTML strings).
* Idempotent: a fresh pick re-renders the same strip in place. */
function showAttachPreview() {
if (!attachedImage || !attachPreview) return;
attachPreview.querySelector("img").src = attachedImage.dataUrl;
attachPreview.querySelector(".attach-preview-name").textContent = attachedImage.name;
attachPreview.hidden = false;
}
/* Phase 123 (task 02, TODO L6): clear the attachment + hide the strip
* (idempotent — calling it with nothing attached is a no-op). The
* strip must not linger into a turn, and a New chat resets the
* composer's draft (the question text + its attachment) together. */
function clearAttachedImage() {
attachedImage = null;
if (attachPreview) attachPreview.hidden = true;
}
/* Phase 132 (task 01, TODO L8; A5, owner-confirmed 2026-09-27): the
* PRE-turn uploading state — the visible proof that the attached
* image is moving while POST /api/chat-images is in flight. The
* affordance is BOTH halves at once: the preview strip shows the
* spinner + "Uploading image…" (the status part unhidden, the
* .is-uploading class for task 02's CSS) with its remove X disabled
* (the attachment is mid-flight — it can't be removed out from under
* the upload), AND the Send button is DISABLED with the "Uploading…"
* label — the user-facing half of the attachUpload double-fire guard
* (a second press does nothing: the top-of-handleSend
* `sendBtn.disabled` guard AND the `attachUpload` guard both no-op).
* exitUploadState is the mirror image: the strip returns to its
* normal preview (thumbnail + filename stay visible), the remove X
* re-enables, and the button restores EXACTLY the idle look —
* enabled + "Send" (byte-identical to setUiState's idle label),
* restored BEFORE the success path calls runTurn (whose
* setUiState("thinking") then takes the button over as usual) and
* before the failure path returns (banner shown, input text intact,
* the attachment still attached — the user can retry or remove).
* setUiState / uiState / the turn loader are deliberately UNTOUCHED:
* the upload is PRE-turn (uiState stays idle; the turn's
* thinking/streaming feedback starts only when runTurn does).
* Both are idempotent and null-guarded on the attach family (the
* house pattern — a New chat mid-upload hides the strip; the class
* on a hidden element is harmless and still cleared at resolution);
* the send refs are unconditional (the composer is required for the
* chat view to exist at all). */
function enterUploadState() {
if (attachPreview) attachPreview.classList.add("is-uploading");
if (attachStatus) attachStatus.hidden = false;
if (attachRemove) attachRemove.disabled = true;
sendBtn.disabled = true;
sendLabel.textContent = "Uploading…";
}
function exitUploadState() {
if (attachPreview) attachPreview.classList.remove("is-uploading");
if (attachStatus) attachStatus.hidden = true;
if (attachRemove) attachRemove.disabled = false;
sendBtn.disabled = false;
sendLabel.textContent = "Send";
}
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 123 (task 02, TODO L6; locked A8): an attached image uploads
// FIRST — BEFORE the input is cleared, so a failed upload BLOCKS the
// send and the typed question stays exactly where the user left it
// (the question is never sent without the image the user attached;
// the banner says what failed, the attachment stays for the retry).
// The returned STORED path (never the bytes, A5) then rides runTurn
// into the record + the request body.
let image = null; // { path, src, alt } | null — null = text-only send
if (attachedImage) {
if (attachUpload) return; // a second submit mid-upload: the first owns the send (never two uploads, never two turns)
attachUpload = true;
// Phase 132 (task 01, TODO L8): the uploading state is entered
// exactly ONCE per upload — after the guard, before the await —
// so the gap between the press and the resolution is never
// frozen-looking (the strip's spinner + "Uploading image…", the
// disabled "Uploading…" Send).
enterUploadState();
const path = await uploadAttachedImage(attachedImage.file);
attachUpload = false; // the helper never throws (every failure path is a banner + null)
// Phase 132: exited on BOTH resolution paths — the helper never
// throws, so this line is reached on success AND failure. Before
// the success path continues to runTurn (whose setUiState takes
// the button over) and before the failure path returns below
// (banner + editable composer + the attachment for the retry).
exitUploadState();
if (path === null) return; // A8: the send is blocked — the question stays
image = {
path, // the record + the /api/chat body (A5: the path, never base64)
src: attachedImage.dataUrl, // the live bubble (no fetch); restore uses the path
alt: attachedImage.name, // the filename (task 03's restore picks its own alt)
};
}
// 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, image });
}
/* Phase 120 (TODO.md L3–4, locked A1): the single funnel for every
* NON-STOP, NON-ABORT turn failure — the SSE error-frame catch's else
* branch (network error, pre-stream HTTP error, the `error` frame's
* throw) and the stream-drop guard (frames arrived, no `done`). A failed
* turn persists as a BRAIN record marked `failed: true` + the capped
* error detail (the phase-48 `stopped` precedent — no separate error
* table, no new API): the question's user record immediately precedes it,
* so retryLastTurn's pop-the-last-brain-record-then-re-ask logic works
* UNCHANGED, and lastBrainWrap is set BEFORE the caller's
* setUiState(UI_STATE.error, …) — so showErrorBanner's EXISTING
* `opts.retryable && lastBrainWrap` condition finally reveals the
* phase-111 banner Retry on a network error (showErrorBanner itself is
* byte-unchanged).
*
* Two shapes: a partial `wrap` exists (a frame streamed) — close the
* thinking block + tool calls (the stop-finalize pattern), keep the
* streamed answer text, add the in-bubble error note; or zero frames —
* a new FAILED_TURN_TEXT bubble. Either way the record rides
* rememberBrainTurn (localStorage + the phase-55 auto-save) and the
* in-bubble Retry button lands on the failed bubble (markLastRetryable).
* `detail` is trimmed + capped at 500 before persistence (the schema's
* ChatMessage.error bound is the backstop; the banner keeps the original
* untrimmed string). NOT this funnel: the stop path (its own branch),
* the 300s abort (the `aborted` branch), and the zero-frame-but-completed
* case (the stream settled — runTurn's inline fallback branch keeps its
* EMPTY_ANSWER_FALLBACK bubble and only gains the failed marker).
*
* Phase 164 (D3): the `bubbleText` option — the caller-supplied zero-frame
* bubble line (runTurn's catch forwards `err.bubbleText`; ONLY the
* pre-stream HTTP-rejection throw sets one — FAILED_REQUEST_TEXT). The
* ZERO-frame branch settles `bubbleText || FAILED_TURN_TEXT` into BOTH
* the visible bubble and the persisted record ("what the user saw is what
* is stored" — the phase-120 contract, extended). The partial-stream
* branch never reads the key: a pre-stream throw has no `wrap` by
* construction, so a bubbleText can only ever reach the zero-frame path. */
function finalizeFailedTurn(
detail,
{ acc, thinking, tools, segments, wrap, leavePartialIndex, bubbleText }
) {
const error = (detail || "").trim().slice(0, 500);
if (wrap) {
// Partial streamed: keep the answer text the user saw, settle every
// open run closed (phase 158, D5 — the stop-finalize pattern),
// mark it failed.
closeAllRuns(wrap);
// Phase 161 (D2): the failed-turn funnel is a settle — fold the
// container alongside the runs; the summary reads the live run
// count (D6 — segRuns, module scope).
settleReasoning(wrap);
setReasoningStatus(`${segRuns.length} steps`);
appendFailedNote(wrap, error);
rememberBrainTurn(
acc,
{
thinking: thinking || undefined,
tools: tools.length ? tools : undefined,
// Phase 158 (D4): the failed partial's timeline persists too
// (non-empty only — the undefined-drops-the-key house pattern).
segments: segments?.length ? segments : undefined,
failed: true,
error: error || undefined,
},
leavePartialIndex
);
lastBrainWrap = wrap;
} else {
// Zero frames (network error, pre-stream HTTP error): a new failed
// bubble with the fixed honest line — the real detail rides the note
// + the persisted `error` key. Phase 164 (D3): a caller-supplied
// bubbleText (the pre-stream HTTP rejection — FAILED_REQUEST_TEXT)
// replaces the connection-dropped line; the SAME value goes into the
// bubble and the record (stored-what-you-saw).
const fwrapText = bubbleText || FAILED_TURN_TEXT;
const fwrap = addMessage("brain", fwrapText);
appendFailedNote(fwrap, error);
rememberBrainTurn(
fwrapText,
{ failed: true, error: error || undefined },
leavePartialIndex
);
lastBrainWrap = fwrap;
}
markLastRetryable(); // the in-bubble Retry lands on the failed bubble
markLastEditable(); // phase 159: the failed turn's question stays editable (the trim-and-re-ask remedy — the settle re-runs at idle)
}
/* 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, image = null } = {}) {
if (!reask) {
// Phase 123 (task 02, TODO L6): the attached image rides the user
// bubble (the data URL live — the stored path is the fallback, so
// any future caller passing only a path still renders) and the
// STORED RECORD (the path, A5: never base64). A null image (every
// text-only send, every re-ask — A7: a redo re-sends the text
// only) leaves the bubble and the record byte-identical to
// pre-phase.
const userWrap = addMessage(
"user",
renderMarkdown(text),
true, // reveal my message (owner-kept)
image ? { src: image.src || image.path, alt: image.alt } : null
);
lastUserWrap = userWrap; // phase 159: the newest question bubble is the editable one
// Persistence save point 1: the question is stored the moment it is
// sent, so a failed/interrupted turn never loses it. The `image`
// key (the stored path) joins the `bor.chat.v1` record only when an
// attachment exists (A5 — the phase-14 shape gains one optional key;
// a text-only record is byte-identical to pre-phase).
conversation.push(
image ? { who: "user", text, image: image.path } : { 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();
// Phase 123 (task 02): the strip must not linger into the turn —
// cleared AFTER the bubble is rendered (the bubble already holds
// the image; the record holds the path; a failed turn keeps both).
clearAttachedImage();
}
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 = "";
segRuns = []; // phase 158: a fresh turn starts with no runs (the pagehide partial reads the current turn's runs)
currentRun = null;
reasoningWrapEl = null; // phase 161: a fresh turn starts with no container (the pagehide partial reads the live container, like segRuns)
persistedOnLeave = false;
leftThePage = 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);
// phase 139: a range read's {offset, limit} join
// additively (each field only when well-formed)
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,
}));
// Phase 123 (task 02, TODO L6): the attached image's STORED path
// rides the body top-level (A5: the path, never base64; the server
// builds the multimodal content from the stored bytes). Added only
// when present — a text-only body omits the `image` key entirely
// (byte-identical to pre-phase). HISTORY entries stay {who, text,
// thinking}: prior turns' images are never replayed (locked A7 —
// the image is turn-local to the original send).
const payload = { message: text, history };
if (image) payload.image = image.path;
res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
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();
// Phase 164 (D2): the 422 `detail` is a LIST OF OBJECTS — the
// shared serializer yields the `msg` text, never
// "[object Object]" and never the rejected `input`.
detail = errorDetailText(body.detail, detail);
} catch { /* non-JSON error body */ }
// Phase 164 (D3, locked A3/A4): a pre-stream HTTP rejection gets
// its own honest bubble (the server ANSWERED — it did not drop),
// and the phase-114 hint channel: the banner reassures that only
// this send died. runTurn's catch threads bubbleText into
// finalizeFailedTurn; hint rides setUiState's opts unchanged.
const err = new Error(detail);
err.bubbleText = FAILED_REQUEST_TEXT;
err.hint =
"The app answered — it rejected the request before the model ran. Nothing was lost but this send.";
throw err;
}
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 per-run
// Thinking block (the D5 refinement, owner 2026-10-06 — a
// plain div, always visible; the container is the ONE
// collapsible). 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 158 (D1, owner direction 2026-10-04): PER-RUN blocks —
// the D15 "re-open the one shared block" is superseded. Each
// CONTIGUOUS thinking run streams into its OWN block
// (ensureThinkingRun: a frame of a new kind ends the previous
// run and opens a new block — always visible inside the
// container; D5 refinement). A post-delta thinking frame (the
// next agent round: the model answered, called a tool, then
// thinks again) lands in a NEW block — not a re-open of the
// block that sits on top of it (the owner's "thinking resumed
// above the answer" awkwardness is gone; the runs stay in
// arrival order, D2).
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
// Phase 161 (D3): the container window's PRE-update pin
// capture — BEFORE ensureThinkingRun appends the (new) block:
// the append's own height growth must not leak into the
// "pre" reading (an append taller than the 32px band would
// read as "the user scrolled up" and kill the follow at the
// first frame of every run after the first — the D5
// refinement, owner 2026-10-06, exposed the ordering defect:
// the old details collapse used to reset the window to a
// fitting state, masking it). The phase-17 pre-measure
// convention, verbatim.
// convention, verbatim. A turn's FIRST frame (the container
// did not exist yet — reasoningWrapEl is null) is always
// pinned: the fresh window starts at its bottom (st=0), and
// a first chunk taller than the 320px window must still
// land on the tail, not the top.
const rbody = reasoningWrapEl?.querySelector(".reasoning-body");
const rPinned = rbody ? isReasoningPinned(rbody) : true;
const block = ensureThinkingRun(wrap);
// Phase 161 (D6): the container's live status — "Thinking…"
// while a thinking frame streams (a tool frame overwrites it
// with "Calling <name>…"; the settle sites overwrite it with
// "N steps").
setReasoningStatus("Thinking…");
currentRun.run.text += ev.text || ""; // the RUN's text — the per-run block renders it
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. The container capture above is taken BEFORE
// the block append for the same reason (the D5 refinement's
// exposed ordering defect). Phase 158: the same pin logic, now
// operating on THIS block (per-run — each run's block pins its
// own tail; the pre-update capture is verbatim). The D5
// refinement (owner 2026-10-06): the in-container block is a
// plain div (block.open is undefined → the gate is false) and
// its .thinking-text is not scrollable, so this capture
// no-ops there — the container window's follow below is the
// live scroll surface.
const pinned = block.open && isThinkingNearBottom(textEl);
textEl.innerHTML = renderMarkdown(currentRun.run.text); // 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;
}
if (rPinned && rbody) scrollReasoningToBottom(rbody); // post-append apply
} 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;
// Phase 139 (task 02): the phase-131 wire fields — the `tool`
// frame already carries offset/limit for a range read (ABSENT
// on every non-range frame, never null — the server's
// _whole_line_number drops them when malformed). The SAME
// well-formedness guard as readRangeSuffix (Number.isInteger(x)
// && x >= 1) → null when absent / malformed, and each field
// lands on the record ONLY when non-null: a non-range frame
// keeps the exact pre-phase {name, argument} shape (the
// bor.chat.v1 save JSONB is additive — old records restore
// byte-identically, no migration), while a range read persists
// WHICH lines the model requested (the owner's "same file over
// and over" confusion — two reads of one file now differ).
const frameOffset =
Number.isInteger(ev.offset) && ev.offset >= 1 ? ev.offset : null;
const frameLimit =
Number.isInteger(ev.limit) && ev.limit >= 1 ? ev.limit : null;
// Phase 146 (task 02): the SCOPE of a scoped grep — the
// frame's additive `path` field (ABSENT on every non-scoped
// frame, never null — the ChatToolEvent serializer drops it
// when None). The SAME string guard as `argument` → null when
// absent / malformed; the record gains `path` ONLY when
// non-null (a bare grep keeps the exact pre-phase
// {name, argument} shape — the bor.chat.v1 save JSONB is
// additive, old records restore byte-identically, no
// migration), and the line + the status label show WHERE the
// search runs (the "looks like the LLM is repeating itself"
// confusion, TODO.md L3).
const framePath =
typeof ev.path === "string" && ev.path ? ev.path : null;
const toolRecord = { name, argument };
if (frameOffset !== null) toolRecord.offset = frameOffset;
if (frameLimit !== null) toolRecord.limit = frameLimit;
if (framePath !== null) toolRecord.path = framePath;
toolAcc.push(toolRecord);
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. Phase 139 (task 02,
// ASSUMPTION A2): the read status carries the SAME requested-
// range suffix as the line (readRangeSuffix — the single source
// of truth for the wording; a non-range read renders
// byte-identical, the helper returns "").
const toolStatus =
(name === "read" || name === "read_document") && argument
? `${brand()} is reading ${argument}${readRangeSuffix(frameOffset, frameLimit)}`
: (name === "grep" || name === "search_documents") && argument
? `${brand()} is searching for ${argument}${framePath ? ` in ${framePath}` : ""}`
: 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);
}
// Phase 158 (D1): PER-RUN disclosure — each CONTIGUOUS tool-call
// run streams into its OWN block (ensureToolRun: a frame of a
// new kind ends the previous run and opens a new disclosure —
// the D5 refinement, owner 2026-10-06: a plain div, always
// visible inside the container; the container is the ONE
// collapsible), inserted in arrival order (D2). The record
// rides the run's `records` too — the SAME toolAcc objects, so
// the phase-95 tool_result stamp (truncated / chars_shown /
// chars_total) flows into the segments payload with no extra
// wiring.
// Phase 161 (D3): the container window's PRE-update pin
// capture — BEFORE ensureToolRun appends the (new) block:
// the append's own height growth must not leak into the
// "pre" reading (an append taller than the 32px band would
// read as "the user scrolled up" and kill the follow — the
// D5 refinement, owner 2026-10-06, exposed the ordering
// defect: the old details collapse used to reset the window
// to a fitting state, masking it). The phase-17 pre-measure
// convention, verbatim.
// convention, verbatim. A turn's FIRST frame (the container
// did not exist yet — reasoningWrapEl is null) is always
// pinned: the fresh window starts at its bottom (st=0), and
// a first line taller than the 320px window must still land
// on the tail, not the top.
const tbody = reasoningWrapEl?.querySelector(".reasoning-body");
const tPinned = tbody ? isReasoningPinned(tbody) : true;
const disc = ensureToolRun(wrap);
currentRun.run.records.push(toolRecord);
// Phase 161 (D6): "Calling <name>…" — the display name the
// tool line already shows (the phase-70 legacy-name mapping
// produced `name` before this point).
setReasoningStatus(`Calling ${name}…`);
const line = appendToolLine(
disc,
name,
argument,
frameOffset,
frameLimit,
framePath
);
if (tPinned && tbody) scrollReasoningToBottom(tbody); // post-append apply
// 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.
// Phase 158: the clock TARGETS this line (the tracked reference
// — the `:last-child` query is gone; multiple disclosures make
// it the wrong line).
armToolLineClock(wrap, line);
// 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
// Phase 158 (D5): the first delta ENDS the active run (the
// D5 refinement, owner 2026-10-06 — the in-container runs are
// non-collapsible divs, so nothing folds; the run simply
// stops accumulating frames — a later thinking frame opens a
// NEW run in the container — the D15 re-open of the shared
// block is gone). Idempotent — a no-op without an active run.
closeCurrentRun();
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
// No page scroll (phase 42): the answer never follows the viewport.
} else if (ev.type === "done") {
sawDone = true;
// Phase 158 (D5): the turn is over — the D5 refinement (owner
// 2026-10-06) made the in-container runs non-collapsible divs,
// so this is the DEFENSIVE legacy guard (the pre-phase-161
// details blocks the restore path creates, already closed — a
// no-op in every live path). Replaces the phase-117
// closeToolCalls fold + the D15 block close.
closeAllRuns(wrap);
// Phase 161 (D2): the turn settled — fold the container too
// (the final response has been returned); the summary reads
// the live run count (D6 — segRuns, module scope).
settleReasoning(wrap);
setReasoningStatus(`${segRuns.length} steps`);
if (!wrap) {
setUiState(UI_STATE.streaming);
wrap = addMessage("brain", "…");
}
if (ev.deflected) {
wrap.classList.add("is-deflected");
appendMaybeTry(wrap, ev.suggestions);
}
// Phase 124 (task 06, TODO L3): the LLM's answer image — the
// answer's first-class figure (A4): appended to .msg-body
// right BEFORE the source clusters (phase 130: the chips row,
// then the images row), so it lands below the answer text
// and above the sources. The text is untouched (the
// figure is additive — "summaries should still be provided");
// a frame without it renders byte-identically (no figure DOM).
if (ev.answer_image) appendAnswerImage(wrap, ev.answer_image);
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 BELOW the citation clusters
// and ABOVE the actions row (phase 130 task 02 — the
// .msg-actions row is the bubble's LAST row, so the action
// appenders after this get-or-create it; the getActionsRow
// scoping keeps the buttons out of the related row either way
// — a deflected turn with related docs still gets its own
// actions row).
// Thinking-without-answer (reasoning can exhaust max_tokens): the
// bubble gets the empty-answer fallback — what the user saw is
// what gets persisted.
const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : "");
if (!acc && sawThinking) {
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText);
}
// Persistence save point 2: the answer lands only when the turn is
// complete (raw text + the done metadata; phase 17: + optional
// thinking, phase 37: + optional tools — `undefined` drops the
// key from the JSON).
// 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).
// Phase 158 (D4): the turn's CHRONOLOGICAL run order persists
// with the turn — segments (non-empty only: the
// undefined-drops-the-key house pattern; a no-thinking/no-tools
// turn stores no key). Additive: thinking + tools stay UNCHANGED
// (the history wire + the old-client degradation path).
const turnSegments = buildSegments(segRuns);
rememberBrainTurn(finalText || acc, {
thinking: thinkingAcc || undefined,
tools: toolAcc.length ? toolAcc : undefined,
segments: turnSegments.length ? turnSegments : undefined,
deflected: !!ev.deflected,
sources: ev.sources,
// Phase 124 (task 06, TODO L3): the answer image persists
// with the turn (undefined drops the key from the JSON — the
// house optional-meta pattern, the `related` line below is
// the precedent): an image-less / pre-phase turn stores a
// byte-identical record.
answer_image: ev.answer_image || undefined,
// 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,
// Phase 160 (task 04, TODO L4): the cap-reached marker
// persists with the turn — `undefined` drops the key from
// the JSON (the house optional-meta pattern, the
// answer_image line above is the precedent), so every
// non-cap record stays byte-identical to pre-phase.
budget_exhausted: ev.budget_exhausted || undefined,
}, leavePartialIndex);
lastBrainWrap = wrap; // this bubble is now the last brain answer
// Phase 130 (task 02): the row order is the DOM order — the
// related row above, then the actions row LAST (Tune → Retry
// → Save-as-doc in insertion order, the bubble's bottom-right
// cluster).
appendRelated(wrap, ev.related); // phase 113 — above the actions row
appendTuneButton(wrap); // every completed brain bubble is tunable
markLastRetryable(); // phase 49: the Retry button is last-bubble-only
markLastEditable(); // phase 159: the done turn's question is editable (the in-flight guard no-ops here — the runTurn idle settle lands it)
// Phase 160 (task 04, TODO L4): a cap-reached answer offers
// the one-click continuation — first, every earlier bubble's
// continue button is cleared (the markLastRetryable
// remove-first pattern: the moment a newer turn lands, an old
// bubble's stale affordance is gone — its "continue" would
// append to a history the conversation already moved past),
// then the button is appended ONLY when the done frame is
// flagged (ABSENT on every other turn, so the button never
// appears elsewhere); the row order stays Tune → Retry →
// Continue → Save-as-doc (the insertion order phase 130
// relies on — markLastEditable above acts on the user
// bubble, so the brain bubble's row order is unchanged).
messagesEl.querySelectorAll(".continue-btn").forEach((b) => b.remove());
if (ev.budget_exhausted) appendContinueButton(wrap);
// Phase 59: the RAW persisted markdown (never the rendered
// HTML) — exactly the string rememberBrainTurn stores above,
// so a reload (the restore path) offers the identical draft.
appendSaveAsDocButton(wrap, finalText || acc || "…");
} 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(errorDetailText(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)) {
// Phase 120 (TODO.md L3–4): a half-answer is a FAILED answer — the
// partial persists as failed (its text + the in-bubble error note +
// a working Retry) BEFORE the error state, so Refresh restores what
// the user saw + a Retry (no bare question, no "new chat").
const detail = "The stream ended before my answer finished — try again?";
finalizeFailedTurn(detail, {
acc,
thinking: thinkingAcc,
tools: toolAcc,
segments: buildSegments(segRuns), // phase 158 (D4): the partial's timeline
wrap,
leavePartialIndex,
});
setUiState(UI_STATE.error, detail);
}
if (!aborted && !wrap) {
const fallback = EMPTY_ANSWER_FALLBACK;
// Phase 120 (TODO.md L3–4, task 01 ASSUMPTION): the stream
// COMPLETED with ZERO frames — it is a failed turn too. The bubble
// text stays EMPTY_ANSWER_FALLBACK (a meaningful record text); the
// failed marker + error note make it persist + restore as a failure
// with a working Retry (the pre-phase record persisted with no
// marker — inconsistent with the refresh case this phase fixes).
const nothing = "The model answered with nothing.";
const fwrap = addMessage("brain", fallback);
appendTuneButton(fwrap);
appendFailedNote(fwrap, nothing);
// 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, {
failed: true,
error: nothing,
}, leavePartialIndex); // persist what the user actually saw
lastBrainWrap = fwrap;
markLastRetryable(); // phase 49: the fallback bubble is retryable too
markLastEditable(); // phase 159: the failed turn's question stays editable (the in-flight guard no-ops here — the runTurn idle settle lands it)
appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path
}
} 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.
// Phase 159 (task 03): the aborted settle must not strand the
// question — the same mark pair as the other non-`done` settles.
// markLastRetryable re-targets (a timed-out turn persists NO
// brain record — the phase-120 exclusion — so the pair re-lands
// the previous turn's target and clears stale buttons), and
// markLastEditable lands Edit on the question NOW (the guard
// bails only on thinking/streaming — the error state passes;
// the finally idle-settle re-runs it — the remove-then-append
// keeps the double pass harmless).
markLastRetryable();
markLastEditable();
} 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) {
// Phase 158 (D5): the turn was stopped — fold every open run
// (replaces the phase-117 closeToolCalls fold + the D15 block
// close, like `done`).
closeAllRuns(wrap);
// Phase 161 (D2): the session stopped — fold the container
// alongside the runs; the summary reads the live run count
// (D6 — segRuns, module scope).
settleReasoning(wrap);
setReasoningStatus(`${segRuns.length} steps`);
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. Phase 158
// (D4): the stopped partial's timeline persists too
// (non-empty only).
const stopSegments = buildSegments(segRuns);
rememberBrainTurn(acc, {
thinking: thinkingAcc || undefined,
tools: toolAcc.length ? toolAcc : undefined,
segments: stopSegments.length ? stopSegments : undefined,
stopped: true,
}, leavePartialIndex);
lastBrainWrap = wrap; // the stopped partial is the prime retry candidate
markLastRetryable(); // phase 49: Retry on the stopped partial
markLastEditable(); // phase 159: the stopped turn's question stays editable (the in-flight guard no-ops here — the runTurn idle settle lands it)
}
// 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 120 (TODO.md L3–4, locked A1): persist the failed turn
// BEFORE the error state — the funnel sets lastBrainWrap, so the
// existing `opts.retryable && lastBrainWrap` condition in
// setUiState → showErrorBanner now reveals the phase-111 banner
// Retry on a network error (the button's precondition finally
// holds; showErrorBanner itself is byte-unchanged).
// Phase 120 verification (navigate-away regression): a REAL
// departure (pagehide fired) tears the document down and the
// browser cancels the in-flight fetch — that teardown rejection
// is NOT a failed turn. The phase-20 convention stands: a
// thinking-only navigate-away persists NOTHING brain-side, and a
// partial navigate-away persists the pagehide partial (above) as
// a plain record — never a failed one. A real network error
// while the page is still alive (no pagehide) takes the funnel
// as before.
if (!leftThePage) {
finalizeFailedTurn(detail, {
acc,
thinking: thinkingAcc,
tools: toolAcc,
segments: buildSegments(segRuns), // phase 158 (D4): the partial's timeline
wrap,
leavePartialIndex,
// Phase 164 (D3): the pre-stream HTTP rejection carries its own
// honest bubble line (FAILED_REQUEST_TEXT); every other throw
// (transport, SSE error frame) leaves it undefined → the
// zero-frame settle keeps FAILED_TURN_TEXT byte-identically.
bubbleText: err?.bubbleText,
});
}
// 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);
// Phase 159 (task 01): the terminal settle (idle) is the ONE mark
// pass that lands the Edit button at rest — the mid-turn paired
// call sites (done / zero-frame / stop / failed funnel) no-op on
// the in-flight guard, so this re-run at idle is what actually
// appends (a New-chat-mid-turn clears the records + the wrap, so
// this is a no-op there by construction).
markLastEditable();
if (stoppedByUser) sendStatus.textContent = "Answer stopped.";
// Phase 143 (TODO L5): the focus-back is a DESKTOP courtesy — on a
// coarse pointer (a phone/tablet) focusing the composer pops the
// virtual keyboard over the completed answer, which is exactly what
// the owner found annoying. Fine pointers keep the immediate
// refocus (type the follow-up without moving a hand); a coarse
// pointer stays unfocused — the composer is a ≥44px tap target
// (PLAN §7.1), so the next question starts with a tap. The
// never-scroll contract (phase 42) and the single-settle rule are
// unchanged on every device — only the focus call is gated.
if (!COARSE_POINTER) 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 123 (task 02, TODO L6): the attach flow. The button (revealed
* at boot ONLY when the config flag says images on) opens the hidden
* file input; a pick is validated CLIENT-side against the six
* extensions (the server re-validates on upload — a bad pick gets the
* out-of-turn banner and NO state change: a previous attachment, if
* any, survives), then the { file, name, dataUrl } triple lives in
* attachedImage until the send flow uploads it. The remove button clears
* the state + hides the strip; a fresh pick replaces the triple in
* place (the strip re-renders through showAttachPreview). */
attachBtn?.addEventListener("click", () => attachFile?.click());
attachRemove?.addEventListener("click", () => {
clearAttachedImage();
attachBtn?.focus(); // back to the trigger (reachable only while the strip is visible — which means the button is too)
});
attachFile?.addEventListener("change", () => {
const file = attachFile.files && attachFile.files[0];
attachFile.value = ""; // the same file re-picked must fire change again
if (!file) return;
// The file NAME's extension (lowercased) is the client pre-check —
// the accept attribute is advisory (a renamed file can carry any
// extension); the upload endpoint is the authority (422).
const dot = file.name.lastIndexOf(".");
const ext = dot >= 0 ? file.name.slice(dot + 1).toLowerCase() : "";
if (!ATTACHABLE_IMAGE_EXTENSIONS.includes(ext)) {
showErrorBanner(
"Only PNG, JPEG, WebP, GIF, and BMP images can be attached."
);
return; // no state change — a previous attachment survives
}
const reader = new FileReader();
reader.onload = () => {
attachedImage = { file, name: file.name, dataUrl: String(reader.result) };
showAttachPreview();
};
reader.onerror = () => {
// The bytes never became readable (the file evicted mid-pick) —
// the same copy as an upload failure; no state change.
showErrorBanner("Couldn't attach the image — try again.");
};
reader.readAsDataURL(file);
});
/* 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", () => {
leftThePage = true; // the dying fetch's teardown rejection is not a failed turn
if (persistedOnLeave) return;
if (uiState !== UI_STATE.thinking && uiState !== UI_STATE.streaming)
return;
if (!acc) return; // nothing brain-side to save yet
persistedOnLeave = true;
// Phase 158 (D4): the navigate-away partial's timeline persists too
// (segRuns is module scope — readable here, like thinkingAcc;
// non-empty only — the undefined-drops-the-key house pattern).
const pagehideSegments = buildSegments(segRuns);
rememberBrainTurn(acc, {
thinking: thinkingAcc || undefined,
segments: pagehideSegments.length ? pagehideSegments : 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).
const bootConfig = await (window.BOR_CONFIG_PROMISE ?? Promise.resolve());
docsRepoConfigured = window.BOR_DOCS_REPO_CONFIGURED === true;
// Phase 123 (task 02, TODO L6): the attach control reveals ONLY when
// the SAME settled config says images: true (the brand boot's ONE
// /api/config request — no second round-trip). Off (the default) or
// an unanswered fetch → the button stays hidden for good: the
// flag-off DOM is byte-identical to pre-phase (A5's default-off
// contract), and a degraded boot degrades quietly (the loadHealth
// house style — the page never breaks, the affordance is simply
// absent; the API contract still enforces the toggle server-side).
if (attachBtn) attachBtn.hidden = bootConfig?.images !== 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");
}),
);
})();