fix(assistant): #91 maintien de l'ancre en haut pendant le streaming, libération au scroll manuel
CI / lint (push) Successful in 1m22s
CI / security (push) Successful in 1m0s
CI / test (push) Successful in 3m12s
CI / build (push) Successful in 50s
CI / e2e (push) Successful in 10m34s

This commit is contained in:
2026-09-15 16:24:57 -04:00
parent 731b3e4b47
commit 18b7dee2f6
3 changed files with 43 additions and 7 deletions
@@ -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 }`
+19 -3
View File
@@ -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();
+19 -1
View File
@@ -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;