phase: 182_verbatim_show_text
**Phase 182 — final verification pass (all 8 tasks already complete; this was verification + docs + one defect fix).**
- Verified: `app/rag/verbatim.py` (locator grammar, `splitlines()` slice, line-boundary cap, `resolve_answer_blocks`), `SHOW_TEXT_TOOL` + `shown_texts`, `SHOW_TEXT_CLAUSE`, `done.answer_text` (9 fields, key dropped when empty) + `ChatMessageRecord.answer_text` (≤50, 600 000-char cap counted in the 8 M budget), `verbatim.js` + 4 `{verbatim:true}` renderers, `Containerfile` minify step, `.env.example` 3 vars.
- `uv run python -c "…answer_text, show_text_max_chars, show_text_max_blocks"` → `True 12000 5`; each negative refuses startup naming its field; `0` accepted as inert ✅
- `bash .agents/validate.sh` → **validation OK**: 4320 unit+integration tests green, `TOTAL 6849 17 → 99%` (gate app/ >90% OK), `ruff check .` "All checks passed!", `pyright` 0 errors (re-run after my edits, 08:28).
- E2E, each in isolation `--no-cov`: `test_verbatim_show_text.py` 13 passed (panel bytes == `documents.content`, server caption, clipboard == server string, `/document.html?source=…&path=…`, honest unresolved state, reload+share, `show_image` regression, flag-off app) ✅; green: `test_answer_images` `test_harness_aligned_tools` `test_agent_document_tools` `test_markdown_tables` `test_markdown_inline_links_images` `test_svg_document_preview` `test_smoke` `test_document_viewer` `test_markdown_blockquotes` `test_chat_persistence` `test_thinking_timeline` `test_chat_rag` `test_settings_mobile_value_width`.
- **Defect found & fixed (pre-existing, not phase 182):** `tests/e2e/test_admin_settings_panel.py::test_admin_sees_groups_and_source_badges` failed — its whole-page `assert "reese" not in page.content()` tripped on the shell's onboarding chips, which follow the newest saved chats in the one shared E2E DB (a leftover `reeselink.json` question from a tool suite). Scoped the strict sweep to `.settings-shell` and kept a whole-page `reese:reese` credential needle → 12 passed with the leftovers still present.
- Docs: README fixed 2 inconsistencies — Quick Start omitted the **required** `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` (the app fail-loud refuses boot) and the broken anchor `#chat--` → `#chat-`; full re-read of README (1816 lines) + AGENTS confirmed the phase-182 feature/settings/log/UI-contract text matches the code, and every `.env.example` var + every `Settings` field is documented.
- Notable: kept `resolve_answer_blocks` synchronous on the SSE terminal path (bounded indexed SELECTs, matching the existing `query_log` precedent and the phase's documented fence-lookup bound); no live-infra changes made.
- Next pending phase: none — `todo/` holds only 182 (its 8 task files are in `complete/`; the harness commits + moves `00_phase.md`).
This commit is contained in:
+111
-34
@@ -550,6 +550,19 @@ export function documentUrl(source, path, back = "/") {
|
||||
return url;
|
||||
}
|
||||
|
||||
/* Phase 182 (task 07): the ONE options object handed to the verbatim panel
|
||||
* client (assets/verbatim.js, a classic script loaded before this module),
|
||||
* so every panel on this page navigates EXACTLY like a source chip: the
|
||||
* same `documentUrl(…, "/")` href (back → the chat, phase 153) and the same
|
||||
* left-click → same-page modal (phase 26). The shared page passes no such
|
||||
* hook — there the plain `/document.html?…` href verbatim.js builds by
|
||||
* default is the whole affordance (no modal, admin-gated API). */
|
||||
const VERBATIM_LINK_OPTS = {
|
||||
documentHref: (source, path) => documentUrl(source, path, "/"),
|
||||
onOpen: (source, path, opener) =>
|
||||
openDocumentModal(source, path, opener, "/"), // back → the chat page
|
||||
};
|
||||
|
||||
/* ---------- steering notes (phase 15; panel module-owned from phase 34) ----------
|
||||
*
|
||||
* The owner's tuning notes steer every future answer: they live in
|
||||
@@ -2916,7 +2929,15 @@ function renderStoredMessage(m) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const wrap = addMessage("brain", renderMarkdown(m.text), true);
|
||||
// Phase 182 (task 07): the brain restore renderer OPTS IN the verbatim
|
||||
// pass (the ONE answer renderer pair — this and the live per-delta write),
|
||||
// so a stored ` ```bor ` fence becomes the same placeholder the live turn
|
||||
// painted and hydrates from the record's `answer_text` below. A pre-phase
|
||||
// record without the key renders its fences as the honest unresolved
|
||||
// state — the record is what it is, the client never resurrects model
|
||||
// bytes (locked A7). USER bubbles and the thinking blocks stay
|
||||
// one-argument calls: their text is not a locator surface.
|
||||
const wrap = addMessage("brain", renderMarkdown(m.text, { verbatim: true }), true);
|
||||
// Phase 158 (task 03, D4): a record WITH `segments` renders the
|
||||
// per-run timeline through restoreTimeline (collapsed) — the runs
|
||||
// carry the SAME data as `m.thinking` + `m.tools`, in order, so
|
||||
@@ -3009,6 +3030,18 @@ function renderStoredMessage(m) {
|
||||
// the chips row, then the images row). A record without the key
|
||||
// renders byte-identically (pre-phase chats restore unchanged).
|
||||
if (m.answer_image) appendAnswerImage(wrap, m.answer_image);
|
||||
// Phase 182 (task 07): the verbatim panels restore WITH the bubble, in the
|
||||
// SAME order as the live `done` path — hydrate the placeholders inside the
|
||||
// bubble, then append the blocks the answer never located, then the source
|
||||
// clusters (the phase-124/130 position: below the text, above the chips).
|
||||
// The SAME builders as the live turn, so a reload renders the identical
|
||||
// panel (parity is the phase's hard requirement); a record with no
|
||||
// `answer_text` settles its placeholders to the unresolved state.
|
||||
hydrateVerbatim(wrap.querySelector(".bubble"), m.answer_text || [], VERBATIM_LINK_OPTS);
|
||||
appendUnlocatedVerbatim(wrap, m.answer_text || [], VERBATIM_LINK_OPTS);
|
||||
// The settled answer's ordinary code blocks get their Copy buttons here
|
||||
// too (never mid-stream — the live path does the same at `done`).
|
||||
enhanceAnswerCodeBlocks(wrap);
|
||||
appendSources(wrap, m.sources);
|
||||
// Phase 113 (task 02) / phase 130 (task 02): the related tier
|
||||
// restores with the bubble — BELOW the citation clusters and ABOVE
|
||||
@@ -3235,44 +3268,27 @@ function absoluteShareUrl(shareUrl) {
|
||||
return new URL(shareUrl, window.location.origin).toString();
|
||||
}
|
||||
|
||||
/* Select every text node in an element — the link field's
|
||||
* select-on-focus (an <a> has no .select(); a range does the job).
|
||||
* Best-effort: selection failure only means the user copies by hand. */
|
||||
function selectAllInField(el) {
|
||||
try {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
} catch {
|
||||
/* selection is best-effort — the field still shows the full URL */
|
||||
}
|
||||
}
|
||||
|
||||
/* Clipboard copy with the owner-locked inline-link fallback: a
|
||||
* non-secure (http) homelab origin rejects navigator.clipboard, so the
|
||||
* failure path renders a TRANSIENT <a> link field near the status line
|
||||
* (appended to the composer, beside the send button that carries
|
||||
* #send-status) — input-like, it selects its full URL on focus (click
|
||||
* or Tab, then Ctrl/Cmd+C). One field at a time (a new offer replaces
|
||||
* the old). Returns true when the clipboard took it. */
|
||||
* the old). Returns true when the clipboard took it.
|
||||
*
|
||||
* Phase 182 (task 07): the mechanism DELEGATES to the one implementation in
|
||||
* assets/verbatim.js (`copyTextWithFallback` + its select-on-focus helper),
|
||||
* because the verbatim panels and the ordinary answer code blocks needed the
|
||||
* same degradation and a second copy would have been a second source of
|
||||
* truth. Nothing else changes: the same host (the composer, beside the
|
||||
* status line), the same owner-locked field title, the same return value —
|
||||
* the share suites are the guard (tests/unit/test_save_chat_ui.py). */
|
||||
async function copyShareLinkWithFallback(absoluteUrl) {
|
||||
document.querySelectorAll(".share-link-fallback").forEach((el) => el.remove());
|
||||
try {
|
||||
await navigator.clipboard.writeText(absoluteUrl);
|
||||
return true;
|
||||
} catch {
|
||||
const field = document.createElement("a");
|
||||
field.className = "share-link-fallback";
|
||||
field.href = absoluteUrl; // carries the full URL (copy link address works too)
|
||||
field.textContent = absoluteUrl; // the URL is data — textContent, never innerHTML
|
||||
field.title = "Share link — click, then copy (Ctrl/Cmd+C)";
|
||||
field.addEventListener("focus", () => selectAllInField(field));
|
||||
composer.appendChild(field); // near the status line (inside the send button)
|
||||
field.focus({ preventScroll: true }); // selects the URL — ready to copy
|
||||
return false;
|
||||
}
|
||||
return copyTextWithFallback(
|
||||
absoluteUrl,
|
||||
composer, // near the status line (inside the send button)
|
||||
"Share link — click, then copy (Ctrl/Cmd+C)"
|
||||
);
|
||||
}
|
||||
|
||||
/* Share-success toast (phase 55, task 04 — TODO.md L5): the VISIBLE
|
||||
@@ -3903,6 +3919,10 @@ function finalizeFailedTurn(
|
||||
// open run closed (phase 158, D5 — the stop-finalize pattern),
|
||||
// mark it failed.
|
||||
closeAllRuns(wrap);
|
||||
// Phase 182 (task 07): the failure funnel is a settle as well — a partial
|
||||
// stream that carried a locator fence keeps its text, but the placeholder
|
||||
// settles to the unresolved state (no `done`, no server bytes).
|
||||
settleVerbatimPlaceholders(wrap.querySelector(".bubble"), "unverified");
|
||||
// Phase 161 (D2): the failed-turn funnel is a settle — fold the
|
||||
// container alongside the runs; the summary reads the live run
|
||||
// count (D6 — segRuns, module scope).
|
||||
@@ -4028,6 +4048,10 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
turnAbort?.abort(); // phase 48: same outcome, one owner — aborted is
|
||||
// set first, so the catch never reads the guard's
|
||||
// abort as a user stop
|
||||
// Phase 182 (task 07): the 300 s guard is a terminal frame — a panel
|
||||
// still reading "Retrieving the exact text…" would be a lie by then
|
||||
// (the hydrate at `done` never runs), so settle it to the honest state.
|
||||
settleVerbatimPlaceholders(wrap?.querySelector(".bubble"), "unverified");
|
||||
setUiState(UI_STATE.error, "That's taking a long time — the answer may be stuck.");
|
||||
});
|
||||
|
||||
@@ -4329,7 +4353,14 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// NEW run in the container — the D15 re-open of the shared
|
||||
// block is gone). Idempotent — a no-op without an active run.
|
||||
closeCurrentRun();
|
||||
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
|
||||
// Phase 182 (task 07): the OPT-IN verbatim pass on the ONE answer
|
||||
// renderer. A ` ```bor ` locator fence becomes a pending placeholder
|
||||
// here (the model's re-typed body DROPPED, locked A7) that the `done`
|
||||
// branch below hydrates against the frame's server-resolved blocks —
|
||||
// mid-stream the reader sees "Retrieving the exact text…", never a
|
||||
// copy that might be wrong. Every other renderer on the page (user
|
||||
// bubbles, thinking runs, the document viewer) stays one-argument.
|
||||
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc, { verbatim: true });
|
||||
// No page scroll (phase 42): the answer never follows the viewport.
|
||||
} else if (ev.type === "done") {
|
||||
sawDone = true;
|
||||
@@ -4361,6 +4392,25 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// figure is additive — "summaries should still be provided");
|
||||
// a frame without it renders byte-identically (no figure DOM).
|
||||
if (ev.answer_image) appendAnswerImage(wrap, ev.answer_image);
|
||||
// Phase 182 (task 07, locked A1/A2): the answer's verbatim blocks —
|
||||
// `ev.answer_text`, the bytes the SERVER read out of the index — land
|
||||
// in the SAME band as the answer image (below the text, above the
|
||||
// source clusters): hydrate every placeholder the renderer painted,
|
||||
// then append the blocks the answer never located (a `show_text` call
|
||||
// the prose never named). A locator the server could not resolve
|
||||
// renders the honest unresolved sentence with NONE of the model's
|
||||
// bytes, and no panel is left at "Retrieving the exact text…".
|
||||
hydrateVerbatim(
|
||||
wrap.querySelector(".bubble"),
|
||||
ev.answer_text || [],
|
||||
VERBATIM_LINK_OPTS
|
||||
);
|
||||
appendUnlocatedVerbatim(wrap, ev.answer_text || [], VERBATIM_LINK_OPTS);
|
||||
// The turn SETTLED — this is where the ordinary code blocks get their
|
||||
// Copy buttons (never mid-stream: per-delta the bubble is rebuilt from
|
||||
// scratch, and a button that vanishes on the next token is not a
|
||||
// control). NO badge, NO caption on those (locked A5).
|
||||
enhanceAnswerCodeBlocks(wrap);
|
||||
appendSources(wrap, ev.sources);
|
||||
// Phase 113 (task 02): the cited tier stays the citation
|
||||
// surface (the chips above); the related tier (scored docs
|
||||
@@ -4378,7 +4428,11 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// what gets persisted.
|
||||
const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : "");
|
||||
if (!acc && sawThinking) {
|
||||
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText);
|
||||
// Phase 182 (task 07): the same opt-in as the per-delta write —
|
||||
// this line paints the answer the user keeps (the fallback text,
|
||||
// so no placeholder exists in practice, but the ONE answer
|
||||
// renderer must stay ONE code path in shape).
|
||||
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText, { verbatim: true });
|
||||
}
|
||||
// Persistence save point 2: the answer lands only when the turn is
|
||||
// complete (raw text + the done metadata; phase 17: + optional
|
||||
@@ -4405,6 +4459,13 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// the precedent): an image-less / pre-phase turn stores a
|
||||
// byte-identical record.
|
||||
answer_image: ev.answer_image || undefined,
|
||||
// Phase 182 (task 07): the verbatim blocks persist with the turn
|
||||
// (task 05's additive `answer_text` key) so a reload — and a shared
|
||||
// page — re-renders the identical panel. `undefined` drops the key
|
||||
// from the JSON (the house optional-meta pattern, the answer_image
|
||||
// line above is the precedent): a pre-phase or answer_text=False
|
||||
// record stays byte-identical.
|
||||
answer_text: ev.answer_text || undefined,
|
||||
// Phase 113 (task 02): the related tier persists with the
|
||||
// turn (undefined drops the key from the JSON — the house
|
||||
// optional-meta pattern), so the restore path re-renders the
|
||||
@@ -4477,6 +4538,14 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
}
|
||||
}
|
||||
} else if (ev.type === "error") {
|
||||
// Phase 182 (task 07): an `error` frame is terminal — the `done`
|
||||
// hydrate will never run, so a placeholder that streamed in stays at
|
||||
// "Retrieving the exact text…" forever unless it settles HERE (the
|
||||
// honest unresolved state, never a stale promise of the exact text).
|
||||
settleVerbatimPlaceholders(
|
||||
wrap?.querySelector(".bubble"),
|
||||
"unverified"
|
||||
);
|
||||
// Phase 114 (TODO L6): carry the frame's optional hint (the
|
||||
// "question too long" frame has one) through the throw — the
|
||||
// banner shows it in place of the default reachability hint.
|
||||
@@ -4531,6 +4600,10 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
}
|
||||
} catch (err) {
|
||||
if (aborted) {
|
||||
// Phase 182 (task 07): defensive settle for the guard's own abort
|
||||
// surface (the timeout callback already ran it — idempotent, and the
|
||||
// turn must not leave a pending panel on ANY terminal path).
|
||||
settleVerbatimPlaceholders(wrap?.querySelector(".bubble"), "unverified");
|
||||
// The 300s guard already took the turn to the error state — its
|
||||
// own abort surfaces here, and it is never read as a user stop.
|
||||
// Phase 159 (task 03): the aborted settle must not strand the
|
||||
@@ -4551,6 +4624,10 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
// convention; no sources/suggestions — the turn never settled).
|
||||
// The persistedOnLeave guard (phase 20) keeps a navigate-away —
|
||||
// which aborts the same fetch — from saving the partial twice.
|
||||
// Phase 182 (task 07): the STOP is terminal for this answer too — a
|
||||
// placeholder in the kept partial settles to the honest state (the
|
||||
// blocks never arrive without a `done` frame).
|
||||
settleVerbatimPlaceholders(wrap?.querySelector(".bubble"), "unverified");
|
||||
if (wrap && acc && !persistedOnLeave) {
|
||||
// Phase 158 (D5): the turn was stopped — fold every open run
|
||||
// (replaces the phase-117 closeToolCalls fold + the D15 block
|
||||
|
||||
+138
-6
@@ -102,6 +102,43 @@
|
||||
* out of scope. Everything without a link/image/`####`+ construct renders
|
||||
* byte-identically to
|
||||
* the pre-phase renderer (pinned by the phase-180 unit suite).
|
||||
*
|
||||
* Phase 182 (task 06) adds the OPT-IN verbatim pass and the second,
|
||||
* optional argument: `renderMarkdown(md, opts)`, where the ONLY truthy path
|
||||
* is `opts && opts.verbatim === true`. When on, a ` ```bor ` LOCATOR fence
|
||||
* (``` ```bor homelab/runbooks/nginx.md#12-40 ````) is replaced — info
|
||||
* line, body and closing fence — with a `\u0000VERBATIMn\u0000` placeholder
|
||||
* that the final restore step emits as
|
||||
* `<div class="verbatim verbatim-pending" data-bor-locator="…">`.
|
||||
* WHY THE MODEL'S BODY IS DISCARDED (the phase's core contract, locked A7):
|
||||
* the bytes inside a `bor` fence are the MODEL's re-typed copy of a
|
||||
* knowledge-base file, and a re-typed copy is exactly the bug this phase
|
||||
* kills — it can silently differ from the indexed bytes. So the renderer
|
||||
* keeps only the LOCATION (normalized by the local `borLocator`, the client
|
||||
* mirror of Python `app.rag.verbatim.canonical_locator`) and paints nothing
|
||||
* but the pending caption; the server
|
||||
* (`app.rag.verbatim.resolve_answer_blocks`) supplies the real bytes and
|
||||
* `verbatim.js` (task 07) hydrates the placeholder by matching
|
||||
* `data-bor-locator`. An UNPARSEABLE locator is put back byte-for-byte and
|
||||
* falls through to the ordinary fence pass — no placeholder, no badge, the
|
||||
* model's own body — and a fence left OPEN at the very end of the input
|
||||
* (the info line typed, the body not yet streamed) shows that same pending
|
||||
* state instead of raw backticks. The server's `fences_in` is STRICTER
|
||||
* there (it ignores an unclosed fence: the server only ever sees a finished
|
||||
* answer, the client sees a partial one); a half-typed BODY therefore still
|
||||
* reads as ordinary text until its closing fence lands, exactly like every
|
||||
* other incomplete fence does today. PASS ORDER with the option on:
|
||||
* verbatim → fences → tables → escape+inline → blockquotes → paragraphs →
|
||||
* restore (a ` ``` ` inside a `bor` body ends the fence at the FIRST closer
|
||||
* — the documented limitation the server shares, so both sides agree). The
|
||||
* placeholder is the one block element the paragraph pass lets through
|
||||
* unwrapped. BYTE-IDENTICAL WHEN OFF: with no second argument — every
|
||||
* existing call site, the document viewer (`document.js`), the thinking
|
||||
* blocks and the user bubbles — `opts` is undefined, the pass never runs,
|
||||
* no `\u0000VERBATIM` placeholder exists anywhere in the text, the restore
|
||||
* replace is a no-op, and a document that legitimately contains a ` ```bor `
|
||||
* fence still renders its own text (the phase-44/138/180 fixtures stay
|
||||
* byte-identical, pinned by their own suites).
|
||||
*/
|
||||
|
||||
function escapeHtml(s) {
|
||||
@@ -156,10 +193,83 @@ function mdAnchorOpen(safe) {
|
||||
);
|
||||
}
|
||||
|
||||
function renderMarkdown(md) {
|
||||
// Phase 182 (task 06): the CLIENT MIRROR of Python
|
||||
// `app.rag.verbatim.canonical_locator` — the tolerant locator grammar of
|
||||
// phase 182 D7, re-implemented here because the renderer runs in the
|
||||
// browser and cannot import Python. PARITY IS LOAD-BEARING: the canonical
|
||||
// string this returns becomes the `data-bor-locator` key that `verbatim.js`
|
||||
// (task 07) matches against the server's `done.answer_text[].locator`, so a
|
||||
// divergence means a resolved block never hydrates. The parity table in
|
||||
// tests/unit/test_frontend_verbatim_markdown.py pins both sides; `fences_in`
|
||||
// on the server pins the fence shape.
|
||||
//
|
||||
// Grammar: a combined `source/path` (the `read`/`grep` identity contract,
|
||||
// split at the FIRST `/`) plus an OPTIONAL separator (` `, `:`, `#`, `#L`)
|
||||
// and line range — `12-40`, `12\u201340` (en dash), `L12-L40`, or a bare `12`
|
||||
// (one line). Returns `source/path` for a whole document and
|
||||
// `source/path#12-40` for a range (canonical DIGITS only, never the en dash,
|
||||
// never the `L`), or `""` (NOT a locator) for an empty/whitespace input, a
|
||||
// value with no `/` in the identity, a non-numeric or non-positive bound,
|
||||
// `start > end`, trailing junk after the range, a dangling separator
|
||||
// (`a.md#`), or a leading `bor` token the caller left in.
|
||||
function borLocator(raw) {
|
||||
const text = String(raw).trim();
|
||||
if (!text) return "";
|
||||
// A caller that pasted the fence's language token in has already lost the
|
||||
// identity — refuse rather than guess which token was the path.
|
||||
if (text.split(" ")[0] === "bor") return "";
|
||||
// Leftmost match of: one-or-more separators + the range, ANCHORED to the
|
||||
// end of the string (so a range-shaped tail followed by more text is
|
||||
// junk, not a range). Everything before it is the identity.
|
||||
const match = /[ \t:#]+(L?\d+(?:[ \t]*[-\u2013][ \t]*L?\d+)?)$/i.exec(text);
|
||||
const identity = match ? text.slice(0, match.index) : text;
|
||||
const rangePart = match ? match[1] : null;
|
||||
// One whitespace-free `source/path` token, never ending in a dangling
|
||||
// separator (`a.md#` with no range is a typo, not a whole document).
|
||||
if (!identity || /\s/.test(identity)) return "";
|
||||
if (/[#:\-]$/.test(identity)) return "";
|
||||
if (!identity.includes("/")) return "";
|
||||
if (rangePart === null) return identity;
|
||||
const bounds = rangePart.split(/[\u2013-]/).map((b) => b.trim().replace(/^[Ll]+/, ""));
|
||||
if (bounds.some((b) => !/^\d+$/.test(b))) return "";
|
||||
const start = parseInt(bounds[0], 10);
|
||||
const end = bounds.length === 2 ? parseInt(bounds[1], 10) : start;
|
||||
if (start < 1 || end < 1 || start > end) return "";
|
||||
return `${identity}#${start}-${end}`;
|
||||
}
|
||||
|
||||
function renderMarkdown(md, opts) {
|
||||
// 0. Protect the phase-182 ` ```bor ` LOCATOR fences — BEFORE the fence
|
||||
// pass, so a locator fence never reaches the ordinary code renderer.
|
||||
// OPT-IN ONLY (the byte-identity contract in the header): `opts` is
|
||||
// undefined at every existing call site and `opts.verbatim === true` is
|
||||
// the single truthy path. The whole match is DROPPED — info line, body
|
||||
// and closing fence — because the model's copy of the file is exactly
|
||||
// what must never be painted (locked A7); only the normalized locator is
|
||||
// kept. The closing alternative (`(?![\s\S])`) matches a fence left OPEN
|
||||
// at the very end of the input, so as soon as the model has typed the
|
||||
// info line the answer shows the pending state instead of raw backticks
|
||||
// (the deliberate asymmetry with the server's stricter `fences_in`: the
|
||||
// server only ever sees a finished answer, the client sees a partial
|
||||
// one). A bare ` ```bor ` with no path does NOT match — it is an ordinary
|
||||
// code block and must survive to the fence pass untouched — and an
|
||||
// unparseable locator is returned byte-for-byte, degrading to the
|
||||
// `<pre><code>` that pass produces today with the model's own body.
|
||||
const verbatim = [];
|
||||
let text = md;
|
||||
if (opts && opts.verbatim === true) {
|
||||
text = text.replace(/^```bor[ \t]+\S[^\n]*\n(?:[\s\S]*?^```[ \t]*$|(?![\s\S]))/gm, (match) => {
|
||||
const infoLine = match.slice(0, match.indexOf("\n")).replace(/^```bor[ \t]+/, "").trim();
|
||||
const canonical = borLocator(infoLine);
|
||||
if (canonical === "") return match;
|
||||
verbatim.push(canonical);
|
||||
return `\u0000VERBATIM${verbatim.length - 1}\u0000`;
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Protect fenced code blocks.
|
||||
const codeBlocks = [];
|
||||
let text = md.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
|
||||
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
|
||||
codeBlocks.push(`<pre><code>${escapeHtml(code.replace(/\n$/, ""))}</code></pre>`);
|
||||
return `\u0000CODE${codeBlocks.length - 1}\u0000`;
|
||||
});
|
||||
@@ -356,19 +466,41 @@ function renderMarkdown(md) {
|
||||
}
|
||||
text = quoted.join("\n");
|
||||
|
||||
// 3. Paragraphs (double newline separated).
|
||||
// 3. Paragraphs (double newline separated). The first guard test is the
|
||||
// untouched pre-existing one; the second is the phase-182 placeholder,
|
||||
// which restores to a `<div>` (a BLOCK element) and must not be wrapped
|
||||
// in a `<p>` the way a code placeholder is. It can only ever match when
|
||||
// `opts.verbatim` was on, so every other caller keeps byte-identical
|
||||
// paragraph behavior.
|
||||
text = text
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => {
|
||||
const b = block.trim();
|
||||
if (!b) return "";
|
||||
if (/^<(h\d|ul|ol|pre|li|blockquote)/.test(b)) return b;
|
||||
if (/^<(h\d|ul|ol|pre|li|blockquote)/.test(b) || /^\u0000VERBATIM\d+\u0000$/.test(b)) return b;
|
||||
return `<p>${b.replace(/\n/g, "<br>")}</p>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
// 4. Restore protected spans: tables (already final HTML) and code.
|
||||
// 4. Restore protected spans: tables (already final HTML), code, and the
|
||||
// phase-182 verbatim placeholders. A placeholder restores as the PENDING
|
||||
// panel and NEVER as the model's bytes (locked A7): the caption carries
|
||||
// the `source/path` identity, the state line is a polite live region
|
||||
// (`role="status"` announces the pending text once and the task-07
|
||||
// hydration swap as a change), and every interpolated value goes through
|
||||
// escapeHtml — the locator is model-written text landing in an ATTRIBUTE,
|
||||
// so this is the escape-first boundary with no exception.
|
||||
return text
|
||||
.replace(/\u0000TABLE(\d+)\u0000/g, (_m, i) => tables[Number(i)])
|
||||
.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => codeBlocks[Number(i)]);
|
||||
.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => codeBlocks[Number(i)])
|
||||
.replace(/\u0000VERBATIM(\d+)\u0000/g, (_m, i) => {
|
||||
const canonical = verbatim[Number(i)];
|
||||
const identity = canonical.split("#")[0];
|
||||
return (
|
||||
`<div class="verbatim verbatim-pending" data-bor-locator="${escapeHtml(canonical)}">` +
|
||||
`<span class="verbatim-caption">${escapeHtml(identity)}</span>` +
|
||||
`<span class="verbatim-state" role="status">Retrieving the exact text…</span>` +
|
||||
"</div>"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,6 +64,25 @@ const invalidEl = document.querySelector("#shared-invalid");
|
||||
* literal is only the no-config fallback. */
|
||||
const brand = () => window.BOR_BRAND || "Brain of Reese";
|
||||
|
||||
/* Phase 182 (task 07): the shared page's verbatim link target — the plain
|
||||
* `/document.html?source=…&path=…` href, with NO modal hook (this page has
|
||||
* no document modal, and the documents API is admin-gated: a guest who
|
||||
* follows the link lands on the sign-in page, which is the honest answer to
|
||||
* "show me that file"). verbatim.js builds this same href by default; naming
|
||||
* it here is the parity statement — the SAME panel client, the SAME three
|
||||
* globals the chat page calls, so a shared turn renders identically to the
|
||||
* owner's (the phase's hard requirement). */
|
||||
const SHARED_VERBATIM_OPTS = {
|
||||
documentHref: (
|
||||
source,
|
||||
path
|
||||
) =>
|
||||
"/document.html?source=" +
|
||||
encodeURIComponent(source) +
|
||||
"&path=" +
|
||||
encodeURIComponent(path),
|
||||
};
|
||||
|
||||
/* ---------- the token (from the URL) ----------
|
||||
* The last path segment of /shared/<token>. A malformed or missing
|
||||
* token (a path without a final segment, or a non-uuid segment) →
|
||||
@@ -608,9 +627,13 @@ function addAnswerImage(wrap, ref) {
|
||||
* 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
|
||||
* stopped note. NO interactive markup is ever created HERE — no
|
||||
* buttons, no forms, no links, no click handlers (owner-locked:
|
||||
* zero controls). Markdown goes
|
||||
* zero controls). The ONE exception is phase 182's verbatim panel, and it
|
||||
* is not built HERE either: hydrateVerbatim / appendUnlocatedVerbatim /
|
||||
* enhanceAnswerCodeBlocks (assets/verbatim.js, the classic-script client the
|
||||
* chat page calls too) own that Copy button and that plain viewer link —
|
||||
* parity first, so a guest reads the owner's answer. Markdown goes
|
||||
* through the global escape-first renderMarkdown (markdown.js): the
|
||||
* stored payloads are raw text, so the renderer's XSS safety applies
|
||||
* unchanged. */
|
||||
@@ -628,7 +651,12 @@ function renderSharedMessage(m) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const wrap = addSharedMessage("brain", renderMarkdown(m.text));
|
||||
// Phase 182 (task 07): the shared page's answer renderer OPTS IN the
|
||||
// verbatim pass — the same opt-in as the chat page's restore, so a stored
|
||||
// ` ```bor ` fence paints the same placeholder and hydrates from the
|
||||
// record's `answer_text` below. The USER bubble above stays one-argument
|
||||
// (a question is never a locator).
|
||||
const wrap = addSharedMessage("brain", renderMarkdown(m.text, { verbatim: true }));
|
||||
// Phase 158 (task 03, D4): a record WITH `segments` renders the
|
||||
// per-run timeline through addSegments (collapsed — the parity
|
||||
// copy of the chat page's restoreTimeline); the runs carry the
|
||||
@@ -654,8 +682,24 @@ function renderSharedMessage(m) {
|
||||
// (below the text, above the chip row). A record without the key
|
||||
// renders byte-identically (no figure DOM).
|
||||
addAnswerImage(wrap, m.answer_image);
|
||||
// Phase 182 (task 07): the verbatim panels render with the bubble in the
|
||||
// SAME order as the chat page's restore — hydrate the placeholders inside
|
||||
// the bubble, append the blocks the answer never located, then the source
|
||||
// chips (below the text, above the clusters). The panel DOM, the Copy
|
||||
// button and the unresolved sentence all come from verbatim.js — the ONE
|
||||
// implementation shared with the chat page (parity, not a second copy);
|
||||
// this file still builds no control of its own.
|
||||
hydrateVerbatim(
|
||||
wrap.querySelector(".bubble"),
|
||||
m.answer_text || [],
|
||||
SHARED_VERBATIM_OPTS
|
||||
);
|
||||
appendUnlocatedVerbatim(wrap, m.answer_text || [], SHARED_VERBATIM_OPTS);
|
||||
addSources(wrap, m.sources);
|
||||
if (m.stopped) addStoppedNote(wrap);
|
||||
// The settled answer's ordinary code blocks get the same Copy button the
|
||||
// chat page gives them (no badge, no caption — locked A5).
|
||||
enhanceAnswerCodeBlocks(wrap);
|
||||
}
|
||||
|
||||
/* ---------- the public read ----------
|
||||
|
||||
@@ -701,6 +701,289 @@ body::before {
|
||||
.doc-md h5, .msg .bubble h5, .thinking-text h5 { margin: 0.8rem 0 0.3rem; font-size: 0.95rem; }
|
||||
.doc-md h6, .msg .bubble h6, .thinking-text h6 { margin: 0.7rem 0 0.3rem; font-size: 0.88rem; }
|
||||
|
||||
/* ---------- Verbatim panels (phase 182, task 06) — BEGIN ----------
|
||||
A ` ```bor <source/path> <range> ` locator fence (markdown.js step 0) and
|
||||
its hydrated panel (verbatim.js, task 07): the answer shows the EXACT
|
||||
bytes of an indexed file, typed by the SERVER, with a caption whose
|
||||
numbers are the server's resolved extent, a VERBATIM badge, a line-number
|
||||
gutter and a Copy button whose clipboard content equals those bytes.
|
||||
The phase's UI contract (PLAN §7.1 + AGENTS rule 5): an answer's code
|
||||
block is EITHER verbatim-and-badged OR plainly the model's — never a
|
||||
silently-substituted hybrid, which is why `.verbatim-badge` belongs to a
|
||||
resolved panel alone and `.code-block` (the plain wrapper task 07 adds
|
||||
around an ordinary `<pre>`) carries the Copy button and no badge.
|
||||
|
||||
PHASE-08 TOKENS ONLY — no hex/rgb/hsl literal anywhere in this block (the
|
||||
phase-92 monochrome invariant, and the :root-drift test parses the
|
||||
built-ins out of this file, so a literal would be a second source of
|
||||
truth). A monochrome or inverted theme keeps every state readable because
|
||||
the states are carried by TEXT and STRUCTURE, never by colour alone
|
||||
(WCAG 1.4.1): `Copy` → `Copied` is a text change with the --ok family as
|
||||
support, the badge is the WORD "VERBATIM", the unresolved state is a
|
||||
sentence, and the line gutter is digits. Contrast of the pairs used here
|
||||
(built-in palette, all AA ≥4.5:1):
|
||||
--ink on --surface 15.3:1 (the code text)
|
||||
--ink-soft on --surface 8.2:1 (caption, gutter, pending state, button label)
|
||||
--ok-ink on --ok-bg 10.6:1 (the badge, the copied state's fill)
|
||||
--ok-ink on --surface 12.2:1 (the copied state's label on hover-off)
|
||||
--ink-soft on --err-bg 7.9:1 (the unresolved sentence)
|
||||
A code surface is also readable in a LIGHT theme for the same reason: no
|
||||
#fff/#000, no new hue — every token comes from the theme's own palette.
|
||||
|
||||
Static content: NOTHING in this block animates or transitions, so
|
||||
prefers-reduced-motion has nothing to still (the pending state is a
|
||||
`role="status"` live region — the announcement IS the feedback, a pulse
|
||||
would add nothing but motion). */
|
||||
.verbatim {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
margin: 0.5rem 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
/* The head row is the panel's sticky header — PLAN §7.1's bounded-panel
|
||||
rule (the .table-wrap table thead th idiom, phase 156): it stays put
|
||||
while the body scrolls, so the caption (source/path · lines 12–40 of 210)
|
||||
is never off-screen in a 40-line slice. OPAQUE --surface background, so
|
||||
scrolled bytes never show through it. */
|
||||
.verbatim-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.25rem 0.5rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
/* The clipped label keeps the phase-156 `.cell-clip` ellipsis convention
|
||||
(block/flex item that shrinks below its text width, hard cut → "…"); the
|
||||
FULL text stays reachable through the control's aria-label / title, so
|
||||
the clip is visual only. */
|
||||
.verbatim-caption {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-soft); /* 8.2:1 on --surface */
|
||||
}
|
||||
/* The badge is a WORD, never an icon alone (locked A5: the VERBATIM claim
|
||||
must survive a screen reader and a monochrome theme), and
|
||||
`text-transform: none` keeps the lowercase letters the clipboard/AT
|
||||
would otherwise have to reconcile (no uppercase surprises spelled out
|
||||
letter-by-letter). --ok-ink on --ok-bg is the app's existing AA pair. */
|
||||
.verbatim-badge {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--ok-bg);
|
||||
color: var(--ok-ink); /* 10.6:1 on --ok-bg */
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem;
|
||||
text-transform: none;
|
||||
}
|
||||
/* The bounded scroll panel (PLAN §7.1: a bounded panel, never a page-long
|
||||
paste). CODE scrolls sideways and never wraps (phase 116 locked A1 —
|
||||
wrapping breaks code readability), so overflow-wrap is pinned back to
|
||||
normal against the .bubble's `overflow-wrap: anywhere` (the phase-165
|
||||
prose/code split: prose wraps, code scrolls). min-width: 0 keeps the
|
||||
long-line escape hatch that every other scroller in the app carries. */
|
||||
.verbatim-body {
|
||||
max-height: min(40rem, 70vh);
|
||||
overflow: auto;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--surface);
|
||||
color: var(--ink); /* 15.3:1 on --surface */
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
tab-size: 2;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
/* No scrollbar-color here ON PURPOSE: phase 116 scoped the themed
|
||||
scrollbar to `.doc-modal .doc-raw` alone (pinned by
|
||||
tests/unit/test_prose_word_wrap.py — "no global scrollbar restyle"), and
|
||||
the chat's other scrollers (.bubble pre, .md-table-wrap) keep the
|
||||
browser-native bar. The verbatim panel joins THOSE, not the modal. */
|
||||
/* The gutter: the SERVER's line numbers (the phase-131 coordinate system —
|
||||
the same 1-based lines `read` and `grep` report), never the model's
|
||||
claim. `user-select: none` is the load-bearing rule: a reader who
|
||||
selects the text — or hits Copy on a panel whose selection the browser
|
||||
picks up — never drags line numbers into the clipboard. The digits are
|
||||
rendered as list-item TEXT (list-style is off) so the rule behaves the
|
||||
same in every engine, tabular-nums keeps the column straight, and the
|
||||
gutter sticks to the left edge while the code scrolls under it. */
|
||||
.verbatim-lines {
|
||||
list-style: none;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
flex: 0 0 auto;
|
||||
min-width: 2.5rem;
|
||||
background: var(--surface); /* opaque: scrolled code never shows THROUGH
|
||||
the gutter (the .table-wrap thead rule) */
|
||||
margin: 0;
|
||||
padding: 0 0.6rem 0 0;
|
||||
text-align: right;
|
||||
color: var(--ink-soft); /* 8.2:1 on --surface — a gutter is still text */
|
||||
font-variant-numeric: tabular-nums;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.verbatim-lines li {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
/* Neutralize the .bubble pre treatment INSIDE the panel: the panel is its
|
||||
own surface (its own padding, hairline, scroll), so a <pre> child must
|
||||
not stack a second border/padding on top of it. */
|
||||
.verbatim pre {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
/* The streaming/pending state (markdown.js step 0 → the restore step) and
|
||||
the settled-but-unresolvable state. The pending look is MUTED on purpose
|
||||
— it is not an answer yet — and it never paints the model's bytes. */
|
||||
.verbatim-pending {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.15rem 0.6rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: var(--surface);
|
||||
}
|
||||
.verbatim-state {
|
||||
min-width: 0;
|
||||
color: var(--ink-soft); /* 8.2:1 on --surface */
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
/* The honest failure state (locked A7): a locator the server could not
|
||||
resolve says so in a sentence, carries NO badge and NO body, and shows
|
||||
none of the model's text. --err-line is the app's existing UI boundary
|
||||
token (decorative, 4.6:1 — the sentence, not the colour, carries the
|
||||
meaning); the badge rule below is CSS-side enforcement of the claim that
|
||||
an unresolved panel never badges, whatever DOM reaches it. */
|
||||
.verbatim-unresolved {
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: var(--err-bg);
|
||||
border-color: var(--err-line);
|
||||
color: var(--ink-soft); /* 7.9:1 on --err-bg */
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.verbatim-unresolved .verbatim-badge { display: none; }
|
||||
/* The two Copy controls — `.verbatim-copy` inside a resolved panel's head,
|
||||
`.code-block-copy` under an ordinary answer code block (locked A5: every
|
||||
fenced block gets a Copy button, only a resolved one gets the badge).
|
||||
The house pill family (mono text, --line hairline, --surface fill,
|
||||
ink-soft label 8.2:1); the label is the designated clip target
|
||||
(text-overflow: ellipsis, the phase-156 convention) while the control's
|
||||
aria-label names the full path and line count. `min-height: 2rem` is the
|
||||
desktop pill; the ≤640px block below lifts both to the 44px touch floor,
|
||||
and the global :focus-visible ring is restated with the SAME values
|
||||
(explicit here because the panel clips with overflow: hidden). */
|
||||
.code-block {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
.code-block pre { margin: 0; }
|
||||
.code-block-copy,
|
||||
.verbatim-copy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 2rem;
|
||||
padding: 0.25rem 0.65rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
color: var(--ink-soft); /* 8.2:1 on --surface */
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.code-block-copy > span,
|
||||
.verbatim-copy > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.code-block-copy:hover,
|
||||
.verbatim-copy:hover { color: var(--ink); border-color: var(--ink-soft); }
|
||||
.code-block-copy:focus-visible,
|
||||
.verbatim-copy:focus-visible {
|
||||
outline: 3px solid var(--brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* The `.copied` state: the TEXT changes ("Copy" → "Copied", task 07 owns
|
||||
the swap) and this rule SUPPORTS it with the --ok family — colour is
|
||||
never the only signal (WCAG 1.4.1), so a monochrome theme still reads
|
||||
"Copied". */
|
||||
.code-block-copy.copied,
|
||||
.verbatim-copy.copied {
|
||||
color: var(--ok-ink); /* 10.0:1 on --surface */
|
||||
border-color: var(--ok-ink);
|
||||
background: var(--ok-bg);
|
||||
}
|
||||
/* A resolved panel's Copy pill sits at the END of the head row (the caption
|
||||
already took the flexible space; the auto margin keeps it right even
|
||||
when the caption clips). */
|
||||
.verbatim-copy { margin-left: auto; flex: 0 0 auto; }
|
||||
.code-block-copy { margin: 0.25rem 0 0; }
|
||||
/* What task 07's REAL DOM needs on top of task 06's contract — palette
|
||||
variables only, no new literal, nothing animated. The body is a ROW of
|
||||
(gutter, text): the gutter's `flex: 0 0 auto` + `position: sticky;
|
||||
left: 0` above are written for exactly this, and the text takes its own
|
||||
width so a long line scrolls UNDER the sticky gutter instead of wrapping
|
||||
(phase 116 locked A1). */
|
||||
.verbatim-body { display: flex; align-items: flex-start; }
|
||||
.verbatim-text { flex: 0 0 auto; }
|
||||
/* The panel's link to the document viewer (the chat page opens the modal,
|
||||
the shared page navigates — one affordance, one look). It is a TEXT link
|
||||
(underlined, not colour-only — WCAG 1.4.1) in the mono family of the
|
||||
caption it sits beside, and it restates the app-wide focus ring with the
|
||||
SAME values because the panel clips with `overflow: hidden`. */
|
||||
.verbatim-open {
|
||||
flex: 0 0 auto;
|
||||
color: var(--ink-soft); /* 8.2:1 on --surface */
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.verbatim-open:hover { color: var(--ink); }
|
||||
.verbatim-open:focus-visible {
|
||||
outline: 3px solid var(--brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* The truncation line of a capped block: one muted sentence naming that the
|
||||
rest lives in the document, plus the same link. It sits OUTSIDE the scroll
|
||||
body, so it stays readable without scrolling the bytes. */
|
||||
.verbatim-more {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
color: var(--ink-soft); /* 8.2:1 on --surface */
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
/* ---------- Verbatim panels (phase 182, task 06) — END ---------- */
|
||||
|
||||
.msg.user { justify-content: flex-end; }
|
||||
.msg.user .msg-body { align-items: flex-end; }
|
||||
.msg.user .bubble {
|
||||
@@ -6672,6 +6955,17 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
flex-shrink: 0 would keep each at 100%); the "Page N of M"
|
||||
status sits between them (the 0.75rem gap goes vertical). */
|
||||
.history-pager { flex-direction: column; align-items: center; }
|
||||
/* Phase 182 (task 06): the two Copy controls of a verbatim panel and of
|
||||
an ordinary answer code block reach the 44px touch floor on a phone
|
||||
(the desktop pill is 32px tall — fine for a pointer, too small a tap
|
||||
target for a thumb). The pill itself grows; its label keeps the
|
||||
ellipsis escape, so a long "Copy 210 lines from …" never widens the
|
||||
panel. */
|
||||
.code-block-copy, .verbatim-copy { min-height: 44px; }
|
||||
/* Phase 182 (task 07): the panel's viewer link is a control too — it
|
||||
reaches the same touch floor (inline-flex so the line box grows without
|
||||
moving the baseline of the caption row it sits in). */
|
||||
.verbatim-open { min-height: 44px; display: inline-flex; align-items: center; }
|
||||
}
|
||||
|
||||
/* Phase 46: prefers-reduced-motion stills the mobile menu — no
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
/* Brain of Reese — the phase-182 VERBATIM panel client (task 07).
|
||||
*
|
||||
* A ` ```bor <source/path>#12-40 ` locator fence is a CLAIM about a file,
|
||||
* never its contents: `markdown.js` (task 06) drops the model's body and
|
||||
* paints a `.verbatim.verbatim-pending` placeholder keyed by the canonical
|
||||
* locator, and the SERVER (`app.rag.verbatim.resolve_answer_blocks`,
|
||||
* task 05) puts the real bytes on the `done` frame as `answer_text`. This
|
||||
* file is the other half of that handshake: it RESOLVES the placeholders
|
||||
* against the frame's blocks (`hydrateVerbatim`), places the blocks the
|
||||
* answer never located (`appendUnlocatedVerbatim`), settles leftovers when
|
||||
* a turn ends without a `done` (`settleVerbatimPlaceholders`), gives every
|
||||
* settled answer code block a Copy button (`enhanceAnswerCodeBlocks`) and
|
||||
* owns the ONE clipboard path (`copyTextWithFallback`).
|
||||
*
|
||||
* WHY IT IS A CLASSIC SCRIPT, NOT A MODULE (the `markdown.js` pattern):
|
||||
* `index.html` and `shared.html` load it as a plain `<script>` right after
|
||||
* `markdown.js` and before their page modules, so the top-level function
|
||||
* declarations below are globals both module pages can call — the ONE
|
||||
* implementation shared by the live turn, the reload restore and the
|
||||
* anonymous shared page (parity is the phase's hard requirement). The
|
||||
* Containerfile minifies it WITHOUT `--bundle` for the same reason.
|
||||
*
|
||||
* NO LOCATOR GRAMMAR LIVES HERE (the phase's main drift risk): the canonical
|
||||
* string is produced in exactly ONE client place,
|
||||
* `markdown.js::borLocator` (the mirror of Python
|
||||
* `app.rag.verbatim.canonical_locator`). Hydration reads the already
|
||||
* normalized `data-bor-locator` attribute and compares it to the server's
|
||||
* `locator` with a plain string `===`. Nothing here re-parses a range,
|
||||
* canonicalizes an en dash, or normalizes a path.
|
||||
*
|
||||
* THE CONTRACT WITH THE SERVER (LOCKED A2/A4/A7):
|
||||
* • `block.text` is painted with `textContent` and copied with
|
||||
* `navigator.clipboard` VERBATIM — no trim, no re-wrap, no join, no
|
||||
* model byte ever substituted in, and the line numbers never enter the
|
||||
* clipboard (they live in the `user-select: none` gutter, a separate
|
||||
* node the copy path never reads);
|
||||
* • the caption's numbers are the SERVER's resolved extent, so a locator
|
||||
* whose range the model got wrong shows what the server actually read
|
||||
* (A2 made visible);
|
||||
* • a locator the server could not resolve says so — the unresolved node
|
||||
* carries NO badge, NO body and NONE of the model's bytes.
|
||||
*
|
||||
* The house frontend rules hold throughout: every node is built with
|
||||
* `createElement` + `textContent` (no HTML string anywhere — the data here
|
||||
* is model-written text and indexed file bytes), every handler is attached
|
||||
* with `addEventListener` (the CSP is `default-src 'self'`: no inline
|
||||
* handlers, no `onclick=`/`oncopy=` attribute), and the states are carried
|
||||
* by TEXT and STRUCTURE, never by colour alone (WCAG 1.4.1).
|
||||
*/
|
||||
|
||||
/* The one honest sentence for a locator nothing resolved (the pinned copy —
|
||||
* the whole unresolved state is this sentence plus the document link, so a
|
||||
* screen reader, a monochrome theme and a screenshot all agree on it). */
|
||||
function verbatimUnresolvedCopy() {
|
||||
return "I could not verify this against the knowledge base";
|
||||
}
|
||||
|
||||
/* The badge's claim, spelled out for the pointer tooltip — the WORD is the
|
||||
* signal (locked A5), the tooltip only explains it. */
|
||||
function verbatimBadgeTitle() {
|
||||
return "These bytes are copied from the knowledge base, not written by the model.";
|
||||
}
|
||||
|
||||
/* ---------- the small guards (a malformed frame never throws) ----------
|
||||
* `answer_text` crosses the wire, so every field is treated as untrusted
|
||||
* shape: a block that is not a plain object with the three fields the panel
|
||||
* cannot live without is DROPPED, exactly like the restore path drops a
|
||||
* malformed timeline entry. */
|
||||
|
||||
function isVerbatimBlock(block) {
|
||||
return !!(
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
typeof block.source === "string" &&
|
||||
block.source &&
|
||||
typeof block.path === "string" &&
|
||||
block.path &&
|
||||
typeof block.text === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function verbatimBlocks(blocks) {
|
||||
return Array.isArray(blocks) ? blocks.filter(isVerbatimBlock) : [];
|
||||
}
|
||||
|
||||
/* A positive line count, or null (a missing/garbage count never prints). */
|
||||
function verbatimCount(value) {
|
||||
return Number.isInteger(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
/* The one `source/path` identity the whole app uses (the read/grep
|
||||
* coordinate). NO normalization here — see the header: markdown.js owns the
|
||||
* grammar, this only joins the two halves the server already resolved. */
|
||||
function verbatimIdentity(source, path) {
|
||||
return `${source}/${path}`;
|
||||
}
|
||||
|
||||
/* Split a canonical locator into its identity half and its range suffix.
|
||||
* The split is on the `#` the server PUT THERE (canonical_locator's own
|
||||
* separator) — never a re-parse of the range. */
|
||||
function verbatimSplitLocator(locator) {
|
||||
const text = typeof locator === "string" ? locator : "";
|
||||
const hash = text.indexOf("#");
|
||||
return hash === -1
|
||||
? { identity: text, hasRange: false }
|
||||
: { identity: text.slice(0, hash), hasRange: true };
|
||||
}
|
||||
|
||||
/* The plain viewer href (the shared page's whole affordance — that page has
|
||||
* no modal, and the documents API is admin-gated so a guest lands on the
|
||||
* sign-in page by design). app.js OVERRIDES this through
|
||||
* `opts.documentHref` with `documentUrl(…, "/")`, the chip's own
|
||||
* back-to-the-chat URL (phase 153). A rooted path built from
|
||||
* encodeURIComponent — never a `javascript:`/`data:` target. */
|
||||
function verbatimDocumentHref(source, path) {
|
||||
return (
|
||||
"/document.html?source=" +
|
||||
encodeURIComponent(source) +
|
||||
"&path=" +
|
||||
encodeURIComponent(path)
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- the panel's text pieces ---------- */
|
||||
|
||||
/* `homelab/a.md · lines 12–40 of 210`, or `homelab/a.md · all 210 lines`
|
||||
* when the block IS the whole document (the caption is the SERVER's extent:
|
||||
* `start_line`/`end_line` describe exactly the lines `text` contains after
|
||||
* any character cap, so a capped block reads as a range, never as "all"). */
|
||||
function verbatimCaptionText(block) {
|
||||
const identity = verbatimIdentity(block.source, block.path);
|
||||
const start = verbatimCount(block.start_line);
|
||||
const end = verbatimCount(block.end_line);
|
||||
const total = verbatimCount(block.total_lines);
|
||||
if (start === null || end === null || end < start) return identity;
|
||||
if (!block.truncated && start === 1 && total !== null && end === total) {
|
||||
return `${identity} · all ${total} lines`;
|
||||
}
|
||||
return total === null
|
||||
? `${identity} · lines ${start}–${end}`
|
||||
: `${identity} · lines ${start}–${end} of ${total}`;
|
||||
}
|
||||
|
||||
/* `Verbatim text from homelab/a.md, lines 12–40` (the range clause only when
|
||||
* the panel shows a range — a whole-document panel is named by its file). */
|
||||
function verbatimPanelLabel(block) {
|
||||
const identity = verbatimIdentity(block.source, block.path);
|
||||
const start = verbatimCount(block.start_line);
|
||||
const end = verbatimCount(block.end_line);
|
||||
if (start === null || end === null || end < start) {
|
||||
return `Verbatim text from ${identity}`;
|
||||
}
|
||||
if (start === end) return `Verbatim text from ${identity}, line ${start}`;
|
||||
return `Verbatim text from ${identity}, lines ${start}–${end}`;
|
||||
}
|
||||
|
||||
/* The lines the gutter numbers: one entry per LINE OF THE SERVER TEXT, each
|
||||
* `{ shown, number }`. A truncated block's last line is the server's
|
||||
* `[…truncated…]` marker — a real line of the panel, but NOT a line of the
|
||||
* file, so it gets no number (an empty cell keeps the rows aligned; the
|
||||
* phase's rule is that the gutter never claims a coordinate the file does
|
||||
* not have). */
|
||||
function verbatimGutter(block) {
|
||||
const lines = String(block.text).split("\n");
|
||||
const start = verbatimCount(block.start_line);
|
||||
const end = verbatimCount(block.end_line);
|
||||
const numbered = start !== null && end !== null && end >= start ? end - start + 1 : 0;
|
||||
return lines.map((_, i) => ({
|
||||
number: i < numbered ? String(start + i) : "\u00a0",
|
||||
}));
|
||||
}
|
||||
|
||||
/* ---------- the controls ---------- */
|
||||
|
||||
/* The select-on-focus of the clipboard fallback field (an `<a>` has no
|
||||
* `.select()`, a range does the job). Best-effort: a selection failure only
|
||||
* means the reader copies by hand — the field still shows the whole text.
|
||||
* (app.js's `copyShareLinkWithFallback` used to own this; task 07 moved the
|
||||
* pair into verbatim.js so the code blocks and the share link share ONE
|
||||
* degradation.) */
|
||||
function selectAllInFallbackField(el) {
|
||||
try {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
} catch {
|
||||
/* selection is best-effort — the field still carries the full text */
|
||||
}
|
||||
}
|
||||
|
||||
/* The phase-55 degradation, generalized (LOCKED: the owner-locked share
|
||||
* strings and behaviour are unchanged — app.js delegates here).
|
||||
* `navigator.clipboard` is unavailable on a non-secure (http) homelab origin
|
||||
* and rejects outside a user gesture, so the failure path renders ONE
|
||||
* transient link field inside `hostEl` that selects its text on focus
|
||||
* (click or Tab, then Ctrl/Cmd+C). Returns true when the clipboard took it.
|
||||
* NEVER reads the DOM — the caller hands over the exact string, which is
|
||||
* what makes "the clipboard content equals the server text" true. */
|
||||
async function copyTextWithFallback(text, hostEl, title) {
|
||||
const host = hostEl || document.body;
|
||||
document.querySelectorAll(".share-link-fallback").forEach((el) => el.remove());
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
const field = document.createElement("a");
|
||||
field.className = "share-link-fallback";
|
||||
// An <a> needs an href to focus; a code block has none, so it takes the
|
||||
// tabindex instead and stays a select-to-copy field either way.
|
||||
if (/^(?:https?:|mailto:|\/)/i.test(text)) field.href = text;
|
||||
else field.tabIndex = 0;
|
||||
field.textContent = text; // the text is data — textContent, never innerHTML
|
||||
field.title = title || "Click, then copy (Ctrl/Cmd+C)";
|
||||
field.addEventListener("focus", () => selectAllInFallbackField(field));
|
||||
host.appendChild(field); // beside the control that failed, never elsewhere
|
||||
field.focus({ preventScroll: true });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* One pill: `Copy` → `Copied` (the TEXT carries the state; `.copied` only
|
||||
* supports it — WCAG 1.4.1). The label rides a `<span>` because the clipped
|
||||
* label is the CSS ellipsis target (the phase-156 rule) while the control's
|
||||
* `aria-label` names the full path. Sticky by design: no timer, so a reader
|
||||
* who tabs away and back finds the state the click left. */
|
||||
function buildCopyButton(className, text, ariaLabel, hostEl) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = className;
|
||||
btn.setAttribute("aria-label", ariaLabel);
|
||||
const label = document.createElement("span");
|
||||
label.textContent = "Copy";
|
||||
btn.appendChild(label);
|
||||
btn.addEventListener("click", () => {
|
||||
copyTextWithFallback(text, hostEl || btn.parentElement, ariaLabel).then((ok) => {
|
||||
if (!ok) return; // the field is the feedback (it is focused and selected)
|
||||
btn.classList.add("copied");
|
||||
label.textContent = "Copied";
|
||||
});
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
/* The panel's link to the document. `opts.onOpen(source, path, linkEl)` is
|
||||
* the chat page's modal hook (phase 26/153 — left-click opens the viewer IN
|
||||
* the page, the href stays the `/document.html` escape hatch for
|
||||
* middle-click and copy-link); with no hook the href simply navigates,
|
||||
* which is the shared page's whole affordance. */
|
||||
function buildVerbatimOpenLink(source, path, opts, text) {
|
||||
const link = document.createElement("a");
|
||||
link.className = "verbatim-open";
|
||||
const identity = verbatimIdentity(source, path);
|
||||
const href =
|
||||
opts && typeof opts.documentHref === "function"
|
||||
? opts.documentHref(source, path)
|
||||
: verbatimDocumentHref(source, path);
|
||||
link.href = href;
|
||||
link.textContent = text || identity;
|
||||
link.title = identity; // the full path as the native tooltip (chip pattern)
|
||||
if (opts && typeof opts.onOpen === "function") {
|
||||
link.addEventListener("click", (event) => {
|
||||
event.preventDefault(); // no new tab — the modal takes over
|
||||
event.stopPropagation();
|
||||
opts.onOpen(source, path, link);
|
||||
});
|
||||
}
|
||||
return link;
|
||||
}
|
||||
|
||||
/* ---------- the panel ---------- */
|
||||
|
||||
/* buildVerbatimPanel(block, opts) → the `.verbatim` node.
|
||||
*
|
||||
* .verbatim[role=group][aria-label][data-bor-locator]
|
||||
* .verbatim-head sticky (PLAN §7.1)
|
||||
* .verbatim-caption source/path · lines 12–40 of 210 (clipped)
|
||||
* .verbatim-badge VERBATIM (a WORD)
|
||||
* .verbatim-open → the document viewer (optional)
|
||||
* .verbatim-copy Copy → Copied (aria-label
|
||||
* names path)
|
||||
* .verbatim-body the bounded scroll panel
|
||||
* ol.verbatim-lines the SERVER's line numbers (user-select: none)
|
||||
* pre.verbatim-text block.text, byte for byte
|
||||
* .verbatim-more the truncation line + the link (optional)
|
||||
*
|
||||
* `opts = { documentHref, onOpen }` — both optional (see the two builders
|
||||
* above). Every value reaches the DOM through `textContent`/`title`/a
|
||||
* built `href`: the caption carries indexed text and the aria-labels carry
|
||||
* model-written locators, so there is no HTML string to escape. */
|
||||
function buildVerbatimPanel(block, opts) {
|
||||
const identity = verbatimIdentity(block.source, block.path);
|
||||
const root = document.createElement("div");
|
||||
root.className = "verbatim";
|
||||
root.setAttribute("role", "group");
|
||||
root.setAttribute("aria-label", verbatimPanelLabel(block));
|
||||
// The SERVER's canonical locator is the identity of the placed block (the
|
||||
// pending placeholder's own attribute is replaced with it — that is how
|
||||
// `appendUnlocatedVerbatim` knows this block already has a home).
|
||||
root.dataset.borLocator =
|
||||
typeof block.locator === "string" && block.locator
|
||||
? block.locator
|
||||
: identity;
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "verbatim-head";
|
||||
const caption = document.createElement("span");
|
||||
caption.className = "verbatim-caption";
|
||||
caption.textContent = verbatimCaptionText(block);
|
||||
caption.title = verbatimCaptionText(block); // the clipped text stays reachable
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "verbatim-badge";
|
||||
badge.textContent = "VERBATIM";
|
||||
badge.title = verbatimBadgeTitle();
|
||||
head.append(caption, badge);
|
||||
head.appendChild(buildVerbatimOpenLink(block.source, block.path, opts));
|
||||
|
||||
const gutter = verbatimGutter(block);
|
||||
// The clipboard's line count (the aria-label's N): the SERVER's extent,
|
||||
// not the marker line the cap appended.
|
||||
const from = verbatimCount(block.start_line);
|
||||
const to = verbatimCount(block.end_line);
|
||||
const shown = from !== null && to !== null && to >= from ? to - from + 1 : gutter.length;
|
||||
head.appendChild(
|
||||
buildCopyButton(
|
||||
"verbatim-copy",
|
||||
block.text,
|
||||
`Copy ${shown} lines from ${identity}`,
|
||||
root
|
||||
)
|
||||
);
|
||||
root.appendChild(head);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "verbatim-body";
|
||||
const numbers = document.createElement("ol");
|
||||
numbers.className = "verbatim-lines";
|
||||
numbers.setAttribute("aria-hidden", "true"); // the range rides the panel's label
|
||||
for (const row of gutter) {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = row.number;
|
||||
numbers.appendChild(li);
|
||||
}
|
||||
const pre = document.createElement("pre");
|
||||
pre.className = "verbatim-text";
|
||||
pre.textContent = block.text; // THE POINT OF THE PHASE: the server's bytes
|
||||
body.append(numbers, pre);
|
||||
root.appendChild(body);
|
||||
|
||||
if (block.truncated) {
|
||||
// One muted line naming where the rest lives, plus the same viewer link
|
||||
// — the panel never pretends the slice is the file.
|
||||
const more = document.createElement("div");
|
||||
more.className = "verbatim-more";
|
||||
const note = document.createElement("span");
|
||||
note.textContent = "Cut short by the size limit — the rest is in the document. ";
|
||||
more.appendChild(note);
|
||||
more.appendChild(
|
||||
buildVerbatimOpenLink(block.source, block.path, opts, `open ${identity}`)
|
||||
);
|
||||
root.appendChild(more);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/* The honest state for a locator the server did not resolve (and for a
|
||||
* placeholder no terminal frame ever settled): the sentence, plus the
|
||||
* document link ONLY when the identity actually parsed into a
|
||||
* `source/path`. NO badge, NO body, NONE of the model's bytes — the fence's
|
||||
* own text is already gone (task 06 dropped it), so there is nothing here
|
||||
* to leak even by accident. */
|
||||
function buildVerbatimUnresolved(locator, opts) {
|
||||
const root = document.createElement("div");
|
||||
root.className = "verbatim verbatim-unresolved";
|
||||
root.setAttribute("role", "note");
|
||||
const key = typeof locator === "string" ? locator : "";
|
||||
if (key) root.dataset.borLocator = key;
|
||||
root.dataset.verbatimState = (opts && opts.kind) || "unverified";
|
||||
const state = document.createElement("span");
|
||||
state.className = "verbatim-state";
|
||||
state.textContent = verbatimUnresolvedCopy();
|
||||
root.appendChild(state);
|
||||
const { identity } = verbatimSplitLocator(key);
|
||||
const slash = identity.indexOf("/");
|
||||
if (slash > 0 && slash < identity.length - 1) {
|
||||
root.appendChild(document.createTextNode(" — open "));
|
||||
root.appendChild(
|
||||
buildVerbatimOpenLink(identity.slice(0, slash), identity.slice(slash + 1), opts)
|
||||
);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/* ---------- hydration ---------- */
|
||||
|
||||
/* The ONE match rule (LOCKED A2 made visible):
|
||||
* 1. the canonical locator, compared as PLAIN STRINGS against the
|
||||
* server's `locator` — the attribute markdown.js already normalized;
|
||||
* 2. failing that, exactly ONE block sharing the same `source/path`
|
||||
* (the model's RANGE was wrong; the panel shows the server's extent);
|
||||
* 3. failing that, null → the unresolved state. An ambiguous identity
|
||||
* (two blocks, same file, different ranges) never guesses. */
|
||||
function matchVerbatimBlock(blocks, key) {
|
||||
for (const block of blocks) {
|
||||
if (block.locator === key) return block;
|
||||
}
|
||||
const identity = verbatimSplitLocator(key).identity;
|
||||
const samePath = blocks.filter(
|
||||
(block) => verbatimIdentity(block.source, block.path) === identity
|
||||
);
|
||||
return samePath.length === 1 ? samePath[0] : null;
|
||||
}
|
||||
|
||||
/* hydrateVerbatim(root, blocks, opts) — replace every PENDING placeholder
|
||||
* under `root` with its panel, or with the unresolved state.
|
||||
*
|
||||
* Only `.verbatim-pending` is touched, so the pass is idempotent: a panel
|
||||
* this function built (or one restored from storage) is skipped on a re-run,
|
||||
* which keeps a `Copied` state and a scrolled body alive. Never throws —
|
||||
* a missing/malformed `blocks` settles every placeholder to the honest
|
||||
* state instead. Returns the number of placeholders settled. */
|
||||
function hydrateVerbatim(root, blocks, opts) {
|
||||
if (!root || typeof root.querySelectorAll !== "function") return 0;
|
||||
const list = verbatimBlocks(blocks);
|
||||
let settled = 0;
|
||||
root.querySelectorAll(".verbatim-pending[data-bor-locator]").forEach((node) => {
|
||||
const key = node.getAttribute("data-bor-locator") || "";
|
||||
const block = matchVerbatimBlock(list, key);
|
||||
node.replaceWith(
|
||||
block ? buildVerbatimPanel(block, opts) : buildVerbatimUnresolved(key, opts)
|
||||
);
|
||||
settled += 1;
|
||||
});
|
||||
return settled;
|
||||
}
|
||||
|
||||
/* appendUnlocatedVerbatim(wrap, blocks, opts) — the blocks NO placeholder
|
||||
* matched (a `show_text` call whose answer never located the file), appended
|
||||
* in the frame's order at the `answer_image` position: below the answer
|
||||
* text, above the source clusters (the phase-124/130 DOM order — callers run
|
||||
* this BEFORE `appendSources`).
|
||||
*
|
||||
* State-free idempotence: a block is already placed when the wrap holds a
|
||||
* resolved panel carrying its canonical `locator`, so calling this twice
|
||||
* (or after a hydration that matched) appends nothing. */
|
||||
function appendUnlocatedVerbatim(wrap, blocks, opts) {
|
||||
if (!wrap || typeof wrap.querySelectorAll !== "function") return 0;
|
||||
const list = verbatimBlocks(blocks);
|
||||
if (!list.length) return 0;
|
||||
const host = wrap.querySelector(".msg-body") || wrap;
|
||||
const placed = new Set();
|
||||
wrap.querySelectorAll(".verbatim[data-bor-locator]").forEach((el) => {
|
||||
// An unresolved placeholder is NOT a placement (it names a locator the
|
||||
// server never returned, and the block belongs on screen anyway).
|
||||
if (el.classList.contains("verbatim-unresolved")) return;
|
||||
placed.add(el.getAttribute("data-bor-locator"));
|
||||
});
|
||||
let appended = 0;
|
||||
for (const block of list) {
|
||||
const key =
|
||||
typeof block.locator === "string" && block.locator
|
||||
? block.locator
|
||||
: verbatimIdentity(block.source, block.path);
|
||||
if (placed.has(key)) continue;
|
||||
host.appendChild(buildVerbatimPanel(block, opts));
|
||||
placed.add(key);
|
||||
appended += 1;
|
||||
}
|
||||
return appended;
|
||||
}
|
||||
|
||||
/* settleVerbatimPlaceholders(root, kind) — the terminal-frame sweep the
|
||||
* failure paths run (the `error` frame, the 300 s guard, the Stop action,
|
||||
* the failed-turn funnel): a panel NEVER sits at "Retrieving the exact
|
||||
* text…" once a turn is over. `kind` is recorded on the node
|
||||
* (`data-verbatim-state`) for the next reader; every kind renders the same
|
||||
* honest sentence, because "I could not verify this" is the truth for a
|
||||
* turn that ended before `done` too. */
|
||||
function settleVerbatimPlaceholders(root, kind) {
|
||||
if (!root || typeof root.querySelectorAll !== "function") return 0;
|
||||
let settled = 0;
|
||||
root.querySelectorAll(".verbatim-pending").forEach((node) => {
|
||||
node.replaceWith(
|
||||
buildVerbatimUnresolved(node.getAttribute("data-bor-locator") || "", { kind })
|
||||
);
|
||||
settled += 1;
|
||||
});
|
||||
return settled;
|
||||
}
|
||||
|
||||
/* ---------- the ordinary answer code block ---------- */
|
||||
|
||||
/* enhanceAnswerCodeBlocks(root) — every `<pre>` inside an answer bubble that
|
||||
* is NOT inside a `.verbatim` gets the `.code-block` wrapper and a Copy
|
||||
* button, ONCE the turn settled (the callers run it on the settled answer,
|
||||
* never mid-stream). NO badge, NO caption: a `VERBATIM` claim belongs only
|
||||
* to a server-resolved block (LOCKED A5) — plainly the model's, or verbatim
|
||||
* and badged, never a hybrid.
|
||||
*
|
||||
* Idempotent on re-render: a `<pre>` already inside a `.code-block` is left
|
||||
* alone (the live path re-runs the renderer per delta; the restore path runs
|
||||
* this once). Returns the number of blocks wrapped. */
|
||||
function enhanceAnswerCodeBlocks(root) {
|
||||
if (!root || typeof root.querySelectorAll !== "function") return 0;
|
||||
const bubbles = [];
|
||||
if (root.classList && root.classList.contains("bubble")) bubbles.push(root);
|
||||
root.querySelectorAll(".bubble").forEach((el) => bubbles.push(el));
|
||||
let wrapped = 0;
|
||||
for (const bubble of bubbles) {
|
||||
bubble.querySelectorAll("pre").forEach((pre) => {
|
||||
if (pre.closest(".verbatim")) return; // the panel owns its own Copy
|
||||
const parent = pre.parentElement;
|
||||
if (!parent || parent.classList.contains("code-block")) return;
|
||||
const box = document.createElement("div");
|
||||
box.className = "code-block";
|
||||
parent.insertBefore(box, pre);
|
||||
box.appendChild(pre);
|
||||
box.appendChild(
|
||||
buildCopyButton(
|
||||
"code-block-copy",
|
||||
pre.textContent || "",
|
||||
"Copy this code block",
|
||||
box
|
||||
)
|
||||
);
|
||||
wrapped += 1;
|
||||
});
|
||||
}
|
||||
return wrapped;
|
||||
}
|
||||
@@ -1695,6 +1695,12 @@
|
||||
identical no-op for the default name). -->
|
||||
<script src="assets/brand.js"></script>
|
||||
<script src="assets/markdown.js"></script>
|
||||
<!-- Phase 182 (task 07): the verbatim panel client — a CLASSIC script
|
||||
immediately AFTER markdown.js (it hydrates the placeholders that
|
||||
renderer emits, so it must be a global before app.js evaluates) and
|
||||
before the module scripts. No bundle, no import — same shape as
|
||||
markdown.js. -->
|
||||
<script src="assets/verbatim.js"></script>
|
||||
<!-- Phase 19: the shared header module loads through the page script's
|
||||
own `import "./header.js"` — a hoisted import that is evaluated
|
||||
before the page script body calls initSharedHeader() at boot. -->
|
||||
|
||||
+13
-1
@@ -187,7 +187,15 @@
|
||||
evaluated before the page script body calls initSharedHeader()
|
||||
at boot (no direct header.js <script> tag — single-evaluation
|
||||
design). NO modal overlay, NO composer, NO Save/Share/
|
||||
Retry/Tune markup anywhere (owner-locked: zero controls). -->
|
||||
Retry/Tune markup anywhere (owner-locked: zero controls).
|
||||
Phase 182 (task 07) is the ONE owner-directed exception, and it is
|
||||
narrow: a shared answer's verbatim panel and its ordinary code blocks
|
||||
carry a Copy button and a plain `/document.html?source=&path=` link,
|
||||
because a shared page that shows a slice of a file has to let the
|
||||
reader copy and check it. Both come from the SHARED panel client
|
||||
(verbatim.js — the same globals the chat page calls, so a shared turn
|
||||
renders pixel-identically); this page still builds no control of its
|
||||
own, wires no modal, and keeps every chip a plain span. -->
|
||||
<!-- ABSOLUTE asset paths on purpose: the page is served from the
|
||||
NESTED route /shared/<token> (not a root-level .html), so a
|
||||
relative "assets/…" ref would resolve to /shared/assets/… and
|
||||
@@ -196,6 +204,10 @@
|
||||
works with absolute refs too. -->
|
||||
<script src="/assets/brand.js"></script>
|
||||
<script src="/assets/markdown.js"></script>
|
||||
<!-- Phase 182 (task 07): the verbatim panel client, AFTER markdown.js
|
||||
(absolute path like its neighbours — the nested /shared/<token> route
|
||||
breaks a relative ref). -->
|
||||
<script src="/assets/verbatim.js"></script>
|
||||
<script type="module" src="/assets/shared.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user