eleven / ocho Public

web: keep the feed pinned to the bottom while a chat's content settles

Opening a chat scrolled to the bottom once, but link previews and dimensionless
images load a moment later and grow the feed below that point, leaving you a
message or two short of the latest. Pin to the bottom for a ~1.5s settle window
after opening (re-pinning as content grows), yielding immediately if you scroll up
or switch threads.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell a0a6d84103942e84b271fcebacc8e228e3bc94fd parent e838f78
1 file changed, +28 −1
  • web/static/chat.js +28 −1
diff --git a/web/static/chat.js b/web/static/chat.js
index 2315ada..e1fec51 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -769,10 +769,37 @@ async function openThread(id) {
} catch (e) {
console.error("load messages", e);
}
- if (myOpen === openSeq) feed.scrollTop = feed.scrollHeight;
+ if (myOpen === openSeq) pinFeedToBottom(feed, myOpen);
if (!isCoarse) document.getElementById("text").focus();
}
+// Pin the feed to the bottom, and keep it pinned for a short settle window as
+// images / link previews finish loading and grow the feed (otherwise opening a
+// chat lands a message or two short of the latest). Yields immediately if the
+// user scrolls up, or if they switch threads.
+function pinFeedToBottom(feed, gen) {
+ if (!feed) return;
+ feed.scrollTop = feed.scrollHeight;
+ let active = true;
+ const onScroll = () => {
+ // A scroll event with the viewport well off the bottom means the user scrolled
+ // up (content growth changes scrollHeight, which doesn't fire 'scroll').
+ if (feed.scrollHeight - feed.scrollTop - feed.clientHeight > 60) active = false;
+ };
+ feed.addEventListener("scroll", onScroll, { passive: true });
+ const stopAt = Date.now() + 1500;
+ const tick = () => {
+ if (!active || gen !== openSeq || !document.body.contains(feed)) {
+ feed.removeEventListener("scroll", onScroll);
+ return;
+ }
+ feed.scrollTop = feed.scrollHeight;
+ if (Date.now() < stopAt) requestAnimationFrame(tick);
+ else feed.removeEventListener("scroll", onScroll);
+ };
+ requestAnimationFrame(tick);
+}
+
async function appendMessage(threadId, m, scroll = true) {
if (threadId !== activeId) return;
const key = threadKeys[threadId];