fix(assistant): #91 maintien de l'ancre en haut pendant le streaming, libération au scroll manuel
This commit is contained in:
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user