From d45329df306ecd77fb08a51b51d92dee6b1bc5d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 20:50:44 +0000 Subject: [PATCH] Chat: keep view pinned to bottom while a re-opened thread settles Opening an existing conversation called scrollToBottom once after a single tick, so the view landed on the bottom of the *currently-rendered* content. But image attachments, KaTeX, highlight.js, and the inline cohort/intuition panels all paint asynchronously after that tick. As each one mounted, scrollHeight grew while scrollTop stayed put - and the last message drifted up off the bottom of the viewport. The user arrived at the conversation already scrolled away from where they expected to be. Replace the single-shot snap with a brief rAF watchdog that re-pins to bottom whenever scrollHeight grows during the post-load window. It exits as soon as scrollHeight has been stable for a handful of frames, or after a 3s hard cap, or the moment the user scrolls up (followBottom flips false), or another loadMessages supersedes it. The thread-switch path also cancels any in-flight watchdog explicitly so a slow-loading previous thread can't tug the new one back to its bottom. No changes to the streaming or send paths - those still go through the sending-gated effects. --- src/screens/Chat.svelte | 69 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/src/screens/Chat.svelte b/src/screens/Chat.svelte index edd668a..c209a3d 100644 --- a/src/screens/Chat.svelte +++ b/src/screens/Chat.svelte @@ -1737,6 +1737,10 @@ // Opening a thread starts in follow-bottom mode; the autoscroll // effect lands the view on the newest messages once they load. followBottom = true; + // Cancel any post-load scroll watchdog still running for the + // previously-open thread - the new thread's loadMessages will + // start a fresh one once its content commits. + cancelPostLoadScroll(); // Tool-call timings are a session-scoped display aid; nav to another // thread drops them so the previous thread's pills don't leak into // the new one. @@ -1793,8 +1797,15 @@ // commits the new messages. Re-check activeThreadId post-tick: a // fast thread-hop during the await would have us scrolling the // wrong list otherwise. + // + // The transcript keeps growing after the initial tick as image + // attachments decode, markdown highlighting runs, KaTeX mounts, + // and inline panels paint. pinBottomWhileSettling() keeps the + // view glued to the bottom across that window so the user lands + // on the last message, not on the last message minus an image's + // worth of pixels. await tick(); - if (activeThreadId === id) scrollToBottom(false); + if (activeThreadId === id) pinBottomWhileSettling(); } catch (err) { error = { text: err instanceof Error ? err.message : String(err) }; } @@ -3336,6 +3347,62 @@ }); } + // Re-pin to bottom while async-rendering content (image attachments, + // markdown highlighting, KaTeX, the cohort/intuition panels) keeps + // growing the transcript after the initial post-load snap. Without + // this, opening a thread lands the view at the bottom of the + // *currently-rendered* content, then the last message drifts upward + // as images and other deferred work finish painting - the user + // arrives at the conversation already scrolled away from the + // newest message. + // + // Strategy: poll scrollHeight on rAF. Re-snap whenever it grows; + // exit when it's been stable for STABLE_FRAMES frames, or the + // budget runs out, or the user scrolls up (followBottom flips), + // or another loadMessages supersedes us. + let postLoadScrollRaf = 0; + let postLoadScrollToken = 0; + + function cancelPostLoadScroll(): void { + if (postLoadScrollRaf !== 0) { + cancelAnimationFrame(postLoadScrollRaf); + postLoadScrollRaf = 0; + } + } + + function pinBottomWhileSettling(): void { + cancelPostLoadScroll(); + const el = messagesEl; + if (!el) return; + const token = ++postLoadScrollToken; + const STABLE_FRAMES = 6; + const TIMEOUT_MS = 3000; + const start = performance.now(); + let lastHeight = el.scrollHeight; + let stable = 0; + scrollToBottom(false); + + const step = () => { + postLoadScrollRaf = 0; + // Superseded by a newer load, container unmounted, or user + // scrolled up - drop the watchdog. + if (token !== postLoadScrollToken) return; + if (!messagesEl || messagesEl !== el) return; + if (!followBottom) return; + if (performance.now() - start > TIMEOUT_MS) return; + const h = el.scrollHeight; + if (h !== lastHeight) { + lastHeight = h; + stable = 0; + scrollToBottom(false); + } else if (++stable >= STABLE_FRAMES) { + return; + } + postLoadScrollRaf = requestAnimationFrame(step); + }; + postLoadScrollRaf = requestAnimationFrame(step); + } + function onMessagesScroll(): void { const el = messagesEl; if (!el) return;