From 18b7dee2f6582b09009909080aecefe51e760a71 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 15 Sep 2026 16:24:57 -0400 Subject: [PATCH] =?UTF-8?q?fix(assistant):=20#91=20maintien=20de=20l'ancre?= =?UTF-8?q?=20en=20haut=20pendant=20le=20streaming,=20lib=C3=A9ration=20au?= =?UTF-8?q?=20scroll=20manuel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/features/ai-assistant-conversation-ux.md | 8 ++++--- frontend/js/bookslm.js | 22 ++++++++++++++++--- tests/frontend/ai.test.mjs | 20 ++++++++++++++++- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/docs/features/ai-assistant-conversation-ux.md b/docs/features/ai-assistant-conversation-ux.md index 7be7687..4e1cb5d 100644 --- a/docs/features/ai-assistant-conversation-ux.md +++ b/docs/features/ai-assistant-conversation-ux.md @@ -26,9 +26,11 @@ l'ouverture d'un contexte, au rechargement d'une session et à la reprise après confirmation agent : cible = dernier `.bookslm-msg.user`, `scrollIntoView` fluide `block: 'start'`, repli calcul `scrollTop += delta` si indisponible. - - [x] **A2.** Re-rendus de streaming : la position de scroll est **préservée** - (plus de `scrollTop = scrollHeight`) — la réponse s'allonge sous la question - sans jamais déplacer la vue. + - [x] **A2.** Re-rendus de streaming : tant que la réponse arrive (`_isLoading`) et + que l'utilisateur n'a pas touché à la molette (flag `_pinnedTurn`, libéré sur + `wheel`/`touchmove`), chaque frame re-ancre instantanément la question en haut — + la réponse s'allonge sous la question sans jamais déplacer la vue. Hors + streaming, la position de scroll est préservée (plus de `scrollTop = scrollHeight`). - [x] **A3.** Marge supérieure : `scroll-margin-top: 14px` sur `.bookslm-msg` pour que le post ne colle pas au bord. - [x] **A4.** Seule la zone du fil défile : `.bookslm-messages { flex: 1; overflow-y: auto }` diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index 739104a..2f47dea 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -122,6 +122,9 @@ class BooksLM { this._menuSeq = 0; // Timer that hides the "done" activity indicator after a short delay. this._activityHideTimer = null; + // True while the latest question should stay pinned to the top of the + // thread (cleared as soon as the user scrolls manually). + this._pinnedTurn = false; // Cache of every indexed path per vault, backing instant `@` filtering. this._pathCache = null; this._pathCacheVault = null; @@ -887,6 +890,14 @@ class BooksLM { this._activatePath(path, kind); }); + // A manual scroll releases the top-pinning of the latest question. + const messagesEl = panel.querySelector('.bookslm-messages'); + if (messagesEl) { + const unpin = () => { this._pinnedTurn = false; }; + messagesEl.addEventListener('wheel', unpin, { passive: true }); + messagesEl.addEventListener('touchmove', unpin, { passive: true }); + } + // Close the session / command menus when clicking elsewhere in the panel. panel.addEventListener('click', (e) => { if (!e.target.closest('.bookslm-history-menu') && !e.target.closest('.bookslm-btn-history')) { @@ -1793,16 +1804,20 @@ class BooksLM { } if (opts.anchor) this._anchorLatestUser(); + else if (this._isLoading && this._pinnedTurn) this._anchorLatestUser({ instant: true }); else container.scrollTop = prevTop; } /** * Scroll the messages window so the most recent user post sits at the very * top of the viewport (its `scroll-margin-top` keeps a breathing gap), - * reproducing Notion-AI's conversation anchoring. + * reproducing Notion-AI's conversation anchoring. The pin stays active + * while the answer streams, so the question never leaves the top — until + * the user scrolls manually (see the wheel/touch listeners in `_render()`). */ - _anchorLatestUser() { + _anchorLatestUser(opts = {}) { if (!this._panel) return; + this._pinnedTurn = true; const container = this._panel.querySelector('.bookslm-messages'); if (!container) return; const users = container.querySelectorAll('.bookslm-msg.user'); @@ -1810,9 +1825,10 @@ class BooksLM { // Fallback (admin notes with no user post): anchor the newest message. const el = users[users.length - 1] || all[all.length - 1] || container.firstElementChild; if (!el) return; + const behavior = opts.instant ? 'auto' : 'smooth'; const apply = () => { try { - el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + el.scrollIntoView({ behavior, block: 'start' }); } catch { const cRect = container.getBoundingClientRect(); const eRect = el.getBoundingClientRect(); diff --git a/tests/frontend/ai.test.mjs b/tests/frontend/ai.test.mjs index 72e150b..9938ef1 100644 --- a/tests/frontend/ai.test.mjs +++ b/tests/frontend/ai.test.mjs @@ -304,7 +304,25 @@ async function main() { panel.remove(); }); - await test("empty content renders no action bar", () => { + await test("manual wheel scroll releases the top-pinning", async () => { + const b = new BooksLM(); + const panel = b._render(); + b._panel = panel; + document.body.appendChild(panel); + b._messages = [ + { role: "user", content: "premier" }, + { role: "assistant", content: "réponse" }, + { role: "user", content: "deuxième" }, + ]; + b._renderMessages({ anchor: true }); + assert.equal(b._pinnedTurn, true, "anchor sets the pin"); + const container = panel.querySelector(".bookslm-messages"); + container.dispatchEvent(new w.WheelEvent("wheel", { deltaY: -30, bubbles: true })); + assert.equal(b._pinnedTurn, false, "wheel releases the pin"); + panel.remove(); + }); + + await test("empty content renders no action bar", () => { const b = new BooksLM(); const panel = b._render(); b._panel = panel;