summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-20 13:47:33 +0900
committerAdam Malczewski <[email protected]>2026-05-20 13:47:33 +0900
commit4d54b90fbf9273c3dc2f26a856d67e16c3927e94 (patch)
tree4788b66723ddcc8c98de06491dca1203314cfb80
parent6ec5803e953534c077f863d723972c78796fe16a (diff)
downloaddispatch-4d54b90fbf9273c3dc2f26a856d67e16c3927e94.tar.gz
dispatch-4d54b90fbf9273c3dc2f26a856d67e16c3927e94.zip
feat: smart scroll — auto-follow during streaming, manual mode on scroll up, scroll-to-bottom button
-rw-r--r--packages/frontend/src/lib/components/ChatMessage.svelte2
-rw-r--r--packages/frontend/src/lib/components/ChatPanel.svelte86
-rw-r--r--packages/frontend/src/lib/components/ToolCallDisplay.svelte4
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}
>