feat: chat timeline is now a viewport-bounded scroll box with sticky-bottom autoscroll
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.
This commit is contained in:
+6
-1
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user