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/`
722 lines
35 KiB
JavaScript
722 lines
35 KiB
JavaScript
/* 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);
|
||
})();
|