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

@ -54,7 +54,8 @@
"noUselessStringConcat": "warn", // Not in recommended ruleset, turning on manually
"noForEach": "off", // forEach is too familiar to ban
"noUselessSwitchCase": "off", // Turned off due to developer preferences
"noUselessThisAlias": "off" // Turned off due to developer preferences
"noUselessThisAlias": "off", // Turned off due to developer preferences
"noBannedTypes": "off"
},
"correctness": {
"noUnusedImports": "warn", // Not in recommended ruleset, turning on manually

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];
}

45
hooks/use-messages.tsx Normal file
View file

@ -0,0 +1,45 @@
import { useState, useEffect } from 'react';
import { useScrollToBottom } from './use-scroll-to-bottom';
import type { UseChatHelpers } from '@ai-sdk/react';
export function useMessages({
chatId,
status,
}: {
chatId: string;
status: UseChatHelpers['status'];
}) {
const {
containerRef,
endRef,
isAtBottom,
scrollToBottom,
onViewportEnter,
onViewportLeave,
} = useScrollToBottom();
const [hasSentMessage, setHasSentMessage] = useState(false);
useEffect(() => {
if (chatId) {
scrollToBottom('instant');
setHasSentMessage(false);
}
}, [chatId, scrollToBottom]);
useEffect(() => {
if (status === 'submitted') {
setHasSentMessage(true);
}
}, [status]);
return {
containerRef,
endRef,
isAtBottom,
scrollToBottom,
onViewportEnter,
onViewportLeave,
hasSentMessage,
};
}

View file

@ -0,0 +1,49 @@
import useSWR from 'swr';
import { useRef, useEffect, useCallback } from 'react';
type ScrollFlag = ScrollBehavior | false;
export function useScrollToBottom() {
const containerRef = useRef<HTMLDivElement>(null);
const endRef = useRef<HTMLDivElement>(null);
const { data: isAtBottom = false, mutate: setIsAtBottom } = useSWR(
'messages:is-at-bottom',
null,
{ fallbackData: false },
);
const { data: scrollBehavior = false, mutate: setScrollBehavior } =
useSWR<ScrollFlag>('messages:should-scroll', null, { fallbackData: false });
useEffect(() => {
if (scrollBehavior) {
endRef.current?.scrollIntoView({ behavior: scrollBehavior });
setScrollBehavior(false);
}
}, [setScrollBehavior, scrollBehavior]);
const scrollToBottom = useCallback(
(scrollBehavior: ScrollBehavior = 'smooth') => {
setScrollBehavior(scrollBehavior);
},
[setScrollBehavior],
);
function onViewportEnter() {
setIsAtBottom(true);
}
function onViewportLeave() {
setIsAtBottom(false);
}
return {
containerRef,
endRef,
isAtBottom,
scrollToBottom,
onViewportEnter,
onViewportLeave,
};
}

View file

@ -1,6 +1,6 @@
{
"name": "ai-chatbot",
"version": "3.0.14",
"version": "3.0.15",
"private": true,
"scripts": {
"dev": "next dev --turbo",

View file

@ -150,4 +150,21 @@ test.describe('Chat activity', () => {
const assistantMessage = await chatPage.getRecentAssistantMessage();
expect(assistantMessage.content).toContain("It's just blue duh!");
});
test('auto-scrolls to bottom after submitting new messages', async () => {
await chatPage.sendMultipleMessages(5, (i) => `filling message #${i}`);
await chatPage.waitForScrollToBottom();
});
test('scroll button appears when user scrolls up, hides on click', async () => {
await chatPage.sendMultipleMessages(5, (i) => `filling message #${i}`);
await expect(chatPage.scrollToBottomButton).not.toBeVisible();
await chatPage.scrollToTop();
await expect(chatPage.scrollToBottomButton).toBeVisible();
await chatPage.scrollToBottomButton.click();
await chatPage.waitForScrollToBottom();
await expect(chatPage.scrollToBottomButton).not.toBeVisible();
});
});

View file

@ -8,7 +8,7 @@ interface Fixtures {
curieContext: UserContext;
}
export const test = baseTest.extend<any, Fixtures>({
export const test = baseTest.extend<{}, Fixtures>({
adaContext: [
async ({ browser }, use, workerInfo) => {
const ada = await createAuthenticatedContext({

View file

@ -18,6 +18,14 @@ export class ChatPage {
return this.page.getByTestId('multimodal-input');
}
public get scrollContainer() {
return this.page.locator('.overflow-y-scroll');
}
public get scrollToBottomButton() {
return this.page.getByTestId('scroll-to-bottom-button');
}
async createNewChat() {
await this.page.goto('/');
}
@ -195,4 +203,37 @@ export class ChatPage {
const sidebarToggleButton = this.page.getByTestId('sidebar-toggle-button');
await sidebarToggleButton.click();
}
public async isScrolledToBottom(): Promise<boolean> {
return this.scrollContainer.evaluate(
(el) => Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 1,
);
}
public async waitForScrollToBottom(timeout = 5_000): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeout) {
if (await this.isScrolledToBottom()) return;
await this.page.waitForTimeout(100);
}
throw new Error(`Timed out waiting for scroll bottom after ${timeout}ms`);
}
public async sendMultipleMessages(
count: number,
makeMessage: (i: number) => string,
) {
for (let i = 0; i < count; i++) {
await this.sendUserMessage(makeMessage(i));
await this.isGenerationComplete();
}
}
public async scrollToTop(): Promise<void> {
await this.scrollContainer.evaluate((element) => {
element.scrollTop = 0;
});
}
}