refactor: update auto scroll mechanism (#970)
This commit is contained in:
parent
1fd2302914
commit
45978c27a2
13 changed files with 250 additions and 52 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue