From 524e5d8cf7e7cc514c96434ff1f0dcbfac2cde72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Sun, 10 May 2026 10:47:52 +0200 Subject: [PATCH] fix: scroll AI chat to bottom on side panel reopen (#20413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Fix: when reopening the AI chat side panel, users landed at the top of the conversation and had to scroll down to find the latest messages - Root cause: the side panel fully unmounts on close ([SidePanelForDesktop.tsx](packages/twenty-front/src/modules/side-panel/components/SidePanelForDesktop.tsx) clears `shouldShowContent` after the close transition), so on reopen the scroll wrapper is recreated with `scrollTop = 0`. The existing initial-scroll-to-bottom only fires on thread change, but the displayed-thread atom outlives the unmount, so no thread change is detected on a remount and the scroll-to-bottom never runs - Fix: add a tiny `AgentChatScrollToBottomOnMountLayoutEffect` rendered inside the message list that calls `scrollAiChatToBottom()` directly in `useLayoutEffect`. Because the parent returns `null` when there are no messages, the mount only fires when there is content to scroll past ## Why direct scroll, not the existing flag `agentChatIsInitialScrollPendingOnThreadChangeState` is paired with a `MutationObserver` settle that only clears the flag once the subtree is quiet for 150 ms. During a live stream the message subtree mutates on every token, the settle resets indefinitely and `visibility: hidden` never lifts. The thread-change handler avoids this because it is gated on `!agentChatIsStreaming` ([AgentChatStreamSubscriptionEffect.tsx:78-79](packages/twenty-front/src/modules/ai/components/AgentChatStreamSubscriptionEffect.tsx)), but a mount can happen at any time, including mid-stream. Scrolling the DOM directly in `useLayoutEffect` runs synchronously between commit and paint, so the user sees the bottom on the first paint with no flash and no settle dependency. ## Tradeoff A user who scrolled up to read history and then closes/reopens the panel will land back at the bottom instead of where they were. Standard chat UX (Slack, ChatGPT, iMessage); preserving per-thread scroll position would need a new atom and is left out of scope. ## Test plan - [ ] Open AI chat with messages, close the side panel, reopen → lands at the bottom (latest messages visible) - [ ] Reopen the side panel **mid-stream** → lands at the bottom and continues to follow new tokens (chat is not hidden) - [ ] Switch between threads → existing thread-change scroll still works (no double-scroll, no regression) - [ ] Open AI chat with no messages → no flash, no errors - [ ] Rapidly close/reopen the panel a few times → each reopen lands at the bottom --------- Co-authored-by: Claude Opus 4.7 --- .../AgentChatScrollToBottomOnMountLayoutEffect.tsx | 10 ++++++++++ .../src/modules/ai/components/AiChatTabMessageList.tsx | 2 ++ 2 files changed, 12 insertions(+) create mode 100644 packages/twenty-front/src/modules/ai/components/AgentChatScrollToBottomOnMountLayoutEffect.tsx diff --git a/packages/twenty-front/src/modules/ai/components/AgentChatScrollToBottomOnMountLayoutEffect.tsx b/packages/twenty-front/src/modules/ai/components/AgentChatScrollToBottomOnMountLayoutEffect.tsx new file mode 100644 index 0000000000..e44c689202 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/AgentChatScrollToBottomOnMountLayoutEffect.tsx @@ -0,0 +1,10 @@ +import { scrollAiChatToBottom } from '@/ai/utils/scrollAiChatToBottom'; +import { useLayoutEffect } from 'react'; + +export const AgentChatScrollToBottomOnMountLayoutEffect = () => { + useLayoutEffect(() => { + scrollAiChatToBottom(); + }, []); + + return null; +}; diff --git a/packages/twenty-front/src/modules/ai/components/AiChatTabMessageList.tsx b/packages/twenty-front/src/modules/ai/components/AiChatTabMessageList.tsx index bb622e75de..8256c0c0ac 100644 --- a/packages/twenty-front/src/modules/ai/components/AiChatTabMessageList.tsx +++ b/packages/twenty-front/src/modules/ai/components/AiChatTabMessageList.tsx @@ -3,6 +3,7 @@ import { AiChatLastMessageWithStreamingState } from '@/ai/components/AiChatLastM import { AiChatNonLastMessageIdsList } from '@/ai/components/AiChatNonLastMessageIdsList'; import { AiChatScrollToBottomButton } from '@/ai/components/AiChatScrollToBottomButton'; import { AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect } from '@/ai/components/AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect'; +import { AgentChatScrollToBottomOnMountLayoutEffect } from '@/ai/components/AgentChatScrollToBottomOnMountLayoutEffect'; import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId'; import { agentChatHasMessageComponentSelector } from '@/ai/states/selectors/agentChatHasMessageComponentSelector'; import { agentChatIsInitialScrollPendingOnThreadChangeState } from '@/ai/states/agentChatIsInitialScrollPendingOnThreadChangeState'; @@ -49,6 +50,7 @@ export const AiChatTabMessageList = () => { +