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>
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]; |