diff --git a/CHANGELOG.md b/CHANGELOG.md index a2caff421..ac4273056 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Changed the `webUrl` property of the `/api/repos` api to return a URL rather than just a path. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) - Changed the ask search scope selector to allow submitting questions with no search scope selected. When no selection is made, the agent will be able to search over all repos the user has access to. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) - Renamed the `search_code` tool to `grep` for ask and mcp. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) +- Improved auto-scroll behavior in the ask chat thread. [#1031](https://github.com/sourcebot-dev/sourcebot/pull/1031) ### Added - Added `glob`, `find_symbol_definitions`, and `find_symbol_references` tools to the ask agent and MCP server. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) diff --git a/packages/web/src/features/chat/components/chatThread/chatThread.tsx b/packages/web/src/features/chat/components/chatThread/chatThread.tsx index c69f54ade..cff328e4d 100644 --- a/packages/web/src/features/chat/components/chatThread/chatThread.tsx +++ b/packages/web/src/features/chat/components/chatThread/chatThread.tsx @@ -2,7 +2,6 @@ import { useToast } from '@/components/hooks/use-toast'; import { Button } from '@/components/ui/button'; -import { ScrollArea } from '@/components/ui/scroll-area'; import { Separator } from '@/components/ui/separator'; import { CustomSlateEditor } from '@/features/chat/customSlateEditor'; import { AdditionalChatRequestParams, CustomEditor, LanguageModelInfo, SBChatMessage, SearchScope, Source } from '@/features/chat/types'; @@ -12,6 +11,7 @@ import { CreateUIMessage, DefaultChatTransport } from 'ai'; import { ArrowDownIcon, CopyIcon } from 'lucide-react'; import { useNavigationGuard } from 'next-navigation-guard'; import { Fragment, useCallback, useEffect, useRef, useState } from 'react'; +import { useStickToBottom } from 'use-stick-to-bottom'; import { Descendant } from 'slate'; import { useMessagePairs } from '../../useMessagePairs'; import { useSelectedLanguageModel } from '../../useSelectedLanguageModel'; @@ -67,10 +67,8 @@ export const ChatThread = ({ chatName, }: ChatThreadProps) => { const [isErrorBannerVisible, setIsErrorBannerVisible] = useState(false); - const scrollAreaRef = useRef(null); - const latestMessagePairRef = useRef(null); const hasSubmittedInputMessage = useRef(false); - const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(false); + const { scrollRef, contentRef, scrollToBottom, isAtBottom } = useStickToBottom({ initial: false }); const { toast } = useToast(); const router = useRouter(); const params = useParams<{ domain: string }>(); @@ -204,9 +202,9 @@ export const ChatThread = ({ } sendMessage(inputMessage); - setIsAutoScrollEnabled(true); + scrollToBottom(); hasSubmittedInputMessage.current = true; - }, [inputMessage, sendMessage]); + }, [inputMessage, scrollToBottom, sendMessage]); // Restore pending message after OAuth redirect (askgh login wall) useEffect(() => { @@ -234,28 +232,24 @@ export const ChatThread = ({ const mentions = getAllMentionElements(children); const message = createUIMessage(text, mentions.map(({ data }) => data), selectedSearchScopes); sendMessage(message); - setIsAutoScrollEnabled(true); + scrollToBottom(); } catch (error) { console.error('Failed to restore pending message:', error); } - }, [isAuthenticated, isOwner, chatId, sendMessage, selectedSearchScopes]); + }, [isAuthenticated, isOwner, chatId, sendMessage, selectedSearchScopes, scrollToBottom]); - // Track scroll position changes. + // Track scroll position for history state restoration. useEffect(() => { - const scrollElement = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement; - if (!scrollElement) return; + const scrollElement = scrollRef.current; + if (!scrollElement) { + return; + } let timeout: NodeJS.Timeout | null = null; const handleScroll = () => { const scrollOffset = scrollElement.scrollTop; - const threshold = 50; // pixels from bottom to consider "at bottom" - const { scrollHeight, clientHeight } = scrollElement; - const isAtBottom = scrollHeight - scrollOffset - clientHeight <= threshold; - setIsAutoScrollEnabled(isAtBottom); - - // Debounce the history state update if (timeout) { clearTimeout(timeout); } @@ -279,10 +273,11 @@ export const ChatThread = ({ clearTimeout(timeout); } }; - }, []); + }, [scrollRef]); + // Restore scroll position from history state on mount. useEffect(() => { - const scrollElement = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement; + const scrollElement = scrollRef.current; if (!scrollElement) { return; } @@ -298,26 +293,7 @@ export const ChatThread = ({ behavior: 'instant', }); }, 10); - }, []); - - // When messages are being streamed, scroll to the latest message - // assuming auto scrolling is enabled. - useEffect(() => { - if ( - !latestMessagePairRef.current || - !isAutoScrollEnabled || - messages.length === 0 - ) { - return; - } - - latestMessagePairRef.current.scrollIntoView({ - behavior: 'smooth', - block: 'end', - inline: 'nearest', - }); - - }, [isAutoScrollEnabled, messages]); + }, [scrollRef]); // Keep the error state & banner visibility in sync. @@ -345,10 +321,10 @@ export const ChatThread = ({ const message = createUIMessage(text, mentions.map(({ data }) => data), selectedSearchScopes); sendMessage(message); - setIsAutoScrollEnabled(true); + scrollToBottom(); resetEditor(editor); - }, [sendMessage, selectedSearchScopes, isAuthenticated, captureEvent, chatId]); + }, [sendMessage, selectedSearchScopes, isAuthenticated, captureEvent, chatId, scrollToBottom]); const onDuplicate = useCallback(async (newName: string): Promise => { if (!defaultChatId) { @@ -379,64 +355,61 @@ export const ChatThread = ({ /> )} - - { - messagePairs.length === 0 ? ( -
-

no messages

-
- ) : ( - <> - {messagePairs.map(([userMessage, assistantMessage], index) => { - const isLastPair = index === messagePairs.length - 1; - const isStreaming = isLastPair && (status === "streaming" || status === "submitted"); - // Use a stable key based on user message ID - const key = userMessage.id; - - return ( - - - {index !== messagePairs.length - 1 && ( - - )} - - ); - })} - - ) - } +
+
+
+ { + messagePairs.length === 0 ? ( +
+

no messages

+
+ ) : ( + <> + {messagePairs.map(([userMessage, assistantMessage], index) => { + const isLastPair = index === messagePairs.length - 1; + const isStreaming = isLastPair && (status === "streaming" || status === "submitted"); + // Use a stable key based on user message ID + const key = userMessage.id; + + return ( + + + {index !== messagePairs.length - 1 && ( + + )} + + ); + })} + + ) + } +
+
{ - (!isAutoScrollEnabled && status === "streaming") && ( + (!isAtBottom && status === "streaming") && (
) } - +