fix(assistant): #91 question positionnee au bord haut de la fenetre (padding dynamique, overflow-anchor, passe de correction)
CI / lint (push) Successful in 1m27s
CI / security (push) Successful in 1m0s
CI / test (push) Successful in 3m23s
CI / build (push) Successful in 51s
CI / e2e (push) Successful in 10m56s

This commit is contained in:
2026-09-15 20:29:01 -04:00
parent 1ed0d52619
commit daf9ff3b2d
6 changed files with 130 additions and 24 deletions
+11
View File
@@ -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 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 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. 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 - **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). renvoie plus `⚠ Error: Aucun fichier markdown trouvé dans ce dossier` (HTTP 404).
+19 -7
View File
@@ -27,13 +27,25 @@
confirmation agent : cible = dernier `.bookslm-msg.user`, `scrollIntoView` confirmation agent : cible = dernier `.bookslm-msg.user`, `scrollIntoView`
`block: 'start'` (instantané à l'envoi, fluide à l'ouverture d'une session), `block: 'start'` (instantané à l'envoi, fluide à l'ouverture d'une session),
repli calcul `scrollTop += delta` si indisponible. repli calcul `scrollTop += delta` si indisponible.
- [x] **A2.** Re-rendus de streaming : tant que la réponse arrive (`_isLoading`) et - [x] **A2.** Re-rendus de streaming : tant que l'ancre est active (flag
que l'utilisateur n'a pas touché à la molette (flag `_pinnedTurn`, libéré sur `_pinnedTurn` posé à l'envoi, libéré sur `wheel`/`touchmove`/`mousedown`), chaque
`wheel`/`touchmove`), chaque frame re-ancre instantanément la question en haut — re-rendu — frames de streaming **et** rendu final — ré-ancre instantanément la
la réponse s'allonge sous la question sans jamais déplacer la vue. Hors question en haut ; la réponse s'allonge sous la question sans jamais déplacer la
streaming, la position de scroll est préservée (plus de `scrollTop = scrollHeight`). vue. Hors épinglage, la position de scroll est préservée (plus de
- [x] **A3.** Marge supérieure : `scroll-margin-top: 14px` sur `.bookslm-msg` `scrollTop = scrollHeight`).
pour que le post ne colle pas au bord. - [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 }` - [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). (en-tête, toolbar, statut et barre de saisie fixes — structure déjà en place).
+55 -13
View File
@@ -890,12 +890,20 @@ class BooksLM {
this._activatePath(path, kind); 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'); const messagesEl = panel.querySelector('.bookslm-messages');
if (messagesEl) { 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('wheel', unpin, { passive: true });
messagesEl.addEventListener('touchmove', 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. // Close the session / command menus when clicking elsewhere in the panel.
@@ -1794,6 +1802,9 @@ class BooksLM {
if (!container) return; if (!container) return;
const prevTop = container.scrollTop; const prevTop = container.scrollTop;
container.innerHTML = ''; 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 // Notion-style thread: chronological order, the user post anchored at the
// top of the visible area (never scroll-to-bottom — the answer streams // top of the visible area (never scroll-to-bottom — the answer streams
@@ -1803,17 +1814,29 @@ class BooksLM {
container.appendChild(this._chatBubble(msg)); container.appendChild(this._chatBubble(msg));
} }
if (opts.anchor) this._anchorLatestUser({ instant: !!opts.instant }); // While the pin is active (question submitted, released by a manual
else if (this._isLoading && this._pinnedTurn) this._anchorLatestUser({ instant: true }); // scroll) every re-render — streaming frames *and* the final one — must
else container.scrollTop = prevTop; // 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 * Bring the newest user post flush to the top of the thread and keep it
* top of the viewport (its `scroll-margin-top` keeps a breathing gap), * there while the answer streams in.
* reproducing Notion-AI's conversation anchoring. The pin stays active *
* while the answer streams, so the question never leaves the top — until * Two things are needed for the post to *really* sit at the top:
* the user scrolls manually (see the wheel/touch listeners in `_render()`). * 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 = {}) { _anchorLatestUser(opts = {}) {
if (!this._panel) return; if (!this._panel) return;
@@ -1827,12 +1850,31 @@ class BooksLM {
if (!el) return; if (!el) return;
const behavior = opts.instant ? 'auto' : 'smooth'; const behavior = opts.instant ? 'auto' : 'smooth';
const apply = () => { 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 { try {
el.scrollIntoView({ behavior, block: 'start' }); el.scrollIntoView({ behavior, block: 'start' });
} catch { } catch {
const cRect = container.getBoundingClientRect(); container.scrollTop = target;
const eRect = el.getBoundingClientRect(); }
container.scrollTop += eRect.top - cRect.top; // 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); if (typeof requestAnimationFrame === 'function') requestAnimationFrame(apply);
+5 -3
View File
@@ -9436,10 +9436,12 @@ body.popup-mode .content-area {
@keyframes bookslm-spin { to { transform: rotate(360deg); } } @keyframes bookslm-spin { to { transform: rotate(360deg); } }
/* Notion-style AI thread: only this zone scrolls; entries stack vertically /* 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 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; .bookslm-messages { flex: 1; overflow-y: auto; padding: 20px 16px; display: flex;
flex-direction: column; gap: 24px; } flex-direction: column; gap: 24px; overflow-anchor: none; }
.bookslm-msg { display: flex; flex-direction: column; gap: 4px; scroll-margin-top: 14px; } .bookslm-msg { display: flex; flex-direction: column; gap: 4px; }
/* The user bubble is capped against the *thread* width, not against a /* The user bubble is capped against the *thread* width, not against a
shrink-to-fit wrapper: capping both would compound (80% × 85% ≈ 68%) and shrink-to-fit wrapper: capping both would compound (80% × 85% ≈ 68%) and
produced very short lines. Wrapper stretches, bubble carries the cap. */ produced very short lines. Wrapper stretches, bubble carries the cap. */
+1 -1
View File
@@ -11,7 +11,7 @@
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is * cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
* a separate store. Bumping SW_VERSION invalidates it on every release. * 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 CODE_CACHE = `obsigate-code-${SW_VERSION}`;
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`; const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
const API_CACHE = `obsigate-api-${SW_VERSION}`; const API_CACHE = `obsigate-api-${SW_VERSION}`;
+39
View File
@@ -235,6 +235,45 @@ async function main() {
panel.remove(); 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 = `<div class="bookslm-messages"></div>`;
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 () => { await test("the placeholder render keeps the anchor (no scroll reset before first token)", async () => {
// Regression: the assistant placeholder used to be rendered while // Regression: the assistant placeholder used to be rendered while
// `_isLoading` was still false, so it took the "restore previous // `_isLoading` was still false, so it took the "restore previous