Fix AI chat scroll behavior (#15686)

This commit is contained in:
Abdul Rahman
2025-11-14 05:50:25 +05:30
committed by GitHub
parent 9d43058e50
commit bff079d3b5
8 changed files with 67 additions and 27 deletions
@@ -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 && (
<StyledScrollWrapper componentInstanceId={scrollWrapperId}>
<StyledScrollWrapper
componentInstanceId={AI_CHAT_SCROLL_WRAPPER_ID}
>
{messages.map((message, index) => {
const isLastMessage = index === messages.length - 1;
const isLastMessageStreaming = isStreaming && isLastMessage;
@@ -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<ExtendedUIMessage[] | null>(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;
};
@@ -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,
}}
>
<AgentChatMessagesEffect messages={chatState.messages} />
{children}
</AgentChatContext.Provider>
);
@@ -0,0 +1 @@
export const AI_CHAT_SCROLL_WRAPPER_ID = 'ai-chat-scroll-wrapper';
@@ -13,7 +13,6 @@ export type AgentChatContextValue = {
handleSendMessage: (records?: ObjectRecord[]) => Promise<void>;
scrollWrapperId: string;
handleRetry: () => void;
};
@@ -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,
@@ -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 || []);
@@ -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 };
};