From cc3563d332c156211dd05b6c3f0eccc498de0aae Mon Sep 17 00:00:00 2001 From: Bran18 Date: Wed, 11 Sep 2024 22:49:15 -0600 Subject: [PATCH 1/3] fix:introducing Two-phase scroll --- .../components/routes/chat/chat-list.tsx | 38 ++++++- .../routes/thread/thread-component.tsx | 102 ++++++++++++++---- 2 files changed, 113 insertions(+), 27 deletions(-) diff --git a/apps/masterbots.ai/components/routes/chat/chat-list.tsx b/apps/masterbots.ai/components/routes/chat/chat-list.tsx index 482c0493d..f62fdd8a7 100644 --- a/apps/masterbots.ai/components/routes/chat/chat-list.tsx +++ b/apps/masterbots.ai/components/routes/chat/chat-list.tsx @@ -1,8 +1,10 @@ +'use client' + +import React, { useEffect, useRef, useCallback } from 'react' import { type Message } from 'ai' import { useThread } from '@/lib/hooks/use-thread' import { cn, createMessagePairs } from '@/lib/utils' import { Chatbot } from 'mb-genql' -import React from 'react' import { ShortMessage } from '@/components/shared/short-message' import { ChatAccordion } from '@/components/routes/chat/chat-accordion' import { ChatMessage } from '@/components/routes/chat/chat-message' @@ -16,6 +18,8 @@ export interface ChatList { chatContentClass?: string chatTitleClass?: string chatArrowClass?: string + containerRef?: React.RefObject + isNearBottom?: boolean } type MessagePair = { @@ -31,7 +35,9 @@ export function ChatList({ isThread = true, chatContentClass, chatTitleClass, - chatArrowClass + chatArrowClass, + containerRef, + isNearBottom }: ChatList) { const [pairs, setPairs] = React.useState([]) const { @@ -40,15 +46,30 @@ export function ChatList({ allMessages, sendMessageFromResponse } = useThread() + const localContainerRef = useRef(null) + + const effectiveContainerRef = containerRef || localContainerRef + + const smoothScrollToBottom = useCallback(() => { + if (effectiveContainerRef.current) { + const scrollHeight = effectiveContainerRef.current.scrollHeight + const height = effectiveContainerRef.current.clientHeight + const maxScrollTop = scrollHeight - height + + // ? Two-phase scroll + effectiveContainerRef.current.scrollTop = maxScrollTop - 1 // ? First scroll to near bottom + requestAnimationFrame(() => { + effectiveContainerRef.current!.scrollTop = maxScrollTop // ? Then scroll to actual bottom + }) + } + }, [effectiveContainerRef]) - React.useEffect(() => { + useEffect(() => { const messageList = messages.length > 0 ? messages : allMessages - // *Prevent unnecessary updates: only set pairs if the new message list is different if (messageList.length) { const prePairs: MessagePair[] = createMessagePairs( messageList ) as MessagePair[] - // * Compare the current pairs with the new ones to avoid unnecessary updates setPairs(prevPairs => { const prevString = JSON.stringify(prevPairs) const newString = JSON.stringify(prePairs) @@ -62,10 +83,17 @@ export function ChatList({ } }, [messages, allMessages]) + useEffect(() => { + if (isNewResponse && isNearBottom) { + smoothScrollToBottom() + } + }, [isNewResponse, isNearBottom, smoothScrollToBottom]) + if (messages.length === 0 && allMessages.length === 0) return null return (
{pairs.map((pair: MessagePair, key: number) => ( diff --git a/apps/masterbots.ai/components/routes/thread/thread-component.tsx b/apps/masterbots.ai/components/routes/thread/thread-component.tsx index b883b15f3..11edd0061 100644 --- a/apps/masterbots.ai/components/routes/thread/thread-component.tsx +++ b/apps/masterbots.ai/components/routes/thread/thread-component.tsx @@ -6,7 +6,7 @@ import { sleep } from '@/lib/utils' import { Thread } from 'mb-genql' import { ShortMessage } from '@/components/shared/short-message' import { ChatbotAvatar } from '@/components/shared/chatbot-avatar' -import React from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import { useThread } from '@/lib/hooks/use-thread' export default function ThreadComponent({ @@ -22,21 +22,73 @@ export default function ThreadComponent({ isLast: boolean hasMore: boolean }) { - const threadRef = React.useRef(null) - const { allMessages } = useThread() - React.useEffect(() => { + const threadRef = useRef(null) + const contentRef = useRef(null) + const { allMessages, isNewResponse } = useThread() + const [isNearBottom, setIsNearBottom] = useState(false) + + //* scroll to the bottom of the thread + const scrollToBottom = useCallback(() => { + if (contentRef.current) { + const scrollHeight = contentRef.current.scrollHeight + const height = contentRef.current.clientHeight + const maxScrollTop = scrollHeight - height + + // ? Two-phase scroll + contentRef.current.scrollTop = maxScrollTop - 1 // ? First scroll to near bottom + requestAnimationFrame(() => { + contentRef.current!.scrollTop = maxScrollTop // ? Then scroll to actual bottom + }) + } + }, []) + + //* detect if the thread is near the bottom + useEffect(() => { + if (contentRef.current) { + const observer = new IntersectionObserver( + ([entry]) => { + setIsNearBottom(entry.isIntersecting) + }, + { + root: contentRef.current, + threshold: 0.1, + rootMargin: '0px 0px 100px 0px' + } + ) + + const dummy = document.createElement('div') + dummy.style.height = '1px' + contentRef.current.appendChild(dummy) + observer.observe(dummy) + + return () => { + observer.disconnect() + dummy.remove() + } + } + }, []) + + useEffect(() => { + if (isNewResponse && isNearBottom) { + scrollToBottom() + } + }, [isNewResponse, isNearBottom, scrollToBottom]) + + //* load more content when the thread is at the bottom + useEffect(() => { if (!threadRef.current) return - const observer = new IntersectionObserver(([entry]) => { - if (hasMore && isLast && entry.isIntersecting && !loading) { - const timeout = setTimeout(() => { - console.log('loading more content') + const observer = new IntersectionObserver( + ([entry]) => { + if (hasMore && isLast && entry.isIntersecting && !loading) { loadMore() - clearTimeout(timeout) - }, 150) - - observer.unobserve(entry.target) + } + }, + { + root: null, + rootMargin: '100px', + threshold: 0.1 } - }) + ) observer.observe(threadRef.current) @@ -44,6 +96,8 @@ export default function ThreadComponent({ observer.disconnect() } }, [threadRef, isLast, hasMore, loading, loadMore]) + + //* scroll to the top of the thread const scrollToTop = async () => { await sleep(300) // animation time if (!threadRef.current) return @@ -56,7 +110,6 @@ export default function ThreadComponent({ onToggle={scrollToTop} className="relative" contentClass="!pt-0 !border-b-[3px] max-h-[70vh] scrollbar !border-l-[3px]" - // handleTrigger={goToThread} triggerClass="gap-[0.375rem] py-3 dark:border-b-mirage border-b-iron sticky top-0 z-[1] dark:hover:bg-mirage hover:bg-gray-300 sticky top-0 z-[1] dark:bg-[#18181b] bg-[#f4f4f5] @@ -65,10 +118,8 @@ export default function ThreadComponent({ thread={thread} > {/* Thread Title */} -
- {thread.messages .filter(m => m.role === 'user')[0] ?.content.substring(0, 100) || 'wat'} @@ -90,12 +141,19 @@ export default function ThreadComponent({
{/* Thread Content */} - +
+ +
) From 5ef9f76a35f197f23b867a22ed3799e81bd2c17c Mon Sep 17 00:00:00 2001 From: Bran18 Date: Thu, 12 Sep 2024 10:20:25 -0600 Subject: [PATCH 2/3] impr: new hook to handle scrolling --- .../components/routes/chat/chat-list.tsx | 32 +++------- .../routes/thread/thread-component.tsx | 62 +++--------------- apps/masterbots.ai/lib/hooks/use-scroll.tsx | 64 +++++++++++++++++++ 3 files changed, 82 insertions(+), 76 deletions(-) create mode 100644 apps/masterbots.ai/lib/hooks/use-scroll.tsx diff --git a/apps/masterbots.ai/components/routes/chat/chat-list.tsx b/apps/masterbots.ai/components/routes/chat/chat-list.tsx index f62fdd8a7..49f8443ee 100644 --- a/apps/masterbots.ai/components/routes/chat/chat-list.tsx +++ b/apps/masterbots.ai/components/routes/chat/chat-list.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { useEffect, useRef, useCallback } from 'react' +import React, { useRef } from 'react' import { type Message } from 'ai' import { useThread } from '@/lib/hooks/use-thread' import { cn, createMessagePairs } from '@/lib/utils' @@ -8,6 +8,7 @@ import { Chatbot } from 'mb-genql' import { ShortMessage } from '@/components/shared/short-message' import { ChatAccordion } from '@/components/routes/chat/chat-accordion' import { ChatMessage } from '@/components/routes/chat/chat-message' +import { useScroll } from '@/lib/hooks/use-scroll' export interface ChatList { messages?: Message[] @@ -50,21 +51,12 @@ export function ChatList({ const effectiveContainerRef = containerRef || localContainerRef - const smoothScrollToBottom = useCallback(() => { - if (effectiveContainerRef.current) { - const scrollHeight = effectiveContainerRef.current.scrollHeight - const height = effectiveContainerRef.current.clientHeight - const maxScrollTop = scrollHeight - height + useScroll({ + containerRef: effectiveContainerRef, + isNewContent: isNewResponse + }) - // ? Two-phase scroll - effectiveContainerRef.current.scrollTop = maxScrollTop - 1 // ? First scroll to near bottom - requestAnimationFrame(() => { - effectiveContainerRef.current!.scrollTop = maxScrollTop // ? Then scroll to actual bottom - }) - } - }, [effectiveContainerRef]) - - useEffect(() => { + React.useEffect(() => { const messageList = messages.length > 0 ? messages : allMessages if (messageList.length) { const prePairs: MessagePair[] = createMessagePairs( @@ -83,12 +75,6 @@ export function ChatList({ } }, [messages, allMessages]) - useEffect(() => { - if (isNewResponse && isNearBottom) { - smoothScrollToBottom() - } - }, [isNewResponse, isNearBottom, smoothScrollToBottom]) - if (messages.length === 0 && allMessages.length === 0) return null return ( @@ -104,8 +90,8 @@ export function ChatList({ } className={` ${isThread ? 'relative' : ''}`} triggerClass={`dark:border-b-mirage border-b-gray-300 - ${isThread ? 'sticky top-0 md:-top-10 z-[1] dark:bg-[#18181b] bg-[#f4f4f5] !border-l-[transparent] px-3 [&[data-state=open]]:!bg-gray-300 dark:[&[data-state=open]]:!bg-mirage [&[data-state=open]]:rounded-t-[8px]' : 'px-[calc(47px-0.25rem)] '} - py-[0.4375rem] dark:hover:bg-mirage hover:bg-gray-300 ${!isThread && key === 0 ? 'hidden' : ''} ${chatTitleClass || ''}`} + ${isThread ? 'sticky top-0 md:-top-10 z-[1] dark:bg-[#18181b] bg-[#f4f4f5] !border-l-[transparent] px-3 [&[data-state=open]]:!bg-gray-300 dark:[&[data-state=open]]:!bg-mirage [&[data-state=open]]:rounded-t-[8px]' : 'px-[calc(47px-0.25rem)] '} + py-[0.4375rem] dark:hover:bg-mirage hover:bg-gray-300 ${!isThread && key === 0 ? 'hidden' : ''} ${chatTitleClass || ''}`} contentClass="!border-l-[transparent]" arrowClass={`${isThread ? 'top-4' : 'right-5 top-4'} ${chatArrowClass || ''}`} > diff --git a/apps/masterbots.ai/components/routes/thread/thread-component.tsx b/apps/masterbots.ai/components/routes/thread/thread-component.tsx index 11edd0061..1dcaeb22d 100644 --- a/apps/masterbots.ai/components/routes/thread/thread-component.tsx +++ b/apps/masterbots.ai/components/routes/thread/thread-component.tsx @@ -6,8 +6,9 @@ import { sleep } from '@/lib/utils' import { Thread } from 'mb-genql' import { ShortMessage } from '@/components/shared/short-message' import { ChatbotAvatar } from '@/components/shared/chatbot-avatar' -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { useEffect, useRef } from 'react' import { useThread } from '@/lib/hooks/use-thread' +import { useScroll } from '@/lib/hooks/use-scroll' export default function ThreadComponent({ thread, @@ -25,56 +26,12 @@ export default function ThreadComponent({ const threadRef = useRef(null) const contentRef = useRef(null) const { allMessages, isNewResponse } = useThread() - const [isNearBottom, setIsNearBottom] = useState(false) - //* scroll to the bottom of the thread - const scrollToBottom = useCallback(() => { - if (contentRef.current) { - const scrollHeight = contentRef.current.scrollHeight - const height = contentRef.current.clientHeight - const maxScrollTop = scrollHeight - height + const { isNearBottom } = useScroll({ + containerRef: contentRef, + isNewContent: isNewResponse + }) - // ? Two-phase scroll - contentRef.current.scrollTop = maxScrollTop - 1 // ? First scroll to near bottom - requestAnimationFrame(() => { - contentRef.current!.scrollTop = maxScrollTop // ? Then scroll to actual bottom - }) - } - }, []) - - //* detect if the thread is near the bottom - useEffect(() => { - if (contentRef.current) { - const observer = new IntersectionObserver( - ([entry]) => { - setIsNearBottom(entry.isIntersecting) - }, - { - root: contentRef.current, - threshold: 0.1, - rootMargin: '0px 0px 100px 0px' - } - ) - - const dummy = document.createElement('div') - dummy.style.height = '1px' - contentRef.current.appendChild(dummy) - observer.observe(dummy) - - return () => { - observer.disconnect() - dummy.remove() - } - } - }, []) - - useEffect(() => { - if (isNewResponse && isNearBottom) { - scrollToBottom() - } - }, [isNewResponse, isNearBottom, scrollToBottom]) - - //* load more content when the thread is at the bottom useEffect(() => { if (!threadRef.current) return const observer = new IntersectionObserver( @@ -97,7 +54,6 @@ export default function ThreadComponent({ } }, [threadRef, isLast, hasMore, loading, loadMore]) - //* scroll to the top of the thread const scrollToTop = async () => { await sleep(300) // animation time if (!threadRef.current) return @@ -111,9 +67,9 @@ export default function ThreadComponent({ className="relative" contentClass="!pt-0 !border-b-[3px] max-h-[70vh] scrollbar !border-l-[3px]" triggerClass="gap-[0.375rem] py-3 - dark:border-b-mirage border-b-iron - sticky top-0 z-[1] dark:hover:bg-mirage hover:bg-gray-300 sticky top-0 z-[1] dark:bg-[#18181b] bg-[#f4f4f5] - [&[data-state=open]]:!bg-gray-300 dark:[&[data-state=open]]:!bg-mirage [&[data-state=open]]:rounded-t-[8px]" + dark:border-b-mirage border-b-iron + sticky top-0 z-[1] dark:hover:bg-mirage hover:bg-gray-300 sticky top-0 z-[1] dark:bg-[#18181b] bg-[#f4f4f5] + [&[data-state=open]]:!bg-gray-300 dark:[&[data-state=open]]:!bg-mirage [&[data-state=open]]:rounded-t-[8px]" arrowClass="-right-1 top-[1.125rem]" thread={thread} > diff --git a/apps/masterbots.ai/lib/hooks/use-scroll.tsx b/apps/masterbots.ai/lib/hooks/use-scroll.tsx new file mode 100644 index 000000000..907b57368 --- /dev/null +++ b/apps/masterbots.ai/lib/hooks/use-scroll.tsx @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useState, RefObject } from 'react' + +interface UseSmoothScrollOptions { + containerRef: RefObject + isNewContent: boolean + rootMargin?: string + threshold?: number +} + +export function useScroll({ + containerRef, + isNewContent, + rootMargin = '0px 0px 100px 0px', + threshold = 0.1 +}: UseSmoothScrollOptions) { + const [isNearBottom, setIsNearBottom] = useState(false) + + const smoothScrollToBottom = useCallback(() => { + if (containerRef.current) { + const scrollHeight = containerRef.current.scrollHeight + const height = containerRef.current.clientHeight + const maxScrollTop = scrollHeight - height + + //? Two-phase scroll + containerRef.current.scrollTop = maxScrollTop - 1 // ? First scroll to near bottom + requestAnimationFrame(() => { + containerRef.current!.scrollTop = maxScrollTop // ? Then scroll to actual bottom + }) + } + }, [containerRef]) + + useEffect(() => { + if (containerRef.current) { + const observer = new IntersectionObserver( + ([entry]) => { + setIsNearBottom(entry.isIntersecting) + }, + { + root: containerRef.current, + threshold, + rootMargin + } + ) + + const dummy = document.createElement('div') + dummy.style.height = '1px' + containerRef.current.appendChild(dummy) + observer.observe(dummy) + + return () => { + observer.disconnect() + dummy.remove() + } + } + }, [containerRef, rootMargin, threshold]) + + useEffect(() => { + if (isNewContent && isNearBottom) { + smoothScrollToBottom() + } + }, [isNewContent, isNearBottom, smoothScrollToBottom]) + + return { isNearBottom, smoothScrollToBottom } +} From 113168c9e7887700c6da5dfa4452e57f36999c99 Mon Sep 17 00:00:00 2001 From: Bran18 Date: Thu, 12 Sep 2024 10:47:48 -0600 Subject: [PATCH 3/3] impr: useScroll + respo --- .../components/routes/chat/chat-list.tsx | 9 +++- .../routes/thread/thread-component.tsx | 40 ++++----------- apps/masterbots.ai/lib/hooks/use-scroll.tsx | 49 +++++++++++++++++-- 3 files changed, 60 insertions(+), 38 deletions(-) diff --git a/apps/masterbots.ai/components/routes/chat/chat-list.tsx b/apps/masterbots.ai/components/routes/chat/chat-list.tsx index 49f8443ee..8413b7cc1 100644 --- a/apps/masterbots.ai/components/routes/chat/chat-list.tsx +++ b/apps/masterbots.ai/components/routes/chat/chat-list.tsx @@ -53,7 +53,12 @@ export function ChatList({ useScroll({ containerRef: effectiveContainerRef, - isNewContent: isNewResponse + threadRef: effectiveContainerRef, + isNewContent: isNewResponse, + hasMore: false, + isLast: true, + loading: isLoadingMessages, + loadMore: () => {} }) React.useEffect(() => { @@ -135,7 +140,7 @@ export function ChatList({ {/* TODO: place a better loader */} {isLoadingMessages ? (
-
+
) : ( '' diff --git a/apps/masterbots.ai/components/routes/thread/thread-component.tsx b/apps/masterbots.ai/components/routes/thread/thread-component.tsx index 1dcaeb22d..da3697926 100644 --- a/apps/masterbots.ai/components/routes/thread/thread-component.tsx +++ b/apps/masterbots.ai/components/routes/thread/thread-component.tsx @@ -2,11 +2,10 @@ import { ChatAccordion } from '@/components/routes/chat/chat-accordion' import { ChatList } from '@/components/routes/chat/chat-list' -import { sleep } from '@/lib/utils' import { Thread } from 'mb-genql' import { ShortMessage } from '@/components/shared/short-message' import { ChatbotAvatar } from '@/components/shared/chatbot-avatar' -import React, { useEffect, useRef } from 'react' +import React, { useRef } from 'react' import { useThread } from '@/lib/hooks/use-thread' import { useScroll } from '@/lib/hooks/use-scroll' @@ -27,38 +26,17 @@ export default function ThreadComponent({ const contentRef = useRef(null) const { allMessages, isNewResponse } = useThread() - const { isNearBottom } = useScroll({ + const { isNearBottom, scrollToTop } = useScroll({ containerRef: contentRef, - isNewContent: isNewResponse + threadRef, + isNewContent: isNewResponse, + hasMore, + isLast, + loading, + loadMore }) - useEffect(() => { - if (!threadRef.current) return - const observer = new IntersectionObserver( - ([entry]) => { - if (hasMore && isLast && entry.isIntersecting && !loading) { - loadMore() - } - }, - { - root: null, - rootMargin: '100px', - threshold: 0.1 - } - ) - - observer.observe(threadRef.current) - - return () => { - observer.disconnect() - } - }, [threadRef, isLast, hasMore, loading, loadMore]) - - const scrollToTop = async () => { - await sleep(300) // animation time - if (!threadRef.current) return - threadRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }) - } + console.log('isNearBottom 🖐️', isNearBottom) return (
  • diff --git a/apps/masterbots.ai/lib/hooks/use-scroll.tsx b/apps/masterbots.ai/lib/hooks/use-scroll.tsx index 907b57368..8ac975291 100644 --- a/apps/masterbots.ai/lib/hooks/use-scroll.tsx +++ b/apps/masterbots.ai/lib/hooks/use-scroll.tsx @@ -1,18 +1,28 @@ import { useCallback, useEffect, useState, RefObject } from 'react' -interface UseSmoothScrollOptions { +interface UseScrollOptions { containerRef: RefObject + threadRef: RefObject isNewContent: boolean + hasMore: boolean + isLast: boolean + loading: boolean + loadMore: () => void rootMargin?: string threshold?: number } export function useScroll({ containerRef, + threadRef, isNewContent, + hasMore, + isLast, + loading, + loadMore, rootMargin = '0px 0px 100px 0px', threshold = 0.1 -}: UseSmoothScrollOptions) { +}: UseScrollOptions) { const [isNearBottom, setIsNearBottom] = useState(false) const smoothScrollToBottom = useCallback(() => { @@ -21,7 +31,7 @@ export function useScroll({ const height = containerRef.current.clientHeight const maxScrollTop = scrollHeight - height - //? Two-phase scroll + // ? Two-phase scroll containerRef.current.scrollTop = maxScrollTop - 1 // ? First scroll to near bottom requestAnimationFrame(() => { containerRef.current!.scrollTop = maxScrollTop // ? Then scroll to actual bottom @@ -29,6 +39,13 @@ export function useScroll({ } }, [containerRef]) + const scrollToTop = useCallback(async () => { + await new Promise(resolve => setTimeout(resolve, 300)) // animation time + if (threadRef.current) { + threadRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + }, [threadRef]) + useEffect(() => { if (containerRef.current) { const observer = new IntersectionObserver( @@ -60,5 +77,27 @@ export function useScroll({ } }, [isNewContent, isNearBottom, smoothScrollToBottom]) - return { isNearBottom, smoothScrollToBottom } -} + useEffect(() => { + if (!threadRef.current) return + const observer = new IntersectionObserver( + ([entry]) => { + if (hasMore && isLast && entry.isIntersecting && !loading) { + loadMore() + } + }, + { + root: null, + rootMargin: '100px', + threshold: 0.1 + } + ) + + observer.observe(threadRef.current) + + return () => { + observer.disconnect() + } + }, [threadRef, isLast, hasMore, loading, loadMore]) + + return { isNearBottom, smoothScrollToBottom, scrollToTop } +} \ No newline at end of file