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