phase: 182_verbatim_show_text
Build and Push Containers / build-and-push-app (push) Successful in 1m46s
Build and Push Containers / build-and-push-db (push) Successful in 12s

**Phase 182 — 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:
2026-10-15 08:33:12 -04:00
parent 814a400e6d
commit b34b8ca672
104 changed files with 10852 additions and 203 deletions
+111 -34
View File
@@ -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