diff --git a/frontend/src/routes/_auth.chat.$chatId.tsx b/frontend/src/routes/_auth.chat.$chatId.tsx index 129574bb7..1d96e504b 100644 --- a/frontend/src/routes/_auth.chat.$chatId.tsx +++ b/frontend/src/routes/_auth.chat.$chatId.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, useCallback } from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { AsteriskIcon, Check, Copy, UserIcon } from "lucide-react"; +import { AsteriskIcon, Check, Copy, UserIcon, ChevronDown } from "lucide-react"; import ChatBox from "@/components/ChatBox"; import { useOpenAI } from "@/ai/useOpenAi"; import { useLocalState } from "@/state/useLocalState"; @@ -67,23 +67,46 @@ function SystemMessage({ text, loading }: { text: string; loading?: boolean }) { ); } -const scrollToMessage = (messageId: string) => { - const element = document.getElementById(messageId); - if (element) { - element.scrollIntoView({ behavior: "smooth", block: "start" }); - } -}; - function ChatComponent() { const { chatId } = Route.useParams(); const { model, persistChat, getChatById, userPrompt, setUserPrompt } = useLocalState(); const openai = useOpenAI(); const queryClient = useQueryClient(); + const [showScrollButton, setShowScrollButton] = useState(false); const [error, setError] = useState(""); const chatContainerRef = useRef(null); + // Memoize the scroll handler + const handleScroll = useCallback(() => { + const container = chatContainerRef.current; + if (!container) return; + const { scrollTop, scrollHeight, clientHeight } = container; + const isNearBottom = scrollHeight - scrollTop - clientHeight < 100; + setShowScrollButton(!isNearBottom); + }, []); + + // Add scroll detection + useEffect(() => { + const container = chatContainerRef.current; + if (!container) return; + + container.addEventListener("scroll", handleScroll); + // Initial check + handleScroll(); + return () => container.removeEventListener("scroll", handleScroll); + }, [handleScroll]); + + const scrollToBottom = useCallback(() => { + if (chatContainerRef.current) { + chatContainerRef.current.scrollTo({ + top: chatContainerRef.current.scrollHeight, + behavior: "smooth" + }); + } + }, []); + // Query the chat from the backend, in case it already exists const { isPending, @@ -175,10 +198,13 @@ function ChatComponent() { messages: newMessages })); - // Scroll to the new user message - setTimeout(() => { - scrollToMessage(`message-user-${newMessages.length - 1}`); - }, 0); + // Scroll to bottom when user sends message + requestAnimationFrame(() => { + chatContainerRef.current?.scrollTo({ + top: chatContainerRef.current.scrollHeight, + behavior: "smooth" + }); + }); setIsLoading(true); @@ -190,13 +216,29 @@ function ChatComponent() { }); let fullResponse = ""; + let isFirstChunk = true; for await (const chunk of stream) { const content = chunk.choices[0]?.delta?.content || ""; fullResponse += content; setCurrentStreamingMessage(fullResponse); + + // Scroll to bottom on first chunk of the response + if (isFirstChunk && content.trim()) { + requestAnimationFrame(() => { + chatContainerRef.current?.scrollTo({ + top: chatContainerRef.current.scrollHeight, + behavior: "smooth" + }); + }); + isFirstChunk = false; + } } + // Save scroll position before state updates + const container = chatContainerRef.current; + const scrollPosition = container?.scrollTop; + const finalMessages = [ ...newMessages, { role: "assistant", content: fullResponse } as ChatMessage @@ -207,6 +249,17 @@ function ChatComponent() { })); setCurrentStreamingMessage(undefined); + // Restore scroll position after state updates + if (container && scrollPosition !== undefined) { + // Use requestAnimationFrame to ensure this runs after the render + requestAnimationFrame(() => { + // Ensure we don't scroll beyond bounds + const maxScroll = container.scrollHeight - container.clientHeight; + const boundedPosition = Math.min(scrollPosition, maxScroll); + container.scrollTop = boundedPosition; + }); + } + let title = localChat.title; // Generate and update the chat title, if the current title isn't "New Chat" @@ -289,6 +342,15 @@ function ChatComponent() { )} + {showScrollButton && ( + + )} {/* Place the chat box inline (below messages) in normal flow */}