Commit 8ab29faec for llama.cpp
commit 8ab29faec401829db0e617f40af2c942f4a9eaab
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date: Thu Oct 29 09:18:13 2026 +0100
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)
diff --git a/tools/ui/src/app.css b/tools/ui/src/app.css
index 53464de8c..bf1a071a9 100644
--- a/tools/ui/src/app.css
+++ b/tools/ui/src/app.css
@@ -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;
+}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte
index de7205443..665238a29 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte
@@ -334,10 +334,6 @@
}
}
- .terminal-output {
- overscroll-behavior: contain;
- }
-
.terminal-output.is-clamped {
max-height: 28rem;
overflow-y: auto;
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageReasoningBlock.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageReasoningBlock.svelte
index 31a63259d..ae460258e 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageReasoningBlock.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageReasoningBlock.svelte
@@ -155,7 +155,6 @@
.reasoning-content.is-streaming {
max-height: 28rem;
overflow-y: auto;
- overscroll-behavior: contain;
scrollbar-gutter: stable;
padding-right: 0.25rem;
}
diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
index 790aa95c9..f67b610ea 100644
--- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
@@ -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)"
diff --git a/tools/ui/src/lib/components/app/content/SyntaxHighlightedCode.svelte b/tools/ui/src/lib/components/app/content/SyntaxHighlightedCode.svelte
index 68c23ca6c..048043954 100644
--- a/tools/ui/src/lib/components/app/content/SyntaxHighlightedCode.svelte
+++ b/tools/ui/src/lib/components/app/content/SyntaxHighlightedCode.svelte
@@ -140,10 +140,6 @@
</div>
<style>
- .code-preview-wrapper {
- overscroll-behavior: contain;
- }
-
.code-preview-wrapper pre {
background: transparent;
padding: 0;
diff --git a/tools/ui/src/lib/hooks/use-stick-to-bottom.svelte.ts b/tools/ui/src/lib/hooks/use-stick-to-bottom.svelte.ts
index f4e201e9b..2ba504434 100644
--- a/tools/ui/src/lib/hooks/use-stick-to-bottom.svelte.ts
+++ b/tools/ui/src/lib/hooks/use-stick-to-bottom.svelte.ts
@@ -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
+ );
}
}