/* Brain of Reese — the anonymous shared conversation page (phase 51, * task 03). * * /shared/ (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/ (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/. 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 = ''; const USER_AVATAR = ''; /* ---------- 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 = `
${html}
`; 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- 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
* 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 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); } 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")); } /* "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 = ''; 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); } /* 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 * 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)); 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); } addSources(wrap, m.sources); if (m.stopped) addStoppedNote(wrap); } /* ---------- the public read ---------- * GET /api/shared/ — 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); })();