feat(assistant): #91 refonte Notion de la zone de discussion — question ancrée en haut, fil chronologique, étapes repliables, barre Copier/Ajouter
CI / lint (push) Successful in 1m23s
CI / security (push) Successful in 55s
CI / test (push) Successful in 2m55s
CI / build (push) Successful in 50s
CI / e2e (push) Successful in 10m58s

This commit is contained in:
2026-09-15 15:51:19 -04:00
parent 256f5a4a03
commit cdb4d29676
9 changed files with 4114 additions and 3936 deletions
+132 -77
View File
@@ -104,6 +104,7 @@ async function main() {
const bookslmMod = await import(pathToFileURL(path.join(JS_DIR, "bookslm.js")).href);
const { BooksLM, MODE } = bookslmMod;
const { collectOpenDocuments, documentsSignature } = bookslmMod;
const { state } = await import(pathToFileURL(path.join(JS_DIR, "state.js")).href);
// ── 1. Rewrite modal resolves with the typed instruction ──
await test("rewrite modal returns the typed instruction on confirm", async () => {
@@ -190,87 +191,141 @@ async function main() {
panel.remove();
});
// ── 4b. Latest turn pinned to the top + provider/model tag + copy buttons ──
await test("latest turn (user post + answer) is pinned to the top of messages", () => {
const b = new BooksLM();
const panel = b._render();
b._panel = panel;
document.body.appendChild(panel);
b._messages = [
{ role: "user", content: "ancien" },
{ role: "assistant", content: "ancienne réponse" },
{ role: "user", content: "Nouveau sujet" },
{ role: "assistant", content: "Nouvelle réponse", provider: "deepseek", model: "deepseek-chat" },
];
b._renderMessages();
const msgs = panel.querySelectorAll(".bookslm-msg");
// Both bubbles of the latest turn carry the pinning class...
assert.ok(msgs[2].classList.contains("latest"), "latest user pinned");
assert.ok(msgs[3].classList.contains("latest"), "latest assistant pinned");
assert.ok(!msgs[0].classList.contains("latest"), "older user not pinned");
assert.ok(!msgs[1].classList.contains("latest"), "older assistant not pinned");
panel.remove();
});
// ── 4b. Notion-style thread: top-anchored latest post, provider/model tag, action bar ──
await test("thread renders chronologically (user post above its answer)", () => {
const b = new BooksLM();
const panel = b._render();
b._panel = panel;
document.body.appendChild(panel);
b._messages = [
{ role: "user", content: "ancien" },
{ role: "assistant", content: "ancienne réponse" },
{ role: "user", content: "Nouveau sujet" },
{ role: "assistant", content: "Nouvelle réponse", provider: "deepseek", model: "deepseek-chat" },
];
b._renderMessages();
const roles = [...panel.querySelectorAll(".bookslm-messages > .bookslm-msg")].map((el) =>
el.classList.contains("user") ? "user" : "assistant");
assert.deepEqual(roles, ["user", "assistant", "user", "assistant"], "chronological order kept");
// No background bubble on the assistant answer (Notion-style full-width text).
const aBubble = panel.querySelector(".bookslm-msg.assistant .bookslm-bubble");
assert.ok(aBubble.classList.contains("assistant"), "assistant bubble class");
panel.remove();
});
await test("assistant answer shows a discreet provider/model tag", () => {
const b = new BooksLM();
const panel = b._render();
b._panel = panel;
document.body.appendChild(panel);
b._messages = [
{ role: "user", content: "Bonjour" },
{ role: "assistant", content: "Réponse", provider: "deepseek", model: "deepseek-v3" },
];
b._renderMessages();
const meta = panel.querySelector(".bookslm-msg.assistant.latest .bookslm-msg-meta");
assert.ok(meta, "provider/model meta line rendered");
assert.equal(meta.textContent, "deepseek · deepseek-v3");
panel.remove();
});
await test("sending anchors the latest user post to the top of the thread", async () => {
const b = new BooksLM();
const panel = b._render();
b._panel = panel;
document.body.appendChild(panel);
b._messages = [
{ role: "user", content: "ancien" },
{ role: "assistant", content: "ancienne réponse" },
{ role: "user", content: "Dernier sujet" },
];
b._renderMessages();
let anchored = null;
const users = panel.querySelectorAll(".bookslm-msg.user");
const last = users[users.length - 1];
last.scrollIntoView = (opts) => { anchored = opts; };
b._anchorLatestUser();
await sleep(10); // requestAnimationFrame shim → setTimeout(0)
assert.ok(anchored, "scrollIntoView called on the latest user post");
assert.equal(anchored.block, "start");
panel.remove();
});
await test("both user and assistant blocks expose a copy button", () => {
const b = new BooksLM();
const panel = b._render();
b._panel = panel;
document.body.appendChild(panel);
b._messages = [
{ role: "user", content: "Question copiable" },
{ role: "assistant", content: "Réponse copiable", provider: "deepseek", model: "m" },
];
b._renderMessages();
const wrapped = panel.querySelectorAll(".bookslm-msg");
assert.equal(wrapped.length, 2);
// Both wraps carry a copy button.
assert.ok(panel.querySelector(".bookslm-msg.user .bookslm-copy-btn"), "user copy button");
assert.ok(panel.querySelector(".bookslm-msg.assistant .bookslm-copy-btn"), "assistant copy button");
panel.remove();
});
await test("assistant answer shows a discreet provider/model tag", () => {
const b = new BooksLM();
const panel = b._render();
b._panel = panel;
document.body.appendChild(panel);
b._messages = [
{ role: "user", content: "Bonjour" },
{ role: "assistant", content: "Réponse", provider: "deepseek", model: "deepseek-v3" },
];
b._renderMessages();
const meta = panel.querySelector(".bookslm-msg.assistant .bookslm-msg-meta");
assert.ok(meta, "provider/model meta line rendered");
assert.equal(meta.textContent, "deepseek · deepseek-v3");
panel.remove();
});
await test("copy button copies the raw message text to the clipboard", async () => {
const b = new BooksLM();
const panel = b._render();
b._panel = panel;
document.body.appendChild(panel);
b._messages = [{ role: "user", content: "texte à copier" }];
b._renderMessages();
const copied = [];
b._copyText = (t) => copied.push(t);
const btn = panel.querySelector(".bookslm-msg.user .bookslm-copy-btn");
assert.ok(btn, "copy button present");
btn.click();
assert.deepEqual(copied, ["texte à copier"], "copy handler receives raw content");
panel.remove();
});
await test("action bar: user has copy, assistant has copy + add", () => {
const b = new BooksLM();
const panel = b._render();
b._panel = panel;
document.body.appendChild(panel);
b._messages = [
{ role: "user", content: "Question copiable" },
{ role: "assistant", content: "Réponse copiable", provider: "deepseek", model: "m" },
];
b._renderMessages();
assert.ok(panel.querySelector(".bookslm-msg.user .bookslm-msg-action-copy"), "user copy action");
assert.ok(!panel.querySelector(".bookslm-msg.user .bookslm-msg-action-insert"), "no add on user post");
assert.ok(panel.querySelector(".bookslm-msg.assistant .bookslm-msg-action-copy"), "assistant copy action");
assert.ok(panel.querySelector(".bookslm-msg.assistant .bookslm-msg-action-insert"), "assistant add action");
panel.remove();
});
await test("empty user content renders no copy button", () => {
const b = new BooksLM();
const panel = b._render();
b._panel = panel;
b._messages = [{ role: "user", content: " " }];
b._renderMessages();
assert.equal(panel.querySelectorAll(".bookslm-copy-btn").length, 0);
panel.remove();
});
await test("copy action copies the raw message text to the clipboard", async () => {
const b = new BooksLM();
const panel = b._render();
b._panel = panel;
document.body.appendChild(panel);
b._messages = [{ role: "user", content: "texte à copier" }];
b._renderMessages();
const copied = [];
b._copyText = (t) => copied.push(t);
const btn = panel.querySelector(".bookslm-msg.user .bookslm-msg-action-copy");
assert.ok(btn, "copy button present");
btn.click();
assert.deepEqual(copied, ["texte à copier"], "copy handler receives raw content");
panel.remove();
});
await test("add action inserts the answer at the editor cursor", () => {
const b = new BooksLM();
const panel = b._render();
b._panel = panel;
document.body.appendChild(panel);
b._messages = [{ role: "assistant", content: "Réponse à insérer" }];
b._renderMessages();
const dispatched = [];
state.editorView = {
state: { selection: { main: { to: 7 } } },
dispatch: (spec) => dispatched.push(spec),
focus: () => {},
};
panel.querySelector(".bookslm-msg.assistant .bookslm-msg-action-insert").click();
assert.equal(dispatched.length, 1, "editor dispatch called");
assert.ok(dispatched[0].changes.insert.includes("Réponse à insérer"));
state.editorView = null;
panel.remove();
});
await test("empty content renders no action bar", () => {
const b = new BooksLM();
const panel = b._render();
b._panel = panel;
b._messages = [{ role: "user", content: " " }];
b._renderMessages();
assert.equal(panel.querySelectorAll(".bookslm-msg-actions").length, 0);
panel.remove();
});
await test("agent tool calls collapse into a discreet steps block", () => {
const b = new BooksLM();
const trace = b._renderToolActivity([
{ name: "read_file", ok: true },
{ name: "search", ok: true },
]);
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");
assert.equal(trace.querySelectorAll(".bookslm-tool-line").length, 2, "each step listed inside");
});
// ── 5. Source guards: no window.prompt() and authenticated BooksLM ──
await test("ai.js no longer uses window.prompt()", async () => {