From 8ab29faec401829db0e617f40af2c942f4a9eaab Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Thu, 29 Oct 2026 09:18:13 +0100 Subject: [PATCH] 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) --- tools/ui/src/app.css | 19 ++++ ...essageToolCallBlockExecShellCommand.svelte | 4 - .../ChatMessageReasoningBlock.svelte | 1 - .../app/chat/ChatScreen/ChatScreen.svelte | 18 +++- .../app/content/SyntaxHighlightedCode.svelte | 4 - .../lib/hooks/use-stick-to-bottom.svelte.ts | 86 +++++++++++++++++-- 6 files changed, 115 insertions(+), 17 deletions(-) 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} + + +