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: