diff options
| author | Adam Malczewski <[email protected]> | 2026-05-20 13:47:33 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-20 13:47:33 +0900 |
| commit | 4d54b90fbf9273c3dc2f26a856d67e16c3927e94 (patch) | |
| tree | 4788b66723ddcc8c98de06491dca1203314cfb80 | |
| parent | 6ec5803e953534c077f863d723972c78796fe16a (diff) | |
| download | dispatch-4d54b90fbf9273c3dc2f26a856d67e16c3927e94.tar.gz dispatch-4d54b90fbf9273c3dc2f26a856d67e16c3927e94.zip | |
feat: smart scroll — auto-follow during streaming, manual mode on scroll up, scroll-to-bottom button
3 files changed, 70 insertions, 22 deletions
diff --git a/packages/frontend/src/lib/components/ChatMessage.svelte b/packages/frontend/src/lib/components/ChatMessage.svelte index 09182f3..46fd654 100644 --- a/packages/frontend/src/lib/components/ChatMessage.svelte +++ b/packages/frontend/src/lib/components/ChatMessage.svelte @@ -11,7 +11,7 @@ const isUser = $derived(message.role === "user"); <div class="chat {isUser ? 'chat-end' : 'chat-start'} mb-2"> <div class="chat-bubble {isUser ? 'chat-bubble-primary' : 'chat-bubble-secondary'} max-w-[80%] break-words"> {#if message.thinking} - <div class="collapse collapse-arrow mb-2"> + <div class="collapse collapse-arrow mb-2 p-1"> <input type="checkbox" /> <div class="collapse-title text-sm opacity-60 italic py-0 pl-0 pr-8 min-h-0">Thinking...</div> <div class="collapse-content text-sm opacity-60 italic p-0"> diff --git a/packages/frontend/src/lib/components/ChatPanel.svelte b/packages/frontend/src/lib/components/ChatPanel.svelte index e0491e7..11c3b5d 100644 --- a/packages/frontend/src/lib/components/ChatPanel.svelte +++ b/packages/frontend/src/lib/components/ChatPanel.svelte @@ -1,18 +1,46 @@ <script lang="ts"> -import { chatStore } from "../chat.svelte.js"; -import { wsClient } from "../ws.svelte.js"; -import ChatMessageComponent from "./ChatMessage.svelte"; + import { untrack } from "svelte"; + import { chatStore } from "../chat.svelte.js"; + import { wsClient } from "../ws.svelte.js"; + import ChatMessageComponent from "./ChatMessage.svelte"; -let messagesEl: HTMLDivElement | undefined; + let messagesEl: HTMLDivElement | undefined; + let userScrolledUp = $state(false); + let isAutoScrolling = false; + function isNearBottom(el: HTMLElement): boolean { + return el.scrollHeight - el.scrollTop - el.clientHeight < 64; + } + + function scrollToBottom(animate = false) { + if (!messagesEl) return; + messagesEl.scrollTo({ + top: messagesEl.scrollHeight, + behavior: animate ? "smooth" : "instant", + }); + } -$effect(() => { - // Trigger on messages change to scroll - void chatStore.messages; - if (messagesEl) { - messagesEl.scrollTop = messagesEl.scrollHeight; + function handleScroll() { + if (!messagesEl || isAutoScrolling) return; + userScrolledUp = !isNearBottom(messagesEl); } -}); + + function resumeAutoScroll() { + userScrolledUp = false; + isAutoScrolling = true; + scrollToBottom(true); + } + + $effect(() => { + // Trigger on any message appended; track length explicitly + const count = chatStore.messages.length; + void count; + if (messagesEl) { + untrack(() => { + if (!userScrolledUp) scrollToBottom(false); + }); + } + }); </script> <div class="flex flex-col h-full"> @@ -38,14 +66,34 @@ $effect(() => { </div> <!-- Messages --> - <div bind:this={messagesEl} class="flex-1 overflow-y-auto p-4"> - {#if chatStore.messages.length === 0} - <div class="flex items-center justify-center h-full text-base-content/40 text-sm"> - Send a message to start a conversation - </div> - {/if} - {#each chatStore.messages as message (message.id)} - <ChatMessageComponent {message} /> - {/each} + <div class="relative flex-1 min-h-0"> + <div + bind:this={messagesEl} + class="h-full overflow-y-auto p-4" + onscroll={handleScroll} + onscrollend={() => { + isAutoScrolling = false; + }} + > + {#if chatStore.messages.length === 0} + <div class="flex items-center justify-center h-full text-base-content/40 text-sm"> + Send a message to start a conversation + </div> + {/if} + {#each chatStore.messages as message (message.id)} + <ChatMessageComponent {message} /> + {/each} + </div> + + <!-- Scroll-to-bottom button --> + <button + type="button" + class="absolute bottom-0 left-1/2 -translate-x-1/2 mb-4 btn btn-circle btn-sm shadow-lg transition-opacity duration-200 {userScrolledUp ? 'opacity-100' : 'opacity-0 pointer-events-none'}" + onclick={resumeAutoScroll} + aria-label="Scroll to bottom" + tabindex={userScrolledUp ? 0 : -1} + > + ↓ + </button> </div> </div> diff --git a/packages/frontend/src/lib/components/ToolCallDisplay.svelte b/packages/frontend/src/lib/components/ToolCallDisplay.svelte index f92f0f7..d85372c 100644 --- a/packages/frontend/src/lib/components/ToolCallDisplay.svelte +++ b/packages/frontend/src/lib/components/ToolCallDisplay.svelte @@ -43,10 +43,10 @@ const shellResult = $derived( ); </script> -<div class="collapse collapse-arrow bg-base-200 text-base-content my-1 rounded-lg border border-base-300 {isExpanded ? 'collapse-open' : ''}"> +<div class="collapse collapse-arrow mb-2 p-1 opacity-60 {isExpanded ? 'collapse-open' : ''}"> <button type="button" - class="collapse-title flex items-center gap-2 text-sm font-medium cursor-pointer w-full text-left" + class="collapse-title flex items-center gap-2 text-sm italic cursor-pointer w-full text-left" onclick={toggle} aria-expanded={isExpanded} > |
