refactor: update auto scroll mechanism (#970)

This commit is contained in:
Jeremy 2025-05-01 02:28:24 -07:00 committed by GitHub
parent 1fd2302914
commit 45978c27a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 250 additions and 52 deletions

View file

@ -1,11 +1,12 @@
import { PreviewMessage } from './message';
import { useScrollToBottom } from './use-scroll-to-bottom';
import { Vote } from '@/lib/db/schema';
import { UIMessage } from 'ai';
import { PreviewMessage, ThinkingMessage } from './message';
import type { Vote } from '@/lib/db/schema';
import type { UIMessage } from 'ai';
import { memo } from 'react';
import equal from 'fast-deep-equal';
import { UIArtifact } from './artifact';
import { UseChatHelpers } from '@ai-sdk/react';
import type { UIArtifact } from './artifact';
import type { UseChatHelpers } from '@ai-sdk/react';
import { motion } from 'framer-motion';
import { useMessages } from '@/hooks/use-messages';
interface ArtifactMessagesProps {
chatId: string;
@ -27,8 +28,16 @@ function PureArtifactMessages({
reload,
isReadonly,
}: ArtifactMessagesProps) {
const [messagesContainerRef, messagesEndRef] =
useScrollToBottom<HTMLDivElement>();
const {
containerRef: messagesContainerRef,
endRef: messagesEndRef,
onViewportEnter,
onViewportLeave,
hasSentMessage,
} = useMessages({
chatId,
status,
});
return (
<div
@ -49,12 +58,21 @@ function PureArtifactMessages({
setMessages={setMessages}
reload={reload}
isReadonly={isReadonly}
requiresScrollPadding={
hasSentMessage && index === messages.length - 1
}
/>
))}
<div
{status === 'submitted' &&
messages.length > 0 &&
messages[messages.length - 1].role === 'user' && <ThinkingMessage />}
<motion.div
ref={messagesEndRef}
className="shrink-0 min-w-[24px] min-h-[24px]"
onViewportLeave={onViewportLeave}
onViewportEnter={onViewportEnter}
/>
</div>
);

View file

@ -26,7 +26,7 @@ import { codeArtifact } from '@/artifacts/code/client';
import { sheetArtifact } from '@/artifacts/sheet/client';
import { textArtifact } from '@/artifacts/text/client';
import equal from 'fast-deep-equal';
import { UseChatHelpers } from '@ai-sdk/react';
import type { UseChatHelpers } from '@ai-sdk/react';
export const artifactDefinitions = [
textArtifact,
@ -309,7 +309,7 @@ function PureArtifact({
)}
</AnimatePresence>
<div className="flex flex-col h-full justify-between items-center gap-4">
<div className="flex flex-col h-full justify-between items-center">
<ArtifactMessages
chatId={chatId}
status={status}

View file

@ -28,6 +28,7 @@ const PurePreviewMessage = ({
setMessages,
reload,
isReadonly,
requiresScrollPadding,
}: {
chatId: string;
message: UIMessage;
@ -36,6 +37,7 @@ const PurePreviewMessage = ({
setMessages: UseChatHelpers['setMessages'];
reload: UseChatHelpers['reload'];
isReadonly: boolean;
requiresScrollPadding: boolean;
}) => {
const [mode, setMode] = useState<'view' | 'edit'>('view');
@ -65,7 +67,11 @@ const PurePreviewMessage = ({
</div>
)}
<div className="flex flex-col gap-4 w-full">
<div
className={cn('flex flex-col gap-4 w-full', {
'min-h-96': message.role === 'assistant' && requiresScrollPadding,
})}
>
{message.experimental_attachments &&
message.experimental_attachments.length > 0 && (
<div
@ -236,6 +242,8 @@ export const PreviewMessage = memo(
(prevProps, nextProps) => {
if (prevProps.isLoading !== nextProps.isLoading) return false;
if (prevProps.message.id !== nextProps.message.id) return false;
if (prevProps.requiresScrollPadding !== nextProps.requiresScrollPadding)
return false;
if (!equal(prevProps.message.parts, nextProps.message.parts)) return false;
if (!equal(prevProps.vote, nextProps.vote)) return false;
@ -249,7 +257,7 @@ export const ThinkingMessage = () => {
return (
<motion.div
data-testid="message-assistant-loading"
className="w-full mx-auto max-w-3xl px-4 group/message "
className="w-full mx-auto max-w-3xl px-4 group/message min-h-96"
initial={{ y: 5, opacity: 0 }}
animate={{ y: 0, opacity: 1, transition: { delay: 1 } }}
data-role={role}

View file

@ -1,11 +1,12 @@
import type { UIMessage } from 'ai';
import { PreviewMessage, ThinkingMessage } from './message';
import { useScrollToBottom } from './use-scroll-to-bottom';
import { Greeting } from './greeting';
import { memo } from 'react';
import type { Vote } from '@/lib/db/schema';
import equal from 'fast-deep-equal';
import type { UseChatHelpers } from '@ai-sdk/react';
import { motion } from 'framer-motion';
import { useMessages } from '@/hooks/use-messages';
interface MessagesProps {
chatId: string;
@ -27,13 +28,21 @@ function PureMessages({
reload,
isReadonly,
}: MessagesProps) {
const [messagesContainerRef, messagesEndRef] =
useScrollToBottom<HTMLDivElement>();
const {
containerRef: messagesContainerRef,
endRef: messagesEndRef,
onViewportEnter,
onViewportLeave,
hasSentMessage,
} = useMessages({
chatId,
status,
});
return (
<div
ref={messagesContainerRef}
className="flex flex-col min-w-0 gap-6 flex-1 overflow-y-scroll pt-4"
className="flex flex-col min-w-0 gap-6 flex-1 overflow-y-scroll pt-4 relative"
>
{messages.length === 0 && <Greeting />}
@ -51,6 +60,9 @@ function PureMessages({
setMessages={setMessages}
reload={reload}
isReadonly={isReadonly}
requiresScrollPadding={
hasSentMessage && index === messages.length - 1
}
/>
))}
@ -58,9 +70,11 @@ function PureMessages({
messages.length > 0 &&
messages[messages.length - 1].role === 'user' && <ThinkingMessage />}
<div
<motion.div
ref={messagesEndRef}
className="shrink-0 min-w-[24px] min-h-[24px]"
onViewportLeave={onViewportLeave}
onViewportEnter={onViewportEnter}
/>
</div>
);

View file

@ -23,6 +23,9 @@ import { Textarea } from './ui/textarea';
import { SuggestedActions } from './suggested-actions';
import equal from 'fast-deep-equal';
import type { UseChatHelpers } from '@ai-sdk/react';
import { AnimatePresence, motion } from 'framer-motion';
import { ArrowDown } from 'lucide-react';
import { useScrollToBottom } from '@/hooks/use-scroll-to-bottom';
function PureMultimodalInput({
chatId,
@ -179,8 +182,41 @@ function PureMultimodalInput({
[setAttachments],
);
const { isAtBottom, scrollToBottom } = useScrollToBottom();
useEffect(() => {
if (status === 'submitted') {
scrollToBottom();
}
}, [status, scrollToBottom]);
return (
<div className="relative w-full flex flex-col gap-4">
<AnimatePresence>
{!isAtBottom && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
className="absolute left-1/2 bottom-28 -translate-x-1/2 z-50"
>
<Button
data-testid="scroll-to-bottom-button"
className="rounded-full"
size="icon"
variant="outline"
onClick={(event) => {
event.preventDefault();
scrollToBottom();
}}
>
<ArrowDown />
</Button>
</motion.div>
)}
</AnimatePresence>
{messages.length === 0 &&
attachments.length === 0 &&
uploadQueue.length === 0 && (

View file

@ -1,31 +0,0 @@
import { useEffect, useRef, type RefObject } from 'react';
export function useScrollToBottom<T extends HTMLElement>(): [
RefObject<T>,
RefObject<T>,
] {
const containerRef = useRef<T>(null);
const endRef = useRef<T>(null);
useEffect(() => {
const container = containerRef.current;
const end = endRef.current;
if (container && end) {
const observer = new MutationObserver(() => {
end.scrollIntoView({ behavior: 'instant', block: 'end' });
});
observer.observe(container, {
childList: true,
subtree: true,
attributes: true,
characterData: true,
});
return () => observer.disconnect();
}
}, []);
return [containerRef, endRef];
}