Files
brain-of-reese/frontend/assets/shared.js
T
ducoterra b34b8ca672
Build and Push Containers / build-and-push-app (push) Successful in 1m46s
Build and Push Containers / build-and-push-db (push) Successful in 12s
phase: 182_verbatim_show_text
**Phase 182 — final verification pass (all 8 tasks already complete; this was verification + docs + one defect fix).**

- Verified: `app/rag/verbatim.py` (locator grammar, `splitlines()` slice, line-boundary cap, `resolve_answer_blocks`), `SHOW_TEXT_TOOL` + `shown_texts`, `SHOW_TEXT_CLAUSE`, `done.answer_text` (9 fields, key dropped when empty) + `ChatMessageRecord.answer_text` (≤50, 600 000-char cap counted in the 8 M budget), `verbatim.js` + 4 `{verbatim:true}` renderers, `Containerfile` minify step, `.env.example` 3 vars.
- `uv run python -c "…answer_text, show_text_max_chars, show_text_max_blocks"` → `True 12000 5`; each negative refuses startup naming its field; `0` accepted as inert ✅
- `bash .agents/validate.sh` → **validation OK**: 4320 unit+integration tests green, `TOTAL 6849 17 → 99%` (gate app/ >90% OK), `ruff check .` "All checks passed!", `pyright` 0 errors (re-run after my edits, 08:28).
- E2E, each in isolation `--no-cov`: `test_verbatim_show_text.py` 13 passed (panel bytes == `documents.content`, server caption, clipboard == server string, `/document.html?source=…&path=…`, honest unresolved state, reload+share, `show_image` regression, flag-off app) ✅; green: `test_answer_images` `test_harness_aligned_tools` `test_agent_document_tools` `test_markdown_tables` `test_markdown_inline_links_images` `test_svg_document_preview` `test_smoke` `test_document_viewer` `test_markdown_blockquotes` `test_chat_persistence` `test_thinking_timeline` `test_chat_rag` `test_settings_mobile_value_width`.
- **Defect found & fixed (pre-existing, not phase 182):** `tests/e2e/test_admin_settings_panel.py::test_admin_sees_groups_and_source_badges` failed — its whole-page `assert "reese" not in page.content()` tripped on the shell's onboarding chips, which follow the newest saved chats in the one shared E2E DB (a leftover `reeselink.json` question from a tool suite). Scoped the strict sweep to `.settings-shell` and kept a whole-page `reese:reese` credential needle → 12 passed with the leftovers still present.
- Docs: README fixed 2 inconsistencies — Quick Start omitted the **required** `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` (the app fail-loud refuses boot) and the broken anchor `#chat--` → `#chat-`; full re-read of README (1816 lines) + AGENTS confirmed the phase-182 feature/settings/log/UI-contract text matches the code, and every `.env.example` var + every `Settings` field is documented.
- Notable: kept `resolve_answer_blocks` synchronous on the SSE terminal path (bounded indexed SELECTs, matching the existing `query_log` precedent and the phase's documented fence-lookup bound); no live-infra changes made.
- Next pending phase: none — `todo/` holds only 182 (its 8 task files are in `complete/`; the harness commits + moves `00_phase.md`).
2026-10-15 08:33:12 -04:00

770 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Brain of Reese — the anonymous shared conversation page (phase 51,
* task 03).
*
* /shared/<token> (owner-locked 2026-08-29, TODO.md L6): anyone with
* the link sees the conversation READ-ONLY — zero interactive controls
* (no composer, no Save/Share/Tune/Retry, no document access). The
* page fetches GET /api/shared/<token> (public — the token IS the
* credential, no admin dependency) and renders the SAME record shape
* the chat page uses (phase 14 bor.chat.v1 — { who, text, sources?,
* deflected?, suggestions?, thinking?, tools?, stopped? }):
*
* • user → the .msg.user bubble (markdown, escape-first);
* • brain → the .msg.brain bubble: the optional thinking block
* restored COLLAPSED (the phase-17 restore convention — a guest
* can still expand it; reading is not mutating), the tool lines
* in saved order (phase 37), the is-deflected treatment + the
* "Maybe try" chips as PLAIN SPAN text (a guest tapping a chip
* has nowhere to go — owner-locked zero controls), the source
* chips as PLAIN TEXT spans (no href, no modal wiring — the
* documents API is admin-only, so a guest cannot open documents),
* and the stopped note (phase 48) when the turn was user-stopped.
*
* Per-page duplication house style (history.js keeps its own clipboard
* helper, the chat page's stays in app.js): this file carries its own
* small copies of the chat page's message-fragment builders — the
* thinking block, the tool lines, the stopped note, the chip rows.
* Nothing is imported from app.js (a page script is never imported by
* another page; header.js is the only cross-page module).
*
* Failure contract: a malformed or missing token in the URL → the
* invalid state shows immediately with NO fetch of any kind (not even
* whoami — the header ships in its guest state, which is already
* correct); a 404 (wrong or revoked token), a network failure, or a
* malformed body → the invalid state (the h1 keeps its fallback), no
* data is rendered, and there is no error banner on this page (zero
* controls — the muted invalid card is the whole failure UI).
*
* The header works for guests: initSharedHeader() runs the cached
* whoami (anonymous → the admin-only links stay hidden, the Sign in
* link is shown) and rewrites ?next= to the current pathname for a
* signed-in admin; the guest's static fallback is ?next=/ — a guest
* signing in from a shared page returns to the app root (see
* shared.html).
*
* All DOM ids match frontend/shared.html.
*/
import { bindSharedHeaderControls, initSharedHeader } from "./header.js";
// The shared header's control bindings (sign-out / mobile hamburger) —
// EXPLICIT init, once per document (header.js is bundle-inlined per
// entry; import-time side effects would double-bind — 2026-09-08 fix).
bindSharedHeaderControls();
const titleEl = document.querySelector("#shared-title");
const noteEl = document.querySelector(".shared-note");
const messagesEl = document.querySelector("#messages");
const invalidEl = document.querySelector("#shared-invalid");
/* 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):
* the note set after the fetch lands carries the configured name; the
* literal is only the no-config fallback. */
const brand = () => window.BOR_BRAND || "Brain of Reese";
/* Phase 182 (task 07): the shared page's verbatim link target — the plain
* `/document.html?source=…&path=…` href, with NO modal hook (this page has
* no document modal, and the documents API is admin-gated: a guest who
* follows the link lands on the sign-in page, which is the honest answer to
* "show me that file"). verbatim.js builds this same href by default; naming
* it here is the parity statement — the SAME panel client, the SAME three
* globals the chat page calls, so a shared turn renders identically to the
* owner's (the phase's hard requirement). */
const SHARED_VERBATIM_OPTS = {
documentHref: (
source,
path
) =>
"/document.html?source=" +
encodeURIComponent(source) +
"&path=" +
encodeURIComponent(path),
};
/* ---------- the token (from the URL) ----------
* The last path segment of /shared/<token>. A malformed or missing
* token (a path without a final segment, or a non-uuid segment) →
* null: the boot shows the invalid state immediately and makes NO
* fetch of any kind (the server's page route 404s a hand-typed
* /shared/garbage anyway — the client gate keeps the no-fetch rule
* and renders the page's own invalid state instead of a JSON error). */
const TOKEN_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function parseSharedToken() {
const segments = window.location.pathname.split("/").filter(Boolean);
const last = segments[segments.length - 1] || "";
return TOKEN_RE.test(last) ? last : null;
}
/* ---------- the invalid / revoked state ----------
* The one failure UI on the page: the centered muted card (ship-
* hidden in the markup, revealed here). The h1 keeps its static
* fallback, the conversation section stays empty — no data rendered,
* no banner. */
export function showInvalid() {
if (invalidEl) invalidEl.hidden = false;
}
/* ---------- avatar glyphs (duplicated from app.js, phase 08) ----------
* Inline SVG as string constants so the message renderer shares the
* exact marks the chat page uses. currentColor lets the CSS theme the
* stroke (brand-ink for Brain, ink-soft for the user). */
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 (read-only) ----------
* The SAME .msg/.msg-body/.bubble structure the chat page renders, so
* the existing CSS applies unchanged. No scroll intent (a guest lands
* where the browser puts them — no composer to reveal), no empty
* state to hide (the section is empty by construction until records
* are appended). */
function addSharedMessage(who, html) {
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);
return wrap;
}
/* The thinking block (phase 17) — the local copy of the chat page's
* restore path: the model's reasoning ABOVE the answer bubble,
* restored COLLAPSED (the phase-17 restore convention — the live path
* opens it while streaming; a shared chat is a finished conversation,
* so it lands closed). Native details/summary — expanding is reading,
* not mutating. The reasoning text is stored RAW, so it goes through
* the same escape-first global renderMarkdown as the answer. */
function addThinkingBlock(wrap, thinking) {
const body = wrap.querySelector(".msg-body");
if (!body) return;
const block = document.createElement("details");
block.className = "thinking";
block.open = false; // restored COLLAPSED
const summary = document.createElement("summary");
summary.textContent = "Thinking";
const textEl = document.createElement("div");
textEl.className = "thinking-text";
textEl.innerHTML = renderMarkdown(thinking); // escape-first, XSS-safe
block.append(summary, textEl);
body.insertBefore(block, body.querySelector(".bubble"));
}
/* Tool-call lines (phase 37; phase 70 remapped the tool names to the
* harness surface ls / read(path) / grep(pattern, path?)) — the local
* copy of the chat page's appendToolLine: one visible "calling tool"
* row per saved {name, argument} record, in saved order, above the
* answer. 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: the NEW names
* render (read → the Reading line, grep → the Searching-for line,
* ls → the Listing-documents line, scoped ls → the
* Listing-documents-in-<scope> line), and the pre-phase-70 names
* (read_document / search_documents / list_documents) still render
* exactly as before — a row saved before the remap keeps its exact
* line (no migration). The content marks are the exact app.js
* template strings — the frontend emoji guard (tests/integration/
* test_api.py) strips precisely those literals in this file, as in
* app.js.
*
* Phase 95 (task 02): the truncation marker rides the SAME stored
* record the chat page uses — a {name, argument, truncated,
* chars_shown, chars_total} entry (the live `tool_result` frame's stamp,
* persisted with the turn) re-renders the identical " (truncated —
* showing N of M chars)" span next to its Reading line, so a shared
* page shows the truncation pixel-identically to the chat page (the
* phase-50 restore contract). A record saved before phase 95 (no
* fields) renders exactly as before (no marker, no migration).
* createElement + textContent only — nothing HTML-shaped from storage.
*
* Phase 117 (D2/D3): the lines ride the SAME native <details>
* disclosure as the chat page (details.tool-calls-disclosure + a
* plain-text "Tool call(s) (N)" summary) — but a pure render has no
* live turn, so it is created CLOSED: the shared page shows one
* compact line, never an auto-expanded record. The line bytes, the
* four label literals, and the <code> textContent arguments are
* untouched (the parity pins stay green). */
function addToolLines(wrap, tools) {
if (!Array.isArray(tools) || !tools.length) return;
const body = wrap.querySelector(".msg-body");
if (!body) return;
// Phase 117 (D3): the SAME disclosure as the chat page — created
// CLOSED here (pure render, always folded; no live turn to open it).
const disclosure = document.createElement("details");
disclosure.className = "tool-calls-disclosure";
disclosure.open = false; // D3: closed on the shared page (pure render)
const summary = document.createElement("summary");
summary.className = "tool-calls-summary";
disclosure.appendChild(summary);
const container = document.createElement("div");
container.className = "tool-calls";
container.setAttribute("role", "list");
container.setAttribute("aria-label", "Tool calls");
disclosure.appendChild(container);
for (const t of tools) {
if (!t || typeof t.name !== "string") continue;
const line = document.createElement("span");
line.className = "tool-call";
line.setAttribute("role", "listitem");
const argument =
typeof t.argument === "string" && t.argument ? t.argument : null;
// Phase 70: new names first, legacy names kept — a conversation
// saved before the remap renders byte-identical (no migration).
if ((t.name === "read" || t.name === "read_document") && argument) {
line.textContent = "📄 Reading ";
const code = document.createElement("code");
code.textContent = argument; // the path is data, never markup
line.appendChild(code);
// Phase 139 (task 01): the SAME requested-range suffix as the chat
// page's appendToolLine (the parity contract) — a span SIBLING
// after the <code> child, appended ONLY when the suffix is
// non-empty: a record without the fields renders exactly as today.
const rangeSuffix = readRangeSuffix(t.offset, t.limit);
if (rangeSuffix) {
const range = document.createElement("span");
range.className = "read-range";
range.textContent = rangeSuffix; // the range is data, never markup
line.appendChild(range);
}
} else if (
(t.name === "grep" || t.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 (t.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: the stored truncation record — the same marker the chat
// page's restore path renders (plain integers, no separators);
// only argument-bearing (Reading) lines can carry it.
if (t.truncated && argument) {
const note = document.createElement("span");
note.className = "truncated-note";
note.textContent =
" (truncated — showing " + (Number(t.chars_shown) || 0) + " of " + (Number(t.chars_total) || 0) + " chars)";
line.appendChild(note);
}
}
// Phase 117 (D5): the count once, after every line is in.
const n = container.children.length;
summary.textContent = `Tool call${n === 1 ? "" : "s"} (${n})`;
body.insertBefore(disclosure, body.querySelector(".bubble"));
}
/* Phase 161 (task 02, D1/D6): the SHARED page's parity copy of
* app.js's createReasoningWrap — the SAME details.reasoning
* container (summary.reasoning-summary with the "Reasoning" literal
* — D6's ONLY new static literal — + the EMPTY span.reasoning-status
* the at-rest count fills, and the .reasoning-body window), built
* with createElement only (innerHTML ABSENT — the house rule; the
* "Reasoning" literal is a static text node, no data flows through
* it). The caller owns open/fold + insertion: the shared page
* renders it COLLAPSED around the per-run timeline (D5 — the
* pure-render convention) with the D6 "N steps" summary. A parity
* copy, not an import (the shared page has no import of app.js). */
function createReasoningWrap() {
const cont = document.createElement("details");
cont.className = "reasoning";
const summary = document.createElement("summary");
summary.className = "reasoning-summary";
summary.textContent = "Reasoning"; // D6: the ONLY new static literal (parity with app.js)
const status = document.createElement("span");
status.className = "reasoning-status"; // empty until the at-rest count fills it
summary.appendChild(status);
cont.appendChild(summary);
const body = document.createElement("div");
body.className = "reasoning-body";
cont.appendChild(body);
return cont;
}
/* Phase 158 (task 03, D4/D5): the SHARED page's per-run timeline —
* the parity copy of the chat page's restoreTimeline: the record's
* `segments` array (the per-run order, task 01's ChatMessage field)
* renders each CONTIGUOUS thinking run as its own .thinking block and
* each CONTIGUOUS run of tool entries as ONE .tool-calls-disclosure
* ("Tool call(s) (N)" per run), in arrival order, ABOVE the answer —
* a pure render. Phase 161 (task 02, D5/D6): the
* per-run timeline renders inside ONE details.reasoning container —
* the local parity builder above, created CLOSED (the pure-render
* convention) and inserted ONCE before the bubble (the shared
* page's existing insertion convention); every per-run block
* appends into the container's .reasoning-body (the per-run bubble
* anchor is superseded — arrival order preserved). The D5 refinement
* (owner 2026-10-06, parity with app.js): the in-container runs are
* plain divs (NEVER collapsible — the container is the ONE
* disclosure; the per-run blocks are always visible while the
* container is open). The D6 at-rest
* step count uses the SAME two-increment semantics as app.js's
* restoreTimeline (a parity copy, not an import — contiguous runs:
* each thinking entry is ONE run; the first entry of a new
* contiguous tool group is ONE run), with the same uniform
* `${runs} steps` literal. The structure is
* createElement/textContent; the line bytes mirror addToolLines
* EXACTLY (the four pinned label literals + the phase-70 legacy
* names + the phase-139 readRangeSuffix + the phase-95 truncation
* note — the shared-page parity), and the thinking text flows
* through the SAME escape-first renderMarkdown write addThinkingBlock
* uses (XSS-safe). An entry with an UNKNOWN `kind` (or a malformed
* shape) is SKIPPED — never render from storage (textContent only,
* the house rule). A record WITHOUT `segments` never reaches here —
* the dispatch keeps the legacy addThinkingBlock + addToolLines path
* byte-identical. */
function addSegments(wrap, segments) {
const body = wrap.querySelector(".msg-body");
if (!body) return;
const bubble = body.querySelector(".bubble");
// Phase 161 (task 02, D5/D6): the container — the local parity
// builder, created CLOSED (D5 — the pure-render convention;
// explicit for the pin) and inserted ONCE before the bubble (the
// shared page's existing insertion convention — the per-run blocks
// nest into its .reasoning-body, arrival order preserved).
const cont = createReasoningWrap();
cont.open = false; // D5: collapsed (the pure-render convention; explicit for the pin)
const runsEl = cont.querySelector(".reasoning-body");
body.insertBefore(cont, bubble); // the shared page's existing insertion convention
let toolDisc = null; // the CURRENT run of consecutive tool entries (one disclosure per run, D1)
let runs = 0; // D6: the at-rest step count — contiguous runs, not entry count
for (const entry of segments || []) {
// a malformed entry (or one with an unknown `kind`) is SKIPPED —
// never render from storage (textContent only, the house rule).
if (!entry || typeof entry !== "object") continue;
if (entry.kind === "thinking") {
if (typeof entry.text !== "string") continue; // malformed: skipped
if (toolDisc) toolDisc = null; // the run is sealed — the count is final
runs += 1; // D6: each thinking entry is ONE run (parity with app.js — a thinking entry after a tool group starts a run; two adjacent thinking entries are TWO runs)
const block = document.createElement("div"); // D5 refinement (owner 2026-10-06): the in-container run is a plain div — never collapsible (the container is the ONE disclosure)
block.className = "thinking";
const label = document.createElement("span");
label.className = "thinking-summary";
label.textContent = "Thinking"; // the phase-17 literal (D3 — position distinguishes the runs)
const textEl = document.createElement("div");
textEl.className = "thinking-text";
textEl.innerHTML = renderMarkdown(entry.text); // escape-first, XSS-safe (parity with addThinkingBlock)
block.append(label, textEl);
runsEl.appendChild(block); // Phase 161 D5: inside the container, in arrival order
} else if (entry.kind === "tool") {
if (typeof entry.name !== "string") continue; // malformed: skipped
if (!toolDisc) {
toolDisc = document.createElement("div"); // D5 refinement: the in-container run is a plain div (never collapsible)
toolDisc.className = "tool-calls-disclosure";
runs += 1; // D6: the first entry of a NEW contiguous group is ONE run (parity with app.js — a group of N entries is ONE run)
const summary = document.createElement("span");
summary.className = "tool-calls-summary";
toolDisc.appendChild(summary);
const container = document.createElement("div");
container.className = "tool-calls";
container.setAttribute("role", "list");
container.setAttribute("aria-label", "Tool calls");
toolDisc.appendChild(container);
runsEl.appendChild(toolDisc); // Phase 161 D5: inside the container, in arrival order
}
const container = toolDisc.querySelector(".tool-calls");
const line = document.createElement("span");
line.className = "tool-call";
line.setAttribute("role", "listitem");
const argument =
typeof entry.argument === "string" && entry.argument
? entry.argument
: null;
// The line bytes MIRROR addToolLines exactly (the shared-page
// parity — phase 70: new names first, legacy names kept; the
// phase-139 range suffix; the phase-95 truncation note).
if ((entry.name === "read" || entry.name === "read_document") && argument) {
line.textContent = "📄 Reading ";
const code = document.createElement("code");
code.textContent = argument; // the path is data, never markup
line.appendChild(code);
const rangeSuffix = readRangeSuffix(entry.offset, entry.limit);
if (rangeSuffix) {
const range = document.createElement("span");
range.className = "read-range";
range.textContent = rangeSuffix; // the range is data, never markup
line.appendChild(range);
}
} else if (
(entry.name === "grep" || entry.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 (entry.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: the stored truncation record — the same marker
// addToolLines renders (plain integers, no separators); only
// argument-bearing (Reading) lines can carry it.
if (entry.truncated && argument) {
const note = document.createElement("span");
note.className = "truncated-note";
note.textContent =
" (truncated — showing " + (Number(entry.chars_shown) || 0) + " of " + (Number(entry.chars_total) || 0) + " chars)";
line.appendChild(note);
}
// The per-run count (parity with addToolLines — one disclosure
// per contiguous run, D3) rides every append; the final
// append leaves the final count. The D5 refinement (owner
// 2026-10-06): the target is the .tool-calls-summary CLASS —
// the legacy `<summary>` and the in-container `<span>` both
// carry it, so ONE query serves both.
const n = container.children.length;
toolDisc.querySelector(".tool-calls-summary").textContent =
`Tool call${n === 1 ? "" : "s"} (${n})`;
}
}
// an entry with an unknown `kind` (or a malformed shape) is
// SKIPPED — never render from storage (textContent only, the
// house rule); a guard comment names the skip.
// Phase 161 (task 02, D6): the at-rest step count — the UNIFORM
// literal (parity with app.js — no singular/plural special-case).
cont.querySelector(".reasoning-status").textContent = `${runs} steps`;
}
/* Phase 139 (task 01): the shared page's parity copy of app.js's
* readRangeSuffix — the requested line range on the Reading line. The
* saved record now carries the chat page's offset/limit (the toolAcc
* stamp, phase 139); a record saved before the phase (no fields) renders
* exactly as today (no migration). IDENTICAL literals and IDENTICAL
* Number.isInteger guard (mirrors the server's _whole_line_number,
* app/api/chat.py) — the shared page re-renders saved records
* pixel-identically to the chat page's restore path (the phase-50
* contract). The range shown is the model's REQUESTED extent (the
* ASSUMPTION A1 pin in app.js's readRangeSuffix). */
function readRangeSuffix(offset, limit) {
const offOk = Number.isInteger(offset) && offset >= 1;
const limOk = Number.isInteger(limit) && limit >= 1;
if (offOk && limOk) return ` (lines ${offset}–${offset + limit - 1})`;
if (offOk) return ` (from line ${offset})`;
if (limOk) return ` (first ${limit} lines)`;
return "";
}
/* "Maybe try:" chips under a deflected bubble (honesty gate, phase
* 04) — PLAIN SPAN text, not buttons (owner-locked 2026-08-29: a
* guest tapping a chip has nowhere to go — zero interactive
* controls). Same .suggestion-chip pill look as the chat page; the
* shared-page CSS kills the pointer (pointer-events: none, scoped to
* .shared-shell — the chat page's interactive chips are untouched). */
function addMaybeTry(wrap, suggestions) {
if (!Array.isArray(suggestions) || !suggestions.length) return;
const body = wrap.querySelector(".msg-body");
if (!body) return;
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);
for (const item of suggestions) {
const text = String(item || "").trim();
if (!text) continue;
const chip = document.createElement("span");
chip.className = "suggestion-chip";
chip.setAttribute("role", "listitem");
chip.textContent = text; // raw text — never markup
group.appendChild(chip);
}
body.appendChild(group);
}
/* Source chips (mono, source/path) under a brain bubble — PLAIN TEXT
* spans: no href, no modal wiring, no click handler (owner-locked:
* guests cannot open documents — the documents API is admin-only,
* phase 16). Same .source-chip pill look as the chat page; the
* shared-page CSS kills the pointer, scoped to .shared-shell. */
function addSources(wrap, sources) {
if (!Array.isArray(sources) || !sources.length) return;
const body = wrap.querySelector(".msg-body");
if (!body) return;
const meta = document.createElement("div");
meta.className = "msg-meta";
meta.setAttribute("role", "list");
meta.setAttribute("aria-label", "Sources");
for (const s of sources) {
if (!s || typeof s.source !== "string" || typeof s.path !== "string") continue;
const label = `${s.source}/${s.path}`;
const chip = document.createElement("span");
chip.className = "source-chip";
chip.setAttribute("role", "listitem");
chip.textContent = label; // the path is data — never markup
chip.title = label;
meta.appendChild(chip);
}
body.appendChild(meta);
}
/* The "Stopped" note (phase 48) — the local copy of the chat page's
* appendStoppedNote: the non-interactive meta-row mark on a
* user-stopped brain bubble (the partial answer is what the owner
* shared). Reuses the .msg-meta row when one exists (the source
* chips' row) so the note joins it as a listitem, ARIA-valid. */
function addStoppedNote(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);
}
/* The question's attached image (phase 123, task 03, TODO L6) — the
* local copy of the chat page's attachBubbleImage (the per-page
* duplication house style: this file keeps its own small copies of
* the chat page's message-fragment builders). The SAME .msg-image
* treatment the chat page uses — styles.css is shared by both pages,
* so the rule needs no second copy: the img at the TOP of the user
* bubble (the attachment is part of the question), lazy, alt = the
* record's text or the fallback. The load failure degrades IDENTICALLY
* to the chat page: the img is replaced by the small "image
* unavailable" line (the stored file was deleted out-of-band — the
* record keeps its path, the render degrades; never a broken icon).
* The serve route is public (the token is the shared chat's
* credential, like saved-chat content), so the img loads for guests
* exactly as it does for the owner. */
function addBubbleImage(wrap, src, alt) {
const bubble = wrap?.querySelector?.(".bubble");
if (!bubble) return;
const img = document.createElement("img");
img.className = "msg-image";
img.src = src;
img.alt = alt || "attached image";
img.loading = "lazy";
img.onerror = () => {
const note = document.createElement("span");
note.className = "msg-image-unavailable";
note.textContent = "image unavailable";
img.replaceWith(note);
};
bubble.prepend(img);
}
/* The brain answer's image (phase 124, task 06, TODO L3) — the local
* copy of the chat page's appendAnswerImage (the per-page duplication
* house style: this file keeps its own small copies of the chat
* page's message-fragment builders). NON-INTERACTIVE like every other
* shared-page fragment (owner-locked zero controls: no link, no
* modal, no click handler — a plain <figure>, unlike the chat page's
* anchor). The bytes route is PUBLIC (the token is the shared chat's
* credential, like the phase-123 user-image contract), so the img
* loads for guests exactly as it does for the owner. The visible
* caption keeps the document's TITLE: the content endpoint the chat
* page fetches for the summary is user-gated (a guest fetch would
* just 401), and a caption is ALWAYS visible either way (the WCAG
* alt contract holds with the title standing in). A failed load
* REMOVES the figure (the collapse rule — never a broken-image
* icon). */
function addAnswerImage(wrap, ref) {
if (
!ref ||
typeof ref.source !== "string" ||
typeof ref.path !== "string" ||
typeof ref.image_url !== "string" ||
!ref.image_url
) {
return; // no ref (or a malformed one) → no figure DOM at all
}
const body = wrap?.querySelector?.(".msg-body");
if (!body) return;
const title =
typeof ref.title === "string" && ref.title ? ref.title : "image";
const fig = document.createElement("figure");
fig.className = "answer-image";
const img = document.createElement("img");
img.className = "answer-image-img";
img.src = ref.image_url; // the public bytes route
img.loading = "lazy";
img.alt = title;
img.addEventListener("error", () => fig.remove()); // collapse: never a broken icon
const caption = document.createElement("span");
caption.className = "answer-image-caption";
caption.textContent = title; // the visible caption (always)
fig.append(img, caption);
body.appendChild(fig);
}
/* One stored record through the SAME .msg structure the chat page
* uses (pixel-parity with the chat page's restore path): user → the
* .msg.user bubble (with the question's attached image when the
* record carries the stored path — phase 123, task 03); brain → the
* .msg.brain bubble with the optional
* thinking block (restored COLLAPSED — phase 17), the tool lines,
* the deflection treatment + the plain-text "Maybe try" chips, the
* brain answer's image figure (phase 124, task 06 — a plain
* non-interactive <figure>), the plain-text source chips, and the
* stopped note. NO interactive markup is ever created HERE — no
* buttons, no forms, no links, no click handlers (owner-locked:
* zero controls). The ONE exception is phase 182's verbatim panel, and it
* is not built HERE either: hydrateVerbatim / appendUnlocatedVerbatim /
* enhanceAnswerCodeBlocks (assets/verbatim.js, the classic-script client the
* chat page calls too) own that Copy button and that plain viewer link —
* parity first, so a guest reads the owner's answer. Markdown goes
* through the global escape-first renderMarkdown (markdown.js): the
* stored payloads are raw text, so the renderer's XSS safety applies
* unchanged. */
function renderSharedMessage(m) {
if (m.who === "user") {
const wrap = addSharedMessage("user", renderMarkdown(m.text));
// Phase 123 (task 03, TODO L6): the question's attached image —
// the record's `m.image` carries the STORED PATH (A5: never
// base64; a pre-phase record has no key at all, so it renders
// byte-identically — no img). The public image route makes the
// shared view faithful: the SAME bubble treatment, alt, and
// load-failure degradation as the chat page's restore.
if (typeof m.image === "string" && m.image) {
addBubbleImage(wrap, m.image, m.text || "attached image");
}
return;
}
// Phase 182 (task 07): the shared page's answer renderer OPTS IN the
// verbatim pass — the same opt-in as the chat page's restore, so a stored
// ` ```bor ` fence paints the same placeholder and hydrates from the
// record's `answer_text` below. The USER bubble above stays one-argument
// (a question is never a locator).
const wrap = addSharedMessage("brain", renderMarkdown(m.text, { verbatim: true }));
// Phase 158 (task 03, D4): a record WITH `segments` renders the
// per-run timeline through addSegments (collapsed — the parity
// copy of the chat page's restoreTimeline); the runs carry the
// SAME data as `m.thinking` + `m.tools`, in order, so the legacy
// addThinkingBlock + addToolLines are SKIPPED (rendering both
// would double the runs). A record WITHOUT `segments` — every
// pre-phase record — renders BYTE-IDENTICALLY through the legacy
// path below.
if (Array.isArray(m.segments) && m.segments.length) {
addSegments(wrap, m.segments);
} else {
if (typeof m.thinking === "string" && m.thinking) {
addThinkingBlock(wrap, m.thinking);
}
addToolLines(wrap, m.tools);
}
if (m.deflected) {
wrap.classList.add("is-deflected");
addMaybeTry(wrap, m.suggestions);
}
// Phase 124 (task 06, TODO L3): the answer image renders with the
// brain bubble — the SAME A4 position as the chat page's render
// (below the text, above the chip row). A record without the key
// renders byte-identically (no figure DOM).
addAnswerImage(wrap, m.answer_image);
// Phase 182 (task 07): the verbatim panels render with the bubble in the
// SAME order as the chat page's restore — hydrate the placeholders inside
// the bubble, append the blocks the answer never located, then the source
// chips (below the text, above the clusters). The panel DOM, the Copy
// button and the unresolved sentence all come from verbatim.js — the ONE
// implementation shared with the chat page (parity, not a second copy);
// this file still builds no control of its own.
hydrateVerbatim(
wrap.querySelector(".bubble"),
m.answer_text || [],
SHARED_VERBATIM_OPTS
);
appendUnlocatedVerbatim(wrap, m.answer_text || [], SHARED_VERBATIM_OPTS);
addSources(wrap, m.sources);
if (m.stopped) addStoppedNote(wrap);
// The settled answer's ordinary code blocks get the same Copy button the
// chat page gives them (no badge, no caption — locked A5).
enhanceAnswerCodeBlocks(wrap);
}
/* ---------- the public read ----------
* GET /api/shared/<token> — no admin dependency (the token IS the
* credential). Returns the SharedChatOut snapshot (title + messages)
* or null: a 404 (wrong or revoked token — one message server-side,
* no enumeration), a network failure, or a malformed body all
* collapse to null → the invalid state. */
async function fetchSharedChat(token) {
let res;
try {
res = await fetch(`/api/shared/${token}`);
} catch {
return null; // network failure
}
if (!res.ok) return null; // 404 (wrong/revoked) / 5xx
let data;
try {
data = await res.json();
} catch {
return null; // malformed body
}
return data && Array.isArray(data.messages) ? data : null;
}
/* The 200 path: the h1 gets the shared chat's title (the static
* fallback stays when the title is missing/blank) and every record
* renders through renderSharedMessage. The same defensive filter as
* the chat page's restore keeps a corrupted stored row from
* poisoning the render (nothing HTML-shaped, ever). */
function renderSharedChat(data) {
const title = typeof data.title === "string" ? data.title.trim() : "";
if (title) titleEl.textContent = title;
const messages = data.messages.filter(
(m) =>
m &&
(m.who === "user" || m.who === "brain") &&
typeof m.text === "string" &&
m.text.length > 0
);
for (const m of messages) renderSharedMessage(m);
}
/* Boot: the brand note first (call-time resolution — the classic
* brand.js already set the synchronous default), then the token. A
* malformed or missing token shows the invalid state immediately —
* NO fetch of any kind (not even whoami: the header ships in its
* guest state, which is already correct for a bad URL). A well-
* formed token runs the shared header init (guests: whoami
* anonymous, the admin-only links stay hidden) and then the public
* read; a null read (404 / network / malformed) shows the invalid
* state, and a 200 renders the conversation read-only. */
(async () => {
if (noteEl) noteEl.textContent = `Shared via ${brand()} — read-only.`;
const token = parseSharedToken();
if (!token) {
showInvalid();
return;
}
await initSharedHeader();
const data = await fetchSharedChat(token);
if (!data) {
showInvalid();
return;
}
renderSharedChat(data);
})();