eleven / messenger Public

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>
Paul Campbell 74424a47dabf94d0e9bae2504f9c83debeaefbf9 parent 9d154f3
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