Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions apps/masterbots.ai/components/routes/chat/chat-list.tsx
Original file line number Diff line number Diff line change
@@ -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[]
Expand All @@ -16,6 +19,8 @@ export interface ChatList {
chatContentClass?: string
chatTitleClass?: string
chatArrowClass?: string
containerRef?: React.RefObject<HTMLDivElement>
isNearBottom?: boolean
}

type MessagePair = {
Expand All @@ -31,7 +36,9 @@ export function ChatList({
isThread = true,
chatContentClass,
chatTitleClass,
chatArrowClass
chatArrowClass,
containerRef,
isNearBottom
}: ChatList) {
const [pairs, setPairs] = React.useState<MessagePair[]>([])
const {
Expand All @@ -40,15 +47,26 @@ export function ChatList({
allMessages,
sendMessageFromResponse
} = useThread()
const localContainerRef = useRef<HTMLDivElement>(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)
Expand All @@ -66,6 +84,7 @@ export function ChatList({

return (
<div
ref={effectiveContainerRef}
className={`relative max-w-3xl px-4 mx-auto ${className || ''} ${isThread ? 'flex flex-col gap-3' : ''}`}
>
{pairs.map((pair: MessagePair, key: number) => (
Expand All @@ -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 || ''}`}
>
Expand Down Expand Up @@ -121,7 +140,7 @@ export function ChatList({
{/* TODO: place a better loader */}
{isLoadingMessages ? (
<div className="flex items-center justify-center w-full h-12">
<div className="transition-all w-6 h-6 border-2 border-t-[2px] rounded-full border-x-gray-300 animate-spin"></div>
<div className="transition-all w-6 h-6 border-2 border-t-[2px] rounded-full border-x-gray-300 animate-spin" />
</div>
) : (
''
Expand Down
70 changes: 31 additions & 39 deletions apps/masterbots.ai/components/routes/thread/thread-component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,53 +22,38 @@ export default function ThreadComponent({
isLast: boolean
hasMore: boolean
}) {
const threadRef = React.useRef<HTMLLIElement>(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<HTMLLIElement>(null)
const contentRef = useRef<HTMLDivElement>(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 (
<li ref={threadRef}>
<ChatAccordion
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 */}

<div className="px-[11px] flex items-center w-full gap-3">
<ChatbotAvatar thread={thread} />

{thread.messages
.filter(m => m.role === 'user')[0]
?.content.substring(0, 100) || 'wat'}
Expand All @@ -90,12 +75,19 @@ export default function ThreadComponent({
</div>

{/* Thread Content */}
<ChatList
className="max-w-full !px-0"
isThread={false}
chatbot={thread.chatbot}
messages={allMessages}
/>
<div
ref={contentRef}
className="overflow-y-auto max-h-[calc(70vh-100px)]"
>
<ChatList
className="max-w-full !px-0"
isThread={false}
chatbot={thread.chatbot}
messages={allMessages}
containerRef={contentRef}
isNearBottom={isNearBottom}
/>
</div>
</ChatAccordion>
</li>
)
Expand Down
103 changes: 103 additions & 0 deletions apps/masterbots.ai/lib/hooks/use-scroll.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { useCallback, useEffect, useState, RefObject } from 'react'

interface UseScrollOptions {
containerRef: RefObject<HTMLElement>
threadRef: RefObject<HTMLElement>
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 }
}