fix: thread rerender issue

This commit is contained in:
Louis 2025-09-18 16:11:24 +07:00
parent 0f85fce6ef
commit 241a90492e
10 changed files with 439 additions and 290 deletions

View File

@ -33,6 +33,7 @@ import DropdownModelProvider from '@/containers/DropdownModelProvider'
import { ModelLoader } from '@/containers/loaders/ModelLoader'
import DropdownToolsAvailable from '@/containers/DropdownToolsAvailable'
import { useServiceHub } from '@/hooks/useServiceHub'
import { useTools } from '@/hooks/useTools'
type ChatInputProps = {
className?: string
@ -57,6 +58,7 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => {
const { currentThreadId } = useThreads()
const { t } = useTranslation()
const { spellCheckChatInput } = useGeneralSetting()
useTools()
const maxRows = 10

View File

@ -34,7 +34,7 @@ export default function DropdownToolsAvailable({
initialMessage = false,
onOpenChange,
}: DropdownToolsAvailableProps) {
const { tools } = useAppState()
const tools = useAppState((state) => state.tools)
const [isOpen, setIsOpen] = useState(false)
const { t } = useTranslation()

View File

@ -0,0 +1,46 @@
import { useChat } from '@/hooks/useChat'
import { useMessages } from '@/hooks/useMessages'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { Play } from 'lucide-react'
import { useShallow } from 'zustand/react/shallow'
export const GenerateResponseButton = ({ threadId }: { threadId: string }) => {
const { t } = useTranslation()
const deleteMessage = useMessages((state) => state.deleteMessage)
const { messages } = useMessages(
useShallow((state) => ({
messages: state.messages[threadId],
}))
)
const { sendMessage } = useChat()
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 || '')
}
}
}
return (
<div
className="mx-2 bg-main-view-fg/10 px-2 border border-main-view-fg/5 flex items-center justify-center rounded-xl gap-x-2 cursor-pointer pointer-events-auto"
onClick={generateAIResponse}
>
<p className="text-xs">{t('common:generateAiResponse')}</p>
<Play size={12} />
</div>
)
}

View File

