Files
brain-of-reese/frontend/assets/shared.js
T
ducoterra 051f62deb7
Build and Push Containers / build-and-push-app (push) Successful in 1m55s
Build and Push Containers / build-and-push-db (push) Successful in 14s
phase: 161_reasoning_bubble
All gates verified. Final check — AGENTS.md rules against the project (commands, gates, protocols) — the quick-reference commands all exist and the rules (PLAN §2/§7/§9 references, phase protocol, No CDN, Debugpy, git gates) accurately describe the project; the reasoning bubble is a pure UI change with no new commands or env vars, so no AGENTS.md change is needed.

**Verification complete — full results:**

- **Verified** (implemented by prior task passes, all in working tree): `createReasoningWrap`/`ensureReasoningWrap` live container (D1), `settleReasoning` fold at the 3 settle sites + not at delta (D2), 320px `.reasoning-body` window + pinned-tail follow (D3), zero persistence change (D4), collapsed restore + shared-page parity with D6 step counts, `styles.css` family treatment + reduced-motion still, mock `REASONING_MIDROUND_TRIGGER` flow, 6-test story suite, phase-158 pin updates
- **Full suite:** `uv run pytest` → **3738 passed, 0 failed** · `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99% (>90%)** · `uv run ruff check .` → all checks passed · `uv run pyright` → **0 errors, 0 warnings**
- **E2E in isolation** (DB up; port-8123 collision with an unrelated `undercooked` service → `E2E_APP_PORT=8125`/`E2E_MOCK_PORT=8902`, no live change made): `test_reasoning_bubble.py` **6/6** · `test_thinking_timeline.py` ✓ · `test_thinking_display.py` ✓ · `test_tool_call_compact.py` ✓ · `test_big_read_progress.py` ✓ · `test_turn_progress_loader.py` ✓ · `test_share_chat.py` ✓ · `test_chat_persistence.py` ✓ · `test_smoke.py` ✓ (all exit 0, zero failures)
- **Completion criteria:** all 9 checked against the code + test outcomes — container open/live/nested ✓, stays open past mid-round delta ✓, folds at done ✓, restore + shared collapsed with "3 steps" ✓, plain turn = no container ✓, regression suites green ✓, suite/coverage/lint green ✓, diff scope limited to `frontend/assets/{app.js,shared.js,styles.css}` + `tests/` + `README.md` + phase files (no `app/` — D4) ✓
- **Documentation:** README.md "Thinking" section names the Reasoning bubble accurately (320px window, open-until-settle incl. mid-round deltas, "N steps", byte-identical no-container, collapsed on restore/shared) — verified consistent, no changes needed; AGENTS.md — verified consistent, no changes needed; all `.env.example` vars documented
- **Decisions:** commit + phase-dir move left to the harness (per pass rules — no git index/history commands executed); `undercooked` dev server on port 8123 untouched, E2E used the env port overrides
- **Next pending phase:** none — `161_reasoning_bubble` is the last in `todo/`
2026-10-06 06:36:09 -04:00

722 lines
35 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";
/* ---------- 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 details.thinking
* block and each CONTIGUOUS run of tool entries as ONE
* details.tool-calls-disclosure ("Tool call(s) (N)" per run), in
* arrival order, ABOVE the answer — a pure render, so everything is
* created CLOSED (D5; the same collapsed convention as
* addThinkingBlock / addToolLines). 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 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("details");
block.className = "thinking";
block.open = false; // D5: collapsed (the addThinkingBlock convention)
const summary = document.createElement("summary");
summary.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(summary, 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("details");
toolDisc.className = "tool-calls-disclosure";
toolDisc.open = false; // D5: collapsed (parity with addToolLines — phase 117 D3)
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("summary");
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.
const n = container.children.length;
toolDisc.querySelector("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). 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;
}
const wrap = addSharedMessage("brain", renderMarkdown(m.text));
// 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);
addSources(wrap, m.sources);
if (m.stopped) addStoppedNote(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);
})();