diff --git a/apps/masterbots.ai/components/routes/chat/chat-list.tsx b/apps/masterbots.ai/components/routes/chat/chat-list.tsx index 482c0493d..8413b7cc1 100644 --- a/apps/masterbots.ai/components/routes/chat/chat-list.tsx +++ b/apps/masterbots.ai/components/routes/chat/chat-list.tsx @@ -1,11 +1,14 @@ +'use client' + +import React, { useRef } 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' +import { useScroll } from '@/lib/hooks/use-scroll' export interface ChatList { messages?: Message[] @@ -16,6 +19,8 @@ export interface ChatList { chatContentClass?: string chatTitleClass?: string chatArrowClass?: string + containerRef?: React.RefObject + isNearBottom?: boolean } type MessagePair = { @@ -31,7 +36,9 @@ export function ChatList({ isThread = true, chatContentClass, chatTitleClass, - chatArrowClass + chatArrowClass, + containerRef, + isNearBottom }: ChatList) { const [pairs, setPairs] = React.useState([]) const { @@ -40,15 +47,26 @@ export function ChatList({ allMessages, sendMessageFromResponse } = useThread() + const localContainerRef = useRef(null) + + const effectiveContainerRef = containerRef || localContainerRef + + useScroll({ + containerRef: effectiveContainerRef, + threadRef: effectiveContainerRef, + isNewContent: isNewResponse, + hasMore: false, + isLast: true, + loading: isLoadingMessages, + loadMore: () => {} + }) React.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) @@ -66,6 +84,7 @@ export function ChatList({ return (
{pairs.map((pair: MessagePair, key: number) => ( @@ -76,8 +95,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 || ''}`} > @@ -121,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 b883b15f3..da3697926 100644 --- a/apps/masterbots.ai/components/routes/thread/thread-component.tsx +++ b/apps/masterbots.ai/components/routes/thread/thread-component.tsx @@ -2,12 +2,12 @@ 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 from 'react' +import React, { useRef } from 'react' import { useThread } from '@/lib/hooks/use-thread' +import { useScroll } from '@/lib/hooks/use-scroll' export default function ThreadComponent({ thread, @@ -22,33 +22,21 @@ export default function ThreadComponent({ isLast: boolean hasMore: boolean }) { - const threadRef = React.useRef(null) - const { allMessages } = useThread() - React.useEffect(() => { - if (!threadRef.current) return - const observer = new IntersectionObserver(([entry]) => { - if (hasMore && isLast && entry.isIntersecting && !loading) { - const timeout = setTimeout(() => { - console.log('loading more content') - loadMore() - clearTimeout(timeout) - }, 150) + const threadRef = useRef(null) + const contentRef = useRef(null) + const { allMessages, isNewResponse } = useThread() - observer.unobserve(entry.target) - } - }) + const { isNearBottom, scrollToTop } = useScroll({ + containerRef: contentRef, + threadRef, + isNewContent: isNewResponse, + hasMore, + isLast, + loading, + loadMore + }) - 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 (
  • @@ -56,19 +44,16 @@ 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] - [&[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} > {/* Thread Title */} -
    - {thread.messages .filter(m => m.role === 'user')[0] ?.content.substring(0, 100) || 'wat'} @@ -90,12 +75,19 @@ export default function ThreadComponent({
    {/* Thread Content */} - +
    + +
  • ) 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..8ac975291 --- /dev/null +++ b/apps/masterbots.ai/lib/hooks/use-scroll.tsx @@ -0,0 +1,103 @@ +import { useCallback, useEffect, useState, RefObject } from 'react' + +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 +}: UseScrollOptions) { + 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]) + + 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( + ([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]) + + 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