From 241a90492e0faf0853c4672b65bacd641cdd8eb2 Mon Sep 17 00:00:00 2001 From: Louis Date: Thu, 18 Sep 2025 16:11:24 +0700 Subject: [PATCH] fix: thread rerender issue --- web-app/src/containers/ChatInput.tsx | 2 + .../src/containers/DropdownToolsAvailable.tsx | 2 +- .../src/containers/GenerateResponseButton.tsx | 46 +++ web-app/src/containers/LeftPanel.tsx | 13 +- web-app/src/containers/ScrollToBottom.tsx | 67 +++++ web-app/src/hooks/useChat.ts | 92 +++--- web-app/src/hooks/useThreadScrolling.tsx | 231 +++++++++++++++ web-app/src/hooks/useTools.ts | 2 +- web-app/src/routes/index.tsx | 2 - web-app/src/routes/threads/$threadId.tsx | 272 ++---------------- 10 files changed, 439 insertions(+), 290 deletions(-) create mode 100644 web-app/src/containers/GenerateResponseButton.tsx create mode 100644 web-app/src/containers/ScrollToBottom.tsx create mode 100644 web-app/src/hooks/useThreadScrolling.tsx diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index 955a43a31..9460ed98f 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -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 diff --git a/web-app/src/containers/DropdownToolsAvailable.tsx b/web-app/src/containers/DropdownToolsAvailable.tsx index 660a5f683..1aa51dc69 100644 --- a/web-app/src/containers/DropdownToolsAvailable.tsx +++ b/web-app/src/containers/DropdownToolsAvailable.tsx @@ -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() diff --git a/web-app/src/containers/GenerateResponseButton.tsx b/web-app/src/containers/GenerateResponseButton.tsx new file mode 100644 index 000000000..0d1ab339e --- /dev/null +++ b/web-app/src/containers/GenerateResponseButton.tsx @@ -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 ( +
+

{t('common:generateAiResponse')}

+ +
+ ) +} diff --git a/web-app/src/containers/LeftPanel.tsx b/web-app/src/containers/LeftPanel.tsx index 17e17e60c..da596dd4a 100644 --- a/web-app/src/containers/LeftPanel.tsx +++ b/web-app/src/containers/LeftPanel.tsx @@ -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) diff --git a/web-app/src/containers/ScrollToBottom.tsx b/web-app/src/containers/ScrollToBottom.tsx new file mode 100644 index 000000000..ac924df91 --- /dev/null +++ b/web-app/src/containers/ScrollToBottom.tsx @@ -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 +}) => { + 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 ( +
+ {showScrollToBottomBtn && ( +
{ + scrollToBottom(true) + setIsUserScrolling(false) + }} + > +

{t('scrollToBottom')}

+ +
+ )} + {showGenerateAIResponseBtn && ( + + )} +
+ ) +} + +export default memo(ScrollToBottom) diff --git a/web-app/src/hooks/useChat.ts b/web-app/src/hooks/useChat.ts index f56a650b6..838d739a4 100644 --- a/web-app/src/hooks/useChat.ts +++ b/web-app/src/hooks/useChat.ts @@ -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]) } diff --git a/web-app/src/hooks/useThreadScrolling.tsx b/web-app/src/hooks/useThreadScrolling.tsx new file mode 100644 index 000000000..309d3dd9e --- /dev/null +++ b/web-app/src/hooks/useThreadScrolling.tsx @@ -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 +) => { + 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(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] + ) +} diff --git a/web-app/src/hooks/useTools.ts b/web-app/src/hooks/useTools.ts index 3d66e3ab7..8fc9492b5 100644 --- a/web-app/src/hooks/useTools.ts +++ b/web-app/src/hooks/useTools.ts @@ -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() { diff --git a/web-app/src/routes/index.tsx b/web-app/src/routes/index.tsx index a23b29de4..80bf065f2 100644 --- a/web-app/src/routes/index.tsx +++ b/web-app/src/routes/index.tsx @@ -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 diff --git a/web-app/src/routes/threads/$threadId.tsx b/web-app/src/routes/threads/$threadId.tsx index a7c62c807..30106d0ba 100644 --- a/web-app/src/routes/threads/$threadId.tsx +++ b/web-app/src/routes/threads/$threadId.tsx @@ -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(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(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 = (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 (
- {PlatformFeatures[PlatformFeature.ASSISTANTS] && } + {PlatformFeatures[PlatformFeature.ASSISTANTS] && ( + + )}
-
- {showScrollToBottomBtn && ( -
{ - scrollToBottom(true) - setIsUserScrolling(false) - }} - > -

{t('scrollToBottom')}

- -
- )} - {showGenerateAIResponseBtn && ( -
-

{t('common:generateAiResponse')}

- -
- )} -
+