@ -72,7 +72,8 @@ const mainMenus = [
]
const LeftPanel = () => {
const { open, setLeftPanel } = useLeftPanel()
const open = useLeftPanel((state) => state.open)
const setLeftPanel = useLeftPanel((state) => state.setLeftPanel)
const { t } = useTranslation()
const [searchTerm, setSearchTerm] = useState('')
const { isAuthenticated } = useAuth()
@ -119,9 +120,9 @@ const LeftPanel = () => {
prevScreenSizeRef.current !== null &&
prevScreenSizeRef.current !== currentIsSmallScreen
) {
if (currentIsSmallScreen) {
if (currentIsSmallScreen && open) {
setLeftPanel(false)
} else {
} else if(!open) {
setLeftPanel(true)
}
prevScreenSizeRef.current = currentIsSmallScreen
@ -146,8 +147,10 @@ const LeftPanel = () => {
select: (state) => state.location.pathname,
})
const { deleteAllThreads, unstarAllThreads, getFilteredThreads, threads } =
useThreads()
const deleteAllThreads = useThreads((state) => state.deleteAllThreads)
const unstarAllThreads = useThreads((state) => state.unstarAllThreads)
const getFilteredThreads = useThreads((state) => state.getFilteredThreads)
const threads = useThreads((state) => state.threads)
const filteredThreads = useMemo(() => {
return getFilteredThreads(searchTerm)

View File

@ -0,0 +1,67 @@
import { useThreadScrolling } from '@/hooks/useThreadScrolling'
import { memo } from 'react'
import { GenerateResponseButton } from './GenerateResponseButton'
import { useMessages } from '@/hooks/useMessages'
import { useShallow } from 'zustand/react/shallow'
import { useAppearance } from '@/hooks/useAppearance'
import { cn } from '@/lib/utils'
import { ArrowDown } from 'lucide-react'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { useAppState } from '@/hooks/useAppState'
const ScrollToBottom = ({
threadId,
scrollContainerRef,
}: {
threadId: string
scrollContainerRef: React.RefObject<HTMLDivElement | null>
}) => {
const { t } = useTranslation()
const appMainViewBgColor = useAppearance((state) => state.appMainViewBgColor)
const { showScrollToBottomBtn, scrollToBottom, setIsUserScrolling } =
useThreadScrolling(threadId, scrollContainerRef)
const { messages } = useMessages(
useShallow((state) => ({
messages: state.messages[threadId],
}))
)
const streamingContent = useAppState((state) => state.streamingContent)
const showGenerateAIResponseBtn =
(messages[messages.length - 1]?.role === 'user' ||
(messages[messages.length - 1]?.metadata &&
'tool_calls' in (messages[messages.length - 1].metadata ?? {}))) &&
!streamingContent
return (
<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',
(showScrollToBottomBtn || showGenerateAIResponseBtn) &&
'visibility-visible opacity-100'
)}
>
{showScrollToBottomBtn && (
<div
className="bg-main-view-fg/10 px-2 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>
)}
{showGenerateAIResponseBtn && (
<GenerateResponseButton threadId={threadId} />
)}
</div>
)
}
export default memo(ScrollToBottom)

View File

@ -33,35 +33,44 @@ import {
} from '@/utils/reasoning'
export const useChat = () => {
const { prompt, setPrompt } = usePrompt()
const {
tools,
updateTokenSpeed,
resetTokenSpeed,
updateStreamingContent,
updateLoadingModel,
setAbortController,
} = useAppState()
const { assistants, currentAssistant } = useAssistant()
const { updateProvider } = useModelProvider()
const prompt = usePrompt((state) => state.prompt)
const setPrompt = usePrompt((state) => state.setPrompt)
const tools = useAppState((state) => state.tools)
const updateTokenSpeed = useAppState((state) => state.updateTokenSpeed)
const resetTokenSpeed = useAppState((state) => state.resetTokenSpeed)
const updateStreamingContent = useAppState(
(state) => state.updateStreamingContent
)
const updateLoadingModel = useAppState((state) => state.updateLoadingModel)
const setAbortController = useAppState((state) => state.setAbortController)
const assistants = useAssistant((state) => state.assistants)
const currentAssistant = useAssistant((state) => state.currentAssistant)
const updateProvider = useModelProvider((state) => state.updateProvider)
const serviceHub = useServiceHub()
const { approvedTools, showApprovalModal, allowAllMCPPermissions } =
useToolApproval()
const { showApprovalModal: showIncreaseContextSizeModal } =
useContextSizeApproval()
const { getDisabledToolsForThread } = useToolAvailable()
const approvedTools = useToolApproval((state) => state.approvedTools)
const showApprovalModal = useToolApproval((state) => state.showApprovalModal)
const allowAllMCPPermissions = useToolApproval(
(state) => state.allowAllMCPPermissions
)
const showIncreaseContextSizeModal = useContextSizeApproval(
(state) => state.showApprovalModal
)
const getDisabledToolsForThread = useToolAvailable((state) => state.getDisabledToolsForThread)
const { getProviderByName, selectedModel, selectedProvider } =
useModelProvider()
const getProviderByName = useModelProvider((state) => state.getProviderByName)
const selectedModel = useModelProvider((state) => state.selectedModel)
const selectedProvider = useModelProvider((state) => state.selectedProvider)
const {
getCurrentThread: retrieveThread,
createThread,
updateThreadTimestamp,
} = useThreads()
const { getMessages, addMessage } = useMessages()
const { setModelLoadError } = useModelLoad()
const createThread = useThreads((state) => state.createThread)
const retrieveThread = useThreads((state) => state.getCurrentThread)
const updateThreadTimestamp = useThreads(
(state) => state.updateThreadTimestamp
)
const getMessages = useMessages((state) => state.getMessages)
const addMessage = useMessages((state) => state.addMessage)
const setModelLoadError = useModelLoad((state) => state.setModelLoadError)
const router = useRouter()
const provider = useMemo(() => {
@ -94,13 +103,13 @@ export const useChat = () => {
}
return currentThread
}, [
createThread,
prompt,
retrieveThread,
router,
selectedModel?.id,
selectedProvider,
selectedAssistant,
// createThread,
// prompt,
// retrieveThread,
// router,
// selectedModel?.id,
// selectedProvider,
// selectedAssistant,
])
const restartModel = useCallback(
@ -108,7 +117,10 @@ export const useChat = () => {
await serviceHub.models().stopAllModels()
await new Promise((resolve) => setTimeout(resolve, 1000))
updateLoadingModel(true)
await serviceHub.models().startModel(provider, modelId).catch(console.error)
await serviceHub
.models()
.startModel(provider, modelId)
.catch(console.error)
updateLoadingModel(false)
await new Promise((resolve) => setTimeout(resolve, 1000))
},
@ -188,7 +200,9 @@ export const useChat = () => {
settings: newSettings,
}
await serviceHub.providers().updateSettings(providerName, updateObj.settings ?? [])
await serviceHub
.providers()
.updateSettings(providerName, updateObj.settings ?? [])
updateProvider(providerName, {
...provider,
...updateObj,
@ -237,7 +251,9 @@ export const useChat = () => {
const builder = new CompletionMessagesBuilder(
messages,
currentAssistant ? renderInstructions(currentAssistant.instructions) : undefined
currentAssistant
? renderInstructions(currentAssistant.instructions)
: undefined
)
if (troubleshooting) builder.addUserMessage(message, attachments)
@ -476,7 +492,9 @@ export const useChat = () => {
activeThread.model?.id &&
provider?.provider === 'llamacpp'
) {
await serviceHub.models().stopModel(activeThread.model.id, 'llamacpp')
await serviceHub
.models()
.stopModel(activeThread.model.id, 'llamacpp')
throw new Error('No response received from the model')
}
@ -554,5 +572,5 @@ export const useChat = () => {
]
)
return { sendMessage }
return useMemo(() => ({ sendMessage }), [sendMessage])
}

View File

@ -0,0 +1,231 @@
import { UIEventHandler, useEffect, useMemo, useRef, useState } from 'react'
import { useAppState } from './useAppState'
import { useMessages } from './useMessages'
import { useShallow } from 'zustand/react/shallow'
import debounce from 'lodash.debounce'
export const useThreadScrolling = (
threadId: string,
scrollContainerRef: React.RefObject<HTMLDivElement | null>
) => {
const streamingContent = useAppState((state) => state.streamingContent)
const isFirstRender = useRef(true)
const { messages } = useMessages(
useShallow((state) => ({
messages: state.messages[threadId],
}))
)
const wasStreamingRef = useRef(false)
const userIntendedPositionRef = useRef<number | null>(null)
const [isUserScrolling, setIsUserScrolling] = useState(false)
const [isAtBottom, setIsAtBottom] = useState(true)
const [hasScrollbar, setHasScrollbar] = useState(false)
const lastScrollTopRef = useRef(0)
const messagesCount = useMemo(() => messages?.length ?? 0, [messages])
const showScrollToBottomBtn = !isAtBottom && hasScrollbar
const scrollToBottom = (smooth = false) => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight,
...(smooth ? { behavior: 'smooth' } : {}),
})
}
}
const handleScroll = (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
}
useEffect(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.addEventListener('scroll', handleScroll)
return () =>
scrollContainerRef.current?.removeEventListener('scroll', handleScroll)
}
}, [scrollContainerRef])
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)
}
// 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(() => {
// 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])
// 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
}
}, [])
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
}
// 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
}, [])
// Reset scroll state when thread changes
useEffect(() => {
isFirstRender.current = true
scrollToBottom()
setIsAtBottom(true)
setIsUserScrolling(false)
userIntendedPositionRef.current = null
wasStreamingRef.current = false
checkScrollState()
}, [threadId])
return useMemo(
() => ({ showScrollToBottomBtn, scrollToBottom, setIsUserScrolling }),
[showScrollToBottomBtn, scrollToBottom, setIsUserScrolling]
)
}

