# Conflicts: # web-app/src/containers/DropdownModelProvider.tsx # web-app/src/containers/SettingsMenu.tsx # web-app/src/containers/dialogs/DeleteModel.tsx # web-app/src/routes/__root.tsx # web-app/src/routes/settings/general.tsx # web-app/src/routes/threads/$threadId.tsx
282 lines
9.7 KiB
TypeScript
282 lines
9.7 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { createFileRoute, useParams } from '@tanstack/react-router'
|
|
import { UIEventHandler } from 'react'
|
|
import debounce from 'lodash.debounce'
|
|
import { cn } from '@/lib/utils'
|
|
import { ArrowDown } 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 { fetchMessages } from '@/services/messages'
|
|
import { useAppState } from '@/hooks/useAppState'
|
|
import DropdownAssistant from '@/containers/DropdownAssistant'
|
|
import { useAssistant } from '@/hooks/useAssistant'
|
|
import { useAppearance } from '@/hooks/useAppearance'
|
|
import { useTranslation } from '@/i18n/react-i18next-compat'
|
|
|
|
// as route.threadsDetail
|
|
export const Route = createFileRoute('/threads/$threadId')({
|
|
component: ThreadDetail,
|
|
})
|
|
|
|
function ThreadDetail() {
|
|
const { t } = useTranslation()
|
|
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 { currentThreadId, setCurrentThreadId } = useThreads()
|
|
const { setCurrentAssistant, assistants } = useAssistant()
|
|
const { setMessages } = useMessages()
|
|
const { streamingContent } = useAppState()
|
|
const { appMainViewBgColor, chatWidth } = useAppearance()
|
|
|
|
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<HTMLDivElement>(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(() => {
|
|
fetchMessages(threadId).then((fetchedMessages) => {
|
|
if (fetchedMessages) {
|
|
// Update the messages in the store
|
|
setMessages(threadId, fetchedMessages)
|
|
}
|
|
})
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [threadId])
|
|
|
|
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)
|
|
checkScrollState()
|
|
return
|
|
}
|
|
}, [])
|
|
|
|
// Reset scroll state when thread changes
|
|
useEffect(() => {
|
|
isFirstRender.current = true
|
|
scrollToBottom()
|
|
setIsAtBottom(true)
|
|
setIsUserScrolling(false)
|
|
checkScrollState()
|
|
}, [threadId])
|
|
|
|
// Single useEffect for all auto-scrolling logic
|
|
useEffect(() => {
|
|
// 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<HTMLDivElement> = (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)
|
|
}
|
|
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)
|
|
}
|
|
setIsAtBottom(isBottom)
|
|
setHasScrollbar(hasScroll)
|
|
lastScrollTopRef.current = scrollTop
|
|
}
|
|
|
|
// 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
|
|
}, [])
|
|
|
|
const threadModel = useMemo(() => thread?.model, [thread])
|
|
|
|
if (!messages || !threadModel) return null
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
<HeaderPage>
|
|
<div className="flex items-center justify-between w-full pr-2">
|
|
<DropdownAssistant />
|
|
</div>
|
|
</HeaderPage>
|
|
<div className="flex flex-col h-[calc(100%-40px)] ">
|
|
<div
|
|
ref={scrollContainerRef}
|
|
onScroll={handleScroll}
|
|
className={cn(
|
|
'flex flex-col h-full w-full overflow-auto px-4 pt-4 pb-3'
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
'w-4/6 mx-auto flex max-w-full flex-col grow',
|
|
chatWidth === 'compact' ? 'w-4/6' : 'w-full'
|
|
)}
|
|
>
|
|
{messages &&
|
|
messages.map((item, index) => {
|
|
// Only pass isLastMessage to the last message in the array
|
|
const isLastMessage = index === messages.length - 1
|
|
return (
|
|
<div
|
|
key={item.id}
|
|
data-test-id={`message-${item.id}`}
|
|
data-message-author-role={item.role}
|
|
className="mb-4"
|
|
>
|
|
<ThreadContent
|
|
{...item}
|
|
isLastMessage={isLastMessage}
|
|
showAssistant={
|
|
item.role === 'assistant' &&
|
|
(index === 0 ||
|
|
messages[index - 1]?.role !== 'assistant' ||
|
|
!(
|
|
messages[index - 1]?.metadata &&
|
|
'tool_calls' in (messages[index - 1].metadata ?? {})
|
|
))
|
|
}
|
|
index={index}
|
|
/>
|
|
</div>
|
|
)
|
|
})}
|
|
<StreamingContent threadId={threadId} />
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
' mx-auto pt-2 pb-3 shrink-0 relative',
|
|
chatWidth === 'compact' ? 'w-4/6' : 'w-full px-3'
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
'absolute z-0 -top-6 h-8 py-1 flex w-full justify-center pointer-events-none opacity-0 visibility-hidden',
|
|
appMainViewBgColor.a === 1
|
|
? 'from-main-view/20 bg-gradient-to-b to-main-view backdrop-blur'
|
|
: 'bg-transparent',
|
|
!isAtBottom && hasScrollbar && 'visibility-visible opacity-100'
|
|
)}
|
|
>
|
|
<div
|
|
className="bg-main-view-fg/10 px-4 border border-main-view-fg/5 flex items-center justify-center rounded-xl gap-x-2 cursor-pointer pointer-events-auto"
|
|
onClick={() => {
|
|
scrollToBottom(true)
|
|
setIsUserScrolling(false)
|
|
}}
|
|
>
|
|
<p className="text-xs">{t('scrollToBottom')}</p>
|
|
<ArrowDown size={12} />
|
|
</div>
|
|
</div>
|
|
<ChatInput model={threadModel} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|