From 686c59c6bcf43c07b1024996aeedbea5c733b6d2 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Fri, 2 Jan 2026 09:54:20 +0100 Subject: [PATCH] feat: improve auto-scroll to match cloud provider UX Auto-scroll now stops when the top of the assistant's response reaches the top of the viewport, allowing users to read from the beginning while more content streams below. --- frontend/package.json | 2 +- .../lib/components/chat/MessageList.svelte | 60 +++++++++++++++---- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 2857115..8afe586 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "vessel", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/components/chat/MessageList.svelte b/frontend/src/lib/components/chat/MessageList.svelte index 670be7d..eb252de 100644 --- a/frontend/src/lib/components/chat/MessageList.svelte +++ b/frontend/src/lib/components/chat/MessageList.svelte @@ -24,6 +24,10 @@ // Track if user has scrolled away from bottom let userScrolledAway = $state(false); + // Track if auto-scroll is enabled for current streaming session + // Disabled when message top reaches viewport top or user scrolls away + let autoScrollEnabled = $state(true); + // Track previous streaming state to detect when streaming ends // Note: Using plain variables (not $state) to avoid re-triggering effects let wasStreaming = false; @@ -31,6 +35,9 @@ // Threshold for "near bottom" detection const SCROLL_THRESHOLD = 100; + // Buffer space at top of viewport before stopping auto-scroll + const TOP_BUFFER = 20; + /** * Check if scroll position is near the bottom */ @@ -40,6 +47,26 @@ return scrollHeight - scrollTop - clientHeight < SCROLL_THRESHOLD; } + /** + * Check if the streaming message's top has reached the viewport top. + * This implements the "cloud provider" style auto-scroll behavior: + * scroll until the response is visible at the top, then stop. + */ + function hasMessageTopReachedViewportTop(): boolean { + if (!scrollContainer) return false; + + // Find the last assistant message (the streaming message) + const messages = scrollContainer.querySelectorAll('article'); + const lastMessage = messages[messages.length - 1]; + if (!lastMessage) return false; + + const containerRect = scrollContainer.getBoundingClientRect(); + const messageRect = lastMessage.getBoundingClientRect(); + + // Message top has reached viewport top when it's at or above the container's top + return messageRect.top <= containerRect.top + TOP_BUFFER; + } + /** * Handle scroll events - detect when user scrolls away */ @@ -48,6 +75,11 @@ // User is considered "scrolled away" if not near bottom userScrolledAway = !isNearBottom(); + + // If user manually scrolls away during streaming, disable auto-scroll + if (userScrolledAway && chatState.isStreaming) { + autoScrollEnabled = false; + } } /** @@ -74,8 +106,9 @@ $effect(() => { const isStreaming = chatState.isStreaming; - // When streaming starts, scroll to bottom if user is near bottom + // When streaming starts, reset auto-scroll state and do initial scroll if (isStreaming && !wasStreaming) { + autoScrollEnabled = true; if (!userScrolledAway) { // Small delay to let the new message element render requestAnimationFrame(() => { @@ -84,27 +117,26 @@ } } - // When streaming ends, do a final scroll if user hasn't scrolled away - if (!isStreaming && wasStreaming) { - if (!userScrolledAway) { - requestAnimationFrame(() => { - scrollToBottomInstant(); - }); - } - } - wasStreaming = isStreaming; }); // Continuous scroll during streaming as content grows + // Uses "cloud provider" style: scroll until message top reaches viewport top $effect(() => { // Track stream buffer changes - when content grows during streaming, scroll const buffer = chatState.streamBuffer; const isStreaming = chatState.isStreaming; - if (isStreaming && buffer && !userScrolledAway) { + if (isStreaming && buffer && autoScrollEnabled) { requestAnimationFrame(() => { - scrollToBottomInstant(); + // Check if the message top has reached the viewport top + if (hasMessageTopReachedViewportTop()) { + // Stop auto-scrolling - the user can now read from the beginning + autoScrollEnabled = false; + } else { + // Continue scrolling to keep the message visible + scrollToBottomInstant(); + } }); } }); @@ -116,7 +148,9 @@ const currentCount = chatState.visibleMessages.length; if (currentCount > previousMessageCount && currentCount > 0) { - // New message added - always scroll to it + // New message added - reset scroll state and scroll to it + autoScrollEnabled = true; + userScrolledAway = false; requestAnimationFrame(() => { scrollToBottomInstant(); });