From 45978c27a2ccd838e88bc24a4de3cfafca30c8ce Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 1 May 2025 02:28:24 -0700 Subject: [PATCH] refactor: update auto scroll mechanism (#970) --- biome.jsonc | 3 +- components/artifact-messages.tsx | 36 ++++++++++++++++------ components/artifact.tsx | 4 +-- components/message.tsx | 12 ++++++-- components/messages.tsx | 24 ++++++++++++--- components/multimodal-input.tsx | 36 ++++++++++++++++++++++ components/use-scroll-to-bottom.ts | 31 ------------------- hooks/use-messages.tsx | 45 +++++++++++++++++++++++++++ hooks/use-scroll-to-bottom.tsx | 49 ++++++++++++++++++++++++++++++ package.json | 2 +- tests/e2e/chat.test.ts | 17 +++++++++++ tests/fixtures.ts | 2 +- tests/pages/chat.ts | 41 +++++++++++++++++++++++++ 13 files changed, 250 insertions(+), 52 deletions(-) delete mode 100644 components/use-scroll-to-bottom.ts create mode 100644 hooks/use-messages.tsx create mode 100644 hooks/use-scroll-to-bottom.tsx diff --git a/biome.jsonc b/biome.jsonc index 89447ac..d227936 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -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 diff --git a/components/artifact-messages.tsx b/components/artifact-messages.tsx index 208e73b..82b30da 100644 --- a/components/artifact-messages.tsx +++ b/components/artifact-messages.tsx @@ -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(); + const { + containerRef: messagesContainerRef, + endRef: messagesEndRef, + onViewportEnter, + onViewportLeave, + hasSentMessage, + } = useMessages({ + chatId, + status, + }); return (
))} -
0 && + messages[messages.length - 1].role === 'user' && } + +
); diff --git a/components/artifact.tsx b/components/artifact.tsx index fd3c281..451147f 100644 --- a/components/artifact.tsx +++ b/components/artifact.tsx @@ -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({ )} -
+
{ const [mode, setMode] = useState<'view' | 'edit'>('view'); @@ -65,7 +67,11 @@ const PurePreviewMessage = ({
)} -
+
{message.experimental_attachments && message.experimental_attachments.length > 0 && (
{ 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 ( (); + const { + containerRef: messagesContainerRef, + endRef: messagesEndRef, + onViewportEnter, + onViewportLeave, + hasSentMessage, + } = useMessages({ + chatId, + status, + }); return (
{messages.length === 0 && } @@ -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' && } -
); diff --git a/components/multimodal-input.tsx b/components/multimodal-input.tsx index 17d3279..b1f5b5c 100644 --- a/components/multimodal-input.tsx +++ b/components/multimodal-input.tsx @@ -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 (
+ + {!isAtBottom && ( + + + + )} + + {messages.length === 0 && attachments.length === 0 && uploadQueue.length === 0 && ( diff --git a/components/use-scroll-to-bottom.ts b/components/use-scroll-to-bottom.ts deleted file mode 100644 index e45a8a5..0000000 --- a/components/use-scroll-to-bottom.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useEffect, useRef, type RefObject } from 'react'; - -export function useScrollToBottom(): [ - RefObject, - RefObject, -] { - const containerRef = useRef(null); - const endRef = useRef(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]; -} diff --git a/hooks/use-messages.tsx b/hooks/use-messages.tsx new file mode 100644 index 0000000..150866f --- /dev/null +++ b/hooks/use-messages.tsx @@ -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, + }; +} diff --git a/hooks/use-scroll-to-bottom.tsx b/hooks/use-scroll-to-bottom.tsx new file mode 100644 index 0000000..29e1506 --- /dev/null +++ b/hooks/use-scroll-to-bottom.tsx @@ -0,0 +1,49 @@ +import useSWR from 'swr'; +import { useRef, useEffect, useCallback } from 'react'; + +type ScrollFlag = ScrollBehavior | false; + +export function useScrollToBottom() { + const containerRef = useRef(null); + const endRef = useRef(null); + + const { data: isAtBottom = false, mutate: setIsAtBottom } = useSWR( + 'messages:is-at-bottom', + null, + { fallbackData: false }, + ); + + const { data: scrollBehavior = false, mutate: setScrollBehavior } = + useSWR('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, + }; +} diff --git a/package.json b/package.json index f8a2752..d7b84d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ai-chatbot", - "version": "3.0.14", + "version": "3.0.15", "private": true, "scripts": { "dev": "next dev --turbo", diff --git a/tests/e2e/chat.test.ts b/tests/e2e/chat.test.ts index 9e73342..d28141f 100644 --- a/tests/e2e/chat.test.ts +++ b/tests/e2e/chat.test.ts @@ -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(); + }); }); diff --git a/tests/fixtures.ts b/tests/fixtures.ts index 52b10dc..de1cf01 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -8,7 +8,7 @@ interface Fixtures { curieContext: UserContext; } -export const test = baseTest.extend({ +export const test = baseTest.extend<{}, Fixtures>({ adaContext: [ async ({ browser }, use, workerInfo) => { const ada = await createAuthenticatedContext({ diff --git a/tests/pages/chat.ts b/tests/pages/chat.ts index da4ac6d..e1514bc 100644 --- a/tests/pages/chat.ts +++ b/tests/pages/chat.ts @@ -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 { + return this.scrollContainer.evaluate( + (el) => Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 1, + ); + } + + public async waitForScrollToBottom(timeout = 5_000): Promise { + 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 { + await this.scrollContainer.evaluate((element) => { + element.scrollTop = 0; + }); + } }