View File

@ -5,7 +5,7 @@ import { SystemEvent } from '@/types/events'
import { useAppState } from './useAppState'
export const useTools = () => {
const { updateTools } = useAppState()
const updateTools = useAppState((state) => state.updateTools)
useEffect(() => {
function setTools() {

View File

@ -3,7 +3,6 @@ import { createFileRoute, useSearch } from '@tanstack/react-router'
import ChatInput from '@/containers/ChatInput'
import HeaderPage from '@/containers/HeaderPage'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { useTools } from '@/hooks/useTools'
import { useModelProvider } from '@/hooks/useModelProvider'
import SetupScreen from '@/containers/SetupScreen'
@ -34,7 +33,6 @@ function Index() {
const search = useSearch({ from: route.home as any })
const selectedModel = search.model
const { setCurrentThreadId } = useThreads()
useTools()
// Conditional to check if there are any valid providers
// required min 1 api_key or 1 model in llama.cpp or jan provider

View File

@ -1,10 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef } 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'
@ -15,17 +12,14 @@ 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'
import { PlatformFeatures } from '@/lib/platform/const'
import { PlatformFeature } from '@/lib/platform/types'
import ScrollToBottom from '@/containers/ScrollToBottom'
// as route.threadsDetail
export const Route = createFileRoute('/threads/$threadId')({
@ -33,23 +27,18 @@ export const Route = createFileRoute('/threads/$threadId')({
})
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<number | null>(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 setCurrentThreadId = useThreads((state) => state.setCurrentThreadId)
const currentThreadId = useThreads((state) => state.currentThreadId)
const setCurrentAssistant = useAssistant((state) => state.setCurrentAssistant)
const assistants = useAssistant((state) => state.assistants)
const setMessages = useMessages((state) => state.setMessages)
const chatWidth = useAppearance((state) => state.chatWidth)
const isSmallScreen = useSmallScreen()
useTools()
// useTools()
const { messages } = useMessages(
useShallow((state) => ({
@ -60,21 +49,8 @@ function ThreadDetail() {
// 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)
}
console.log('rerender')
useEffect(() => {
if (currentThreadId !== threadId) {
@ -89,12 +65,15 @@ function ThreadDetail() {
}, [threadId, currentThreadId, assistants])
useEffect(() => {
serviceHub.messages().fetchMessages(threadId).then((fetchedMessages) => {
if (fetchedMessages) {
// Update the messages in the store
setMessages(threadId, fetchedMessages)
}
})
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])
@ -106,131 +85,6 @@ function ThreadDetail() {
// 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<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)
// 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) => {
const newMessages: ThreadMessage[] = messages.map((m) => {
if (m.id === item.id) {
@ -251,64 +105,22 @@ function ThreadDetail() {
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 (
<div className="flex flex-col h-full">
<HeaderPage>
<div className="flex items-center justify-between w-full pr-2">
{PlatformFeatures[PlatformFeature.ASSISTANTS] && <DropdownAssistant />}
{PlatformFeatures[PlatformFeature.ASSISTANTS] && (
<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'
)}
@ -362,38 +174,10 @@ function ThreadDetail() {
isSmallScreen && 'w-full'
)}
>
<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',
(showScrollToBottomBtn || showGenerateAIResponseBtn) &&
'visibility-visible opacity-100'
)}
>
{showScrollToBottomBtn && (
<div
className="bg-main-view-fg/10 px-2 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>
)}
{showGenerateAIResponseBtn && (
<div
className="mx-2 bg-main-view-fg/10 px-2 border border-main-view-fg/5 flex items-center justify-center rounded-xl gap-x-2 cursor-pointer pointer-events-auto"
onClick={generateAIResponse}
>
<p className="text-xs">{t('common:generateAiResponse')}</p>
<Play size={12} />
</div>
)}
</div>
<ScrollToBottom
threadId={threadId}
scrollContainerRef={scrollContainerRef}
/>
<ChatInput model={threadModel} />
</div>
</div>