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
|
||||
|
||||
Reference in New Issue
Block a user