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:
1 parent
f5d4d75419
commit
8ab29faec4
6 files changed
+115
-17
No files matched your search
@@ -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;
|
||||
}
|
||||
-4
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user