twenty/packages/twenty-front/src/modules/ai/components/AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect.tsx
Lucas Bordeau fc9723949b
Fix AI chat re-renders and refactored code (#18585)
This PR: 

- Breaks useAgentChatData into focused effect components (streaming,
fetch,
init, auto-scroll, diff sync)
- Splits message list into non-last (stable) + last (streaming/error) to
prevent full re-renders on each stream chunk
- Adds scroll-to-bottom button and MutationObserver-based auto-scroll on
thread switch
- Lifts loading state from context to atoms
- Adds areEqual to selector
factories

We could improve further but this sets up a robust architecture for
further refactoring.

## Messages flow

The flow of messages loading and streaming is now more solid. 

Everything goes out from `AgentChatAiSdkStreamEffect`, whether loaded
from the DB or streaming directly, and every consumers is using only one
atom `agentChatMessagesComponentFamilyState`

## Data sync effect with callbacks new hook 

See
`packages/twenty-front/src/modules/apollo/hooks/useQueryWithCallbacks.ts`
which allows to fix Apollo v4 migration leftovers and is an
implementation of the pattern we talked about with @charlesBochet

We could refine this pattern in another PR.

# Before



https://github.com/user-attachments/assets/84e7a96f-6790-405d-8a73-2dacbf783be5


# After



https://github.com/user-attachments/assets/4c692e3a-2413-4513-abcc-44d0da311203

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-21 12:52:21 +00:00

74 lines
2.2 KiB
TypeScript

import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId';
import { agentChatIsInitialScrollPendingOnThreadChangeState } from '@/ai/states/agentChatIsInitialScrollPendingOnThreadChangeState';
import { scrollAIChatToBottom } from '@/ai/utils/scrollAIChatToBottom';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useEffect } from 'react';
const SCROLL_SETTLE_DELAY_MS = 150;
export const AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect =
() => {
const agentChatIsInitialScrollPendingOnThreadChange = useAtomStateValue(
agentChatIsInitialScrollPendingOnThreadChangeState,
);
const setAgentChatIsInitialScrollPendingOnThreadChange = useSetAtomState(
agentChatIsInitialScrollPendingOnThreadChangeState,
);
useEffect(() => {
if (!agentChatIsInitialScrollPendingOnThreadChange) {
return;
}
const scrollWrapperElement = document.getElementById(
`scroll-wrapper-${AI_CHAT_SCROLL_WRAPPER_ID}`,
);
if (!scrollWrapperElement) {
return;
}
let settleTimeoutId: ReturnType<typeof setTimeout> | null = null;
const scheduleSettle = () => {
if (settleTimeoutId !== null) {
clearTimeout(settleTimeoutId);
}
scrollAIChatToBottom();
settleTimeoutId = setTimeout(() => {
scrollAIChatToBottom();
setAgentChatIsInitialScrollPendingOnThreadChange(false);
mutationObserver.disconnect();
}, SCROLL_SETTLE_DELAY_MS);
};
const mutationObserver = new MutationObserver(() => {
scheduleSettle();
});
mutationObserver.observe(scrollWrapperElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class'],
});
scheduleSettle();
return () => {
if (settleTimeoutId !== null) {
clearTimeout(settleTimeoutId);
}
mutationObserver.disconnect();
};
}, [
agentChatIsInitialScrollPendingOnThreadChange,
setAgentChatIsInitialScrollPendingOnThreadChange,
]);
return null;
};