ui: keep chats at the bottom until the reader scrolls up (#31311)

* ui: land at the bottom of a conversation every time it opens

Opening a long conversation often stopped short of its end:

- the press grace counted from page load (pressedAt = 0), so a chat that
  landed within 400 ms of a refresh did not follow the rows realizing
  after the jump
- the click that opened a chat (sidebar row, tab) started the same grace,
  so navigating by click had the same problem
- any scroll event away from the bottom stopped following, including the
  ones the browser fires itself while the chat settles
- the chat screen outlives its routes, so going back to the last landed
  conversation through the new-chat screen skipped the landing entirely

scrollToBottom() now clears the grace, only an upward scroll away from the
bottom stops following, and every navigation lands anew. Navigations into
a chat also disable SvelteKit's scroll reset, which moved the previous
conversation to its top for a few frames.

Where overflow-anchor is supported, a 1px pin after the messages is the
only scroll anchor while following, so the browser keeps the bottom in
place itself; WebKit keeps the resize observer.

Assisted-by: Claude (Opus 5.5 Medium)

* ui: let nested agentic content scrollers chain to the chat

Reasoning, shell output and code blocks used overscroll-behavior:
contain, so the wheel stopped dead at their edges instead of
scrolling the conversation.

Assisted-by: Claude (Opus 5.5 Medium)

* ui: stop following the stream as soon as the reader scrolls up

A trackpad or smooth wheel scrolls in steps smaller than the at-bottom
threshold, so each step up was re-stuck and snapped back on the next
growth while a response streamed. Any upward move that is not content
shrinking now releases at once.

The attach effect also read the hook's own state, so every flip
re-attached and re-measured, which undid such a release.

Assisted-by: Claude (Opus 5.5 Medium)
This commit is contained in:
Aleksander Grygier authored and GitHub committed 2026-10-29 10:18:13 +02:00
1 parent f5d4d75419
commit 8ab29faec4
6 files changed
+115 -17

No files matched your search

+19
View File
@@ -282,3 +282,22 @@
.mermaidTooltip {
display: none !important;
}
/*
* While the page sticks to its bottom, the pin after the messages is its only
* scroll anchor, so the browser keeps the bottom in place as content above it
* grows or realizes its size. Browsers without overflow-anchor (WebKit) never
* get the attribute, see createStickToBottom.
*/
[data-stick-to-bottom] * {
overflow-anchor: none;
}
[data-stick-to-bottom] *:has(.stick-to-bottom-pin),
[data-stick-to-bottom] .stick-to-bottom-pin {
overflow-anchor: auto;
}
.stick-to-bottom-pin {
height: 1px;
}
@@ -334,10 +334,6 @@
}
}
.terminal-output {
overscroll-behavior: contain;
}
.terminal-output.is-clamped {
max-height: 28rem;
overflow-y: auto;
@@ -155,7 +155,6 @@
.reasoning-content.is-streaming {
max-height: 28rem;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
padding-right: 0.25rem;
}
@@ -2,6 +2,7 @@
import ChatScreenActionScrollDown from './ChatScreenActionScrollDown.svelte';
import ChatScreenDialogsAndAlerts from './ChatScreenDialogsAndAlerts.svelte';
import ChatScreenGreeting from './ChatScreenGreeting.svelte';
import { disableScrollHandling, onNavigate } from '$app/navigation';
import { page } from '$app/state';
import {
ChatMessages,
@@ -65,6 +66,7 @@
// The page is the scroller: it follows new content while the reader sits at
// the bottom, and otherwise leaves their position alone.
const stick = createStickToBottom();
let stickPin: HTMLDivElement | undefined = $state();
const activeModel = useChatScreenActiveModel();
const fileUpload = useChatScreenFileUpload({
activeModelId: () => activeModel.activeModelId,
@@ -126,6 +128,17 @@
let lastScrolledConversationId: string | null = null;
// Every navigation lands anew: this screen outlives its routes, so coming
// back to the conversation it last landed on (via the new-chat screen)
// must not count as already landed. The landing also owns the scroll
// position, so SvelteKit's own reset (or restore, on back/forward) would
// only move the previous conversation, still on screen, to its top.
onNavigate((navigation) => {
lastScrolledConversationId = null;
if (navigation.to?.params?.id) disableScrollHandling();
});
// Lands at the bottom of a conversation the first time its messages render.
// The resize observer keeps it there while the list settles (lazy rows,
// syntax highlighting, images) without polling for a stable height.
@@ -204,7 +217,7 @@
});
$effect(() => {
stick.attach(document.documentElement, document.body);
stick.attach(document.documentElement, document.body, stickPin);
return () => stick.detach();
});
@@ -235,6 +248,9 @@
/>
{/if}
<!-- the scroll anchor that keeps the page at its bottom, see createStickToBottom -->
<div bind:this={stickPin} aria-hidden="true" class="stick-to-bottom-pin"></div>
<div
bind:this={formWrapper}
style:padding-top="var(--chat-form-padding-top)"
@@ -140,10 +140,6 @@
</div>
<style>
.code-preview-wrapper {
overscroll-behavior: contain;
}
.code-preview-wrapper pre {
background: transparent;
padding: 0;
@@ -7,8 +7,16 @@
* exactly where it was. This only adds the one behaviour CSS cannot express -
* following new content while the reader sits at the bottom - and it stops the
* moment they scroll away from it.
*
* Given a `pin` at the end of the content, browsers with scroll anchoring
* follow on their own: while sticking, the pin is the only anchor (see
* `[data-stick-to-bottom]` in app.css), so every layout keeps it in place.
* WebKit has no `overflow-anchor`, so there a resize observer writes the
* scroll position instead, before the frame is painted.
*/
import { untrack } from 'svelte';
/** Distance from the bottom that still counts as being at the bottom. */
const AT_BOTTOM_PX = 24;
/**
@@ -16,6 +24,9 @@ const AT_BOTTOM_PX = 24;
* opening a preview), so it must not drag the viewport down with it.
*/
const INTERACTION_GRACE_MS = 400;
/** Set on the scroller while the pin is its only scroll anchor. */
const STICK_ATTRIBUTE = 'data-stick-to-bottom';
const NATIVE_ANCHORING = typeof CSS !== 'undefined' && CSS.supports('overflow-anchor', 'none');
export class StickToBottom {
/** Measured position, which drives the "scroll to bottom" affordance. */
@@ -25,21 +36,36 @@ export class StickToBottom {
private container: HTMLElement | undefined;
private enabled = true;
private graceTimer: ReturnType<typeof setTimeout> | undefined;
private lastScrollHeight = 0;
private lastScrollTop = 0;
private markPressed = (): void => {
this.pressedAt = performance.now();
this.syncAnchor();
clearTimeout(this.graceTimer);
this.graceTimer = setTimeout(() => this.syncAnchor(), INTERACTION_GRACE_MS);
};
private native = false;
private observer: ResizeObserver | null = null;
private pressedAt = 0;
// -Infinity, not 0: performance.now() starts at page load, so 0 would read
// as a press during the first moments after a refresh, when a chat lands
private pressedAt = -Infinity;
/**
* Binds the scroller and the element whose growth should be followed.
* `content` defaults to the scroller's first child, which is what a panel
* with a single growing body needs.
* with a single growing body needs. `pin` is an empty element at the very
* end of the content that lets the browser do the following.
*/
attach(container: HTMLElement | undefined, content?: HTMLElement | undefined): void {
attach(
container: HTMLElement | undefined,
content?: HTMLElement | undefined,
pin?: HTMLElement | undefined
): void {
this.detach();
this.container = container;
this.native = NATIVE_ANCHORING && Boolean(pin);
const observed = content ?? container?.firstElementChild;
@@ -52,13 +78,19 @@ export class StickToBottom {
passive: true
});
this.measure();
this.lastScrollHeight = container.scrollHeight;
this.lastScrollTop = container.scrollTop;
// untracked: callers attach from an effect, and reading the state here
// would re-attach on every flip and re-measure away a release
untrack(() => this.measure());
}
detach(): void {
this.observer?.disconnect();
this.observer = null;
clearTimeout(this.graceTimer);
this.container?.removeEventListener('pointerdown', this.markPressed, { capture: true });
this.container?.removeAttribute(STICK_ATTRIBUTE);
this.container = undefined;
}
@@ -75,10 +107,15 @@ export class StickToBottom {
el.scrollTo({ behavior, top: el.scrollHeight });
this.stick = true;
this.atBottom = true;
// asking for the bottom outranks the press that led here (a sidebar row,
// the send button), so the growth that follows is followed
this.pressedAt = -Infinity;
this.syncAnchor();
}
setEnabled(enabled: boolean): void {
this.enabled = enabled;
untrack(() => this.syncAnchor());
}
private distanceFromBottom(el: HTMLElement): number {
@@ -91,23 +128,58 @@ export class StickToBottom {
if (!el) return;
const pressedRecently = performance.now() - this.pressedAt < INTERACTION_GRACE_MS;
const atBottom = this.distanceFromBottom(el) <= AT_BOTTOM_PX;
if (this.enabled && this.stick && !pressedRecently) {
// with native anchoring the pin already held the bottom; write only when
// it could not, e.g. once it went offscreen during an interaction grace
if (this.enabled && this.stick && !pressedRecently && !(this.native && atBottom)) {
el.scrollTop = el.scrollHeight;
this.atBottom = true;
return;
}
this.atBottom = this.distanceFromBottom(el) <= AT_BOTTOM_PX;
this.atBottom = atBottom;
}
/**
* Only the reader moving up stops following. Following only ever moves the
* viewport down, and content shrinking lowers scrollHeight, so moving up
* without shrinkage is the reader. It releases right away, even inside
* AT_BOTTOM_PX: a trackpad or smooth wheel scrolls in smaller steps, and
* staying stuck would snap each of them back while a response streams.
* The landing jump, anchoring adjustments and clamping must not unstick a
* reader who never touched the page, and none of them moves up that way.
*/
private measure(): void {
const el = this.container;
if (!el) return;
this.stick = this.atBottom = this.distanceFromBottom(el) <= AT_BOTTOM_PX;
const top = el.scrollTop;
const height = el.scrollHeight;
this.atBottom = this.distanceFromBottom(el) <= AT_BOTTOM_PX;
if (top < this.lastScrollTop && height >= this.lastScrollHeight) {
this.stick = false;
} else if (this.atBottom) {
this.stick = true;
}
this.lastScrollHeight = height;
this.lastScrollTop = top;
this.syncAnchor();
}
/** The pin anchors only while following, so a reader who scrolled up keeps their rows anchored. */
private syncAnchor(): void {
const pressedRecently = performance.now() - this.pressedAt < INTERACTION_GRACE_MS;
this.container?.toggleAttribute(
STICK_ATTRIBUTE,
this.native && this.enabled && this.stick && !pressedRecently
);
}
}