feat(assistant): #91 etapes enrichies (sous-sections Reflechon/Sources, chevrons, indicateur anime, suppression barre de chargement)
CI / lint (push) Successful in 1m23s
CI / security (push) Successful in 58s
CI / test (push) Successful in 2m43s
CI / build (push) Successful in 55s
CI / e2e (push) Successful in 11m8s

This commit is contained in:
2026-09-15 22:53:15 -04:00
parent 431fb5626d
commit f630771150
13 changed files with 477 additions and 80 deletions
+95 -13
View File
@@ -406,17 +406,86 @@ async function main() {
await test("agent tool calls collapse into a discreet steps block", () => {
const b = new BooksLM();
const msg = {};
const trace = b._renderToolActivity([
{ name: "read_file", ok: true, step: { key: "file_read", params: { value: "a.md" } } },
{ name: "search", ok: true },
]);
], msg, false);
assert.equal(trace.tagName, "DETAILS", "collapsible details element");
const summary = trace.querySelector("summary");
assert.ok(summary, "summary present");
assert.ok(summary.textContent.trim().length > 0, "summary shows the steps label");
const label = summary.querySelector(".bookslm-steps-label");
assert.ok(label && label.textContent.length > 0, "summary shows the steps label");
assert.ok(summary.querySelector(".bookslm-chevron"), "chevron after the label");
assert.equal(summary.textContent.includes(">"), false, "no legacy '> ' prefix");
assert.equal(summary.querySelector(".bookslm-steps-dots"), null, "no indicator when idle");
assert.equal(trace.querySelectorAll(".bookslm-tool-line").length, 2, "each step listed inside");
});
await test("the steps header carries the animated indicator while running", () => {
const b = new BooksLM();
const msg = {};
const trace = b._renderToolActivity(
[{ name: "read_file", ok: true, step: { key: "file_read", params: { value: "a.md" } } }],
msg, true);
const summary = trace.querySelector("summary");
const dots = summary.querySelector(".bookslm-steps-dots");
assert.ok(dots, "dots shown before the label while the agent works");
assert.equal(dots.querySelectorAll("i").length, 3, "three animated dots");
assert.equal(dots.getAttribute("aria-hidden"), "true");
assert.ok(summary.getAttribute("aria-label"), "the state is announced to screen readers");
});
await test("reasoning notes become « Réflexion » sub-sections, open state kept", () => {
const b = new BooksLM();
const msg = {};
const calls = [
{ name: "", ok: true, step: { key: "thought", params: { value: "Je dois d'abord lister le vault." } } },
{ name: "list_vaults", ok: true, step: { key: "vaults", params: {} } },
];
const trace = b._renderToolActivity(calls, msg, false);
const thought = trace.querySelector("details.bookslm-thought");
assert.ok(thought, "the note is its own collapsible sub-section");
assert.ok(thought.querySelector(".bookslm-thought-label"), "labelled (Réflexion / Thought)");
assert.equal(thought.querySelector(".bookslm-thought-text").textContent,
"Je dois d'abord lister le vault.", "the thought text sits inside");
assert.equal(thought.open, false, "collapsed by default → ▶");
assert.equal(trace.querySelectorAll(".bookslm-tool-line").length, 1, "the note is not a plain line");
// Expanding is remembered so the per-token re-render cannot collapse it.
thought.open = true;
thought.dispatchEvent(new Event("toggle"));
assert.equal(msg._thoughtOpen["Je dois d'abord lister le vault."], true);
const again = b._renderToolActivity(calls, msg, false);
assert.equal(again.querySelector("details.bookslm-thought").open, true,
"the open state survives a re-render");
});
await test("web searches add a sources section with clickable links", () => {
const b = new BooksLM();
const msg = {};
const trace = b._renderToolActivity([
{
name: "web_search", ok: true, step: { key: "web_search", params: { value: "météo Paris" } },
sources: [
{ title: "Météo Paris", url: "https://exemple.fr/meteo" },
{ title: "", url: "https://exemple.fr/b" },
],
},
], msg, false);
const box = trace.querySelector("details.bookslm-web-sources");
assert.ok(box, "sources section present");
assert.equal(box.open, true, "visible by default so the links show up");
const links = box.querySelectorAll(".bookslm-web-sources-list a");
assert.equal(links.length, 2, "one link per source");
assert.equal(links[0].getAttribute("href"), "https://exemple.fr/meteo");
assert.equal(links[0].target, "_blank");
assert.equal(links[0].rel, "noopener noreferrer");
assert.equal(links[1].textContent, "https://exemple.fr/b", "falls back to the URL label");
// No web search → no sources section.
const bare = b._renderToolActivity([{ name: "read_file", ok: true }], {}, false);
assert.equal(bare.querySelector(".bookslm-web-sources"), null);
});
await test("_stepText uses the backend label key, falls back to tool name", () => {
const b = new BooksLM();
// No locales loaded in the JSDOM harness → t() returns the key, so the
@@ -1518,22 +1587,35 @@ async function main() {
assert.ok(del.includes("refreshAIPickers()"), "deleting a provider key refreshes the picker");
});
// ── 42b. Assistant activity indicator (#82) ──
await test("activity indicator reflects the request workflow", () => {
// ── 42b. Assistant activity indicator (#91 — plus de barre de chargement) ──
await test("no loading bar above the composer; activity is recorded as state", () => {
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
const el = b._panel.querySelector(".bookslm-activity");
assert.ok(el, "activity element present");
assert.ok(el.classList.contains("hidden"), "hidden initially");
assert.equal(b._panel.querySelector(".bookslm-activity"), null,
"the composer loading bar is gone");
// _setActivity stays the single entry point of the streaming code paths.
b._setActivity("working", "Envoi…");
assert.ok(!el.classList.contains("hidden"), "shown while working");
assert.ok(el.classList.contains("working"));
assert.equal(el.querySelector(".bookslm-activity-text").textContent, "Envoi…");
b._setActivity("error", "Échec");
assert.ok(el.classList.contains("error"));
assert.equal(b._activityKind, "working");
assert.equal(b._activityText, "Envoi…");
b._setActivity("idle");
assert.ok(el.classList.contains("hidden"), "hidden when idle");
assert.equal(b._activityKind, "idle");
b._panel.remove();
});
await test("plain chat shows the pending indicator inside the empty answer", () => {
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
b._messages = [{ role: "assistant", content: "", toolCalls: [], sources: [] }];
b._isLoading = true;
b._renderMessages();
const pending = b._panel.querySelector(".bookslm-bubble.assistant .bookslm-pending .bookslm-dots");
assert.ok(pending, "animated dots shown while the first token is pending");
assert.equal(pending.querySelectorAll("i").length, 3, "three dots");
b._isLoading = false;
b._renderMessages();
assert.equal(b._panel.querySelector(".bookslm-pending"), null, "indicator gone once settled");
b._panel.remove();
});