Compare commits

...
Author SHA1 Message Date
Félix MalfaitandClaude Opus 4.7 82968aa5f2 refactor(ai-chat): extract scroll effects into dedicated components
Pull the useLayoutEffect (mount + thread change) and the useEffect
(ResizeObserver) out of AiChatTabMessageListContent and into
AgentChatScrollToBottomOnThreadChangeLayoutEffect and
AgentChatStickToBottomOnContentResizeEffect, matching the project
convention of putting effects in dedicated <XxxEffect /> components.

The local isAtBottom state moves to a Jotai atom
(agentChatIsScrolledToBottomState) so the effect components and the
button can read it without prop drilling. Behavior is unchanged.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-10 14:45:52 +02:00
Félix MalfaitandClaude Opus 4.7 51046ad769 refactor(ai-chat): unify scroll-to-bottom around ResizeObserver
The AI chat had three parallel scroll engines wired together by a
boolean flag, a 150 ms MutationObserver settle, and a visibility:hidden
mask. Streaming threatened to deadlock the settle, so the recent fix
(#20413) bypassed the flag with a direct DOM scroll on mount.

Replace the whole machinery with a single component-local "stick to
bottom" model:

- useLayoutEffect on currentAiChatThreadState scrolls synchronously on
  mount and on thread switch.
- ResizeObserver on the inner content auto-follows new tokens, async
  markdown, and tool-step expansions whenever the user is at the
  bottom (within 100 px).
- onScroll updates an isAtBottom state that gates the auto-follow and
  drives the scroll-to-bottom button visibility.

The flag, MutationObserver, 150 ms settle, visibility:hidden flicker
mask, document.getElementById util, and the shared ScrollWrapper layer
are all removed from the AI tree. The chat now owns its own scroll
container with overflow-y: auto from the first paint, eliminating the
class-toggle race that motivated the original observer.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-10 14:36:29 +02:00
15 changed files with 401 additions and 201 deletions
@@ -2,7 +2,6 @@ import { AgentChatStreamSubscriptionEffect } from '@/ai/components/AgentChatStre
import { AgentChatMessagesFetchEffect } from '@/ai/components/AgentChatMessagesFetchEffect';
import { AgentChatSessionStartTimeEffect } from '@/ai/components/AgentChatSessionStartTimeEffect';
import { AgentChatStreamingAutoScrollEffect } from '@/ai/components/AgentChatStreamingAutoScrollEffect';
import { AgentChatStreamingPartsDiffSyncEffect } from '@/ai/components/AgentChatStreamingPartsDiffSyncEffect';
import { AgentChatThreadInitializationEffect } from '@/ai/components/AgentChatThreadInitializationEffect';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
@@ -23,7 +22,6 @@ export const AgentChatProviderContent = ({
<AgentChatStreamSubscriptionEffect />
<AgentChatStreamingPartsDiffSyncEffect />
<AgentChatSessionStartTimeEffect />
<AgentChatStreamingAutoScrollEffect />
{children}
</AgentChatComponentInstanceContext.Provider>
</Suspense>
@@ -1,74 +0,0 @@
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;
};
@@ -1,10 +0,0 @@
import { scrollAiChatToBottom } from '@/ai/utils/scrollAiChatToBottom';
import { useLayoutEffect } from 'react';
export const AgentChatScrollToBottomOnMountLayoutEffect = () => {
useLayoutEffect(() => {
scrollAiChatToBottom();
}, []);
return null;
};
@@ -0,0 +1,31 @@
import { agentChatIsScrolledToBottomState } from '@/ai/states/agentChatIsScrolledToBottomState';
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { type RefObject, useLayoutEffect } from 'react';
type AgentChatScrollToBottomOnThreadChangeLayoutEffectProps = {
scrollContainerRef: RefObject<HTMLDivElement | null>;
};
export const AgentChatScrollToBottomOnThreadChangeLayoutEffect = ({
scrollContainerRef,
}: AgentChatScrollToBottomOnThreadChangeLayoutEffectProps) => {
const currentAiChatThread = useAtomStateValue(currentAiChatThreadState);
const setAgentChatIsScrolledToBottom = useSetAtomState(
agentChatIsScrolledToBottomState,
);
useLayoutEffect(() => {
setAgentChatIsScrolledToBottom(true);
const scrollContainer = scrollContainerRef.current;
if (scrollContainer === null) {
return;
}
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}, [currentAiChatThread, scrollContainerRef, setAgentChatIsScrolledToBottom]);
return null;
};
@@ -0,0 +1,40 @@
import { agentChatIsScrolledToBottomState } from '@/ai/states/agentChatIsScrolledToBottomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { type RefObject, useEffect } from 'react';
type AgentChatStickToBottomOnContentResizeEffectProps = {
scrollContainerRef: RefObject<HTMLDivElement | null>;
contentRef: RefObject<HTMLDivElement | null>;
};
export const AgentChatStickToBottomOnContentResizeEffect = ({
scrollContainerRef,
contentRef,
}: AgentChatStickToBottomOnContentResizeEffectProps) => {
const agentChatIsScrolledToBottom = useAtomStateValue(
agentChatIsScrolledToBottomState,
);
useEffect(() => {
const content = contentRef.current;
if (content === null) {
return;
}
const observer = new ResizeObserver(() => {
if (!agentChatIsScrolledToBottom) {
return;
}
const scrollContainer = scrollContainerRef.current;
if (scrollContainer === null) {
return;
}
scrollContainer.scrollTop = scrollContainer.scrollHeight;
});
observer.observe(content);
return () => observer.disconnect();
}, [agentChatIsScrolledToBottom, scrollContainerRef, contentRef]);
return null;
};
@@ -9,7 +9,6 @@ import { useEnsureAgentChatThreadExistsForDraft } from '@/ai/hooks/useEnsureAgen
import { useEnsureAgentChatThreadIdForSend } from '@/ai/hooks/useEnsureAgentChatThreadIdForSend';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { agentChatFetchedMessagesComponentFamilyState } from '@/ai/states/agentChatFetchedMessagesComponentFamilyState';
import { agentChatIsInitialScrollPendingOnThreadChangeState } from '@/ai/states/agentChatIsInitialScrollPendingOnThreadChangeState';
import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState';
import { agentChatIsStreamingComponentFamilyState } from '@/ai/states/agentChatIsStreamingComponentFamilyState';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
@@ -70,10 +69,6 @@ export const AgentChatStreamSubscriptionEffect = () => {
agentChatDisplayedThreadState,
);
const setAgentChatIsInitialScrollPendingOnThreadChange = useSetAtomState(
agentChatIsInitialScrollPendingOnThreadChangeState,
);
useEffect(() => {
if (agentChatIsStreaming) {
return;
@@ -82,9 +77,6 @@ export const AgentChatStreamSubscriptionEffect = () => {
setAgentChatMessages(agentChatFetchedMessages);
if (currentAiChatThread !== agentChatDisplayedThread) {
if (agentChatFetchedMessages.length > 0) {
setAgentChatIsInitialScrollPendingOnThreadChange(true);
}
setAgentChatDisplayedThread(currentAiChatThread);
}
}, [
@@ -94,7 +86,6 @@ export const AgentChatStreamSubscriptionEffect = () => {
currentAiChatThread,
agentChatDisplayedThread,
setAgentChatDisplayedThread,
setAgentChatIsInitialScrollPendingOnThreadChange,
]);
const setAgentChatIsLoading = useSetAtomState(agentChatIsLoadingState);
@@ -1,32 +0,0 @@
import { agentChatIsScrolledToBottomSelector } from '@/ai/states/selectors/agentChatIsScrolledToBottomSelector';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { scrollAiChatToBottom } from '@/ai/utils/scrollAiChatToBottom';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useEffect } from 'react';
export const AgentChatStreamingAutoScrollEffect = () => {
const currentAiChatThread = useAtomStateValue(currentAiChatThreadState);
const agentChatMessages = useAtomComponentFamilyStateValue(
agentChatMessagesComponentFamilyState,
{ threadId: currentAiChatThread },
);
const agentChatIsScrolledToBottom = useAtomStateValue(
agentChatIsScrolledToBottomSelector,
);
useEffect(() => {
if (agentChatMessages.length === 0) {
return;
}
if (agentChatIsScrolledToBottom) {
scrollAiChatToBottom();
}
}, [agentChatMessages, agentChatIsScrolledToBottom]);
return null;
};
@@ -1,6 +1,3 @@
import { agentChatIsScrolledToBottomSelector } from '@/ai/states/selectors/agentChatIsScrolledToBottomSelector';
import { scrollAiChatToBottom } from '@/ai/utils/scrollAiChatToBottom';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { IconArrowDown } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -33,16 +30,17 @@ const StyledScrollToBottomButton = styled.button<{ isVisible: boolean }>`
}
`;
export const AiChatScrollToBottomButton = () => {
const agentChatIsScrolledToBottom = useAtomStateValue(
agentChatIsScrolledToBottomSelector,
);
type AiChatScrollToBottomButtonProps = {
isVisible: boolean;
onClick: () => void;
};
export const AiChatScrollToBottomButton = ({
isVisible,
onClick,
}: AiChatScrollToBottomButtonProps) => {
return (
<StyledScrollToBottomButton
isVisible={!agentChatIsScrolledToBottom}
onClick={scrollAiChatToBottom}
>
<StyledScrollToBottomButton isVisible={isVisible} onClick={onClick}>
<IconArrowDown size={16} />
</StyledScrollToBottomButton>
);
@@ -1,27 +1,36 @@
import { AgentChatScrollToBottomOnThreadChangeLayoutEffect } from '@/ai/components/AgentChatScrollToBottomOnThreadChangeLayoutEffect';
import { AgentChatStickToBottomOnContentResizeEffect } from '@/ai/components/AgentChatStickToBottomOnContentResizeEffect';
import { AiChatErrorUnderMessageList } from '@/ai/components/AiChatErrorUnderMessageList';
import { AiChatLastMessageWithStreamingState } from '@/ai/components/AiChatLastMessageWithStreamingState';
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 { agentChatIsScrolledToBottomState } from '@/ai/states/agentChatIsScrolledToBottomState';
import { agentChatHasMessageComponentSelector } from '@/ai/states/selectors/agentChatHasMessageComponentSelector';
import { agentChatIsInitialScrollPendingOnThreadChangeState } from '@/ai/states/agentChatIsInitialScrollPendingOnThreadChangeState';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { styled } from '@linaria/react';
import { useCallback, useRef } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledScrollWrapperContainer = styled.div`
const SCROLL_BOTTOM_THRESHOLD_PX = 100;
const StyledMessageListContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
position: relative;
width: calc(100% - 24px);
`;
const StyledScrollContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
overflow-y: auto;
padding: ${themeCssVariables.spacing[3]};
position: relative;
width: calc(100% - 24px);
`;
export const AiChatTabMessageList = () => {
@@ -29,30 +38,65 @@ export const AiChatTabMessageList = () => {
agentChatHasMessageComponentSelector,
);
const agentChatIsInitialScrollPendingOnThreadChange = useAtomStateValue(
agentChatIsInitialScrollPendingOnThreadChangeState,
);
if (!agentChatHasMessage) {
return null;
}
return <AiChatTabMessageListContent />;
};
const AiChatTabMessageListContent = () => {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const agentChatIsScrolledToBottom = useAtomStateValue(
agentChatIsScrolledToBottomState,
);
const setAgentChatIsScrolledToBottom = useSetAtomState(
agentChatIsScrolledToBottomState,
);
const scrollToBottom = useCallback(() => {
const scrollContainer = scrollContainerRef.current;
if (scrollContainer === null) {
return;
}
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}, []);
const handleScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
const scrollBottom =
target.scrollHeight - target.clientHeight - target.scrollTop;
setAgentChatIsScrolledToBottom(
scrollBottom <= SCROLL_BOTTOM_THRESHOLD_PX,
);
},
[setAgentChatIsScrolledToBottom],
);
return (
<StyledScrollWrapperContainer
style={{
visibility: agentChatIsInitialScrollPendingOnThreadChange
? 'hidden'
: 'visible',
}}
>
<ScrollWrapper componentInstanceId={AI_CHAT_SCROLL_WRAPPER_ID}>
<AiChatNonLastMessageIdsList />
<AiChatLastMessageWithStreamingState />
<AiChatErrorUnderMessageList />
<AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect />
<AgentChatScrollToBottomOnMountLayoutEffect />
</ScrollWrapper>
<AiChatScrollToBottomButton />
</StyledScrollWrapperContainer>
<StyledMessageListContainer>
<AgentChatScrollToBottomOnThreadChangeLayoutEffect
scrollContainerRef={scrollContainerRef}
/>
<AgentChatStickToBottomOnContentResizeEffect
scrollContainerRef={scrollContainerRef}
contentRef={contentRef}
/>
<StyledScrollContainer ref={scrollContainerRef} onScroll={handleScroll}>
<div ref={contentRef}>
<AiChatNonLastMessageIdsList />
<AiChatLastMessageWithStreamingState />
<AiChatErrorUnderMessageList />
</div>
</StyledScrollContainer>
<AiChatScrollToBottomButton
isVisible={!agentChatIsScrolledToBottom}
onClick={scrollToBottom}
/>
</StyledMessageListContainer>
);
};
@@ -0,0 +1,243 @@
import { act, fireEvent, render } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { AiChatTabMessageList } from '@/ai/components/AiChatTabMessageList';
import {
jotaiStore,
resetJotaiStore,
} from '@/ui/utilities/state/jotai/jotaiStore';
jest.mock('@/ai/components/AiChatNonLastMessageIdsList', () => ({
AiChatNonLastMessageIdsList: () => (
<div data-testid="non-last-messages" style={{ height: '5000px' }} />
),
}));
jest.mock('@/ai/components/AiChatLastMessageWithStreamingState', () => ({
AiChatLastMessageWithStreamingState: () => (
<div data-testid="last-message" style={{ height: '500px' }} />
),
}));
jest.mock('@/ai/components/AiChatErrorUnderMessageList', () => ({
AiChatErrorUnderMessageList: () => null,
}));
jest.mock('@/ai/components/AiChatScrollToBottomButton', () => ({
AiChatScrollToBottomButton: ({
isVisible,
onClick,
}: {
isVisible: boolean;
onClick: () => void;
}) => (
<button
data-testid="scroll-to-bottom"
data-visible={isVisible}
onClick={onClick}
/>
),
}));
class MockResizeObserver {
static instances: MockResizeObserver[] = [];
callback: ResizeObserverCallback;
observed: Element[] = [];
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
MockResizeObserver.instances.push(this);
}
observe(target: Element) {
this.observed.push(target);
}
disconnect() {
this.observed = [];
}
unobserve() {}
trigger() {
this.callback(
this.observed.map((target) => ({
target,
contentRect: target.getBoundingClientRect(),
})) as ResizeObserverEntry[],
this as unknown as ResizeObserver,
);
}
}
const setStubScrollDimensions = (
element: HTMLElement,
scrollHeight: number,
clientHeight: number,
) => {
Object.defineProperty(element, 'scrollHeight', {
configurable: true,
get: () => scrollHeight,
});
Object.defineProperty(element, 'clientHeight', {
configurable: true,
get: () => clientHeight,
});
};
const renderMessageList = ({ threadId }: { threadId: string | null }) => {
resetJotaiStore();
jotaiStore.set(currentAiChatThreadState.atom, threadId);
jotaiStore.set(agentChatDisplayedThreadState.atom, threadId);
jotaiStore.set(
agentChatMessagesComponentFamilyState.atomFamily({
instanceId: 'agentChatComponentInstance',
familyKey: { threadId },
}),
[
{
id: 'msg-1',
role: 'user',
parts: [{ type: 'text', text: 'hello' }],
},
{
id: 'msg-2',
role: 'assistant',
parts: [{ type: 'text', text: 'world' }],
},
] as never,
);
return render(
<JotaiProvider store={jotaiStore}>
<AgentChatComponentInstanceContext.Provider
value={{ instanceId: 'agentChatComponentInstance' }}
>
<AiChatTabMessageList />
</AgentChatComponentInstanceContext.Provider>
</JotaiProvider>,
);
};
describe('AiChatTabMessageList', () => {
let originalResizeObserver: typeof globalThis.ResizeObserver;
beforeEach(() => {
MockResizeObserver.instances = [];
originalResizeObserver = globalThis.ResizeObserver;
globalThis.ResizeObserver =
MockResizeObserver as unknown as typeof globalThis.ResizeObserver;
});
afterEach(() => {
globalThis.ResizeObserver = originalResizeObserver;
});
it('returns null when there are no messages', () => {
resetJotaiStore();
jotaiStore.set(currentAiChatThreadState.atom, null);
jotaiStore.set(agentChatDisplayedThreadState.atom, null);
const { container } = render(
<JotaiProvider store={jotaiStore}>
<AgentChatComponentInstanceContext.Provider
value={{ instanceId: 'agentChatComponentInstance' }}
>
<AiChatTabMessageList />
</AgentChatComponentInstanceContext.Provider>
</JotaiProvider>,
);
expect(container.firstChild).toBeNull();
});
// Regression for fix #20413 — panel close+reopen creates a fresh scroll
// wrapper at scrollTop=0; without the mount-scroll, users land at the top
// of the conversation. Switching the active thread re-runs the same
// mount-time layout effect.
it('scrolls to the bottom when the active thread changes', () => {
const { getByTestId } = renderMessageList({ threadId: 'thread-1' });
const scrollContainer = getByTestId('non-last-messages').parentElement
?.parentElement as HTMLElement;
setStubScrollDimensions(scrollContainer, 5500, 600);
act(() => {
jotaiStore.set(currentAiChatThreadState.atom, 'thread-2');
});
expect(scrollContainer.scrollTop).toBe(5500);
});
it('re-scrolls to the bottom when ResizeObserver fires while at-bottom', () => {
const { getByTestId } = renderMessageList({ threadId: 'thread-1' });
const scrollContainer = getByTestId('non-last-messages').parentElement
?.parentElement as HTMLElement;
setStubScrollDimensions(scrollContainer, 5500, 600);
act(() => {
MockResizeObserver.instances[0]?.trigger();
});
expect(scrollContainer.scrollTop).toBe(5500);
});
it('stops auto-scrolling once the user scrolls away from the bottom', () => {
const { getByTestId } = renderMessageList({ threadId: 'thread-1' });
const scrollContainer = getByTestId('non-last-messages').parentElement
?.parentElement as HTMLElement;
setStubScrollDimensions(scrollContainer, 5500, 600);
act(() => {
scrollContainer.scrollTop = 1000;
fireEvent.scroll(scrollContainer);
});
setStubScrollDimensions(scrollContainer, 6000, 600);
const latestObserver =
MockResizeObserver.instances[MockResizeObserver.instances.length - 1];
act(() => {
latestObserver?.trigger();
});
expect(scrollContainer.scrollTop).toBe(1000);
});
it('shows the scroll-to-bottom button only when not at the bottom', () => {
const { getByTestId } = renderMessageList({ threadId: 'thread-1' });
const scrollContainer = getByTestId('non-last-messages').parentElement
?.parentElement as HTMLElement;
setStubScrollDimensions(scrollContainer, 5500, 600);
expect(getByTestId('scroll-to-bottom').dataset.visible).toBe('false');
act(() => {
scrollContainer.scrollTop = 100;
fireEvent.scroll(scrollContainer);
});
expect(getByTestId('scroll-to-bottom').dataset.visible).toBe('true');
});
it('scrolls to the bottom when the scroll-to-bottom button is clicked', () => {
const { getByTestId } = renderMessageList({ threadId: 'thread-1' });
const scrollContainer = getByTestId('non-last-messages').parentElement
?.parentElement as HTMLElement;
setStubScrollDimensions(scrollContainer, 5500, 600);
act(() => {
scrollContainer.scrollTop = 1000;
fireEvent.scroll(scrollContainer);
});
act(() => {
getByTestId('scroll-to-bottom').click();
});
expect(scrollContainer.scrollTop).toBe(5500);
});
});
@@ -1 +0,0 @@
export const AI_CHAT_SCROLL_WRAPPER_ID = 'ai-chat-scroll-wrapper';
@@ -1,7 +0,0 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const agentChatIsInitialScrollPendingOnThreadChangeState =
createAtomState<boolean>({
key: 'ai/agentChatIsInitialScrollPendingOnThreadChangeState',
defaultValue: false,
});
@@ -0,0 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const agentChatIsScrolledToBottomState = createAtomState<boolean>({
key: 'ai/agentChatIsScrolledToBottomState',
defaultValue: true,
});
@@ -1,16 +0,0 @@
import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId';
import { scrollWrapperScrollBottomComponentState } from '@/ui/utilities/scroll/states/scrollWrapperScrollBottomComponentState';
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
const SCROLL_BOTTOM_THRESHOLD_PX = 100;
export const agentChatIsScrolledToBottomSelector = createAtomSelector<boolean>({
key: 'agentChatIsScrolledToBottomSelector',
get: ({ get }) => {
const scrollBottom = get(scrollWrapperScrollBottomComponentState, {
instanceId: AI_CHAT_SCROLL_WRAPPER_ID,
});
return scrollBottom <= SCROLL_BOTTOM_THRESHOLD_PX;
},
});
@@ -1,11 +0,0 @@
import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId';
export const scrollAiChatToBottom = () => {
const scrollWrapperElement = document.getElementById(
`scroll-wrapper-${AI_CHAT_SCROLL_WRAPPER_ID}`,
);
if (scrollWrapperElement) {
scrollWrapperElement.scrollTop = scrollWrapperElement.scrollHeight;
}
};