refactor: update auto scroll mechanism (#970)
This commit is contained in:
parent
1fd2302914
commit
45978c27a2
13 changed files with 250 additions and 52 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
45
hooks/use-messages.tsx
Normal file
45
hooks/use-messages.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
49
hooks/use-scroll-to-bottom.tsx
Normal file
49
hooks/use-scroll-to-bottom.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "ai-chatbot",
|
||||
"version": "3.0.14",
|
||||
"version": "3.0.15",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbo",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue