phase: 130_chat_answer_clusters
Build and Push Containers / build-and-push-app (push) Successful in 2m27s
Build and Push Containers / build-and-push-db (push) Successful in 12s

**Phase 130 — final verification pass: 100% green, complete.**

- **Verified:** chips-only row (no chip for image docs) → images-only row (`.msg-images`) → related row → `.msg-actions` last; `getActionsRow` used by all 3 buttons + stopped/failed notes; `markLastRetryable` save-re-append hack gone; in-place chip fallback on image-load failure; CSS right-align with per-button auto margins removed — implementation matches the design in every detail.
- **Completion criteria:** (1) mixed citation renders chips→images→actions-last, DOM-asserted ✅ (2) all 3 buttons in one right-aligned `.msg-actions`; non-admin sees Retry-only; restore path identical ✅ (3) 404-bytes figure → plain chip, unit-pinned ✅ (4) E2E isolated ✅ (5) no behavior change (modal affordances, related tier, ARIA names) ✅
- **Tests/lint/coverage:** `uv run pytest tests/e2e/test_chat_answer_clusters.py -v --no-cov` → 4 passed; `uv run pytest --cov=app --cov-report=term-missing` → 3061 passed, **99%** app coverage; the 4 E2E files this phase modified (test_answer_images, test_image_documents, test_pdf_support, test_responsive_polish) → 22 passed in isolation; `uv run ruff check .` clean, `uv run pyright` → 0 errors.
- **Docs:** README.md — verified consistent in full (page table vs routes, env-var coverage vs `.env.example`, phase-130 cluster wording already in tree); no changes needed. AGENTS.md — fixed one pre-existing inconsistency: rule 5 still cited the retired "centered 46rem chat column"; now states the 72rem container contract (phase 100).
- **Decisions:** PLAN.md §7.1 still says 46rem, but it's owner-managed (phase 100 records the supersession pending owner sign-off) and out of my editable scope — AGENTS.md now cross-references the supersession.
- **Next pending phase:** `131_read_line_ranges` (todo/).
This commit is contained in:
2026-09-28 07:49:37 -04:00
parent 2d0f608246
commit c88c65641a
46 changed files with 3266 additions and 351 deletions
+228 -155
View File
@@ -466,21 +466,47 @@ const TUNE_ICON =
let tuneSeq = 0; // unique ids for one open tune form's inputs
/* "Tune" button in the meta row of a completed brain bubble. Reuses the
sources' .msg-meta row when it exists (role=list → the button joins as
a listitem so ARIA stays valid); otherwise creates a plain meta row.
Phase 16: anonymous visitors never get the button — this single guard
covers both fresh turns and the phase-14 restore path. */
/* The bubble's ACTIONS ROW (phase 130 task 02, TODO L6) — get-or-
create the .msg-meta.msg-actions row: the ONE row the answer
actions (Tune, Retry, Save-as-doc) and the answer-status notes
(Stopped, Failed) join (ASSUMPTION A7 — the notes are answer-status
markers; the actions row is their home). Pre-phase-130 the buttons
and notes claimed the FIRST .msg-meta row of the bubble — usually
the source chips row — so chips, figures, and buttons pushed each
other around in one soup div; the dedicated row ends that. The row
is the bubble's LAST row (the call sites append the actions after
the source clusters and the related row — DOM order is visual
order) and the CSS right-aligns its contents (phase 130 task 03 —
the TODO's "bottom right" = the bubble's bottom row, right edge).
The house get-or-create pattern, scoped to the msg-actions class so
it NEVER grabs the chips row (or any other meta row): role=list +
the row's aria-label keep the ARIA valid, and each child joins as a
listitem (the appenders keep their role=listitem check — the row is
always role=list, so it always applies). Returns null without a
.msg-body (the appenders no-op, the pre-phase convention). */
function getActionsRow(wrap) {
const body = wrap?.querySelector?.(".msg-body");
if (!body) return null;
let row = body.querySelector(".msg-meta.msg-actions");
if (!row) {
row = document.createElement("div");
row.className = "msg-meta msg-actions";
row.setAttribute("role", "list");
row.setAttribute("aria-label", "Answer actions");
body.appendChild(row);
}
return row;
}
/* "Tune" button in the completed brain bubble's actions row
(getActionsRow — phase 130 task 02; the role=list row joins the
button as a listitem so ARIA stays valid). Phase 16: anonymous
visitors never get the button — this single guard covers both fresh
turns and the phase-14 restore path. */
function appendTuneButton(wrap) {
if (!isAdmin) return; // phase 16: tuning is admin-only
const body = wrap.querySelector(".msg-body");
if (!body) return;
let meta = body.querySelector(".msg-meta");
if (!meta) {
meta = document.createElement("div");
meta.className = "msg-meta";
body.appendChild(meta);
}
const meta = getActionsRow(wrap);
if (!meta) return;
if (meta.querySelector(".tune-btn")) return; // one per bubble
const btn = document.createElement("button");
btn.type = "button";
@@ -491,24 +517,19 @@ function appendTuneButton(wrap) {
meta.appendChild(btn);
}
/* Phase 48 (owner-locked 2026-08-29): the "Stopped" note in the meta row
* of a user-stopped brain bubble. The live stop path (handleSend's stop
* branch) and the phase-14 restore path (a record with `m.stopped`) share
* this helper, so a restored bubble reads exactly like the stopped one.
* Reuses the .msg-meta row the way appendTuneButton does (role=list → the
* span joins as a listitem so ARIA stays valid). Non-interactive (no
* hover/focus — pointer-events: none in the CSS): the small filled-square
* stop glyph is aria-hidden decoration; the "Stopped" text carries the
* accessible meaning. */
/* Phase 48 (owner-locked 2026-08-29): the "Stopped" note in the
* bubble's actions row (getActionsRow — phase 130 task 02, ASSUMPTION
* A7: the notes are answer-status markers, the actions row is their
* home; the role=list row joins the span as a listitem so ARIA stays
* valid). The live stop path (handleSend's stop branch) and the
* phase-14 restore path (a record with `m.stopped`) share this helper,
* so a restored bubble reads exactly like the stopped one.
* Non-interactive (no hover/focus — pointer-events: none in the CSS):
* the small filled-square stop glyph is aria-hidden decoration; the
* "Stopped" text carries the accessible meaning. */
function appendStoppedNote(wrap) {
const body = wrap?.querySelector?.(".msg-body");
if (!body) return;
let meta = body.querySelector(".msg-meta");
if (!meta) {
meta = document.createElement("div");
meta.className = "msg-meta";
body.appendChild(meta);
}
const meta = getActionsRow(wrap);
if (!meta) return;
if (meta.querySelector(".stopped-note")) return; // one per bubble
const note = document.createElement("span");
note.className = "stopped-note";
@@ -527,25 +548,21 @@ function appendStoppedNote(wrap) {
* paths (finalizeFailedTurn / the zero-frame fallback) and the phase-14
* restore path (a record with `m.failed`) share this helper, so a restored
* bubble reads exactly like the failed one. The banner keeps its
* role="alert" summary; this in-bubble note is the refresh-surviving copy
* (on restore it renders from the persisted `error` detail). Reuses the
* .msg-meta row the way appendStoppedNote does (role=list → the span joins
* as a listitem so ARIA stays valid); the triangle glyph is aria-hidden
* decoration — the "Failed" label + the detail text carry the accessible
* meaning (text + color, never color alone — B5). `detail` goes through
* textContent (no HTML from the error string, ever). */
* role="alert" summary; this in-bubble note is the refresh-surviving
* copy (on restore it renders from the persisted `error` detail).
* Joins the bubble's actions row the way appendStoppedNote does
* (getActionsRow — phase 130 task 02, ASSUMPTION A7; role=list → the
* span joins as a listitem so ARIA stays valid); the triangle glyph is
* aria-hidden decoration — the "Failed" label + the detail text carry
* the accessible meaning (text + color, never color alone — B5).
* `detail` goes through textContent (no HTML from the error string,
* ever). */
const FAILED_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>';
function appendFailedNote(wrap, detail) {
const body = wrap?.querySelector?.(".msg-body");
if (!body) return;
let meta = body.querySelector(".msg-meta");
if (!meta) {
meta = document.createElement("div");
meta.className = "msg-meta";
body.appendChild(meta);
}
const meta = getActionsRow(wrap);
if (!meta) return;
if (meta.querySelector(".failed-note")) return; // one per bubble
const note = document.createElement("span");
note.className = "failed-note";
@@ -578,23 +595,17 @@ let lastBrainWrap = null;
const RETRY_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>';
/* "Retry" button in the meta row of the last brain bubble — the redo
/* "Retry" button in the last brain bubble's actions row — the redo
* action of phase 49 (owner-locked 2026-08-29, TODO.md L4). The house
* appendTuneButton pattern: reuses the .msg-meta row when it exists
* (role=list → the button joins as a listitem so ARIA stays valid),
* otherwise creates a plain meta row; one button per bubble. NOT
* admin-gated, unlike appendTuneButton — chat is public, so every
* visitor gets the redo (the meta-row actions read as a pair: Tune for
* the admin, Retry for everyone). Click → retryLastTurn(wrap). */
* pattern through getActionsRow (phase 130 task 02 — the role=list
* row joins the button as a listitem so ARIA stays valid); one button
* per bubble. NOT admin-gated, unlike appendTuneButton — chat is
* public, so every visitor gets the redo (the actions cluster reads as
* a pair: Tune for the admin, Retry for everyone). Click →
* retryLastTurn(wrap). */
function appendRetryButton(wrap) {
const body = wrap.querySelector(".msg-body");
if (!body) return;
let meta = body.querySelector(".msg-meta");
if (!meta) {
meta = document.createElement("div");
meta.className = "msg-meta";
body.appendChild(meta);
}
const meta = getActionsRow(wrap);
if (!meta) return;
if (meta.querySelector(".retry-btn")) return; // one per bubble
const btn = document.createElement("button");
btn.type = "button";
@@ -608,12 +619,21 @@ function appendRetryButton(wrap) {
/* Last-bubble-only management (owner-locked 2026-08-29): the Retry
* button lives on exactly ONE bubble — the last brain answer. Remove
* every rendered .retry-btn FIRST (an earlier bubble's button is stale
* the moment a newer answer lands), then re-append it to the last brain
* bubble — but only when that record has its preceding user record to
* re-ask (the invariant holds in practice: every brain record follows
* its user record). Call sites: on `done`, on the empty-answer
* fallback, on the stop finalize (a stopped partial is the prime retry
* candidate), and once at the end of the phase-14 restore.
* the moment a newer answer lands), then re-append it to the last
* brain bubble — but only when that record has its preceding user
* record to re-ask (the invariant holds in practice: every brain
* record follows its user record). Phase 130 (task 02): the re-append
* lands in the bubble's .msg-actions row (appendRetryButton →
* getActionsRow) and the phase-59 save-button re-append hack is GONE —
* the row's row-level right-align (phase 130 task 03) right-aligns the
* WHOLE cluster, and the DOM order inside the row is the insertion
* order (the live call sites append Tune → Retry → Save-as-doc per
* their gates; the restore's per-message appends precede this
* after-the-loop call, so a restored last bubble reads Tune, Save,
* Retry). Call sites: on `done` (between the Tune and the Save-as-doc
* appends — the insertion order), on the empty-answer fallback, on the
* stop finalize (a stopped partial is the prime retry candidate), and
* once at the end of the phase-14 restore.
* startNewChat needs no call: its list reset removes the buttons along
* with the list. */
function markLastRetryable() {
@@ -629,13 +649,6 @@ function markLastRetryable() {
const prev = lastIdx > 0 ? conversation[lastIdx - 1] : null;
if (!prev || prev.who !== "user") return;
appendRetryButton(lastBrainWrap);
// Phase 59: "Save as doc" stays the meta row's rightmost action —
// when the Retry button lands on the SAME bubble, re-append the save
// button after it (the auto margins split the free space between the
// right-aligned buttons; DOM order decides the right edge).
const saveDocBtn = lastBrainWrap.querySelector(".save-as-doc-btn");
if (saveDocBtn && saveDocBtn.parentElement)
saveDocBtn.parentElement.appendChild(saveDocBtn);
}
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the bottom-right
@@ -650,10 +663,13 @@ function markLastRetryable() {
* stays in the signature (the three call sites are unchanged) but no
* longer travels: phase 75 (TODO L4, A6) drafts the WHOLE conversation
* from the `conversation` record at click time (buildSessionTranscript).
* The .save-as-doc-btn's margin-inline-start: auto pushes it to the
* row's right edge (the TODO's "bottom right"); markLastRetryable
* keeps it rightmost when the last bubble also carries the Retry
* button.
* Phase 130 (task 02): the button joins the bubble's .msg-actions row
* (getActionsRow) — the row's row-level right-align (phase 130 task
* 03) makes the WHOLE cluster the TODO's "bottom right" (the
* per-button auto margins are gone with the markLastRetryable
* save-re-append hack they existed for); the call sites append it
* LAST in the row (Tune, Retry, Save-as-doc per their gates), so it
* stays the rightmost action.
*
* Click: default title (phase 115 — the QUESTION THE ANSWER
* ANSWERED: the saved bubble's paired user bubble, whitespace-
@@ -776,24 +792,18 @@ function buildSessionTranscript() {
return body.replace(/\s+$/, "") + "\n";
}
/* The bottom-right "Save as doc" button — the appendTuneButton
* pattern: reuses the .msg-meta row when it exists (role=list → the
* button joins as a listitem so ARIA stays valid), otherwise creates
* a plain meta row; one button per bubble. */
/* The bottom-right "Save as doc" button — the house pattern through
* getActionsRow (phase 130 task 02: the role=list actions row joins
* the button as a listitem so ARIA stays valid); one button per
* bubble. */
function appendSaveAsDocButton(wrap, markdown) {
if (!isAdmin || !docsRepoConfigured) return; // phase 59: admin + configured
const body = wrap.querySelector(".msg-body");
if (!body) return;
let meta = body.querySelector(".msg-meta");
if (!meta) {
meta = document.createElement("div");
meta.className = "msg-meta";
body.appendChild(meta);
}
const meta = getActionsRow(wrap);
if (!meta) return;
if (meta.querySelector(".save-as-doc-btn")) return; // one per bubble
const btn = document.createElement("button");
btn.type = "button";
btn.className = "save-as-doc-btn"; // margin-inline-start: auto → bottom-right
btn.className = "save-as-doc-btn"; // bottom-right: the row's right-align (task 03)
if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");
btn.innerHTML = SAVE_AS_DOC_ICON + "<span>Save as doc</span>";
btn.addEventListener("click", () => saveAsDoc(btn));
@@ -1575,37 +1585,66 @@ async function readSSE(response, onEvent) {
/* Source chips (mono, source/path) under a Brain bubble. Phase 26:
* clicking a chip opens the document in the same-page modal (no new
* tab) — the /document.html href stays as the no-JS / context-menu
* escape hatch. */
* escape hatch.
*
* Phase 130 (task 01, TODO L6): the block is two CLUSTERS, never one
* interleaved row — the chips row holds the NON-image refs only (a
* ref carrying `image_url` is an image document, phase 122, and gets
* NO chip: its figure IS the citation), and a second images row
* (`.msg-meta.msg-images`) holds one `.source-image` figure per image
* ref, in ref order. Each row is present only when its refs are
* non-empty (an image-only citation gets its images row + the actions
* row and no chips row; a text-only turn renders the pre-phase-130
* single row). Row order in .msg-body: the answer figure (the caller
* appends it first, phase 124) → chips row → images row → the
* related row (the next caller, appendRelated) → the actions row
* (the action appenders after appendRelated — phase 130 task 02,
* the bubble's LAST row). */
function appendSources(wrap, sources) {
if (!sources || !sources.length) return;
const body = wrap.querySelector(".msg-body");
const meta = document.createElement("div");
meta.className = "msg-meta";
meta.setAttribute("role", "list");
meta.setAttribute("aria-label", "Sources");
for (const s of sources) {
const label = `${s.source}/${s.path}`;
const chip = document.createElement("a");
chip.className = "source-chip";
chip.setAttribute("role", "listitem");
chip.href = documentUrl(s.source, s.path, "/"); // back → the chat page
chip.addEventListener("click", (e) => {
e.preventDefault(); // no new tab (phase 26) — the modal takes over
e.stopPropagation();
openDocumentModal(s.source, s.path, chip);
});
chip.textContent = label;
chip.title = label;
meta.appendChild(chip);
// Phase 122 (task 05): a ref for an IMAGE document carries
// `image_url` (the frame's only new field — omitted on text refs):
// its chip gains the compact inline figure right after it.
if (s.image_url) appendSourceImageFigure(meta, s);
// The `image_url` presence is the existing image-doc signal
// (phase 122): image refs cite by figure, text refs by chip.
const imageRefs = sources.filter((s) => s.image_url);
const textRefs = sources.filter((s) => !s.image_url);
// Cluster 1 — the chips row (the pre-phase-130 row, text refs
// only), skipped entirely when there are no text refs.
if (textRefs.length) {
const meta = document.createElement("div");
meta.className = "msg-meta";
meta.setAttribute("role", "list");
meta.setAttribute("aria-label", "Sources");
for (const s of textRefs) {
const label = `${s.source}/${s.path}`;
const chip = document.createElement("a");
chip.className = "source-chip";
chip.setAttribute("role", "listitem");
chip.href = documentUrl(s.source, s.path, "/"); // back → the chat page
chip.addEventListener("click", (e) => {
e.preventDefault(); // no new tab (phase 26) — the modal takes over
e.stopPropagation();
openDocumentModal(s.source, s.path, chip);
});
chip.textContent = label;
chip.title = label;
meta.appendChild(chip);
}
body.appendChild(meta);
// Accessible full path whenever the pill visually truncates
// (the chips row only — the images row carries no chips).
for (const chip of meta.children) {
if (chip.scrollWidth > chip.clientWidth) chip.setAttribute("aria-label", chip.title);
}
}
body.appendChild(meta);
// Accessible full path whenever the pill visually truncates.
for (const chip of meta.children) {
if (chip.scrollWidth > chip.clientWidth) chip.setAttribute("aria-label", chip.title);
// Cluster 2 — the images row: one figure per image ref, in ref
// order, no chips (absent when no image ref exists).
if (imageRefs.length) {
const images = document.createElement("div");
images.className = "msg-meta msg-images";
images.setAttribute("role", "list");
images.setAttribute("aria-label", "Source images");
for (const s of imageRefs) appendSourceImageFigure(images, s);
body.appendChild(images);
}
}
@@ -1617,12 +1656,14 @@ function appendSources(wrap, sources) {
* like the chips — the same documentUrl href (the /document.html escape
* hatch) and the same left-click → same-page modal (phase 26). The
* labeled row ("Nearby docs, in case:") joins the bubble's .msg-meta
* family and stacks below the citation row in the .msg-body flex gap.
* app.js appends it LAST among the meta rows — after appendTuneButton /
* appendSaveAsDocButton / appendRetryButton claimed the FIRST
* .msg-meta row — so a deflected turn (no citation row) never lets a
* meta action join this row. Empty/absent input → no DOM at all
* (pre-phase saved chats carry no `related` — the row is simply absent).
* family and stacks below the citation rows in the .msg-body flex gap.
* app.js appends it BELOW the citation clusters and ABOVE the actions
* row (phase 130 task 02 — the .msg-actions row is the bubble's LAST
* row, and the getActionsRow scoping keeps the buttons out of this row
* either way: a deflected turn with related docs gets its OWN actions
* row, never a button in the related row). Empty/absent input → no
* DOM at all (pre-phase saved chats carry no `related` — the row is
* simply absent).
*/
function appendRelated(wrap, related) {
if (!related || !related.length) return;
@@ -1669,9 +1710,12 @@ function appendRelated(wrap, related) {
* fetches the content endpoint the chip's modal already uses (the
* (source, path) pair is the same lookup key) and swaps the summary
* in; until it settles — and when it fails — the title stands in (a
* caption is ALWAYS visible). A FAILED IMAGE LOAD collapses the figure
* to the plain chip (the figure is removed — never a broken-image
* icon: the row's bytes route 404s when the copy was lost). */
* caption is ALWAYS visible). A FAILED IMAGE LOAD becomes the IN-PLACE
* CHIP FALLBACK (phase 130 task 01, ASSUMPTION A3): the figure is
* replaced, in place, with a plain `.source-chip` anchor for the same
* document — the citation survives a lost byte copy, never a
* broken-image icon (the row's bytes route 404s when the copy was
* lost). */
function appendSourceImageFigure(meta, s) {
const label = `${s.source}/${s.path}`;
const fig = document.createElement("a");
@@ -1690,9 +1734,25 @@ function appendSourceImageFigure(meta, s) {
img.src = s.image_url;
img.alt = s.title || label; // the summary arrives via the fetch below
img.addEventListener("error", () => {
// The plain chip stays (it is the collapse target) — the figure,
// with its not-yet-resolved alt, goes.
fig.remove();
// Phase 130 (task 01, ASSUMPTION A3): the IN-PLACE CHIP FALLBACK —
// there is no chip for image docs anymore (the figure is the
// citation), so the figure itself becomes one: replaced, in place,
// with a plain `.source-chip` anchor for the same document (the
// same documentUrl href, the same left-click → modal, the label as
// text) — the citation survives a lost byte copy, never a
// broken-image icon.
const chip = document.createElement("a");
chip.className = "source-chip";
chip.setAttribute("role", "listitem");
chip.href = documentUrl(s.source, s.path, "/"); // back → the chat page
chip.addEventListener("click", (e) => {
e.preventDefault(); // no new tab (phase 26) — the modal takes over
e.stopPropagation();
openDocumentModal(s.source, s.path, chip);
});
chip.textContent = label;
chip.title = label;
fig.replaceWith(chip);
});
const caption = document.createElement("span");
caption.className = "source-image-caption";
@@ -1701,7 +1761,7 @@ function appendSourceImageFigure(meta, s) {
meta.appendChild(fig);
// …and the document's summary once the content fetch settles.
fetchContentSummary(s.source, s.path).then((summary) => {
if (!fig.isConnected) return; // collapsed (img error) or bubble cleared
if (!fig.isConnected) return; // replaced by the chip fallback (img error) or bubble cleared
if (summary && summary.trim() !== "") {
img.alt = summary;
caption.textContent = summary;
@@ -1729,10 +1789,11 @@ function fetchContentSummary(source, path) {
/* Phase 124 (task 06, TODO L3): the LLM's ANSWER IMAGE — the brain
* answer's first-class figure (the `done` frame's `answer_image` ref,
* task 04). A4: it sits in the .msg-body BELOW the answer text and
* ABOVE the source-chip row — the callers append it right BEFORE
* appendSources creates the chip row, so it lands in between. The
* text bubble is untouched: the figure is ADDITIVE ("summaries
* should still be provided").
* ABOVE the source clusters — the callers append it right BEFORE
* appendSources builds the rows (phase 130 task 01: the chips row,
* then the images row), so it lands in between. The text bubble is
* untouched: the figure is ADDITIVE ("summaries should still be
* provided").
*
* The visual sibling of appendSourceImageFigure (the compact chip
* figure) but a different surface: the answer figure is the answer's
@@ -1983,10 +2044,20 @@ function renderStoredMessage(m) {
}
// Phase 124 (task 06, TODO L3): the answer image restores with the
// bubble — the SAME helper + the SAME A4 position as the live done
// path (below the text, above the chip row). A record without the
// key renders byte-identically (pre-phase chats restore unchanged).
// path (below the text, above the source clusters — phase 130:
// 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);
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
// the actions row: the action appenders below (Tune / Save-as-doc /
// notes, plus the restore loop's after-the-loop markLastRetryable)
// get-or-create the .msg-actions row as the bubble's LAST row,
// exactly like the live done path. Pre-phase records carry no
// `related` → appendRelated no-ops and the row is simply absent
// (graceful).
appendRelated(wrap, m.related);
// Phase 120: a failed turn (m.failed) is a note, not an answer — no
// Tune (the "note, not an answer" scope the m.stopped exclusion
// below shares; the live failure paths add no Tune either). Stopped
@@ -2009,11 +2080,6 @@ function renderStoredMessage(m) {
// precedes the failed record, unchanged; a mid-conversation failed
// bubble gets no button — the phase-49 last-bubble-only rule).
if (m.failed && m.error) appendFailedNote(wrap, m.error);
// Phase 113 (task 02): the related tier restores with the bubble
// (LAST — after the meta-row claimers, exactly like the live done
// path). Pre-phase records carry no `related` → appendRelated
// no-ops and the row is simply absent (graceful).
appendRelated(wrap, m.related);
lastBrainWrap = wrap; // phase 49: the LAST restored brain bubble wins
}
@@ -3067,8 +3133,9 @@ async function runTurn(text, { reask = false, image = null } = {}) {
}
// Phase 124 (task 06, TODO L3): the LLM's answer image — the
// answer's first-class figure (A4): appended to .msg-body
// right BEFORE the chip row, so it lands below the answer
// text and above the sources. The text is untouched (the
// right BEFORE the source clusters (phase 130: the chips row,
// then the images row), so it lands below the answer text
// and above the sources. The text is untouched (the
// 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);
@@ -3077,20 +3144,17 @@ async function runTurn(text, { reask = false, image = null } = {}) {
// surface (the chips above); the related tier (scored docs
// under the usefulness bar — and the weak hits of a DEFLECTED
// turn, whose `ev.sources` is empty → zero chips) renders as
// the de-emphasized labeled row, appended LAST among the meta
// rows below (after the appendTuneButton/SaveAsDoc/Retry
// claimers took the first .msg-meta row, so an action never
// joins the related row — a deflected turn with related docs
// still gets its own meta row for the buttons).
// the de-emphasized labeled row BELOW the citation clusters
// and ABOVE the actions row (phase 130 task 02 — the
// .msg-actions row is the bubble's LAST row, so the action
// appenders after this get-or-create it; the getActionsRow
// scoping keeps the buttons out of the related row either way
// — a deflected turn with related docs still gets its own
// actions row).
// Thinking-without-answer (reasoning can exhaust max_tokens): the
// bubble gets the empty-answer fallback — what the user saw is
// what gets persisted.
const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : "");
appendTuneButton(wrap); // every completed brain bubble is tunable
// Phase 59: the RAW persisted markdown (never the rendered
// HTML) — exactly the string rememberBrainTurn stores below,
// so a reload (the restore path) offers the identical draft.
appendSaveAsDocButton(wrap, finalText || acc || "…");
if (!acc && sawThinking) {
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText);
}
@@ -3120,8 +3184,17 @@ async function runTurn(text, { reask = false, image = null } = {}) {
suggestions: ev.suggestions,
}, leavePartialIndex);
lastBrainWrap = wrap; // this bubble is now the last brain answer
// Phase 130 (task 02): the row order is the DOM order — the
// related row above, then the actions row LAST (Tune → Retry
// → Save-as-doc in insertion order, the bubble's bottom-right
// cluster).
appendRelated(wrap, ev.related); // phase 113 — above the actions row
appendTuneButton(wrap); // every completed brain bubble is tunable
markLastRetryable(); // phase 49: the Retry button is last-bubble-only
appendRelated(wrap, ev.related); // phase 113 (task 02) — LAST meta row
// Phase 59: the RAW persisted markdown (never the rendered
// HTML) — exactly the string rememberBrainTurn stores above,
// so a reload (the restore path) offers the identical draft.
appendSaveAsDocButton(wrap, finalText || acc || "…");
} else if (ev.type === "tool_result") {
// Phase 95 (A15 extension, task 02): the truncation the LLM is
// told about is told to the USER. One frame per truncated read,
@@ -3192,7 +3265,6 @@ async function runTurn(text, { reask = false, image = null } = {}) {
const nothing = "The model answered with nothing.";
const fwrap = addMessage("brain", fallback);
appendTuneButton(fwrap);
appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path
appendFailedNote(fwrap, nothing);
// Phase 73: correlated like the other settles — unreachable when a
// pagehide partial exists (that needs acc, which means a wrap), but
@@ -3203,6 +3275,7 @@ async function runTurn(text, { reask = false, image = null } = {}) {
}, leavePartialIndex); // persist what the user actually saw
lastBrainWrap = fwrap;
markLastRetryable(); // phase 49: the fallback bubble is retryable too
appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path
}
} catch (err) {
if (aborted) {