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')}
-
-
- )}
-
+