diff --git a/CHANGELOG.md b/CHANGELOG.md index e750251..5ef01aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,17 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). restait en bas jusqu'au premier token). `_isLoading` est maintenant posé avant ce rendu et l'ancrage d'envoi est **instantané** — la question passe en haut du fil dès la soumission et y reste pendant tout le streaming. +- **#91 — Question complètement en haut** : la marge `scroll-margin-top` (14 px) + laissait visible la fin de la réponse précédente au-dessus du post, et une réponse + courte ne remplissait pas la fenêtre — le navigateur bloquait alors le défilement et + la question restait à mi-hauteur. La marge est supprimée et le `padding-bottom` du fil + est augmenté de la place manquante tant que l'ancre est active (retiré au + dé-épinglage, aucun remplissage si le fil tient dans la fenêtre). Le rendu final + post-streaming conserve aussi l'ancre, et la libération se fait sur molette, toucher + **ou** clic dans le fil. Enfin, `overflow-anchor: none` sur le fil neutralise le + ré-ancrage automatique de Chrome (le fil est reconstruit à chaque token, ce qui + décalait la vue de ~33 px) et une passe de correction recale le défilement d'après la + géométrie mesurée. - **BUG-041 — Assistant IA bloqué sur un répertoire vide** : l'assistant ne renvoie plus `⚠ Error: Aucun fichier markdown trouvé dans ce dossier` (HTTP 404). diff --git a/docs/features/ai-assistant-conversation-ux.md b/docs/features/ai-assistant-conversation-ux.md index f5ecd9c..27b6dab 100644 --- a/docs/features/ai-assistant-conversation-ux.md +++ b/docs/features/ai-assistant-conversation-ux.md @@ -27,13 +27,25 @@ confirmation agent : cible = dernier `.bookslm-msg.user`, `scrollIntoView` `block: 'start'` (instantané à l'envoi, fluide à l'ouverture d'une session), repli calcul `scrollTop += delta` si indisponible. - - [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] **A2.** Re-rendus de streaming : tant que l'ancre est active (flag + `_pinnedTurn` posé à l'envoi, libéré sur `wheel`/`touchmove`/`mousedown`), chaque + re-rendu — frames de streaming **et** rendu final — ré-ancre instantanément la + question en haut ; la réponse s'allonge sous la question sans jamais déplacer la + vue. Hors épinglage, la position de scroll est préservée (plus de + `scrollTop = scrollHeight`). + - [x] **A3.** Question **au bord** : plus de `scroll-margin-top` (le moindre écart + laissait visible la fin de la réponse précédente au-dessus du post) ; le post + s'aligne exactement sur le haut de la zone de défilement. + - [x] **A3bis.** **Remplissage dynamique** : une réponse courte ne remplit pas la + fenêtre — le navigateur bloquait alors le défilement et la question restait à + mi-hauteur. Le `padding-bottom` du fil est augmenté de la place manquante + (`target - max`) tant que l'ancre est active, puis retiré au dé-épinglage ; si + le fil tient entièrement dans la fenêtre, aucun remplissage n'est ajouté. + - [x] **A3ter.** **Anti-dérive** : `overflow-anchor: none` sur `.bookslm-messages` + (Chrome ré-ancrait le défilement à chaque reconstruction du fil — un token = + un re-rendu — laissant un décalage constant de ~33 px) et passe de correction + après `scrollIntoView` : la géométrie mesurée (`getBoundingClientRect`) fait foi, + pas la demande de défilement. Vérifié en live : 5 questions, 0 décalage. - [x] **A4.** Seule la zone du fil défile : `.bookslm-messages { flex: 1; overflow-y: auto }` (en-tête, toolbar, statut et barre de saisie fixes — structure déjà en place). diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index 561bc16..5945c92 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -890,12 +890,20 @@ class BooksLM { this._activatePath(path, kind); }); - // A manual scroll releases the top-pinning of the latest question. + // A manual scroll (wheel, touch or scrollbar drag) releases the + // top-pinning of the latest question. const messagesEl = panel.querySelector('.bookslm-messages'); if (messagesEl) { - const unpin = () => { this._pinnedTurn = false; }; + const unpin = () => { + if (!this._pinnedTurn) return; + this._pinnedTurn = false; + // The anchor padding is only there to make the post reachable while + // it is pinned — drop it so the reader gets no blank tail. + messagesEl.style.paddingBottom = ''; + }; messagesEl.addEventListener('wheel', unpin, { passive: true }); messagesEl.addEventListener('touchmove', unpin, { passive: true }); + messagesEl.addEventListener('mousedown', unpin, { passive: true }); } // Close the session / command menus when clicking elsewhere in the panel. @@ -1794,6 +1802,9 @@ class BooksLM { if (!container) return; const prevTop = container.scrollTop; container.innerHTML = ''; + // NOTE: the anchor's bottom padding must survive the re-render. Clearing + // it here (outside the synchronous scroll correction) let the browser + // paint one frame with the question pushed back down the window. // Notion-style thread: chronological order, the user post anchored at the // top of the visible area (never scroll-to-bottom — the answer streams @@ -1803,17 +1814,29 @@ class BooksLM { container.appendChild(this._chatBubble(msg)); } - if (opts.anchor) this._anchorLatestUser({ instant: !!opts.instant }); - else if (this._isLoading && this._pinnedTurn) this._anchorLatestUser({ instant: true }); - else container.scrollTop = prevTop; + // While the pin is active (question submitted, released by a manual + // scroll) every re-render — streaming frames *and* the final one — must + // put the question back at the top, otherwise the answer's own growth + // would push it back down the window. + if (opts.anchor || this._pinnedTurn) { + this._anchorLatestUser({ instant: opts.anchor ? !!opts.instant : true }); + } else { + // Unpinned: drop the anchor padding so no blank gap lingers. + container.style.paddingBottom = ''; + 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. 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()`). + * Bring the newest user post flush to the top of the thread and keep it + * there while the answer streams in. + * + * Two things are needed for the post to *really* sit at the top: + * 1. no `scroll-margin-top` (a gap would leave the tail of the previous + * answer visible above the post); + * 2. a short answer cannot fill the viewport on its own, so the thread's + * bottom padding is grown by exactly the missing amount — otherwise the + * browser clamps the scroll and the post stops half-way down the window. */ _anchorLatestUser(opts = {}) { if (!this._panel) return; @@ -1827,12 +1850,31 @@ class BooksLM { if (!el) return; const behavior = opts.instant ? 'auto' : 'smooth'; const apply = () => { + // Grow the bottom padding so the anchor position becomes reachable. + const base = parseFloat(getComputedStyle(container).paddingBottom) || 0; + container.style.paddingBottom = ''; + const cRect = container.getBoundingClientRect(); + const target = el.getBoundingClientRect().top - cRect.top + container.scrollTop; + const max = container.scrollHeight - container.clientHeight; + // Only when the thread really scrolls: a thread shorter than the window + // already shows the post at the top, extra padding would just leave a + // blank tail. + if (max > 0 && target > max) { + container.style.paddingBottom = `${Math.ceil(base + (target - max))}px`; + } else { + container.style.paddingBottom = ''; + } try { el.scrollIntoView({ behavior, block: 'start' }); } catch { - const cRect = container.getBoundingClientRect(); - const eRect = el.getBoundingClientRect(); - container.scrollTop += eRect.top - cRect.top; + container.scrollTop = target; + } + // Correction pass: `scrollIntoView` can land a few pixels off (scroll + // anchoring, sub-pixel padding, momentum scroll). Trust the measured + // geometry, not the request. + if (behavior === 'auto') { + const delta = el.getBoundingClientRect().top - container.getBoundingClientRect().top; + if (Math.abs(delta) > 1) container.scrollTop += delta; } }; if (typeof requestAnimationFrame === 'function') requestAnimationFrame(apply); diff --git a/frontend/style.css b/frontend/style.css index 4829bb7..1f2ec6f 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -9436,10 +9436,12 @@ body.popup-mode .content-area { @keyframes bookslm-spin { to { transform: rotate(360deg); } } /* Notion-style AI thread: only this zone scrolls; entries stack vertically with generous spacing. The latest user post is anchored to the top of the - visible area on send (scrollIntoView block:start + scroll-margin). */ + visible area on send: the post is anchored flush at the top (block:start, + and the bottom padding is grown so a short answer can still roll the post + all the way up). */ .bookslm-messages { flex: 1; overflow-y: auto; padding: 20px 16px; display: flex; - flex-direction: column; gap: 24px; } -.bookslm-msg { display: flex; flex-direction: column; gap: 4px; scroll-margin-top: 14px; } + flex-direction: column; gap: 24px; overflow-anchor: none; } +.bookslm-msg { display: flex; flex-direction: column; gap: 4px; } /* The user bubble is capped against the *thread* width, not against a shrink-to-fit wrapper: capping both would compound (80% × 85% ≈ 68%) and produced very short lines. Wrapper stretches, bubble carries the cap. */ diff --git a/frontend/sw.js b/frontend/sw.js index 5668006..439f0ae 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -11,7 +11,7 @@ * cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is * a separate store. Bumping SW_VERSION invalidates it on every release. */ -const SW_VERSION = 'v12'; +const SW_VERSION = 'v18'; const CODE_CACHE = `obsigate-code-${SW_VERSION}`; const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`; const API_CACHE = `obsigate-api-${SW_VERSION}`; diff --git a/tests/frontend/ai.test.mjs b/tests/frontend/ai.test.mjs index f853961..c27540c 100644 --- a/tests/frontend/ai.test.mjs +++ b/tests/frontend/ai.test.mjs @@ -235,6 +235,45 @@ async function main() { panel.remove(); }); + await test("a short answer still rolls the post up: bottom padding is grown", () => { + // jsdom has no layout, so the geometry is stubbed: the post sits 500px + // down the thread while only 300px of scroll room exist. Without the + // padding trick the browser clamps and the post stops mid-window. + const b = new BooksLM(); + b._messages = [ + { role: "user", content: "question" }, + { role: "assistant", content: "réponse courte" }, + ]; + const panel = document.createElement("div"); + panel.innerHTML = `
`; + document.body.appendChild(panel); + b._panel = panel; + const raf = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = (fn) => fn(); + try { + // Render the messages, then fake the geometry and re-anchor. + b._renderMessages(); + const c = panel.querySelector(".bookslm-messages"); + const post = panel.querySelector(".bookslm-msg.user"); + Object.defineProperty(c, "clientHeight", { value: 400, configurable: true }); + Object.defineProperty(c, "scrollHeight", { value: 700, configurable: true, writable: true }); + c.getBoundingClientRect = () => ({ top: 0, left: 0, right: 0, bottom: 400, width: 400, height: 400 }); + post.getBoundingClientRect = () => ({ top: 500, left: 0, right: 0, bottom: 560, width: 300, height: 60 }); + b._anchorLatestUser({ instant: true }); + // target (500) - max (300) = 200px of missing room. + assert.equal(c.style.paddingBottom, "200px", "padding grown by the missing room"); + + // Plenty of room below the post → no padding at all. + Object.defineProperty(c, "scrollHeight", { value: 2000, configurable: true, writable: true }); + c.scrollTop = 0; + b._anchorLatestUser({ instant: true }); + assert.equal(c.style.paddingBottom, "", "no padding when the thread already scrolls far enough"); + } finally { + globalThis.requestAnimationFrame = raf; + panel.remove(); + } + }); + await test("the placeholder render keeps the anchor (no scroll reset before first token)", async () => { // Regression: the assistant placeholder used to be rendered while // `_isLoading` was still false, so it took the "restore previous