web: cure the WebKit feed drift the ResizeObserver fix (#76) missed (#78)
The composer autogrow (height:auto + scrollHeight read) forces an intermediate layout with a shorter textarea; the feed is momentarily taller there and WebKit clamps its scrollTop down — on every keystroke — overwriting the observer's re-pin (Chrome doesn't clamp, which is why the observer alone looked sufficient). Reproduced and verified with a Playwright WebKit e2e (join → send 25 → type a wrapping draft). - input handler: judge was-at-bottom before the height dance, re-pin after — the last write in the turn wins, every keystroke. - observer's next-frame re-assert made unconditional: the decision is made at observe time, and WebKit's restoration scroll event can flip stickBottom false before the frame fires (e.g. the 216px typeahead). - typing while scrolled up into history verified untouched.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+16
−5
web/static/chat.js+16 −5
diff --git a/web/static/chat.js b/web/static/chat.js| index 01e00a7..c10afbf 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -915,12 +915,14 @@ function renderApp() { |
| if (!stickBottom) return; |
| feedForBlur.scrollTop = feedForBlur.scrollHeight; |
| // WebKit restores the pre-resize scroll position *after* observers run, |
| - // undoing the line above (Chrome doesn't) — re-assert on the next frame, |
| - // once its restoration has happened. The interim scroll event leaves |
| - // stickBottom true (the gap is far below the 80px threshold), so this |
| - // never fights a user who scrolled away. |
| + // undoing the line above (Chrome doesn't). Re-assert on the next frame, |
| + // once its restoration has happened — unconditionally: the was-at-bottom |
| + // decision was made above, and the restoration's own scroll event may |
| + // have already pushed the gap past the threshold (e.g. the 216px |
| + // typeahead), flipping stickBottom false. A user can't scroll within |
| + // the same frame, so this never fights a real scroll-away. |
| requestAnimationFrame(() => { |
| - if (stickBottom) feedForBlur.scrollTop = feedForBlur.scrollHeight; |
| + feedForBlur.scrollTop = feedForBlur.scrollHeight; |
| }); |
| }).observe(feedForBlur); |
| } |
| @@ -939,8 +941,17 @@ function renderApp() { |
| }); |
| text.addEventListener("input", () => { |
| updateSendVisibility(); |
| + // Autogrow, keeping the feed pinned. The height:auto + scrollHeight-read |
| + // dance forces an intermediate layout with a *shorter* textarea — the feed |
| + // is momentarily taller there, and WebKit clamps its scrollTop down during |
| + // that layout (every keystroke), which is what used to strand the chat a |
| + // line short of the bottom while typing. Judge "was at bottom" before the |
| + // dance, re-pin after: the last write in the turn wins. |
| + const feedEl = document.getElementById("feed"); |
| + const atBottom = feedEl && feedEl.scrollHeight - feedEl.scrollTop - feedEl.clientHeight < 80; |
| text.style.height = "auto"; |
| text.style.height = Math.min(text.scrollHeight, 140) + "px"; |
| + if (atBottom) feedEl.scrollTop = feedEl.scrollHeight; |
| if (text.value.trim()) noteTyping(); |
| else stopTyping(); |
| updateTypeahead(); // :emoji / @mention autocomplete |