codacus commited on
Commit
a0ea69f
·
unverified ·
1 Parent(s): 2fe1f1d

fix: auto scroll fix, scroll allow user to scroll up during ai response (#1299)

Browse files
app/components/chat/BaseChat.tsx CHANGED
@@ -303,7 +303,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
303
  data-chat-visible={showChat}
304
  >
305
  <ClientOnly>{() => <Menu />}</ClientOnly>
306
- <div ref={scrollRef} className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
307
  <div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
308
  {!chatStarted && (
309
  <div id="intro" className="mt-[16vh] max-w-chat mx-auto text-center px-4 lg:px-0">
@@ -317,39 +317,40 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
317
  )}
318
  <div
319
  className={classNames('pt-6 px-2 sm:px-6', {
320
- 'h-full flex flex-col': chatStarted,
321
  })}
322
  ref={scrollRef}
323
  >
324
  <ClientOnly>
325
  {() => {
326
  return chatStarted ? (
327
- <Messages
328
- ref={messageRef}
329
- className="flex flex-col w-full flex-1 max-w-chat pb-6 mx-auto z-1"
330
- messages={messages}
331
- isStreaming={isStreaming}
332
- />
 
 
333
  ) : null;
334
  }}
335
  </ClientOnly>
336
  <div
337
- className={classNames('flex flex-col gap-4 w-full max-w-chat mx-auto z-prompt mb-6', {
338
  'sticky bottom-2': chatStarted,
 
339
  })}
340
  >
341
- <div className="bg-bolt-elements-background-depth-2">
342
- {actionAlert && (
343
- <ChatAlert
344
- alert={actionAlert}
345
- clearAlert={() => clearAlert?.()}
346
- postMessage={(message) => {
347
- sendMessage?.({} as any, message);
348
- clearAlert?.();
349
- }}
350
- />
351
- )}
352
- </div>
353
  {progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
354
  <div
355
  className={classNames(
@@ -583,17 +584,16 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
583
  </div>
584
  </div>
585
  </div>
586
- <div className="flex flex-col justify-center gap-5">
587
- {!chatStarted && (
588
  <div className="flex justify-center gap-2">
589
  <div className="flex items-center gap-2">
590
  {ImportButtons(importChat)}
591
  <GitCloneButton importChat={importChat} className="min-w-[120px]" />
592
  </div>
593
  </div>
594
- )}
595
- {!chatStarted &&
596
- ExamplePrompts((event, messageInput) => {
597
  if (isStreaming) {
598
  handleStop?.();
599
  return;
@@ -601,8 +601,9 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
601
 
602
  handleSendMessage?.(event, messageInput);
603
  })}
604
- {!chatStarted && <StarterTemplates />}
605
- </div>
 
606
  </div>
607
  <ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
608
  </div>
 
303
  data-chat-visible={showChat}
304
  >
305
  <ClientOnly>{() => <Menu />}</ClientOnly>
306
+ <div className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
307
  <div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
308
  {!chatStarted && (
309
  <div id="intro" className="mt-[16vh] max-w-chat mx-auto text-center px-4 lg:px-0">
 
317
  )}
318
  <div
319
  className={classNames('pt-6 px-2 sm:px-6', {
320
+ 'h-full flex flex-col pb-4 overflow-y-auto': chatStarted,
321
  })}
322
  ref={scrollRef}
323
  >
324
  <ClientOnly>
325
  {() => {
326
  return chatStarted ? (
327
+ <div className="flex-1 w-full max-w-chat pb-6 mx-auto z-1">
328
+ <Messages
329
+ ref={messageRef}
330
+ className="flex flex-col "
331
+ messages={messages}
332
+ isStreaming={isStreaming}
333
+ />
334
+ </div>
335
  ) : null;
336
  }}
337
  </ClientOnly>
338
  <div
339
+ className={classNames('flex flex-col gap-4 w-full max-w-chat mx-auto z-prompt', {
340
  'sticky bottom-2': chatStarted,
341
+ 'position-absolute': chatStarted,
342
  })}
343
  >
344
+ {actionAlert && (
345
+ <ChatAlert
346
+ alert={actionAlert}
347
+ clearAlert={() => clearAlert?.()}
348
+ postMessage={(message) => {
349
+ sendMessage?.({} as any, message);
350
+ clearAlert?.();
351
+ }}
352
+ />
353
+ )}
 
 
354
  {progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
355
  <div
356
  className={classNames(
 
584
  </div>
585
  </div>
586
  </div>
587
+ {!chatStarted && (
588
+ <div className="flex flex-col justify-center mt-6 gap-5">
589
  <div className="flex justify-center gap-2">
590
  <div className="flex items-center gap-2">
591
  {ImportButtons(importChat)}
592
  <GitCloneButton importChat={importChat} className="min-w-[120px]" />
593
  </div>
594
  </div>
595
+
596
+ {ExamplePrompts((event, messageInput) => {
 
597
  if (isStreaming) {
598
  handleStop?.();
599
  return;
 
601
 
602
  handleSendMessage?.(event, messageInput);
603
  })}
604
+ <StarterTemplates />
605
+ </div>
606
+ )}
607
  </div>
608
  <ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
609
  </div>
app/components/chat/Messages.client.tsx CHANGED
@@ -1,5 +1,5 @@
1
  import type { Message } from 'ai';
2
- import React, { Fragment, useEffect, useRef, useState } from 'react';
3
  import { classNames } from '~/utils/classNames';
4
  import { AssistantMessage } from './AssistantMessage';
5
  import { UserMessage } from './UserMessage';
@@ -10,6 +10,8 @@ import { toast } from 'react-toastify';
10
  import WithTooltip from '~/components/ui/Tooltip';
11
  import { useStore } from '@nanostores/react';
12
  import { profileStore } from '~/lib/stores/profile';
 
 
13
 
14
  interface MessagesProps {
15
  id?: string;
@@ -18,213 +20,113 @@ interface MessagesProps {
18
  messages?: Message[];
19
  }
20
 
21
- export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props: MessagesProps, ref) => {
22
- const { id, isStreaming = false, messages = [] } = props;
23
- const location = useLocation();
24
- const messagesEndRef = useRef<HTMLDivElement>(null);
25
- const containerRef = useRef<HTMLDivElement>(null);
26
- const [isUserInteracting, setIsUserInteracting] = useState(false);
27
- const [lastScrollTop, setLastScrollTop] = useState(0);
28
- const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
29
- const profile = useStore(profileStore);
30
 
31
- // Check if we should auto-scroll based on scroll position
32
- const checkShouldAutoScroll = () => {
33
- if (!containerRef.current) {
34
- return true;
35
- }
36
-
37
- const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
38
- const distanceFromBottom = scrollHeight - (scrollTop + clientHeight);
39
-
40
- return distanceFromBottom < 100;
41
- };
42
-
43
- const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => {
44
- if (!shouldAutoScroll || isUserInteracting) {
45
- return;
46
- }
47
-
48
- messagesEndRef.current?.scrollIntoView({ behavior });
49
- };
50
-
51
- // Handle user interaction and scroll position
52
- useEffect(() => {
53
- const container = containerRef.current;
54
-
55
- if (!container) {
56
- return undefined;
57
- }
58
-
59
- const handleInteractionStart = () => {
60
- setIsUserInteracting(true);
61
- };
62
-
63
- const handleInteractionEnd = () => {
64
- if (checkShouldAutoScroll()) {
65
- setTimeout(() => setIsUserInteracting(false), 100);
66
- }
67
  };
68
 
69
- const handleScroll = () => {
70
- const { scrollTop } = container;
71
- const shouldScroll = checkShouldAutoScroll();
72
-
73
- // Update auto-scroll state based on scroll position
74
- setShouldAutoScroll(shouldScroll);
75
 
76
- // If scrolling up, disable auto-scroll
77
- if (scrollTop < lastScrollTop) {
78
- setIsUserInteracting(true);
 
79
  }
80
-
81
- setLastScrollTop(scrollTop);
82
  };
83
 
84
- container.addEventListener('mousedown', handleInteractionStart);
85
- container.addEventListener('mouseup', handleInteractionEnd);
86
- container.addEventListener('touchstart', handleInteractionStart);
87
- container.addEventListener('touchend', handleInteractionEnd);
88
- container.addEventListener('scroll', handleScroll, { passive: true });
89
-
90
- return () => {
91
- container.removeEventListener('mousedown', handleInteractionStart);
92
- container.removeEventListener('mouseup', handleInteractionEnd);
93
- container.removeEventListener('touchstart', handleInteractionStart);
94
- container.removeEventListener('touchend', handleInteractionEnd);
95
- container.removeEventListener('scroll', handleScroll);
96
- };
97
- }, [lastScrollTop]);
98
-
99
- // Scroll to bottom when new messages are added or during streaming
100
- useEffect(() => {
101
- if (messages.length > 0 && (isStreaming || shouldAutoScroll)) {
102
- scrollToBottom('smooth');
103
- }
104
- }, [messages, isStreaming, shouldAutoScroll]);
105
-
106
- // Initial scroll on component mount
107
- useEffect(() => {
108
- if (messages.length > 0) {
109
- scrollToBottom('instant');
110
- setShouldAutoScroll(true);
111
- }
112
- }, []);
113
-
114
- const handleRewind = (messageId: string) => {
115
- const searchParams = new URLSearchParams(location.search);
116
- searchParams.set('rewindTo', messageId);
117
- window.location.search = searchParams.toString();
118
- };
119
-
120
- const handleFork = async (messageId: string) => {
121
- try {
122
- if (!db || !chatId.get()) {
123
- toast.error('Chat persistence is not available');
124
- return;
125
- }
126
-
127
- const urlId = await forkChat(db, chatId.get()!, messageId);
128
- window.location.href = `/chat/${urlId}`;
129
- } catch (error) {
130
- toast.error('Failed to fork chat: ' + (error as Error).message);
131
- }
132
- };
133
-
134
- return (
135
- <div
136
- id={id}
137
- ref={(el) => {
138
- // Combine refs
139
- if (typeof ref === 'function') {
140
- ref(el);
141
- }
142
-
143
- (containerRef as any).current = el;
144
-
145
- return undefined;
146
- }}
147
- className={props.className}
148
- >
149
- {messages.length > 0
150
- ? messages.map((message, index) => {
151
- const { role, content, id: messageId, annotations } = message;
152
- const isUserMessage = role === 'user';
153
- const isFirst = index === 0;
154
- const isLast = index === messages.length - 1;
155
- const isHidden = annotations?.includes('hidden');
156
-
157
- if (isHidden) {
158
- return <Fragment key={index} />;
159
- }
160
-
161
- return (
162
- <div
163
- key={index}
164
- className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', {
165
- 'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast),
166
- 'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent':
167
- isStreaming && isLast,
168
- 'mt-4': !isFirst,
169
- })}
170
- >
171
- {isUserMessage && (
172
- <div className="flex items-center justify-center w-[40px] h-[40px] overflow-hidden bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-500 rounded-full shrink-0 self-start">
173
- {profile?.avatar ? (
174
- <img
175
- src={profile.avatar}
176
- alt={profile?.username || 'User'}
177
- className="w-full h-full object-cover"
178
- loading="eager"
179
- decoding="sync"
180
- />
181
  ) : (
182
- <div className="i-ph:user-fill text-2xl" />
183
  )}
184
  </div>
185
- )}
186
- <div className="grid grid-col-1 w-full">
187
- {isUserMessage ? (
188
- <UserMessage content={content} />
189
- ) : (
190
- <AssistantMessage content={content} annotations={message.annotations} />
191
- )}
192
- </div>
193
- {!isUserMessage && (
194
- <div className="flex gap-2 flex-col lg:flex-row">
195
- {messageId && (
196
- <WithTooltip tooltip="Revert to this message">
 
 
 
 
197
  <button
198
- onClick={() => handleRewind(messageId)}
199
- key="i-ph:arrow-u-up-left"
200
  className={classNames(
201
- 'i-ph:arrow-u-up-left',
202
  'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
203
  )}
204
  />
205
  </WithTooltip>
206
- )}
207
-
208
- <WithTooltip tooltip="Fork chat from this message">
209
- <button
210
- onClick={() => handleFork(messageId)}
211
- key="i-ph:git-fork"
212
- className={classNames(
213
- 'i-ph:git-fork',
214
- 'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
215
- )}
216
- />
217
- </WithTooltip>
218
- </div>
219
- )}
220
- </div>
221
- );
222
- })
223
- : null}
224
- <div ref={messagesEndRef} /> {/* Add an empty div as scroll anchor */}
225
- {isStreaming && (
226
- <div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
227
- )}
228
- </div>
229
- );
230
- });
 
1
  import type { Message } from 'ai';
2
+ import { Fragment } from 'react';
3
  import { classNames } from '~/utils/classNames';
4
  import { AssistantMessage } from './AssistantMessage';
5
  import { UserMessage } from './UserMessage';
 
10
  import WithTooltip from '~/components/ui/Tooltip';
11
  import { useStore } from '@nanostores/react';
12
  import { profileStore } from '~/lib/stores/profile';
13
+ import { forwardRef } from 'react';
14
+ import type { ForwardedRef } from 'react';
15
 
16
  interface MessagesProps {
17
  id?: string;
 
20
  messages?: Message[];
21
  }
22
 
23
+ export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
24
+ (props: MessagesProps, ref: ForwardedRef<HTMLDivElement> | undefined) => {
25
+ const { id, isStreaming = false, messages = [] } = props;
26
+ const location = useLocation();
27
+ const profile = useStore(profileStore);
 
 
 
 
28
 
29
+ const handleRewind = (messageId: string) => {
30
+ const searchParams = new URLSearchParams(location.search);
31
+ searchParams.set('rewindTo', messageId);
32
+ window.location.search = searchParams.toString();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  };
34
 
35
+ const handleFork = async (messageId: string) => {
36
+ try {
37
+ if (!db || !chatId.get()) {
38
+ toast.error('Chat persistence is not available');
39
+ return;
40
+ }
41
 
42
+ const urlId = await forkChat(db, chatId.get()!, messageId);
43
+ window.location.href = `/chat/${urlId}`;
44
+ } catch (error) {
45
+ toast.error('Failed to fork chat: ' + (error as Error).message);
46
  }
 
 
47
  };
48
 
49
+ return (
50
+ <div id={id} className={props.className} ref={ref}>
51
+ {messages.length > 0
52
+ ? messages.map((message, index) => {
53
+ const { role, content, id: messageId, annotations } = message;
54
+ const isUserMessage = role === 'user';
55
+ const isFirst = index === 0;
56
+ const isLast = index === messages.length - 1;
57
+ const isHidden = annotations?.includes('hidden');
58
+
59
+ if (isHidden) {
60
+ return <Fragment key={index} />;
61
+ }
62
+
63
+ return (
64
+ <div
65
+ key={index}
66
+ className={classNames('flex gap-4 p-6 w-full rounded-[calc(0.75rem-1px)]', {
67
+ 'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast),
68
+ 'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent':
69
+ isStreaming && isLast,
70
+ 'mt-4': !isFirst,
71
+ })}
72
+ >
73
+ {isUserMessage && (
74
+ <div className="flex items-center justify-center w-[40px] h-[40px] overflow-hidden bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-500 rounded-full shrink-0 self-start">
75
+ {profile?.avatar ? (
76
+ <img
77
+ src={profile.avatar}
78
+ alt={profile?.username || 'User'}
79
+ className="w-full h-full object-cover"
80
+ loading="eager"
81
+ decoding="sync"
82
+ />
83
+ ) : (
84
+ <div className="i-ph:user-fill text-2xl" />
85
+ )}
86
+ </div>
87
+ )}
88
+ <div className="grid grid-col-1 w-full">
89
+ {isUserMessage ? (
90
+ <UserMessage content={content} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  ) : (
92
+ <AssistantMessage content={content} annotations={message.annotations} />
93
  )}
94
  </div>
95
+ {!isUserMessage && (
96
+ <div className="flex gap-2 flex-col lg:flex-row">
97
+ {messageId && (
98
+ <WithTooltip tooltip="Revert to this message">
99
+ <button
100
+ onClick={() => handleRewind(messageId)}
101
+ key="i-ph:arrow-u-up-left"
102
+ className={classNames(
103
+ 'i-ph:arrow-u-up-left',
104
+ 'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
105
+ )}
106
+ />
107
+ </WithTooltip>
108
+ )}
109
+
110
+ <WithTooltip tooltip="Fork chat from this message">
111
  <button
112
+ onClick={() => handleFork(messageId)}
113
+ key="i-ph:git-fork"
114
  className={classNames(
115
+ 'i-ph:git-fork',
116
  'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
117
  )}
118
  />
119
  </WithTooltip>
120
+ </div>
121
+ )}
122
+ </div>
123
+ );
124
+ })
125
+ : null}
126
+ {isStreaming && (
127
+ <div className="text-center w-full text-bolt-elements-textSecondary i-svg-spinners:3-dots-fade text-4xl mt-4"></div>
128
+ )}
129
+ </div>
130
+ );
131
+ },
132
+ );
 
 
 
 
 
 
 
 
 
 
 
 
app/lib/hooks/useSnapScroll.ts CHANGED
@@ -1,52 +1,155 @@
1
  import { useRef, useCallback } from 'react';
2
 
3
- export function useSnapScroll() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  const autoScrollRef = useRef(true);
5
  const scrollNodeRef = useRef<HTMLDivElement>();
6
  const onScrollRef = useRef<() => void>();
7
  const observerRef = useRef<ResizeObserver>();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
- const messageRef = useCallback((node: HTMLDivElement | null) => {
10
- if (node) {
11
- const observer = new ResizeObserver(() => {
12
- if (autoScrollRef.current && scrollNodeRef.current) {
13
- const { scrollHeight, clientHeight } = scrollNodeRef.current;
14
- const scrollTarget = scrollHeight - clientHeight;
15
 
16
- scrollNodeRef.current.scrollTo({
17
- top: scrollTarget,
18
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  }
20
- });
21
-
22
- observer.observe(node);
23
- } else {
24
- observerRef.current?.disconnect();
25
- observerRef.current = undefined;
26
- }
27
- }, []);
28
-
29
- const scrollRef = useCallback((node: HTMLDivElement | null) => {
30
- if (node) {
31
- onScrollRef.current = () => {
32
- const { scrollTop, scrollHeight, clientHeight } = node;
33
- const scrollTarget = scrollHeight - clientHeight;
34
-
35
- autoScrollRef.current = Math.abs(scrollTop - scrollTarget) <= 10;
36
  };
37
 
38
- node.addEventListener('scroll', onScrollRef.current);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
- scrollNodeRef.current = node;
41
- } else {
42
- if (onScrollRef.current) {
43
- scrollNodeRef.current?.removeEventListener('scroll', onScrollRef.current);
 
 
 
 
 
 
44
  }
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
- scrollNodeRef.current = undefined;
47
- onScrollRef.current = undefined;
48
- }
49
- }, []);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
- return [messageRef, scrollRef];
52
  }
 
1
  import { useRef, useCallback } from 'react';
2
 
3
+ interface ScrollOptions {
4
+ duration?: number;
5
+ easing?: 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'cubic-bezier';
6
+ cubicBezier?: [number, number, number, number];
7
+ bottomThreshold?: number;
8
+ }
9
+
10
+ export function useSnapScroll(options: ScrollOptions = {}) {
11
+ const {
12
+ duration = 800,
13
+ easing = 'ease-in-out',
14
+ cubicBezier = [0.42, 0, 0.58, 1],
15
+ bottomThreshold = 50, // pixels from bottom to consider "scrolled to bottom"
16
+ } = options;
17
+
18
  const autoScrollRef = useRef(true);
19
  const scrollNodeRef = useRef<HTMLDivElement>();
20
  const onScrollRef = useRef<() => void>();
21
  const observerRef = useRef<ResizeObserver>();
22
+ const animationFrameRef = useRef<number>();
23
+ const lastScrollTopRef = useRef<number>(0);
24
+
25
+ const smoothScroll = useCallback(
26
+ (element: HTMLDivElement, targetPosition: number, duration: number, easingFunction: string) => {
27
+ const startPosition = element.scrollTop;
28
+ const distance = targetPosition - startPosition;
29
+ const startTime = performance.now();
30
+
31
+ const bezierPoints = easingFunction === 'cubic-bezier' ? cubicBezier : [0.42, 0, 0.58, 1];
32
+
33
+ const cubicBezierFunction = (t: number): number => {
34
+ const [, y1, , y2] = bezierPoints;
35
+
36
+ /*
37
+ * const cx = 3 * x1;
38
+ * const bx = 3 * (x2 - x1) - cx;
39
+ * const ax = 1 - cx - bx;
40
+ */
41
+
42
+ const cy = 3 * y1;
43
+ const by = 3 * (y2 - y1) - cy;
44
+ const ay = 1 - cy - by;
45
 
46
+ // const sampleCurveX = (t: number) => ((ax * t + bx) * t + cx) * t;
47
+ const sampleCurveY = (t: number) => ((ay * t + by) * t + cy) * t;
 
 
 
 
48
 
49
+ return sampleCurveY(t);
50
+ };
51
+
52
+ const animation = (currentTime: number) => {
53
+ const elapsedTime = currentTime - startTime;
54
+ const progress = Math.min(elapsedTime / duration, 1);
55
+
56
+ const easedProgress = cubicBezierFunction(progress);
57
+ const newPosition = startPosition + distance * easedProgress;
58
+
59
+ // Only scroll if auto-scroll is still enabled
60
+ if (autoScrollRef.current) {
61
+ element.scrollTop = newPosition;
62
+ }
63
+
64
+ if (progress < 1 && autoScrollRef.current) {
65
+ animationFrameRef.current = requestAnimationFrame(animation);
66
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  };
68
 
69
+ if (animationFrameRef.current) {
70
+ cancelAnimationFrame(animationFrameRef.current);
71
+ }
72
+
73
+ animationFrameRef.current = requestAnimationFrame(animation);
74
+ },
75
+ [cubicBezier],
76
+ );
77
+
78
+ const isScrolledToBottom = useCallback(
79
+ (element: HTMLDivElement): boolean => {
80
+ const { scrollTop, scrollHeight, clientHeight } = element;
81
+ return scrollHeight - scrollTop - clientHeight <= bottomThreshold;
82
+ },
83
+ [bottomThreshold],
84
+ );
85
+
86
+ const messageRef = useCallback(
87
+ (node: HTMLDivElement | null) => {
88
+ if (node) {
89
+ const observer = new ResizeObserver(() => {
90
+ if (autoScrollRef.current && scrollNodeRef.current) {
91
+ const { scrollHeight, clientHeight } = scrollNodeRef.current;
92
+ const scrollTarget = scrollHeight - clientHeight;
93
+
94
+ smoothScroll(scrollNodeRef.current, scrollTarget, duration, easing);
95
+ }
96
+ });
97
 
98
+ observer.observe(node);
99
+ observerRef.current = observer;
100
+ } else {
101
+ observerRef.current?.disconnect();
102
+ observerRef.current = undefined;
103
+
104
+ if (animationFrameRef.current) {
105
+ cancelAnimationFrame(animationFrameRef.current);
106
+ animationFrameRef.current = undefined;
107
+ }
108
  }
109
+ },
110
+ [duration, easing, smoothScroll],
111
+ );
112
+
113
+ const scrollRef = useCallback(
114
+ (node: HTMLDivElement | null) => {
115
+ if (node) {
116
+ onScrollRef.current = () => {
117
+ const { scrollTop } = node;
118
+
119
+ // Detect scroll direction
120
+ const isScrollingUp = scrollTop < lastScrollTopRef.current;
121
 
122
+ // Update auto-scroll based on scroll direction and position
123
+ if (isScrollingUp) {
124
+ // Disable auto-scroll when scrolling up
125
+ autoScrollRef.current = false;
126
+ } else if (isScrolledToBottom(node)) {
127
+ // Re-enable auto-scroll when manually scrolled to bottom
128
+ autoScrollRef.current = true;
129
+ }
130
+
131
+ // Store current scroll position for next comparison
132
+ lastScrollTopRef.current = scrollTop;
133
+ };
134
+
135
+ node.addEventListener('scroll', onScrollRef.current);
136
+ scrollNodeRef.current = node;
137
+ } else {
138
+ if (onScrollRef.current && scrollNodeRef.current) {
139
+ scrollNodeRef.current.removeEventListener('scroll', onScrollRef.current);
140
+ }
141
+
142
+ if (animationFrameRef.current) {
143
+ cancelAnimationFrame(animationFrameRef.current);
144
+ animationFrameRef.current = undefined;
145
+ }
146
+
147
+ scrollNodeRef.current = undefined;
148
+ onScrollRef.current = undefined;
149
+ }
150
+ },
151
+ [isScrolledToBottom],
152
+ );
153
 
154
+ return [messageRef, scrollRef] as const;
155
  }