diff --git a/frontend/components/Chat.tsx b/frontend/components/Chat.tsx index 60ae42c..16b1f8d 100644 --- a/frontend/components/Chat.tsx +++ b/frontend/components/Chat.tsx @@ -12,6 +12,7 @@ import { SidebarTrigger, useSidebar } from './ui/sidebar'; import { Button } from './ui/button'; import { MessageSquareMore } from 'lucide-react'; import { useChatNavigator } from '@/frontend/hooks/useChatNavigator'; +import { useCallback, useEffect, useRef, useState } from 'react'; interface ChatProps { threadId: string; @@ -22,7 +23,8 @@ export default function Chat({ threadId, initialMessages }: ChatProps) { const { getKey } = useAPIKeyStore(); const selectedModel = useModelStore((state) => state.selectedModel); const modelConfig = useModelStore((state) => state.getModelConfig()); - + const bottomDivRef = useRef(null); + const [isAtBottom, setIsAtBottom] = useState(true); const { isNavigatorVisible, handleToggleNavigator, @@ -68,6 +70,42 @@ export default function Chat({ threadId, initialMessages }: ChatProps) { }, }); + const scrollToBottom = useCallback((behaior: 'auto' | 'smooth') => { + bottomDivRef.current?.scrollIntoView({ behavior: behaior}); + }, []); + + useEffect(() => { + if (status === 'submitted') { + scrollToBottom('smooth'); + } + }, [status, scrollToBottom]); + + useEffect(() => { + if (!threadId) return; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + scrollToBottom('auto'); + }); + }); + }, [threadId]); + useEffect(() => { + scrollToBottom('smooth'); + }, [messages.length]); + + useEffect(() => { + if (!bottomDivRef.current) return; + + const observer = new IntersectionObserver( + ([entry]) => { + setIsAtBottom(entry.isIntersecting); + } + ); + + observer.observe(bottomDivRef.current); + + return () => observer.disconnect(); + }, []); + return (
@@ -91,6 +129,14 @@ export default function Chat({ threadId, initialMessages }: ChatProps) { append={append} setInput={setInput} stop={stop} + scrollToBottom={() => scrollToBottom('smooth')} + isAtBottom={isAtBottom} + /> +
diff --git a/frontend/components/ChatInput.tsx b/frontend/components/ChatInput.tsx index 0630f3b..642020c 100644 --- a/frontend/components/ChatInput.tsx +++ b/frontend/components/ChatInput.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, Check, ArrowUpIcon } from 'lucide-react'; +import { ChevronDown, Check, ArrowUpIcon, ArrowDownIcon } from 'lucide-react'; import { memo, useCallback, useMemo } from 'react'; import { Textarea } from '@/frontend/components/ui/textarea'; import { cn } from '@/lib/utils'; @@ -31,6 +31,8 @@ interface ChatInputProps { setInput: UseChatHelpers['setInput']; append: UseChatHelpers['append']; stop: UseChatHelpers['stop']; + scrollToBottom: () => void; + isAtBottom: boolean; } interface StopButtonProps { @@ -41,7 +43,9 @@ interface SendButtonProps { onSubmit: () => void; disabled: boolean; } - +interface ScrollButtonProps { + scrollToBottom: () => void; +} const createUserMessage = (id: string, text: string): UIMessage => ({ id, parts: [{ type: 'text', text }], @@ -57,6 +61,8 @@ function PureChatInput({ setInput, append, stop, + scrollToBottom, + isAtBottom, }: ChatInputProps) { const canChat = useAPIKeyStore((state) => state.hasRequiredKeys()); @@ -133,6 +139,9 @@ function PureChatInput({ return (
+
+ {!isAtBottom && } +
@@ -181,6 +190,7 @@ function PureChatInput({ const ChatInput = memo(PureChatInput, (prevProps, nextProps) => { if (prevProps.input !== nextProps.input) return false; if (prevProps.status !== nextProps.status) return false; + if (prevProps.isAtBottom !== nextProps.isAtBottom) return false; return true; }); @@ -274,6 +284,16 @@ const PureSendButton = ({ onSubmit, disabled }: SendButtonProps) => { ); }; +const PureScrollButton = ({ scrollToBottom }: ScrollButtonProps) => { + return ( + + ); +}; + +const ScrollButton = memo(PureScrollButton); + const SendButton = memo(PureSendButton, (prevProps, nextProps) => { return prevProps.disabled === nextProps.disabled; });