/* eslint-disable @typescript-eslint/no-explicit-any */ import { useEffect, useMemo, useRef, useState } from 'react' import { createFileRoute, useParams } from '@tanstack/react-router' import { UIEventHandler } from 'react' import debounce from 'lodash.debounce' import cloneDeep from 'lodash.clonedeep' import { cn } from '@/lib/utils' import { ArrowDown, Play } from 'lucide-react' import HeaderPage from '@/containers/HeaderPage' import { useThreads } from '@/hooks/useThreads' import ChatInput from '@/containers/ChatInput' import { useShallow } from 'zustand/react/shallow' import { ThreadContent } from '@/containers/ThreadContent' import { StreamingContent } from '@/containers/StreamingContent' import { useMessages } from '@/hooks/useMessages' import { useServiceHub } from '@/hooks/useServiceHub' import { useAppState } from '@/hooks/useAppState' import DropdownAssistant from '@/containers/DropdownAssistant' import { useAssistant } from '@/hooks/useAssistant' import { useAppearance } from '@/hooks/useAppearance' import { ContentType, ThreadMessage } from '@janhq/core' import { useTranslation } from '@/i18n/react-i18next-compat' import { useChat } from '@/hooks/useChat' import { useSmallScreen } from '@/hooks/useMediaQuery' import { useTools } from '@/hooks/useTools' // as route.threadsDetail export const Route = createFileRoute('/threads/$threadId')({ component: ThreadDetail, }) function ThreadDetail() { const { t } = useTranslation() const serviceHub = useServiceHub() const { threadId } = useParams({ from: Route.id }) const [isUserScrolling, setIsUserScrolling] = useState(false) const [isAtBottom, setIsAtBottom] = useState(true) const [hasScrollbar, setHasScrollbar] = useState(false) const lastScrollTopRef = useRef(0) const userIntendedPositionRef = useRef(null) const wasStreamingRef = useRef(false) const { currentThreadId, setCurrentThreadId } = useThreads() const { setCurrentAssistant, assistants } = useAssistant() const { setMessages, deleteMessage } = useMessages() const { streamingContent } = useAppState() const { appMainViewBgColor, chatWidth } = useAppearance() const { sendMessage } = useChat() const isSmallScreen = useSmallScreen() useTools() const { messages } = useMessages( useShallow((state) => ({ messages: state.messages[threadId], })) ) // Subscribe directly to the thread data to ensure updates when model changes const thread = useThreads(useShallow((state) => state.threads[threadId])) const scrollContainerRef = useRef(null) const isFirstRender = useRef(true) const messagesCount = useMemo(() => messages?.length ?? 0, [messages]) // Function to check scroll position and scrollbar presence const checkScrollState = () => { const scrollContainer = scrollContainerRef.current if (!scrollContainer) return const { scrollTop, scrollHeight, clientHeight } = scrollContainer const isBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10 const hasScroll = scrollHeight > clientHeight setIsAtBottom(isBottom) setHasScrollbar(hasScroll) } useEffect(() => { if (currentThreadId !== threadId) { setCurrentThreadId(threadId) const assistant = assistants.find( (assistant) => assistant.id === thread?.assistants?.[0]?.id ) if (assistant) setCurrentAssistant(assistant) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [threadId, currentThreadId, assistants]) useEffect(() => { serviceHub .messages() .fetchMessages(threadId) .then((fetchedMessages) => { if (fetchedMessages) { // Update the messages in the store setMessages(threadId, fetchedMessages) } }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [threadId, serviceHub]) useEffect(() => { return () => { // Clear the current thread ID when the component unmounts setCurrentThreadId(undefined) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // Auto-scroll to bottom when component mounts or thread content changes useEffect(() => { const scrollContainer = scrollContainerRef.current if (!scrollContainer) return // Always scroll to bottom on first render or when thread changes if (isFirstRender.current) { isFirstRender.current = false scrollToBottom() setIsAtBottom(true) setIsUserScrolling(false) userIntendedPositionRef.current = null wasStreamingRef.current = false checkScrollState() return } }, []) // Reset scroll state when thread changes useEffect(() => { isFirstRender.current = true scrollToBottom() setIsAtBottom(true) setIsUserScrolling(false) userIntendedPositionRef.current = null wasStreamingRef.current = false checkScrollState() }, [threadId]) // Single useEffect for all auto-scrolling logic useEffect(() => { // Track streaming state changes const isCurrentlyStreaming = !!streamingContent const justFinishedStreaming = wasStreamingRef.current && !isCurrentlyStreaming wasStreamingRef.current = isCurrentlyStreaming // If streaming just finished and user had an intended position, restore it if (justFinishedStreaming && userIntendedPositionRef.current !== null) { // Small delay to ensure DOM has updated setTimeout(() => { if ( scrollContainerRef.current && userIntendedPositionRef.current !== null ) { scrollContainerRef.current.scrollTo({ top: userIntendedPositionRef.current, behavior: 'smooth', }) userIntendedPositionRef.current = null setIsUserScrolling(false) } }, 100) return } // Clear intended position when streaming starts fresh if (isCurrentlyStreaming && !wasStreamingRef.current) { userIntendedPositionRef.current = null } // Only auto-scroll when the user is not actively scrolling // AND either at the bottom OR there's streaming content if (!isUserScrolling && (streamingContent || isAtBottom) && messagesCount) { // Use non-smooth scrolling for auto-scroll to prevent jank scrollToBottom(false) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [streamingContent, isUserScrolling, messagesCount]) useEffect(() => { if (streamingContent) { const interval = setInterval(checkScrollState, 100) return () => clearInterval(interval) } }, [streamingContent]) const scrollToBottom = (smooth = false) => { if (scrollContainerRef.current) { scrollContainerRef.current.scrollTo({ top: scrollContainerRef.current.scrollHeight, ...(smooth ? { behavior: 'smooth' } : {}), }) } } const handleScroll: UIEventHandler = (e) => { const target = e.target as HTMLDivElement const { scrollTop, scrollHeight, clientHeight } = target // Use a small tolerance to better detect when we're at the bottom const isBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10 const hasScroll = scrollHeight > clientHeight // Detect if this is a user-initiated scroll if (Math.abs(scrollTop - lastScrollTopRef.current) > 10) { setIsUserScrolling(!isBottom) // If user scrolls during streaming and moves away from bottom, record their intended position if (streamingContent && !isBottom) { userIntendedPositionRef.current = scrollTop } } setIsAtBottom(isBottom) setHasScrollbar(hasScroll) lastScrollTopRef.current = scrollTop } // Separate handler for DOM events const handleDOMScroll = (e: Event) => { const target = e.target as HTMLDivElement const { scrollTop, scrollHeight, clientHeight } = target // Use a small tolerance to better detect when we're at the bottom const isBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10 const hasScroll = scrollHeight > clientHeight // Detect if this is a user-initiated scroll if (Math.abs(scrollTop - lastScrollTopRef.current) > 10) { setIsUserScrolling(!isBottom) // If user scrolls during streaming and moves away from bottom, record their intended position if (streamingContent && !isBottom) { userIntendedPositionRef.current = scrollTop } } setIsAtBottom(isBottom) setHasScrollbar(hasScroll) lastScrollTopRef.current = scrollTop } const updateMessage = ( item: ThreadMessage, message: string, imageUrls?: string[] ) => { const newMessages: ThreadMessage[] = messages.map((m) => { if (m.id === item.id) { const msg: ThreadMessage = cloneDeep(m) const newContent = [ { type: ContentType.Text, text: { value: message, annotations: m.content[0].text?.annotations ?? [], }, }, ] // Add image content if imageUrls are provided if (imageUrls && imageUrls.length > 0) { imageUrls.forEach((url) => { newContent.push({ type: 'image_url' as ContentType, image_url: { url: url, }, } as any) }) } msg.content = newContent return msg } return m }) setMessages(threadId, newMessages) } // Use a shorter debounce time for more responsive scrolling const debouncedScroll = debounce(handleDOMScroll) useEffect(() => { const chatHistoryElement = scrollContainerRef.current if (chatHistoryElement) { chatHistoryElement.addEventListener('scroll', debouncedScroll) return () => chatHistoryElement.removeEventListener('scroll', debouncedScroll) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // used when there is a sent/added user message and no assistant message (error or manual deletion) const generateAIResponse = () => { const latestUserMessage = messages[messages.length - 1] if ( latestUserMessage?.content?.[0]?.text?.value && latestUserMessage.role === 'user' ) { sendMessage(latestUserMessage.content[0].text.value, false) } else if (latestUserMessage?.metadata?.tool_calls) { // Only regenerate assistant message is allowed const threadMessages = [...messages] let toSendMessage = threadMessages.pop() while (toSendMessage && toSendMessage?.role !== 'user') { deleteMessage(toSendMessage.thread_id, toSendMessage.id ?? '') toSendMessage = threadMessages.pop() } if (toSendMessage) { deleteMessage(toSendMessage.thread_id, toSendMessage.id ?? '') sendMessage(toSendMessage.content?.[0]?.text?.value || '') } } } const threadModel = useMemo(() => thread?.model, [thread]) if (!messages || !threadModel) return null const showScrollToBottomBtn = !isAtBottom && hasScrollbar const showGenerateAIResponseBtn = (messages[messages.length - 1]?.role === 'user' || (messages[messages.length - 1]?.metadata && 'tool_calls' in (messages[messages.length - 1].metadata ?? {}))) && !streamingContent return (
{messages && messages.map((item, index) => { // Only pass isLastMessage to the last message in the array const isLastMessage = index === messages.length - 1 return (
) })}
{showScrollToBottomBtn && (
{ scrollToBottom(true) setIsUserScrolling(false) }} >

{t('scrollToBottom')}

)} {showGenerateAIResponseBtn && (

{t('common:generateAiResponse')}

)}
) }