|
1 | 1 | <script lang="ts"> |
| 2 | + import { tick, untrack } from 'svelte'; |
2 | 3 | import type { Snippet } from 'svelte'; |
3 | 4 | import type { ChatStore } from '$lib/stores/chat.svelte.js'; |
4 | 5 | import { renderMarkdown, highlightCodeBlocks, addCopyButtons } from '$lib/utils/markdown.js'; |
5 | | - import { Sparkles } from 'lucide-svelte'; |
| 6 | + import { Sparkles, ArrowDown } from 'lucide-svelte'; |
6 | 7 | import Spinner from '$lib/components/shared/Spinner.svelte'; |
7 | 8 | import ChatMessage from '$lib/components/chat/ChatMessage.svelte'; |
8 | 9 | import ReasoningBlock from '$lib/components/chat/ReasoningBlock.svelte'; |
|
19 | 20 |
|
20 | 21 | let messagesEl: HTMLDivElement | undefined = $state(); |
21 | 22 | let streamContentEl: HTMLDivElement | undefined = $state(); |
| 23 | + let stickToBottom = $state(true); |
22 | 24 |
|
23 | 25 | const streamHtml = $derived( |
24 | 26 | chatStore.currentStreamContent |
|
33 | 35 | !chatStore.currentReasoningContent, |
34 | 36 | ); |
35 | 37 |
|
| 38 | + const showScrollButton = $derived(!stickToBottom); |
| 39 | +
|
36 | 40 | function isNearBottom(): boolean { |
37 | 41 | const el = messagesEl; |
38 | 42 | if (!el) return true; |
|
43 | 47 | function scrollToBottom() { |
44 | 48 | const el = messagesEl; |
45 | 49 | if (!el) return; |
46 | | - el.scrollTop = el.scrollHeight; |
| 50 | + el.scrollTo({ top: el.scrollHeight, behavior: 'instant' }); |
| 51 | + } |
| 52 | +
|
| 53 | + function handleScroll() { |
| 54 | + stickToBottom = isNearBottom(); |
47 | 55 | } |
48 | 56 |
|
| 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 | +
|
49 | 69 | // Auto-scroll when new messages arrive or stream content updates |
50 | 70 | $effect(() => { |
51 | | - // Track reactive dependencies |
52 | 71 | chatStore.messages.length; |
53 | 72 | chatStore.currentStreamContent; |
54 | 73 |
|
55 | | - if (isNearBottom()) { |
56 | | - // Use tick-like delay to scroll after DOM update |
57 | | - requestAnimationFrame(() => scrollToBottom()); |
| 74 | + if (untrack(() => stickToBottom)) { |
| 75 | + tick().then(() => scrollToBottom()); |
58 | 76 | } |
59 | 77 | }); |
60 | 78 |
|
|
67 | 85 | }); |
68 | 86 | </script> |
69 | 87 |
|
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?.()} |
72 | 91 |
|
73 | | - {#each chatStore.messages as msg (msg.id)} |
| 92 | + {#each chatStore.messages as msg (msg.id)} |
74 | 93 | <ChatMessage message={msg} {username} {onSendQueued} {onCancelQueued} /> |
75 | 94 | {/each} |
76 | 95 |
|
|
97 | 116 | </div> |
98 | 117 | </div> |
99 | 118 | {/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} |
100 | 130 | </div> |
101 | 131 |
|
102 | 132 | <style> |
| 133 | + .messages-container { |
| 134 | + position: relative; |
| 135 | + flex: 1; |
| 136 | + display: flex; |
| 137 | + flex-direction: column; |
| 138 | + min-height: 0; |
| 139 | + } |
| 140 | +
|
103 | 141 | .messages { |
104 | 142 | flex: 1; |
105 | 143 | overflow-y: auto; |
106 | 144 | overflow-x: hidden; |
107 | 145 | display: flex; |
108 | 146 | flex-direction: column; |
109 | 147 | gap: var(--sp-2); |
110 | | - scroll-behavior: smooth; |
111 | 148 | padding: var(--sp-2) 0; |
112 | 149 | -webkit-overflow-scrolling: touch; |
113 | 150 | overscroll-behavior: contain; |
|
238 | 275 | color: var(--fg-muted); |
239 | 276 | font-size: 0.82em; |
240 | 277 | } |
| 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 | + } |
241 | 311 | </style> |
0 commit comments