fix(assistant): #91 question positionnee au bord haut de la fenetre (padding dynamique, overflow-anchor, passe de correction)
This commit is contained in:
@@ -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).
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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}`;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user