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
+		);
 	}
 }