From a11255a5e6d9b81486f23e98eaad2f50f598ce3f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 27 Apr 2026 14:55:21 -0400 Subject: [PATCH] feat: chat timeline is now a viewport-bounded scroll box with sticky-bottom autoscroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .timeline already had overflow-y: auto but body's min-height: 100vh let the page grow with content, so the overflow-y rule never kicked in — the whole page scrolled instead and the timeline never scrolled independently. Two coupled changes: - CSS: body locked to height: 100vh. The .content pane keeps its own overflow: auto for non-chat pages, but the chat-shell flex-children (timeline, turn-input) now get bounded heights so the timeline can scroll independently. - JS: sticky-bottom autoscroll. Tracks isPinnedToBottom from scroll events with a 64px tolerance. A MutationObserver on .timeline catches every node addition (SSE turn_html, optimistic user-prose render, streaming token edits) and scrolls to bottom ONLY when pinned. appendUserTurn force-pins on submit because the user wants to see what they just sent regardless of prior scroll position. Initial page-load scrolls to bottom via rAF so the latest turn is visible without manual scrolling. --- chat/static/app.css | 7 ++++++- chat/templates/chat.html | 43 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/chat/static/app.css b/chat/static/app.css index 66e76ef..ec52bc9 100644 --- a/chat/static/app.css +++ b/chat/static/app.css @@ -5,7 +5,12 @@ body { color: #1c1c1c; background: #fafafa; display: flex; - min-height: 100vh; + /* Locked to viewport (was ``min-height: 100vh``) so flex children + like the chat ``.timeline`` get a bounded height and can use + ``overflow-y: auto`` to scroll independently. The other pages + have ``.content`` with ``overflow: auto`` so their own + overflow still scrolls inside the right pane. */ + height: 100vh; } .rail { width: 200px; diff --git a/chat/templates/chat.html b/chat/templates/chat.html index 5b1453f..977d849 100644 --- a/chat/templates/chat.html +++ b/chat/templates/chat.html @@ -66,6 +66,44 @@ document.querySelector('.drawer-toggle')?.addEventListener('click', (e) => { let isStreaming = false; let typingEl = null; + // Sticky-bottom autoscroll: scroll the timeline to the latest + // message when new content arrives, but ONLY if the user is + // already pinned to the bottom. Once they scroll up to read older + // turns, we leave their position alone until they manually scroll + // back down. + // + // ``isPinnedToBottom`` flips on every scroll event based on + // distance-from-bottom (with a small tolerance so a few pixels of + // overshoot from a layout shift doesn't unpin). A MutationObserver + // catches every node added to the timeline — covers the SSE- + // injected ``turn_html`` swap, the optimistic ``appendUserTurn`` + // render, and the streaming typing-indicator updates. + const STICK_TOLERANCE_PX = 64; + let isPinnedToBottom = true; + + function distanceFromBottom() { + return timeline.scrollHeight - timeline.scrollTop - timeline.clientHeight; + } + function scrollToBottom() { + timeline.scrollTop = timeline.scrollHeight; + } + // Initial state: stick to the bottom on page load so the latest + // turn is visible without manual scrolling. + requestAnimationFrame(scrollToBottom); + + timeline.addEventListener('scroll', () => { + isPinnedToBottom = distanceFromBottom() <= STICK_TOLERANCE_PX; + }, { passive: true }); + + const timelineObserver = new MutationObserver(() => { + if (isPinnedToBottom) scrollToBottom(); + }); + timelineObserver.observe(timeline, { + childList: true, + subtree: true, + characterData: true, // streaming token-by-token edits + }); + function ensureTypingEl() { if (typingEl) return typingEl; typingEl = document.createElement('div'); @@ -191,8 +229,11 @@ document.querySelector('.drawer-toggle')?.addEventListener('click', (e) => { p.textContent = prose; div.appendChild(strong); div.appendChild(p); + // Sending a message means the user wants to see it land — force + // sticky-bottom even if they were scrolled up reading older + // turns. The MutationObserver handles the actual scroll. + isPinnedToBottom = true; timeline.appendChild(div); - div.scrollIntoView({ behavior: 'smooth', block: 'end' }); } // Intercept the form submit and POST via fetch so we can: