Skip to content

Commit e69309f

Browse files
dmbutkoDmitry ButkoCopilot
authored
fix: reliable auto-scroll with scroll-to-bottom button (#150)
Replace CSS scroll-behavior: smooth with explicit scroll tracking. The old approach used isNearBottom() position checks that broke when smooth scroll animations hadn't completed, permanently disengaging auto-follow with no way to re-engage. Changes: - Track sticky state with explicit stickToBottom flag instead of position-only checks - Use Svelte tick() + instant scroll (no animation race conditions) - Add scroll-to-bottom arrow button when user scrolls up - Re-engage auto-follow automatically when user sends a message - Use untrack() to prevent stickToBottom from triggering the scroll effect as a dependency - Wrap messages in container div for button positioning Co-authored-by: Dmitry Butko <dmitrybutko@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b98a846 commit e69309f

1 file changed

Lines changed: 80 additions & 10 deletions

File tree

src/lib/components/chat/MessageList.svelte

Lines changed: 80 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
<script lang="ts">
2+
import { tick, untrack } from 'svelte';
23
import type { Snippet } from 'svelte';
34
import type { ChatStore } from '$lib/stores/chat.svelte.js';
45
import { renderMarkdown, highlightCodeBlocks, addCopyButtons } from '$lib/utils/markdown.js';
5-
import { Sparkles } from 'lucide-svelte';
6+
import { Sparkles, ArrowDown } from 'lucide-svelte';
67
import Spinner from '$lib/components/shared/Spinner.svelte';
78
import ChatMessage from '$lib/components/chat/ChatMessage.svelte';
89
import ReasoningBlock from '$lib/components/chat/ReasoningBlock.svelte';
@@ -19,6 +20,7 @@
1920
2021
let messagesEl: HTMLDivElement | undefined = $state();
2122
let streamContentEl: HTMLDivElement | undefined = $state();
23+
let stickToBottom = $state(true);
2224
2325
const streamHtml = $derived(
2426
chatStore.currentStreamContent
@@ -33,6 +35,8 @@
3335
!chatStore.currentReasoningContent,
3436
);
3537
38+
const showScrollButton = $derived(!stickToBottom);
39+
3640
function isNearBottom(): boolean {
3741
const el = messagesEl;
3842
if (!el) return true;
@@ -43,18 +47,32 @@
4347
function scrollToBottom() {
4448
const el = messagesEl;
4549
if (!el) return;
46-
el.scrollTop = el.scrollHeight;
50+
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' });
51+
}
52+
53+
function handleScroll() {
54+
stickToBottom = isNearBottom();
4755
}
4856
57+
function handleScrollToBottomClick() {
58+
stickToBottom = true;
59+
scrollToBottom();
60+
}
61+
62+
// Re-engage auto-scroll when user sends a new message
63+
$effect(() => {
64+
if (chatStore.isWaiting) {
65+
stickToBottom = true;
66+
}
67+
});
68+
4969
// Auto-scroll when new messages arrive or stream content updates
5070
$effect(() => {
51-
// Track reactive dependencies
5271
chatStore.messages.length;
5372
chatStore.currentStreamContent;
5473
55-
if (isNearBottom()) {
56-
// Use tick-like delay to scroll after DOM update
57-
requestAnimationFrame(() => scrollToBottom());
74+
if (untrack(() => stickToBottom)) {
75+
tick().then(() => scrollToBottom());
5876
}
5977
});
6078
@@ -67,10 +85,11 @@
6785
});
6886
</script>
6987

70-
<div class="messages" bind:this={messagesEl}>
71-
{@render children?.()}
88+
<div class="messages-container">
89+
<div class="messages" bind:this={messagesEl} onscroll={handleScroll}>
90+
{@render children?.()}
7291

73-
{#each chatStore.messages as msg (msg.id)}
92+
{#each chatStore.messages as msg (msg.id)}
7493
<ChatMessage message={msg} {username} {onSendQueued} {onCancelQueued} />
7594
{/each}
7695

@@ -97,17 +116,35 @@
97116
</div>
98117
</div>
99118
{/if}
119+
</div>
120+
121+
{#if showScrollButton}
122+
<button
123+
class="scroll-to-bottom"
124+
onclick={handleScrollToBottomClick}
125+
aria-label="Scroll to bottom"
126+
>
127+
<ArrowDown size={18} />
128+
</button>
129+
{/if}
100130
</div>
101131

102132
<style>
133+
.messages-container {
134+
position: relative;
135+
flex: 1;
136+
display: flex;
137+
flex-direction: column;
138+
min-height: 0;
139+
}
140+
103141
.messages {
104142
flex: 1;
105143
overflow-y: auto;
106144
overflow-x: hidden;
107145
display: flex;
108146
flex-direction: column;
109147
gap: var(--sp-2);
110-
scroll-behavior: smooth;
111148
padding: var(--sp-2) 0;
112149
-webkit-overflow-scrolling: touch;
113150
overscroll-behavior: contain;
@@ -238,4 +275,37 @@
238275
color: var(--fg-muted);
239276
font-size: 0.82em;
240277
}
278+
279+
/* ── scroll-to-bottom button ─────────────────────────────────────────── */
280+
.scroll-to-bottom {
281+
position: absolute;
282+
bottom: 16px;
283+
left: 50%;
284+
transform: translateX(-50%);
285+
background: var(--bg-overlay);
286+
border: 1px solid var(--border);
287+
border-radius: 50%;
288+
color: var(--fg-dim);
289+
width: 36px;
290+
height: 36px;
291+
display: flex;
292+
align-items: center;
293+
justify-content: center;
294+
cursor: pointer;
295+
opacity: 0.85;
296+
transition: opacity 0.2s ease, background 0.2s ease;
297+
z-index: 10;
298+
backdrop-filter: blur(8px);
299+
animation: fade-in-up 0.2s ease;
300+
}
301+
302+
.scroll-to-bottom:hover {
303+
opacity: 1;
304+
background: var(--bg-surface, var(--bg-overlay));
305+
}
306+
307+
@keyframes fade-in-up {
308+
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
309+
to { opacity: 0.85; transform: translateX(-50%) translateY(0); }
310+
}
241311
</style>

0 commit comments

Comments
 (0)