Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82968aa5f2 | ||
|
|
51046ad769 |
@@ -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>
|
||||
|
||||
-74
@@ -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;
|
||||
};
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
import { scrollAiChatToBottom } from '@/ai/utils/scrollAiChatToBottom';
|
||||
import { useLayoutEffect } from 'react';
|
||||
|
||||
export const AgentChatScrollToBottomOnMountLayoutEffect = () => {
|
||||
useLayoutEffect(() => {
|
||||
scrollAiChatToBottom();
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
+31
@@ -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;
|
||||
};
|
||||
+40
@@ -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);
|
||||
|
||||
-32
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+243
@@ -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';
|
||||
-7
@@ -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,
|
||||
});
|
||||
-16
@@ -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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user