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:
Joseph Doherty
2026-04-27 14:55:21 -04:00
parent 3a81e540a1
commit a11255a5e6
2 changed files with 48 additions and 2 deletions
+6 -1
View File
@@ -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;
+42 -1
View File
@@ -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: