diff --git a/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx b/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx index a95b922d7b..df76d907b1 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx @@ -16,6 +16,7 @@ import { AgentChatContextPreview } from '@/ai/components/internal/AgentChatConte import { SendMessageButton } from '@/ai/components/internal/SendMessageButton'; import { SendMessageWithRecordsContextButton } from '@/ai/components/internal/SendMessageWithRecordsContextButton'; import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId'; +import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId'; import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload'; import { useAgentChatContextOrThrow } from '@/ai/hooks/useAgentChatContextOrThrow'; import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState'; @@ -62,15 +63,8 @@ const StyledButtonsContainer = styled.div` export const AIChatTab = () => { const [isDraggingFile, setIsDraggingFile] = useState(false); - const { - isLoading, - input, - handleInputChange, - scrollWrapperId, - messages, - isStreaming, - error, - } = useAgentChatContextOrThrow(); + const { isLoading, input, handleInputChange, messages, isStreaming, error } = + useAgentChatContextOrThrow(); const contextStoreCurrentObjectMetadataItemId = useRecoilComponentValue( contextStoreCurrentObjectMetadataItemIdComponentState, @@ -94,7 +88,9 @@ export const AIChatTab = () => { {!isDraggingFile && ( <> {messages.length !== 0 && ( - + {messages.map((message, index) => { const isLastMessage = index === messages.length - 1; const isLastMessageStreaming = isStreaming && isLastMessage; diff --git a/packages/twenty-front/src/modules/ai/components/AgentChatMessagesEffect.tsx b/packages/twenty-front/src/modules/ai/components/AgentChatMessagesEffect.tsx new file mode 100644 index 0000000000..17b205c406 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/AgentChatMessagesEffect.tsx @@ -0,0 +1,31 @@ +import { useAgentChatScrollToBottom } from '@/ai/hooks/useAgentChatScrollToBottom'; +import { useEffect, useState } from 'react'; +import { type ExtendedUIMessage } from 'twenty-shared/ai'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; + +export const AgentChatMessagesEffect = ({ + messages, +}: { + messages: ExtendedUIMessage[]; +}) => { + const { scrollToBottom } = useAgentChatScrollToBottom(); + const [, setPreviousMessages] = useState(null); + + useEffect(() => { + setPreviousMessages((previousMessages) => { + if ( + previousMessages !== null && + isDeeplyEqual(previousMessages, messages) + ) { + return previousMessages; + } + + // We intentionally force this effect because the chat transport streams messages incrementally + // and the only reliable way to react to those chunks is through useEffect updates. + scrollToBottom(); + return messages; + }); + }, [messages, scrollToBottom]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/ai/components/AgentChatProvider.tsx b/packages/twenty-front/src/modules/ai/components/AgentChatProvider.tsx index 66f04ec9d4..ac5810f849 100644 --- a/packages/twenty-front/src/modules/ai/components/AgentChatProvider.tsx +++ b/packages/twenty-front/src/modules/ai/components/AgentChatProvider.tsx @@ -1,3 +1,4 @@ +import { AgentChatMessagesEffect } from '@/ai/components/AgentChatMessagesEffect'; import { AgentChatContext } from '@/ai/contexts/AgentChatContext'; import { useAgentChat } from '@/ai/hooks/useAgentChat'; import { useAgentChatData } from '@/ai/hooks/useAgentChatData'; @@ -21,6 +22,7 @@ const AgentChatProviderContent = ({ isLoading: combinedIsLoading, }} > + {children} ); diff --git a/packages/twenty-front/src/modules/ai/constants/AiChatScrollWrapperId.ts b/packages/twenty-front/src/modules/ai/constants/AiChatScrollWrapperId.ts new file mode 100644 index 0000000000..704a920a8c --- /dev/null +++ b/packages/twenty-front/src/modules/ai/constants/AiChatScrollWrapperId.ts @@ -0,0 +1 @@ +export const AI_CHAT_SCROLL_WRAPPER_ID = 'ai-chat-scroll-wrapper'; diff --git a/packages/twenty-front/src/modules/ai/contexts/AgentChatContext.ts b/packages/twenty-front/src/modules/ai/contexts/AgentChatContext.ts index 5abddd422a..d99525f358 100644 --- a/packages/twenty-front/src/modules/ai/contexts/AgentChatContext.ts +++ b/packages/twenty-front/src/modules/ai/contexts/AgentChatContext.ts @@ -13,7 +13,6 @@ export type AgentChatContextValue = { handleSendMessage: (records?: ObjectRecord[]) => Promise; - scrollWrapperId: string; handleRetry: () => void; }; diff --git a/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts b/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts index b6c92555e2..6a1273ffc8 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts @@ -12,7 +12,6 @@ import { tokenPairState } from '@/auth/states/tokenPairState'; import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState'; import { useGetObjectMetadataItemById } from '@/object-metadata/hooks/useGetObjectMetadataItemById'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; import { type ExtendedUIMessage } from 'twenty-shared/ai'; @@ -46,11 +45,6 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => { const [agentChatInput, setAgentChatInput] = useRecoilState(agentChatInputState); - const scrollWrapperId = `scroll-wrapper-ai-chat-${currentAIChatThread}`; - - const { scrollWrapperHTMLElement } = - useScrollWrapperHTMLElement(scrollWrapperId); - const retryFetchWithRenewedToken = async ( input: RequestInfo | URL, init?: RequestInit, @@ -120,13 +114,6 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => { const isStreaming = status === 'streaming'; - const scrollToBottom = () => { - scrollWrapperHTMLElement?.scroll({ - top: scrollWrapperHTMLElement.scrollHeight, - behavior: 'smooth', - }); - }; - const isLoading = !currentAIChatThread || isStreaming || agentChatSelectedFiles.length > 0; @@ -165,9 +152,7 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => { }, }, ); - setAgentChatUploadedFiles([]); - setTimeout(scrollToBottom, 100); }; return { @@ -176,7 +161,6 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => { input: agentChatInput, handleSendMessage, isLoading, - scrollWrapperId, isStreaming, error, handleRetry: regenerate, diff --git a/packages/twenty-front/src/modules/ai/hooks/useAgentChatData.ts b/packages/twenty-front/src/modules/ai/hooks/useAgentChatData.ts index 3a1f646846..549eac6490 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useAgentChatData.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useAgentChatData.ts @@ -1,3 +1,4 @@ +import { useAgentChatScrollToBottom } from '@/ai/hooks/useAgentChatScrollToBottom'; import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState'; import { mapDBMessagesToUIMessages } from '@/ai/utils/mapDBMessagesToUIMessages'; import { useRecoilState } from 'recoil'; @@ -12,6 +13,8 @@ export const useAgentChatData = () => { currentAIChatThreadState, ); + const { scrollToBottom } = useAgentChatScrollToBottom(); + const { loading: threadsLoading } = useGetChatThreadsQuery({ skip: isDefined(currentAIChatThread), onCompleted: (data) => { @@ -24,6 +27,7 @@ export const useAgentChatData = () => { const { loading: messagesLoading, data } = useGetChatMessagesQuery({ variables: { threadId: currentAIChatThread! }, skip: !isDefined(currentAIChatThread), + onCompleted: scrollToBottom, }); const uiMessages = mapDBMessagesToUIMessages(data?.chatMessages || []); diff --git a/packages/twenty-front/src/modules/ai/hooks/useAgentChatScrollToBottom.ts b/packages/twenty-front/src/modules/ai/hooks/useAgentChatScrollToBottom.ts new file mode 100644 index 0000000000..3697ee2f6a --- /dev/null +++ b/packages/twenty-front/src/modules/ai/hooks/useAgentChatScrollToBottom.ts @@ -0,0 +1,23 @@ +import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId'; +import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement'; +import { isDefined } from 'twenty-shared/utils'; + +export const useAgentChatScrollToBottom = () => { + const { getScrollWrapperElement } = useScrollWrapperHTMLElement( + AI_CHAT_SCROLL_WRAPPER_ID, + ); + + const scrollToBottom = () => { + const { scrollWrapperElement } = getScrollWrapperElement(); + if (!isDefined(scrollWrapperElement)) { + return; + } + + scrollWrapperElement.scrollTo({ + top: scrollWrapperElement.scrollHeight, + behavior: 'smooth', + }); + }; + + return { scrollToBottom }; +};