Skip to content

Commit 45978c2

Browse files
refactor: update auto scroll mechanism (#970)
1 parent 1fd2302 commit 45978c2

13 files changed

+250
-52
lines changed

biome.jsonc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@
5454
"noUselessStringConcat": "warn", // Not in recommended ruleset, turning on manually
5555
"noForEach": "off", // forEach is too familiar to ban
5656
"noUselessSwitchCase": "off", // Turned off due to developer preferences
57-
"noUselessThisAlias": "off" // Turned off due to developer preferences
57+
"noUselessThisAlias": "off", // Turned off due to developer preferences
58+
"noBannedTypes": "off"
5859
},
5960
"correctness": {
6061
"noUnusedImports": "warn", // Not in recommended ruleset, turning on manually

components/artifact-messages.tsx

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { PreviewMessage } from './message';
2-
import { useScrollToBottom } from './use-scroll-to-bottom';
3-
import { Vote } from '@/lib/db/schema';
4-
import { UIMessage } from 'ai';
1+
import { PreviewMessage, ThinkingMessage } from './message';
2+
import type { Vote } from '@/lib/db/schema';
3+
import type { UIMessage } from 'ai';
54
import { memo } from 'react';
65
import equal from 'fast-deep-equal';
7-
import { UIArtifact } from './artifact';
8-
import { UseChatHelpers } from '@ai-sdk/react';
6+
import type { UIArtifact } from './artifact';
7+
import type { UseChatHelpers } from '@ai-sdk/react';
8+
import { motion } from 'framer-motion';
9+
import { useMessages } from '@/hooks/use-messages';
910

1011
interface ArtifactMessagesProps {
1112
chatId: string;
@@ -27,8 +28,16 @@ function PureArtifactMessages({
2728
reload,
2829
isReadonly,
2930
}: ArtifactMessagesProps) {
30-
const [messagesContainerRef, messagesEndRef] =
31-
useScrollToBottom<HTMLDivElement>();
31+
const {
32+
containerRef: messagesContainerRef,
33+
endRef: messagesEndRef,
34+
onViewportEnter,
35+
onViewportLeave,
36+
hasSentMessage,
37+
} = useMessages({
38+
chatId,
39+
status,
40+
});
3241

3342
return (
3443
<div
@@ -49,12 +58,21 @@ function PureArtifactMessages({
4958
setMessages={setMessages}
5059
reload={reload}
5160
isReadonly={isReadonly}
61+
requiresScrollPadding={
62+
hasSentMessage && index === messages.length - 1
63+
}
5264
/>
5365
))}
5466

55-
<div
67+
{status === 'submitted' &&
68+
messages.length > 0 &&
69+
messages[messages.length - 1].role === 'user' && <ThinkingMessage />}
70+
71+
<motion.div
5672
ref={messagesEndRef}
5773
className="shrink-0 min-w-[24px] min-h-[24px]"
74+
onViewportLeave={onViewportLeave}
75+
onViewportEnter={onViewportEnter}
5876
/>
5977
</div>
6078
);

components/artifact.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { codeArtifact } from '@/artifacts/code/client';
2626
import { sheetArtifact } from '@/artifacts/sheet/client';
2727
import { textArtifact } from '@/artifacts/text/client';
2828
import equal from 'fast-deep-equal';
29-
import { UseChatHelpers } from '@ai-sdk/react';
29+
import type { UseChatHelpers } from '@ai-sdk/react';
3030

3131
export const artifactDefinitions = [
3232
textArtifact,
@@ -309,7 +309,7 @@ function PureArtifact({
309309
)}
310310
</AnimatePresence>
311311

312-
<div className="flex flex-col h-full justify-between items-center gap-4">
312+
<div className="flex flex-col h-full justify-between items-center">
313313
<ArtifactMessages
314314
chatId={chatId}
315315
status={status}

components/message.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const PurePreviewMessage = ({
2828
setMessages,
2929
reload,
3030
isReadonly,
31+
requiresScrollPadding,
3132
}: {
3233
chatId: string;
3334
message: UIMessage;
@@ -36,6 +37,7 @@ const PurePreviewMessage = ({
3637
setMessages: UseChatHelpers['setMessages'];
3738
reload: UseChatHelpers['reload'];
3839
isReadonly: boolean;
40+
requiresScrollPadding: boolean;
3941
}) => {
4042
const [mode, setMode] = useState<'view' | 'edit'>('view');
4143

@@ -65,7 +67,11 @@ const PurePreviewMessage = ({
6567
</div>
6668
)}
6769

68-
<div className="flex flex-col gap-4 w-full">
70+
<div
71+
className={cn('flex flex-col gap-4 w-full', {
72+
'min-h-96': message.role === 'assistant' && requiresScrollPadding,
73+
})}
74+
>
6975
{message.experimental_attachments &&
7076
message.experimental_attachments.length > 0 && (
7177
<div
@@ -236,6 +242,8 @@ export const PreviewMessage = memo(
236242
(prevProps, nextProps) => {
237243
if (prevProps.isLoading !== nextProps.isLoading) return false;
238244
if (prevProps.message.id !== nextProps.message.id) return false;
245+
if (prevProps.requiresScrollPadding !== nextProps.requiresScrollPadding)
246+
return false;
239247
if (!equal(prevProps.message.parts, nextProps.message.parts)) return false;
240248
if (!equal(prevProps.vote, nextProps.vote)) return false;
241249

@@ -249,7 +257,7 @@ export const ThinkingMessage = () => {
249257
return (
250258
<motion.div
251259
data-testid="message-assistant-loading"
252-
className="w-full mx-auto max-w-3xl px-4 group/message "
260+
className="w-full mx-auto max-w-3xl px-4 group/message min-h-96"
253261
initial={{ y: 5, opacity: 0 }}
254262
animate={{ y: 0, opacity: 1, transition: { delay: 1 } }}
255263
data-role={role}

components/messages.tsx

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import type { UIMessage } from 'ai';
22
import { PreviewMessage, ThinkingMessage } from './message';
3-
import { useScrollToBottom } from './use-scroll-to-bottom';
43
import { Greeting } from './greeting';
54
import { memo } from 'react';
65
import type { Vote } from '@/lib/db/schema';
76
import equal from 'fast-deep-equal';
87
import type { UseChatHelpers } from '@ai-sdk/react';
8+
import { motion } from 'framer-motion';
9+
import { useMessages } from '@/hooks/use-messages';
910

1011
interface MessagesProps {
1112
chatId: string;
@@ -27,13 +28,21 @@ function PureMessages({
2728
reload,
2829
isReadonly,
2930
}: MessagesProps) {
30-
const [messagesContainerRef, messagesEndRef] =
31-
useScrollToBottom<HTMLDivElement>();
31+
const {
32+
containerRef: messagesContainerRef,
33+
endRef: messagesEndRef,
34+
onViewportEnter,
35+
onViewportLeave,
36+
hasSentMessage,
37+
} = useMessages({
38+
chatId,
39+
status,
40+
});
3241

3342
return (
3443
<div
3544
ref={messagesContainerRef}
36-
className="flex flex-col min-w-0 gap-6 flex-1 overflow-y-scroll pt-4"
45+
className="flex flex-col min-w-0 gap-6 flex-1 overflow-y-scroll pt-4 relative"
3746
>
3847
{messages.length === 0 && <Greeting />}
3948

@@ -51,16 +60,21 @@ function PureMessages({
5160
setMessages={setMessages}
5261
reload={reload}
5362
isReadonly={isReadonly}
63+
requiresScrollPadding={
64+
hasSentMessage && index === messages.length - 1
65+
}
5466
/>
5567
))}
5668

5769
{status === 'submitted' &&
5870
messages.length > 0 &&
5971
messages[messages.length - 1].role === 'user' && <ThinkingMessage />}
6072

61-
<div
73+
<motion.div
6274
ref={messagesEndRef}
6375
className="shrink-0 min-w-[24px] min-h-[24px]"
76+
onViewportLeave={onViewportLeave}
77+
onViewportEnter={onViewportEnter}
6478
/>
6579
</div>
6680
);

components/multimodal-input.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ import { Textarea } from './ui/textarea';
2323
import { SuggestedActions } from './suggested-actions';
2424
import equal from 'fast-deep-equal';
2525
import type { UseChatHelpers } from '@ai-sdk/react';
26+
import { AnimatePresence, motion } from 'framer-motion';
27+
import { ArrowDown } from 'lucide-react';
28+
import { useScrollToBottom } from '@/hooks/use-scroll-to-bottom';
2629

2730
function PureMultimodalInput({
2831
chatId,
@@ -179,8 +182,41 @@ function PureMultimodalInput({
179182
[setAttachments],
180183
);
181184

185+
const { isAtBottom, scrollToBottom } = useScrollToBottom();
186+
187+
useEffect(() => {
188+
if (status === 'submitted') {
189+
scrollToBottom();
190+
}
191+
}, [status, scrollToBottom]);
192+
182193
return (
183194
<div className="relative w-full flex flex-col gap-4">
195+
<AnimatePresence>
196+
{!isAtBottom && (
197+
<motion.div
198+
initial={{ opacity: 0, y: 10 }}
199+
animate={{ opacity: 1, y: 0 }}
200+
exit={{ opacity: 0, y: 10 }}
201+
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
202+
className="absolute left-1/2 bottom-28 -translate-x-1/2 z-50"
203+
>
204+
<Button
205+
data-testid="scroll-to-bottom-button"
206+
className="rounded-full"
207+
size="icon"
208+
variant="outline"
209+
onClick={(event) => {
210+
event.preventDefault();
211+
scrollToBottom();
212+
}}
213+
>
214+
<ArrowDown />
215+
</Button>
216+
</motion.div>
217+
)}
218+
</AnimatePresence>
219+
184220
{messages.length === 0 &&
185221
attachments.length === 0 &&
186222
uploadQueue.length === 0 && (

components/use-scroll-to-bottom.ts

Lines changed: 0 additions & 31 deletions
This file was deleted.

hooks/use-messages.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { useState, useEffect } from 'react';
2+
import { useScrollToBottom } from './use-scroll-to-bottom';
3+
import type { UseChatHelpers } from '@ai-sdk/react';
4+
5+
export function useMessages({
6+
chatId,
7+
status,
8+
}: {
9+
chatId: string;
10+
status: UseChatHelpers['status'];
11+
}) {
12+
const {
13+
containerRef,
14+
endRef,
15+
isAtBottom,
16+
scrollToBottom,
17+
onViewportEnter,
18+
onViewportLeave,
19+
} = useScrollToBottom();
20+
21+
const [hasSentMessage, setHasSentMessage] = useState(false);
22+
23+
useEffect(() => {
24+
if (chatId) {
25+
scrollToBottom('instant');
26+
setHasSentMessage(false);
27+
}
28+
}, [chatId, scrollToBottom]);
29+
30+
useEffect(() => {
31+
if (status === 'submitted') {
32+
setHasSentMessage(true);
33+
}
34+
}, [status]);
35+
36+
return {
37+
containerRef,
38+
endRef,
39+
isAtBottom,
40+
scrollToBottom,
41+
onViewportEnter,
42+
onViewportLeave,
43+
hasSentMessage,
44+
};
45+
}

hooks/use-scroll-to-bottom.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import useSWR from 'swr';
2+
import { useRef, useEffect, useCallback } from 'react';
3+
4+
type ScrollFlag = ScrollBehavior | false;
5+
6+
export function useScrollToBottom() {
7+
const containerRef = useRef<HTMLDivElement>(null);
8+
const endRef = useRef<HTMLDivElement>(null);
9+
10+
const { data: isAtBottom = false, mutate: setIsAtBottom } = useSWR(
11+
'messages:is-at-bottom',
12+
null,
13+
{ fallbackData: false },
14+
);
15+
16+
const { data: scrollBehavior = false, mutate: setScrollBehavior } =
17+
useSWR<ScrollFlag>('messages:should-scroll', null, { fallbackData: false });
18+
19+
useEffect(() => {
20+
if (scrollBehavior) {
21+
endRef.current?.scrollIntoView({ behavior: scrollBehavior });
22+
setScrollBehavior(false);
23+
}
24+
}, [setScrollBehavior, scrollBehavior]);
25+
26+
const scrollToBottom = useCallback(
27+
(scrollBehavior: ScrollBehavior = 'smooth') => {
28+
setScrollBehavior(scrollBehavior);
29+
},
30+
[setScrollBehavior],
31+
);
32+
33+
function onViewportEnter() {
34+
setIsAtBottom(true);
35+
}
36+
37+
function onViewportLeave() {
38+
setIsAtBottom(false);
39+
}
40+
41+
return {
42+
containerRef,
43+
endRef,
44+
isAtBottom,
45+
scrollToBottom,
46+
onViewportEnter,
47+
onViewportLeave,
48+
};
49+
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ai-chatbot",
3-
"version": "3.0.14",
3+
"version": "3.0.15",
44
"private": true,
55
"scripts": {
66
"dev": "next dev --turbo",

0 commit comments

Comments
 (0)