eleven / ocho Public

web: keep the feed pinned to the bottom when its viewport resizes (#76)

The composer stack (autogrow textarea, typeahead, reply/edit banners,
keyboard inset) sits in normal flow, so growing it shrinks the feed
without a scroll event — leaving the chat wedged not-quite-at-bottom
while typing. Track near-bottom on scroll and re-pin from a
ResizeObserver on the feed.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3e8584f7706cf1ca19447c10429e85cd7a9f5fd0 parent 2810823
2 files changed, +30 −0
  • changelog/2026-07-02-feed-stays-pinned.md +1 −0
  • web/static/chat.js +29 −0
diff --git a/changelog/2026-07-02-feed-stays-pinned.md b/changelog/2026-07-02-feed-stays-pinned.md
new file mode 100644
index 0000000..595a77e
--- /dev/null
+++ b/changelog/2026-07-02-feed-stays-pinned.md
@@ -0,0 +1 @@
+Fixed: the chat sometimes drifted up from the newest message while you typed (or when the emoji/mention suggestions appeared). It now stays pinned to the bottom.
diff --git a/web/static/chat.js b/web/static/chat.js
index 8254510..01e00a7 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -895,6 +895,35 @@ function renderApp() {
feedForBlur.addEventListener("pointerdown", () => {
if (document.activeElement === text) text.blur();
});
+ // Keep the feed pinned to the bottom when its viewport resizes. The stack
+ // below it — autogrowing textarea, :emoji/@mention typeahead, reply/edit
+ // banners, the keyboard inset — sits in normal flow, so any of it growing
+ // shrinks the feed *without firing a scroll event*, leaving the chat wedged
+ // not-quite-at-bottom while you type. `stickBottom` is refreshed on scroll,
+ // so at resize time it reflects where the user was before the change; the
+ // re-pin fires its own scroll event, which keeps the flag true.
+ if (feedForBlur) {
+ let stickBottom = true; // feeds open pinned to the latest message
+ feedForBlur.addEventListener(
+ "scroll",
+ () => {
+ stickBottom = feedForBlur.scrollHeight - feedForBlur.scrollTop - feedForBlur.clientHeight < 80;
+ },
+ { passive: true },
+ );
+ new ResizeObserver(() => {
+ 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.
+ requestAnimationFrame(() => {
+ if (stickBottom) feedForBlur.scrollTop = feedForBlur.scrollHeight;
+ });
+ }).observe(feedForBlur);
+ }
// A tap on the composer's own blank padding must never close the keyboard.
// The .send-btn::after hit extension is clipped by the field's overflow:hidden,
// so the gutter just right of Send falls through to here — treat a near-miss