@@ -3,7 +3,7 @@ import "./tailwind.css";
3
3
4
4
import clsx from "clsx" ;
5
5
import type React from "react" ;
6
- import { useEffect , useRef , useState } from "react" ;
6
+ import { useCallback , useEffect , useRef , useState } from "react" ;
7
7
import Markdown from "react-markdown" ;
8
8
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter" ;
9
9
import {
@@ -240,6 +240,11 @@ export interface BubbleListProps extends React.ComponentProps<"div"> {
240
240
} ;
241
241
footer ?: React . ReactNode ;
242
242
pending ?: React . ReactNode ;
243
+ /**
244
+ * The height threshold for triggering scroll behavior.
245
+ * @default 8
246
+ */
247
+ threshold ?: number ;
243
248
}
244
249
245
250
export function BubbleList ( {
@@ -254,32 +259,97 @@ export function BubbleList({
254
259
align : "left" ,
255
260
} ,
256
261
isPending = true ,
262
+ messages,
263
+ threshold = 8 ,
257
264
...props
258
265
} : BubbleListProps ) {
259
- const { messages } = props ;
260
- const lastMessageRef = useRef < HTMLDivElement > ( null ) ;
266
+ const containerRef = useRef < HTMLDivElement > ( null ) ;
267
+ const contentRef = useRef < HTMLDivElement > ( null ) ;
268
+
269
+ const pauseScroll = useRef < boolean > ( false ) ;
270
+ const contentRect = useRef < DOMRect > ( new DOMRect ( ) ) ;
271
+
272
+ const scrollContainer = useCallback ( ( smooth ?: boolean ) => {
273
+ if ( pauseScroll . current ) return ;
274
+
275
+ containerRef . current ?. scrollTo ( {
276
+ top : containerRef . current ?. scrollHeight ,
277
+ behavior : smooth === false ? "instant" : "smooth" ,
278
+ } ) ;
279
+ } , [ ] ) ;
261
280
262
- // biome-ignore lint/correctness/useExhaustiveDependencies: This effect runs only when messages change
263
281
useEffect ( ( ) => {
264
- if ( lastMessageRef . current ) {
265
- lastMessageRef . current . scrollIntoView ( {
266
- behavior : "smooth" ,
267
- block : "end" ,
268
- } ) ;
282
+ if ( ! containerRef . current || ! contentRef . current ) return ;
283
+
284
+ const observer = new ResizeObserver ( ( entries ) => {
285
+ for ( const entry of entries ) {
286
+ const { height, width } = entry . contentRect ;
287
+ if (
288
+ Math . abs ( contentRect . current . height - height ) > threshold ||
289
+ Math . abs ( contentRect . current . width - width ) > threshold
290
+ ) {
291
+ contentRect . current = entry . contentRect ;
292
+ scrollContainer ( ) ;
293
+ }
294
+ }
295
+ } ) ;
296
+
297
+ observer . observe ( containerRef . current ) ;
298
+ observer . observe ( contentRef . current ) ;
299
+
300
+ return ( ) => observer . disconnect ( ) ;
301
+ } , [ scrollContainer , threshold ] ) ;
302
+
303
+ const isScrollAtBottom = useCallback ( ( ) => {
304
+ const container = containerRef . current ;
305
+ if ( ! container ) return true ;
306
+
307
+ return (
308
+ Math . abs (
309
+ container . scrollTop + container . clientHeight - container . scrollHeight ,
310
+ ) < threshold
311
+ ) ;
312
+ } , [ threshold ] ) ;
313
+
314
+ const handleWheel = useCallback ( ( ) => {
315
+ const container = containerRef . current ;
316
+ if ( ! container ) return ;
317
+
318
+ if ( isScrollAtBottom ( ) ) {
319
+ pauseScroll . current = false ;
320
+ } else {
321
+ pauseScroll . current = true ;
269
322
}
270
- } , [ messages , isPending ] ) ;
323
+ } , [ isScrollAtBottom ] ) ;
271
324
272
325
return (
273
326
< div
274
327
data-slot = "bubble-list"
275
328
className = { twMerge (
276
329
clsx ( "flex flex-col overflow-y-auto flex-1 gap-4" , className ) ,
277
330
) }
331
+ ref = { containerRef }
332
+ onWheel = { handleWheel }
333
+ onTouchStart = { ( ) => {
334
+ pauseScroll . current = true ;
335
+ } }
336
+ onTouchEnd = { ( ) => {
337
+ if ( isScrollAtBottom ( ) ) {
338
+ pauseScroll . current = false ;
339
+ scrollContainer ( false ) ;
340
+ } else {
341
+ pauseScroll . current = true ;
342
+ }
343
+ } }
344
+ onTouchMove = { ( ) => {
345
+ pauseScroll . current = true ;
346
+ } }
278
347
{ ...props }
279
348
>
280
349
< div
281
350
data-slot = "bubble-items"
282
351
className = "flex flex-col max-w-full flex-1 gap-4"
352
+ ref = { contentRef }
283
353
>
284
354
{ messages . map ( ( message , index ) => (
285
355
< div
@@ -310,7 +380,6 @@ export function BubbleList({
310
380
? "solid"
311
381
: "transparent"
312
382
}
313
- ref = { index === messages . length - 1 ? lastMessageRef : undefined }
314
383
/>
315
384
</ div >
316
385
) ) }
0 commit comments