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
|
"noUselessStringConcat": "warn", // Not in recommended ruleset, turning on manually
|
||||||
"noForEach": "off", // forEach is too familiar to ban
|
"noForEach": "off", // forEach is too familiar to ban
|
||||||
"noUselessSwitchCase": "off", // Turned off due to developer preferences
|
"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": {
|
"correctness": {
|
||||||
"noUnusedImports": "warn", // Not in recommended ruleset, turning on manually
|
"noUnusedImports": "warn", // Not in recommended ruleset, turning on manually
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { PreviewMessage } from './message';
|
import { PreviewMessage, ThinkingMessage } from './message';
|
||||||
import { useScrollToBottom } from './use-scroll-to-bottom';
|
import type { Vote } from '@/lib/db/schema';
|
||||||
import { Vote } from '@/lib/db/schema';
|
import type { UIMessage } from 'ai';
|
||||||
import { UIMessage } from 'ai';
|
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import equal from 'fast-deep-equal';
|
import equal from 'fast-deep-equal';
|
||||||
import { UIArtifact } from './artifact';
|
import type { UIArtifact } from './artifact';
|
||||||
import { UseChatHelpers } from '@ai-sdk/react';
|
import type { UseChatHelpers } from '@ai-sdk/react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { useMessages } from '@/hooks/use-messages';
|
||||||
|
|
||||||
interface ArtifactMessagesProps {
|
interface ArtifactMessagesProps {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
|
|
@ -27,8 +28,16 @@ function PureArtifactMessages({
|
||||||
reload,
|
reload,
|
||||||
isReadonly,
|
isReadonly,
|
||||||
}: ArtifactMessagesProps) {
|
}: ArtifactMessagesProps) {
|
||||||
const [messagesContainerRef, messagesEndRef] =
|
const {
|
||||||
useScrollToBottom<HTMLDivElement>();
|
containerRef: messagesContainerRef,
|
||||||
|
endRef: messagesEndRef,
|
||||||
|
onViewportEnter,
|
||||||
|
onViewportLeave,
|
||||||
|
hasSentMessage,
|
||||||
|
} = useMessages({
|
||||||
|
chatId,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -49,12 +58,21 @@ function PureArtifactMessages({
|
||||||
setMessages={setMessages}
|
setMessages={setMessages}
|
||||||
reload={reload}
|
reload={reload}
|
||||||
isReadonly={isReadonly}
|
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}
|
ref={messagesEndRef}
|
||||||
className="shrink-0 min-w-[24px] min-h-[24px]"
|
className="shrink-0 min-w-[24px] min-h-[24px]"
|
||||||
|
onViewportLeave={onViewportLeave}
|
||||||
|
onViewportEnter={onViewportEnter}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import { codeArtifact } from '@/artifacts/code/client';
|
||||||
import { sheetArtifact } from '@/artifacts/sheet/client';
|
import { sheetArtifact } from '@/artifacts/sheet/client';
|
||||||
import { textArtifact } from '@/artifacts/text/client';
|
import { textArtifact } from '@/artifacts/text/client';
|
||||||
import equal from 'fast-deep-equal';
|
import equal from 'fast-deep-equal';
|
||||||
import { UseChatHelpers } from '@ai-sdk/react';
|
import type { UseChatHelpers } from '@ai-sdk/react';
|
||||||
|
|
||||||
export const artifactDefinitions = [
|
export const artifactDefinitions = [
|
||||||
textArtifact,
|
textArtifact,
|
||||||
|
|
@ -309,7 +309,7 @@ function PureArtifact({
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</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
|
<ArtifactMessages
|
||||||
chatId={chatId}
|
chatId={chatId}
|
||||||
status={status}
|
status={status}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ const PurePreviewMessage = ({
|
||||||
setMessages,
|
setMessages,
|
||||||
reload,
|
reload,
|
||||||
isReadonly,
|
isReadonly,
|
||||||
|
requiresScrollPadding,
|
||||||
}: {
|
}: {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
message: UIMessage;
|
message: UIMessage;
|
||||||
|
|
@ -36,6 +37,7 @@ const PurePreviewMessage = ({
|
||||||
setMessages: UseChatHelpers['setMessages'];
|
setMessages: UseChatHelpers['setMessages'];
|
||||||
reload: UseChatHelpers['reload'];
|
reload: UseChatHelpers['reload'];
|
||||||
isReadonly: boolean;
|
isReadonly: boolean;
|
||||||
|
requiresScrollPadding: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const [mode, setMode] = useState<'view' | 'edit'>('view');
|
const [mode, setMode] = useState<'view' | 'edit'>('view');
|
||||||
|
|
||||||
|
|
@ -65,7 +67,11 @@ const PurePreviewMessage = ({
|
||||||
</div>
|
</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 &&
|
||||||
message.experimental_attachments.length > 0 && (
|
message.experimental_attachments.length > 0 && (
|
||||||
<div
|
<div
|
||||||
|
|
@ -236,6 +242,8 @@ export const PreviewMessage = memo(
|
||||||
(prevProps, nextProps) => {
|
(prevProps, nextProps) => {
|
||||||
if (prevProps.isLoading !== nextProps.isLoading) return false;
|
if (prevProps.isLoading !== nextProps.isLoading) return false;
|
||||||
if (prevProps.message.id !== nextProps.message.id) 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.message.parts, nextProps.message.parts)) return false;
|
||||||
if (!equal(prevProps.vote, nextProps.vote)) return false;
|
if (!equal(prevProps.vote, nextProps.vote)) return false;
|
||||||
|
|
||||||
|
|
@ -249,7 +257,7 @@ export const ThinkingMessage = () => {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
data-testid="message-assistant-loading"
|
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 }}
|
initial={{ y: 5, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1, transition: { delay: 1 } }}
|
animate={{ y: 0, opacity: 1, transition: { delay: 1 } }}
|
||||||
data-role={role}
|
data-role={role}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import type { UIMessage } from 'ai';
|
import type { UIMessage } from 'ai';
|
||||||
import { PreviewMessage, ThinkingMessage } from './message';
|
import { PreviewMessage, ThinkingMessage } from './message';
|
||||||
import { useScrollToBottom } from './use-scroll-to-bottom';
|
|
||||||
import { Greeting } from './greeting';
|
import { Greeting } from './greeting';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import type { Vote } from '@/lib/db/schema';
|
import type { Vote } from '@/lib/db/schema';
|
||||||
import equal from 'fast-deep-equal';
|
import equal from 'fast-deep-equal';
|
||||||
import type { UseChatHelpers } from '@ai-sdk/react';
|
import type { UseChatHelpers } from '@ai-sdk/react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { useMessages } from '@/hooks/use-messages';
|
||||||
|
|
||||||
interface MessagesProps {
|
interface MessagesProps {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
|
|
@ -27,13 +28,21 @@ function PureMessages({
|
||||||
reload,
|
reload,
|
||||||
isReadonly,
|
isReadonly,
|
||||||
}: MessagesProps) {
|
}: MessagesProps) {
|
||||||
const [messagesContainerRef, messagesEndRef] =
|
const {
|
||||||
useScrollToBottom<HTMLDivElement>();
|
containerRef: messagesContainerRef,
|
||||||
|
endRef: messagesEndRef,
|
||||||
|
onViewportEnter,
|
||||||
|
onViewportLeave,
|
||||||
|
hasSentMessage,
|
||||||
|
} = useMessages({
|
||||||
|
chatId,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={messagesContainerRef}
|
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 />}
|
{messages.length === 0 && <Greeting />}
|
||||||
|
|
||||||
|
|
@ -51,6 +60,9 @@ function PureMessages({
|
||||||
setMessages={setMessages}
|
setMessages={setMessages}
|
||||||
reload={reload}
|
reload={reload}
|
||||||
isReadonly={isReadonly}
|
isReadonly={isReadonly}
|
||||||
|
requiresScrollPadding={
|
||||||
|
hasSentMessage && index === messages.length - 1
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
@ -58,9 +70,11 @@ function PureMessages({
|
||||||
messages.length > 0 &&
|
messages.length > 0 &&
|
||||||
messages[messages.length - 1].role === 'user' && <ThinkingMessage />}
|
messages[messages.length - 1].role === 'user' && <ThinkingMessage />}
|
||||||
|
|
||||||
<div
|
<motion.div
|
||||||
ref={messagesEndRef}
|
ref={messagesEndRef}
|
||||||
className="shrink-0 min-w-[24px] min-h-[24px]"
|
className="shrink-0 min-w-[24px] min-h-[24px]"
|
||||||
|
onViewportLeave={onViewportLeave}
|
||||||
|
onViewportEnter={onViewportEnter}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ import { Textarea } from './ui/textarea';
|
||||||
import { SuggestedActions } from './suggested-actions';
|
import { SuggestedActions } from './suggested-actions';
|
||||||
import equal from 'fast-deep-equal';
|
import equal from 'fast-deep-equal';
|
||||||
import type { UseChatHelpers } from '@ai-sdk/react';
|
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({
|
function PureMultimodalInput({
|
||||||
chatId,
|
chatId,
|
||||||
|
|
@ -179,8 +182,41 @@ function PureMultimodalInput({
|
||||||
[setAttachments],
|
[setAttachments],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { isAtBottom, scrollToBottom } = useScrollToBottom();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'submitted') {
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
}, [status, scrollToBottom]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full flex flex-col gap-4">
|
<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 &&
|
{messages.length === 0 &&
|
||||||
attachments.length === 0 &&
|
attachments.length === 0 &&
|
||||||
uploadQueue.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",
|
"name": "ai-chatbot",
|
||||||
"version": "3.0.14",
|
"version": "3.0.15",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbo",
|
"dev": "next dev --turbo",
|
||||||
|
|
|
||||||
|
|
@ -150,4 +150,21 @@ test.describe('Chat activity', () => {
|
||||||
const assistantMessage = await chatPage.getRecentAssistantMessage();
|
const assistantMessage = await chatPage.getRecentAssistantMessage();
|
||||||
expect(assistantMessage.content).toContain("It's just blue duh!");
|
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;
|
curieContext: UserContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const test = baseTest.extend<any, Fixtures>({
|
export const test = baseTest.extend<{}, Fixtures>({
|
||||||
adaContext: [
|
adaContext: [
|
||||||
async ({ browser }, use, workerInfo) => {
|
async ({ browser }, use, workerInfo) => {
|
||||||
const ada = await createAuthenticatedContext({
|
const ada = await createAuthenticatedContext({
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,14 @@ export class ChatPage {
|
||||||
return this.page.getByTestId('multimodal-input');
|
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() {
|
async createNewChat() {
|
||||||
await this.page.goto('/');
|
await this.page.goto('/');
|
||||||
}
|
}
|
||||||
|
|
@ -195,4 +203,37 @@ export class ChatPage {
|
||||||
const sidebarToggleButton = this.page.getByTestId('sidebar-toggle-button');
|
const sidebarToggleButton = this.page.getByTestId('sidebar-toggle-button');
|
||||||
await sidebarToggleButton.click();
|
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