phase: 130_chat_answer_clusters
**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:
+228
-155
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user