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
|
l'ouverture d'un contexte, au rechargement d'une session et à la reprise après
|
||||||
confirmation agent : cible = dernier `.bookslm-msg.user`, `scrollIntoView`
|
confirmation agent : cible = dernier `.bookslm-msg.user`, `scrollIntoView`
|
||||||
fluide `block: 'start'`, repli calcul `scrollTop += delta` si indisponible.
|
fluide `block: 'start'`, repli calcul `scrollTop += delta` si indisponible.
|
||||||
- [x] **A2.** Re-rendus de streaming : la position de scroll est **préservée**
|
- [x] **A2.** Re-rendus de streaming : tant que la réponse arrive (`_isLoading`) et
|
||||||
(plus de `scrollTop = scrollHeight`) — la réponse s'allonge sous la question
|
que l'utilisateur n'a pas touché à la molette (flag `_pinnedTurn`, libéré sur
|
||||||
sans jamais déplacer la vue.
|
`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`
|
- [x] **A3.** Marge supérieure : `scroll-margin-top: 14px` sur `.bookslm-msg`
|
||||||
pour que le post ne colle pas au bord.
|
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 }`
|
- [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;
|
this._menuSeq = 0;
|
||||||
// Timer that hides the "done" activity indicator after a short delay.
|
// Timer that hides the "done" activity indicator after a short delay.
|
||||||
this._activityHideTimer = null;
|
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.
|
// Cache of every indexed path per vault, backing instant `@` filtering.
|
||||||
this._pathCache = null;
|
this._pathCache = null;
|
||||||
this._pathCacheVault = null;
|
this._pathCacheVault = null;
|
||||||
@@ -887,6 +890,14 @@ class BooksLM {
|
|||||||
this._activatePath(path, kind);
|
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.
|
// Close the session / command menus when clicking elsewhere in the panel.
|
||||||
panel.addEventListener('click', (e) => {
|
panel.addEventListener('click', (e) => {
|
||||||
if (!e.target.closest('.bookslm-history-menu') && !e.target.closest('.bookslm-btn-history')) {
|
if (!e.target.closest('.bookslm-history-menu') && !e.target.closest('.bookslm-btn-history')) {
|
||||||
@@ -1793,16 +1804,20 @@ class BooksLM {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (opts.anchor) this._anchorLatestUser();
|
if (opts.anchor) this._anchorLatestUser();
|
||||||
|
else if (this._isLoading && this._pinnedTurn) this._anchorLatestUser({ instant: true });
|
||||||
else container.scrollTop = prevTop;
|
else container.scrollTop = prevTop;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scroll the messages window so the most recent user post sits at the very
|
* 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),
|
* 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;
|
if (!this._panel) return;
|
||||||
|
this._pinnedTurn = true;
|
||||||
const container = this._panel.querySelector('.bookslm-messages');
|
const container = this._panel.querySelector('.bookslm-messages');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
const users = container.querySelectorAll('.bookslm-msg.user');
|
const users = container.querySelectorAll('.bookslm-msg.user');
|
||||||
@@ -1810,9 +1825,10 @@ class BooksLM {
|
|||||||
// Fallback (admin notes with no user post): anchor the newest message.
|
// Fallback (admin notes with no user post): anchor the newest message.
|
||||||
const el = users[users.length - 1] || all[all.length - 1] || container.firstElementChild;
|
const el = users[users.length - 1] || all[all.length - 1] || container.firstElementChild;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
const behavior = opts.instant ? 'auto' : 'smooth';
|
||||||
const apply = () => {
|
const apply = () => {
|
||||||
try {
|
try {
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
el.scrollIntoView({ behavior, block: 'start' });
|
||||||
} catch {
|
} catch {
|
||||||
const cRect = container.getBoundingClientRect();
|
const cRect = container.getBoundingClientRect();
|
||||||
const eRect = el.getBoundingClientRect();
|
const eRect = el.getBoundingClientRect();
|
||||||
|
|||||||
@@ -304,7 +304,25 @@ async function main() {
|
|||||||
panel.remove();
|
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 b = new BooksLM();
|
||||||
const panel = b._render();
|
const panel = b._render();
|
||||||
b._panel = panel;
|
b._panel = panel;
|
||||||
|
|||||||
Reference in New Issue
Block a user