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>
This commit is contained in:
Lucas Bordeau 2026-03-21 13:52:21 +01:00 committed by GitHub
parent d389a4341d
commit fc9723949b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 1299 additions and 567 deletions

7
.claude/settings.json Normal file
View file

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(git stash:*)"
]
}
}

View file

@ -2,8 +2,8 @@
"mcpServers": {
"postgres": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres", "${PG_DATABASE_URL}"],
"command": "bash",
"args": ["-c", "source packages/twenty-server/.env && npx -y @modelcontextprotocol/server-postgres \"$PG_DATABASE_URL\""],
"env": {}
},
"playwright": {

View file

@ -80,6 +80,17 @@ npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/mi
npx nx run twenty-server:command workspace:sync-metadata
```
### Database Inspection (Postgres MCP)
A read-only Postgres MCP server is configured in `.mcp.json`. Use it to:
- Inspect workspace data, metadata, and object definitions while developing
- Verify migration results (columns, types, constraints) after running migrations
- Explore the multi-tenant schema structure (core, metadata, workspace-specific schemas)
- Debug issues by querying raw data to confirm whether a bug is frontend, backend, or data-level
- Inspect metadata tables to debug GraphQL schema generation or `workspace:sync-metadata` issues
This server is read-only — for write operations (reset, migrations, sync), use the CLI commands above.
### GraphQL
```bash
# Generate GraphQL types (run after schema changes)

View file

@ -3,8 +3,9 @@ import { type Editor } from '@tiptap/react';
import { isDefined } from 'twenty-shared/utils';
import { AIChatSuggestedPrompts } from '@/ai/components/suggested-prompts/AIChatSuggestedPrompts';
import { useAgentChatContext } from '@/ai/contexts/AgentChatContext';
import { AGENT_CHAT_NEW_THREAD_DRAFT_KEY } from '@/ai/states/agentChatDraftsByThreadIdState';
import { agentChatMessagesLoadingState } from '@/ai/states/agentChatMessagesLoadingState';
import { agentChatThreadsLoadingState } from '@/ai/states/agentChatThreadsLoadingState';
import { agentChatErrorState } from '@/ai/states/agentChatErrorState';
import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector';
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
@ -26,7 +27,12 @@ type AIChatEmptyStateProps = {
export const AIChatEmptyState = ({ editor }: AIChatEmptyStateProps) => {
const agentChatError = useAtomStateValue(agentChatErrorState);
const { threadsLoading, messagesLoading } = useAgentChatContext();
const agentChatThreadsLoading = useAtomStateValue(
agentChatThreadsLoadingState,
);
const agentChatMessagesLoading = useAtomStateValue(
agentChatMessagesLoadingState,
);
const skipMessagesSkeletonUntilLoaded = useAtomStateValue(
skipMessagesSkeletonUntilLoadedState,
);
@ -37,11 +43,10 @@ export const AIChatEmptyState = ({ editor }: AIChatEmptyStateProps) => {
);
const isOnNewChatSlot =
!isDefined(currentAIChatThread) ||
currentAIChatThread === AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
const skeletonShowing =
(threadsLoading && isOnNewChatSlot) ||
(messagesLoading && !skipMessagesSkeletonUntilLoaded);
(agentChatThreadsLoading && isOnNewChatSlot) ||
(agentChatMessagesLoading && !skipMessagesSkeletonUntilLoaded);
const shouldRender =
!hasMessages && !isDefined(agentChatError) && !skeletonShowing;

View file

@ -0,0 +1,28 @@
import { AIChatMessage } from '@/ai/components/AIChatMessage';
import { agentChatErrorState } from '@/ai/states/agentChatErrorState';
import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState';
import { agentChatLastMessageIdComponentSelector } from '@/ai/states/agentChatLastMessageIdComponentSelector';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined } from 'twenty-shared/utils';
export const AIChatLastMessageWithStreamingState = () => {
const lastMessageId = useAtomComponentSelectorValue(
agentChatLastMessageIdComponentSelector,
);
const agentChatIsStreaming = useAtomStateValue(agentChatIsStreamingState);
const agentChatError = useAtomStateValue(agentChatErrorState);
if (!isDefined(lastMessageId)) {
return null;
}
return (
<AIChatMessage
messageId={lastMessageId}
isLastMessageStreaming={agentChatIsStreaming}
error={agentChatError ?? undefined}
/>
);
};

View file

@ -5,13 +5,9 @@ import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
import { AIChatAssistantMessageRenderer } from '@/ai/components/AIChatAssistantMessageRenderer';
import { AIChatErrorRenderer } from '@/ai/components/AIChatErrorRenderer';
import { agentChatErrorState } from '@/ai/states/agentChatErrorState';
import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState';
import { agentChatMessageComponentFamilySelector } from '@/ai/states/agentChatMessageComponentFamilySelector';
import { agentChatMessageIdsComponentSelector } from '@/ai/states/agentChatMessageIdsComponentSelector';
import { LightCopyIconButton } from '@/object-record/record-field/ui/components/LightCopyIconButton';
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isExtendedFileUIPart } from 'twenty-shared/ai';
@ -143,39 +139,37 @@ const StyledFilesContainer = styled.div`
margin-top: ${themeCssVariables.spacing[2]};
`;
export const AIChatMessage = ({ messageId }: { messageId: string }) => {
type AIChatMessageProps = {
messageId: string;
isLastMessageStreaming?: boolean;
error?: Error | undefined;
};
export const AIChatMessage = ({
messageId,
isLastMessageStreaming = false,
error,
}: AIChatMessageProps) => {
const agentChatMessage = useAtomComponentFamilySelectorValue(
agentChatMessageComponentFamilySelector,
{ messageId },
);
const agentChatMessageIds = useAtomComponentSelectorValue(
agentChatMessageIdsComponentSelector,
);
const agentChatIsStreaming = useAtomStateValue(agentChatIsStreamingState);
const agentChatError = useAtomStateValue(agentChatErrorState);
const { localeCatalog } = useAtomStateValue(dateLocaleState);
if (!isDefined(agentChatMessage)) {
return null;
}
const isLastMessage = agentChatMessageIds.at(-1) === messageId;
const isLastMessageStreaming = agentChatIsStreaming && isLastMessage;
const isLastAssistantMessage =
isLastMessage && agentChatMessage?.role === AgentMessageRole.ASSISTANT;
const shouldShowError = isDefined(agentChatError) && isLastAssistantMessage;
const isUser = agentChatMessage.role === AgentMessageRole.USER;
const isLastAssistantMessage =
agentChatMessage.role === AgentMessageRole.ASSISTANT;
const shouldShowError = isDefined(error) && isLastAssistantMessage;
const fileParts = agentChatMessage.parts.filter(isExtendedFileUIPart);
return (
<StyledMessageBubble key={agentChatMessage.id} isUser={isUser}>
<StyledMessageBubble isUser={isUser}>
<StyledMessageContainer isUser={isUser}>
<StyledMessageText isUser={isUser}>
<AIChatAssistantMessageRenderer
@ -191,7 +185,9 @@ export const AIChatMessage = ({ messageId }: { messageId: string }) => {
))}
</StyledFilesContainer>
)}
{shouldShowError && <AIChatErrorRenderer error={agentChatError} />}
{shouldShowError && isDefined(error) && (
<AIChatErrorRenderer error={error} />
)}
</StyledMessageContainer>
{agentChatMessage.parts.length > 0 &&
agentChatMessage.metadata?.createdAt && (

View file

@ -0,0 +1,13 @@
import { AIChatMessage } from '@/ai/components/AIChatMessage';
import { agentChatNonLastMessageIdsComponentSelector } from '@/ai/states/agentChatNonLastMessageIdsComponentSelector';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
export const AIChatNonLastMessageIdsList = () => {
const agentChatNonLastMessageIds = useAtomComponentSelectorValue(
agentChatNonLastMessageIdsComponentSelector,
);
return agentChatNonLastMessageIds.map((messageId) => (
<AIChatMessage key={messageId} messageId={messageId} />
));
};

View file

@ -0,0 +1,49 @@
import { agentChatIsScrolledToBottomSelector } from '@/ai/states/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';
const StyledScrollToBottomButton = styled.button<{ isVisible: boolean }>`
align-items: center;
background: ${themeCssVariables.background.primary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.rounded};
bottom: ${themeCssVariables.spacing[3]};
box-shadow: ${themeCssVariables.boxShadow.light};
color: ${themeCssVariables.font.color.secondary};
cursor: pointer;
display: flex;
height: 32px;
justify-content: center;
left: 50%;
opacity: ${({ isVisible }) => (isVisible ? 1 : 0)};
pointer-events: ${({ isVisible }) => (isVisible ? 'auto' : 'none')};
position: absolute;
transform: translateX(-50%);
transition:
opacity calc(${themeCssVariables.animation.duration.normal} * 1s) ease,
background calc(${themeCssVariables.animation.duration.fast} * 1s) ease;
width: 32px;
z-index: 1;
&:hover {
background: ${themeCssVariables.background.tertiary};
}
`;
export const AIChatScrollToBottomButton = () => {
const agentChatIsScrolledToBottom = useAtomStateValue(
agentChatIsScrolledToBottomSelector,
);
return (
<StyledScrollToBottomButton
isVisible={!agentChatIsScrolledToBottom}
onClick={scrollAIChatToBottom}
>
<IconArrowDown size={16} />
</StyledScrollToBottomButton>
);
};

View file

@ -28,7 +28,7 @@ export const AIChatTab = () => {
const threadIdCreatedFromDraft = useAtomStateValue(
threadIdCreatedFromDraftState,
);
const draftKey = currentAIChatThread ?? AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
const draftKey = currentAIChatThread;
const editorSectionKey =
draftKey !== AGENT_CHAT_NEW_THREAD_DRAFT_KEY &&
draftKey === threadIdCreatedFromDraft

View file

@ -1,11 +1,15 @@
import { AIChatErrorUnderMessageList } from '@/ai/components/AIChatErrorUnderMessageList';
import { AIChatMessage } from '@/ai/components/AIChatMessage';
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 { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId';
import { agentChatMessageIdsComponentSelector } from '@/ai/states/agentChatMessageIdsComponentSelector';
import { agentChatHasMessageComponentSelector } from '@/ai/states/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 { styled } from '@linaria/react';
import { isNonEmptyArray } from '@sniptt/guards';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledScrollWrapperContainer = styled.div`
@ -15,28 +19,38 @@ const StyledScrollWrapperContainer = styled.div`
gap: ${themeCssVariables.spacing[2]};
overflow-y: auto;
padding: ${themeCssVariables.spacing[3]};
position: relative;
width: calc(100% - 24px);
`;
export const AIChatTabMessageList = () => {
const agentChatMessageIdsComponent = useAtomComponentSelectorValue(
agentChatMessageIdsComponentSelector,
const agentChatHasMessage = useAtomComponentSelectorValue(
agentChatHasMessageComponentSelector,
);
const hasMessages = isNonEmptyArray(agentChatMessageIdsComponent);
const agentChatIsInitialScrollPendingOnThreadChange = useAtomStateValue(
agentChatIsInitialScrollPendingOnThreadChangeState,
);
if (!hasMessages) {
if (!agentChatHasMessage) {
return null;
}
return (
<StyledScrollWrapperContainer>
<StyledScrollWrapperContainer
style={{
visibility: agentChatIsInitialScrollPendingOnThreadChange
? 'hidden'
: 'visible',
}}
>
<ScrollWrapper componentInstanceId={AI_CHAT_SCROLL_WRAPPER_ID}>
{agentChatMessageIdsComponent.map((messageId) => {
return <AIChatMessage messageId={messageId} key={messageId} />;
})}
<AIChatNonLastMessageIdsList />
<AIChatLastMessageWithStreamingState />
<AIChatErrorUnderMessageList />
<AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect />
</ScrollWrapper>
<AIChatScrollToBottomButton />
</StyledScrollWrapperContainer>
);
};

View file

@ -1,10 +1,10 @@
import { styled } from '@linaria/react';
import { AIChatThreadGroup } from '@/ai/components/AIChatThreadGroup';
import { AIChatThreadsListEffect } from '@/ai/components/AIChatThreadsListEffect';
import { AIChatThreadsListFocusEffect } from '@/ai/components/AIChatThreadsListFocusEffect';
import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader';
import { useChatThreads } from '@/ai/hooks/useChatThreads';
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
import { useSwitchToNewAIChat } from '@/ai/hooks/useSwitchToNewAIChat';
import { groupThreadsByDate } from '@/ai/utils/groupThreadsByDate';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { t } from '@lingui/core/macro';
@ -36,7 +36,7 @@ const StyledButtonsContainer = styled.div`
`;
export const AIChatThreadsList = () => {
const { switchToNewChat } = useCreateNewAIChatThread();
const { switchToNewChat } = useSwitchToNewAIChat();
const focusId = 'threads-list';
@ -57,7 +57,7 @@ export const AIChatThreadsList = () => {
return (
<>
<AIChatThreadsListEffect focusId={focusId} />
<AIChatThreadsListFocusEffect focusId={focusId} />
<StyledContainer>
<StyledThreadsContainer>
{Object.entries(groupedThreads).map(([title, threadsInGroup]) => (

View file

@ -3,7 +3,11 @@ import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useEffect } from 'react';
export const AIChatThreadsListEffect = ({ focusId }: { focusId: string }) => {
export const AIChatThreadsListFocusEffect = ({
focusId,
}: {
focusId: string;
}) => {
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();

View file

@ -0,0 +1,132 @@
import { AGENT_CHAT_ENSURE_THREAD_FOR_DRAFT_EVENT_NAME } from '@/ai/constants/AgentChatEnsureThreadForDraftEventName';
import { useAgentChat } from '@/ai/hooks/useAgentChat';
import { useCreateAgentChatThread } from '@/ai/hooks/useCreateAgentChatThread';
import { useEnsureAgentChatThreadExistsForDraft } from '@/ai/hooks/useEnsureAgentChatThreadExistsForDraft';
import { useEnsureAgentChatThreadIdForSend } from '@/ai/hooks/useEnsureAgentChatThreadIdForSend';
import { agentChatErrorState } from '@/ai/states/agentChatErrorState';
import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState';
import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { agentChatMessagesLoadingState } from '@/ai/states/agentChatMessagesLoadingState';
import { agentChatThreadsLoadingState } from '@/ai/states/agentChatThreadsLoadingState';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { agentChatFetchedMessagesComponentFamilyState } from '@/ai/states/agentChatFetchedMessagesComponentFamilyState';
import { agentChatIsInitialScrollPendingOnThreadChangeState } from '@/ai/states/agentChatIsInitialScrollPendingOnThreadChangeState';
import { mergeAgentChatFetchedAndStreamingMessages } from '@/ai/utils/mergeAgentChatFetchedAndStreamingMessages';
import { AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME } from '@/ai/constants/AgentChatRefetchMessagesEventName';
import { dispatchBrowserEvent } from '@/browser-event/utils/dispatchBrowserEvent';
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
import { useListenToBrowserEvent } from '@/browser-event/hooks/useListenToBrowserEvent';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomComponentFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentFamilyState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useCallback, useEffect } from 'react';
export const AgentChatAiSdkStreamEffect = () => {
const currentAIChatThread = useAtomStateValue(currentAIChatThreadState);
const agentChatFetchedMessages = useAtomComponentFamilyStateValue(
agentChatFetchedMessagesComponentFamilyState,
{ threadId: currentAIChatThread },
);
const { createChatThread } = useCreateAgentChatThread();
const { ensureThreadExistsForDraft } =
useEnsureAgentChatThreadExistsForDraft(createChatThread);
const { ensureThreadIdForSend } =
useEnsureAgentChatThreadIdForSend(createChatThread);
useListenToBrowserEvent({
eventName: AGENT_CHAT_ENSURE_THREAD_FOR_DRAFT_EVENT_NAME,
onBrowserEvent: ensureThreadExistsForDraft,
});
const onStreamingComplete = useCallback(() => {
dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME);
}, []);
const chatState = useAgentChat(
agentChatFetchedMessages,
ensureThreadIdForSend,
onStreamingComplete,
);
const setAgentChatMessages = useSetAtomComponentFamilyState(
agentChatMessagesComponentFamilyState,
{ threadId: currentAIChatThread },
);
const agentChatDisplayedThread = useAtomStateValue(
agentChatDisplayedThreadState,
);
const setAgentChatDisplayedThread = useSetAtomState(
agentChatDisplayedThreadState,
);
const setAgentChatIsInitialScrollPendingOnThreadChange = useSetAtomState(
agentChatIsInitialScrollPendingOnThreadChangeState,
);
useEffect(() => {
const mergedMessages = mergeAgentChatFetchedAndStreamingMessages(
agentChatFetchedMessages,
chatState.messages,
);
setAgentChatMessages(mergedMessages);
if (currentAIChatThread !== agentChatDisplayedThread) {
if (mergedMessages.length > 0) {
setAgentChatIsInitialScrollPendingOnThreadChange(true);
}
setAgentChatDisplayedThread(currentAIChatThread);
}
}, [
agentChatFetchedMessages,
chatState.messages,
chatState.status,
setAgentChatMessages,
currentAIChatThread,
agentChatDisplayedThread,
setAgentChatDisplayedThread,
setAgentChatIsInitialScrollPendingOnThreadChange,
]);
const setAgentChatIsLoading = useSetAtomState(agentChatIsLoadingState);
const agentChatThreadsLoading = useAtomStateValue(
agentChatThreadsLoadingState,
);
const agentChatMessagesLoading = useAtomStateValue(
agentChatMessagesLoadingState,
);
useEffect(() => {
const combinedIsLoading =
chatState.isLoading ||
agentChatMessagesLoading ||
agentChatThreadsLoading;
setAgentChatIsLoading(combinedIsLoading);
}, [
chatState.isLoading,
agentChatMessagesLoading,
agentChatThreadsLoading,
setAgentChatIsLoading,
]);
const setAgentChatError = useSetAtomState(agentChatErrorState);
useEffect(() => {
setAgentChatError(chatState.error);
}, [chatState.error, setAgentChatError]);
const setAgentChatIsStreaming = useSetAtomState(agentChatIsStreamingState);
useEffect(() => {
setAgentChatIsStreaming(chatState.status === 'streaming');
}, [chatState.status, setAgentChatIsStreaming]);
return null;
};

View file

@ -1,80 +0,0 @@
import { useAgentChat } from '@/ai/hooks/useAgentChat';
import { useAgentChatData } from '@/ai/hooks/useAgentChatData';
import { useAgentChatScrollToBottom } from '@/ai/hooks/useAgentChatScrollToBottom';
import { useProcessIncrementalStreamMessages } from '@/ai/hooks/useProcessIncrementalStreamMessages';
import { agentChatErrorState } from '@/ai/states/agentChatErrorState';
import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState';
import { agentChatIsStreamingState } from '@/ai/states/agentChatIsStreamingState';
import { agentChatMessagesComponentState } from '@/ai/states/agentChatMessagesComponentState';
import { agentChatUISessionStartTimeState } from '@/ai/states/agentChatUISessionStartTimeState';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useEffect } from 'react';
import { Temporal } from 'temporal-polyfill';
export const AgentChatDataEffect = () => {
const { uiMessages, isLoading, ensureThreadIdForSend } = useAgentChatData();
const chatState = useAgentChat(uiMessages, ensureThreadIdForSend);
const combinedIsLoading = chatState.isLoading || isLoading;
const isStreaming = chatState.status === 'streaming';
const setAgentChatIsLoading = useSetAtomState(agentChatIsLoadingState);
const setAgentChatError = useSetAtomState(agentChatErrorState);
const [agentChatUISessionStartTime, setAgentChatUISessionStartTime] =
useAtomState(agentChatUISessionStartTimeState);
const [, setAgentChatMessages] = useAtomComponentState(
agentChatMessagesComponentState,
);
useEffect(() => {
setAgentChatMessages(chatState.messages);
}, [chatState.messages, setAgentChatMessages]);
useEffect(() => {
setAgentChatIsLoading(combinedIsLoading);
}, [combinedIsLoading, setAgentChatIsLoading]);
useEffect(() => {
setAgentChatError(chatState.error);
}, [chatState.error, setAgentChatError]);
useEffect(() => {
if (agentChatUISessionStartTime === null) {
setAgentChatUISessionStartTime(Temporal.Now.instant());
}
}, [agentChatUISessionStartTime, setAgentChatUISessionStartTime]);
const setAgentChatIsStreaming = useSetAtomState(agentChatIsStreamingState);
useEffect(() => {
setAgentChatIsStreaming(isStreaming);
}, [setAgentChatIsStreaming, isStreaming]);
const { scrollToBottom, isNearBottom } = useAgentChatScrollToBottom();
const { processIncrementalStreamMessages } =
useProcessIncrementalStreamMessages();
useEffect(() => {
if (chatState.messages.length === 0) {
return;
}
if (isNearBottom) {
scrollToBottom();
}
processIncrementalStreamMessages(chatState.messages);
}, [
chatState.messages,
scrollToBottom,
isNearBottom,
processIncrementalStreamMessages,
]);
return null;
};

View file

@ -0,0 +1,85 @@
import { useCallback, useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME } from '@/ai/constants/AgentChatRefetchMessagesEventName';
import { AGENT_CHAT_NEW_THREAD_DRAFT_KEY } from '@/ai/states/agentChatDraftsByThreadIdState';
import { agentChatFetchedMessagesComponentFamilyState } from '@/ai/states/agentChatFetchedMessagesComponentFamilyState';
import { agentChatMessagesLoadingState } from '@/ai/states/agentChatMessagesLoadingState';
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
import { skipMessagesSkeletonUntilLoadedState } from '@/ai/states/skipMessagesSkeletonUntilLoadedState';
import { mapDBMessagesToUIMessages } from '@/ai/utils/mapDBMessagesToUIMessages';
import { useQueryWithCallbacks } from '@/apollo/hooks/useQueryWithCallbacks';
import { useListenToBrowserEvent } from '@/browser-event/hooks/useListenToBrowserEvent';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomComponentFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentFamilyState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import {
GetChatMessagesDocument,
type GetChatMessagesQuery,
} from '~/generated-metadata/graphql';
export const AgentChatMessagesFetchEffect = () => {
const currentAIChatThread = useAtomStateValue(currentAIChatThreadState);
const isNewThread = useMemo(
() => currentAIChatThread === AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
[currentAIChatThread],
);
const setAgentChatMessagesLoading = useSetAtomState(
agentChatMessagesLoadingState,
);
const setSkipMessagesSkeletonUntilLoaded = useSetAtomState(
skipMessagesSkeletonUntilLoadedState,
);
const setAgentChatFetchedMessages = useSetAtomComponentFamilyState(
agentChatFetchedMessagesComponentFamilyState,
{ threadId: currentAIChatThread },
);
const handleFirstLoad = useCallback(
(_data: GetChatMessagesQuery) => {
setSkipMessagesSkeletonUntilLoaded(false);
},
[setSkipMessagesSkeletonUntilLoaded],
);
const handleDataLoaded = useCallback(
(data: GetChatMessagesQuery) => {
const uiMessages = mapDBMessagesToUIMessages(data.chatMessages ?? []);
setAgentChatFetchedMessages(uiMessages);
},
[setAgentChatFetchedMessages],
);
const handleLoadingChange = useCallback(
(loading: boolean) => {
setAgentChatMessagesLoading(loading);
},
[setAgentChatMessagesLoading],
);
const { refetch: refetchAgentChatMessages } = useQueryWithCallbacks(
GetChatMessagesDocument,
{
variables: { threadId: currentAIChatThread },
skip: !isDefined(currentAIChatThread) || isNewThread,
onFirstLoad: handleFirstLoad,
onDataLoaded: handleDataLoaded,
onLoadingChange: handleLoadingChange,
},
);
const handleRefetchMessages = useCallback(() => {
refetchAgentChatMessages();
}, [refetchAgentChatMessages]);
useListenToBrowserEvent({
eventName: AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME,
onBrowserEvent: handleRefetchMessages,
});
return null;
};

View file

@ -1,6 +1,10 @@
import { AgentChatDataEffect } from '@/ai/components/AgentChatDataEffect';
import { AgentChatContext } from '@/ai/contexts/AgentChatContext';
import { useAgentChatData } from '@/ai/hooks/useAgentChatData';
import { AgentChatAiSdkStreamEffect } from '@/ai/components/AgentChatAiSdkStreamEffect';
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/states/AgentChatComponentInstanceContext';
import { Suspense } from 'react';
@ -9,25 +13,19 @@ export const AgentChatProviderContent = ({
}: {
children: React.ReactNode;
}) => {
const { ensureThreadForDraft, threadsLoading, messagesLoading } =
useAgentChatData();
const contextValue = {
ensureThreadForDraft,
threadsLoading,
messagesLoading,
};
return (
<Suspense fallback={null}>
<AgentChatContext.Provider value={contextValue}>
<AgentChatComponentInstanceContext.Provider
value={{ instanceId: 'agentChatComponentInstance' }}
>
<AgentChatDataEffect />
{children}
</AgentChatComponentInstanceContext.Provider>
</AgentChatContext.Provider>
<AgentChatComponentInstanceContext.Provider
value={{ instanceId: 'agentChatComponentInstance' }}
>
<AgentChatThreadInitializationEffect />
<AgentChatMessagesFetchEffect />
<AgentChatAiSdkStreamEffect />
<AgentChatStreamingPartsDiffSyncEffect />
<AgentChatSessionStartTimeEffect />
<AgentChatStreamingAutoScrollEffect />
{children}
</AgentChatComponentInstanceContext.Provider>
</Suspense>
);
};

View file

@ -0,0 +1,74 @@
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;
};

View file

@ -0,0 +1,17 @@
import { agentChatUISessionStartTimeState } from '@/ai/states/agentChatUISessionStartTimeState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useEffect } from 'react';
import { Temporal } from 'temporal-polyfill';
export const AgentChatSessionStartTimeEffect = () => {
const [agentChatUISessionStartTime, setAgentChatUISessionStartTime] =
useAtomState(agentChatUISessionStartTimeState);
useEffect(() => {
if (agentChatUISessionStartTime === null) {
setAgentChatUISessionStartTime(Temporal.Now.instant());
}
}, [agentChatUISessionStartTime, setAgentChatUISessionStartTime]);
return null;
};

View file

@ -0,0 +1,32 @@
import { agentChatIsScrolledToBottomSelector } from '@/ai/states/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;
};

View file

@ -0,0 +1,39 @@
import { useUpdateStreamingPartsWithDiff } from '@/ai/hooks/useUpdateStreamingPartsWithDiff';
import { agentChatLastDiffSyncedThreadState } from '@/ai/states/agentChatLastDiffSyncedThreadState';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useEffect } from 'react';
export const AgentChatStreamingPartsDiffSyncEffect = () => {
const currentAIChatThread = useAtomStateValue(currentAIChatThreadState);
const agentChatMessages = useAtomComponentFamilyStateValue(
agentChatMessagesComponentFamilyState,
{ threadId: currentAIChatThread },
);
const { updateStreamingPartsWithDiff } = useUpdateStreamingPartsWithDiff();
const setAgentChatLastDiffSyncedThread = useSetAtomState(
agentChatLastDiffSyncedThreadState,
);
useEffect(() => {
if (agentChatMessages.length === 0) {
return;
}
updateStreamingPartsWithDiff(agentChatMessages);
setAgentChatLastDiffSyncedThread(currentAIChatThread);
}, [
agentChatMessages,
updateStreamingPartsWithDiff,
currentAIChatThread,
setAgentChatLastDiffSyncedThread,
]);
return null;
};

View file

@ -0,0 +1,103 @@
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { CHAT_THREADS_PAGE_SIZE } from '@/ai/constants/ChatThreads';
import {
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
agentChatDraftsByThreadIdState,
} from '@/ai/states/agentChatDraftsByThreadIdState';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { agentChatThreadsLoadingState } from '@/ai/states/agentChatThreadsLoadingState';
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState';
import { hasTriggeredCreateForDraftState } from '@/ai/states/hasTriggeredCreateForDraftState';
import { useQueryWithCallbacks } from '@/apollo/hooks/useQueryWithCallbacks';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import {
type GetChatThreadsQuery,
GetChatThreadsDocument,
} from '~/generated-metadata/graphql';
export const AgentChatThreadInitializationEffect = () => {
const currentAIChatThread = useAtomStateValue(currentAIChatThreadState);
const setCurrentAIChatThread = useSetAtomState(currentAIChatThreadState);
const setAgentChatInput = useSetAtomState(agentChatInputState);
const setAgentChatUsage = useSetAtomState(agentChatUsageState);
const setCurrentAIChatThreadTitle = useSetAtomState(
currentAIChatThreadTitleState,
);
const setAgentChatThreadsLoading = useSetAtomState(
agentChatThreadsLoadingState,
);
const store = useStore();
const handleFirstLoad = useCallback(
(data: GetChatThreadsQuery) => {
const threads = data.chatThreads.edges.map((edge) => edge.node);
if (threads.length > 0) {
const firstThread = threads[0];
const draftForThread =
store.get(agentChatDraftsByThreadIdState.atom)[firstThread.id] ?? '';
setCurrentAIChatThread(firstThread.id);
setAgentChatInput(draftForThread);
setCurrentAIChatThreadTitle(firstThread.title ?? null);
const hasUsageData =
(firstThread.conversationSize ?? 0) > 0 &&
isDefined(firstThread.contextWindowTokens);
setAgentChatUsage(
hasUsageData
? {
lastMessage: null,
conversationSize: firstThread.conversationSize ?? 0,
contextWindowTokens: firstThread.contextWindowTokens ?? 0,
inputTokens: firstThread.totalInputTokens,
outputTokens: firstThread.totalOutputTokens,
inputCredits: firstThread.totalInputCredits,
outputCredits: firstThread.totalOutputCredits,
}
: null,
);
} else {
store.set(hasTriggeredCreateForDraftState.atom, false);
setCurrentAIChatThread(AGENT_CHAT_NEW_THREAD_DRAFT_KEY);
setAgentChatInput(
store.get(agentChatDraftsByThreadIdState.atom)[
AGENT_CHAT_NEW_THREAD_DRAFT_KEY
] ?? '',
);
setCurrentAIChatThreadTitle(null);
setAgentChatUsage(null);
}
},
[
setCurrentAIChatThread,
setAgentChatInput,
setCurrentAIChatThreadTitle,
setAgentChatUsage,
store,
],
);
const handleLoadingChange = useCallback(
(loading: boolean) => {
setAgentChatThreadsLoading(loading);
},
[setAgentChatThreadsLoading],
);
useQueryWithCallbacks(GetChatThreadsDocument, {
variables: { paging: { first: CHAT_THREADS_PAGE_SIZE } },
skip: isDefined(currentAIChatThread),
onFirstLoad: handleFirstLoad,
onLoadingChange: handleLoadingChange,
});
return null;
};

View file

@ -11,8 +11,11 @@ import { ComponentDecorator } from 'twenty-ui/testing';
import { AIChatMessage } from '@/ai/components/AIChatMessage';
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { agentChatMessageComponentFamilyState } from '@/ai/states/agentChatMessageComponentFamilyState';
import { agentChatMessagesComponentState } from '@/ai/states/agentChatMessagesComponentState';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
import { styled } from '@linaria/react';
import { useStore } from 'jotai';
import { RootDecorator } from '~/testing/decorators/RootDecorator';
@ -255,8 +258,15 @@ const AgentChatMessagesSetterEffect = ({
const store = useStore();
useEffect(() => {
const currentThreadId = store.get(currentAIChatThreadState.atom);
store.set(agentChatDisplayedThreadState.atom, currentThreadId);
store.set(
agentChatMessagesComponentState.atomFamily({ instanceId: INSTANCE_ID }),
agentChatMessagesComponentFamilyState.atomFamily({
instanceId: INSTANCE_ID,
familyKey: { threadId: currentThreadId },
}),
messages,
);

View file

@ -1,11 +1,11 @@
import { styled } from '@linaria/react';
import { useContext } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { isDefined } from 'twenty-shared/utils';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { useAgentChatContext } from '@/ai/contexts/AgentChatContext';
import { AGENT_CHAT_NEW_THREAD_DRAFT_KEY } from '@/ai/states/agentChatDraftsByThreadIdState';
import { agentChatMessagesLoadingState } from '@/ai/states/agentChatMessagesLoadingState';
import { agentChatThreadsLoadingState } from '@/ai/states/agentChatThreadsLoadingState';
import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector';
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
import { skipMessagesSkeletonUntilLoadedState } from '@/ai/states/skipMessagesSkeletonUntilLoadedState';
@ -34,7 +34,12 @@ const NUMBER_OF_SKELETONS = 6;
export const AIChatSkeletonLoader = () => {
const { theme } = useContext(ThemeContext);
const { threadsLoading, messagesLoading } = useAgentChatContext();
const agentChatThreadsLoading = useAtomStateValue(
agentChatThreadsLoadingState,
);
const agentChatMessagesLoading = useAtomStateValue(
agentChatMessagesLoadingState,
);
const skipMessagesSkeletonUntilLoaded = useAtomStateValue(
skipMessagesSkeletonUntilLoadedState,
);
@ -45,13 +50,12 @@ export const AIChatSkeletonLoader = () => {
);
const isOnNewChatSlot =
!isDefined(currentAIChatThread) ||
currentAIChatThread === AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
const showForMessagesLoading =
messagesLoading && !skipMessagesSkeletonUntilLoaded;
agentChatMessagesLoading && !skipMessagesSkeletonUntilLoaded;
const shouldRender =
!hasMessages &&
((threadsLoading && isOnNewChatSlot) || showForMessagesLoading);
((agentChatThreadsLoading && isOnNewChatSlot) || showForMessagesLoading);
if (!shouldRender) {
return null;

View file

@ -0,0 +1,2 @@
export const AGENT_CHAT_ENSURE_THREAD_FOR_DRAFT_EVENT_NAME =
'agent-chat-ensure-thread-for-draft' as const;

View file

@ -0,0 +1,2 @@
export const AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME =
'agent-chat-refetch-messages' as const;

View file

@ -0,0 +1 @@
export const AGENT_CHAT_UNKNOWN_THREAD_ID = 'unknown-thread';

View file

@ -8,7 +8,6 @@ import { useEditor } from '@tiptap/react';
import { useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useAgentChatContext } from '@/ai/contexts/AgentChatContext';
import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
import {
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
@ -16,6 +15,7 @@ import {
} from '@/ai/states/agentChatDraftsByThreadIdState';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
import { dispatchAgentChatEnsureThreadForDraftEvent } from '@/ai/utils/dispatchAgentChatEnsureThreadForDraftEvent';
import { dispatchAgentChatSendMessageEvent } from '@/ai/utils/dispatchAgentChatSendMessageEvent';
import { MENTION_SUGGESTION_PLUGIN_KEY } from '@/mention/constants/MentionSuggestionPluginKey';
import { MentionSuggestion } from '@/mention/extensions/MentionSuggestion';
@ -44,14 +44,12 @@ export const useAIChatEditor = () => {
const currentAIChatThread = useAtomStateValue(currentAIChatThreadState);
const [agentChatDraftsByThreadId, setAgentChatDraftsByThreadId] =
useAtomState(agentChatDraftsByThreadIdState);
const { ensureThreadForDraft } = useAgentChatContext();
const { searchMentionRecords } = useMentionSearch();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const draftKey = currentAIChatThread ?? AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
const draftKey = currentAIChatThread;
const initialDraft = agentChatDraftsByThreadId[draftKey] ?? '';
const initialContent = textToTiptapContent(initialDraft);
@ -102,7 +100,7 @@ export const useAIChatEditor = () => {
setAgentChatInput(text);
setAgentChatDraftsByThreadId((prev) => ({ ...prev, [draftKey]: text }));
if (draftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY && text.trim() !== '') {
ensureThreadForDraft?.();
dispatchAgentChatEnsureThreadForDraftEvent();
}
},
onFocus: () => {

View file

@ -1,7 +1,4 @@
import {
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
agentChatDraftsByThreadIdState,
} from '@/ai/states/agentChatDraftsByThreadIdState';
import { agentChatDraftsByThreadIdState } from '@/ai/states/agentChatDraftsByThreadIdState';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
@ -41,8 +38,7 @@ export const useAIChatThreadClick = (
const handleThreadClick = (thread: AgentChatThread) => {
setThreadIdCreatedFromDraft(null);
const previousDraftKey =
currentAIChatThread ?? AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
const previousDraftKey = currentAIChatThread;
const isSameThread = thread.id === currentAIChatThread;
setAgentChatDraftsByThreadId((prev) => ({

View file

@ -35,6 +35,7 @@ import { cookieStorage } from '~/utils/cookie-storage';
export const useAgentChat = (
uiMessages: ExtendedUIMessage[],
ensureThreadIdForSend: () => Promise<string | null>,
onStreamingComplete?: () => void,
) => {
const setTokenPair = useSetAtomState(tokenPairState);
const setAgentChatUsage = useSetAtomState(agentChatUsageState);
@ -206,6 +207,8 @@ export const useAgentChat = (
}
return null;
});
onStreamingComplete?.();
},
});

View file

@ -1,309 +0,0 @@
import { useApolloClient, useMutation, useQuery } from '@apollo/client/react';
import { getOperationName } from '~/utils/getOperationName';
import { useCallback, useEffect, useMemo } from 'react';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
import { CHAT_THREADS_PAGE_SIZE } from '@/ai/constants/ChatThreads';
import { useAgentChatScrollToBottom } from '@/ai/hooks/useAgentChatScrollToBottom';
import {
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
agentChatDraftsByThreadIdState,
} from '@/ai/states/agentChatDraftsByThreadIdState';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState';
import { focusEditorAfterMigrateState } from '@/ai/states/focusEditorAfterMigrateState';
import { hasTriggeredCreateForDraftState } from '@/ai/states/hasTriggeredCreateForDraftState';
import { isCreatingChatThreadState } from '@/ai/states/isCreatingChatThreadState';
import { isCreatingForFirstSendState } from '@/ai/states/isCreatingForFirstSendState';
import { pendingCreateFromDraftPromiseState } from '@/ai/states/pendingCreateFromDraftPromiseState';
import { skipMessagesSkeletonUntilLoadedState } from '@/ai/states/skipMessagesSkeletonUntilLoadedState';
import { threadIdCreatedFromDraftState } from '@/ai/states/threadIdCreatedFromDraftState';
import { mapDBMessagesToUIMessages } from '@/ai/utils/mapDBMessagesToUIMessages';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import {
type GetChatThreadsQuery,
GetChatThreadsDocument,
CreateChatThreadDocument,
GetChatMessagesDocument,
} from '~/generated-metadata/graphql';
export const useAgentChatData = () => {
const [currentAIChatThread, setCurrentAIChatThread] = useAtomState(
currentAIChatThreadState,
);
const setAgentChatInput = useSetAtomState(agentChatInputState);
const setAgentChatUsage = useSetAtomState(agentChatUsageState);
const setCurrentAIChatThreadTitle = useSetAtomState(
currentAIChatThreadTitleState,
);
const [, setIsCreatingChatThread] = useAtomState(isCreatingChatThreadState);
const setAgentChatDraftsByThreadId = useSetAtomState(
agentChatDraftsByThreadIdState,
);
const setPendingCreateFromDraftPromise = useSetAtomState(
pendingCreateFromDraftPromiseState,
);
const store = useStore();
const apolloClient = useApolloClient();
const { scrollToBottom } = useAgentChatScrollToBottom();
const [createChatThread] = useMutation(CreateChatThreadDocument, {
onCompleted: (data) => {
if (store.get(isCreatingForFirstSendState.atom)) {
store.set(isCreatingForFirstSendState.atom, false);
setIsCreatingChatThread(false);
return;
}
const newThreadId = data.createChatThread.id;
const previousDraftKey =
store.get(currentAIChatThreadState.atom) ??
AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
const draftsSnapshot = store.get(agentChatDraftsByThreadIdState.atom);
const newDraft = draftsSnapshot[AGENT_CHAT_NEW_THREAD_DRAFT_KEY] ?? '';
setIsCreatingChatThread(false);
if (previousDraftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY) {
store.set(hasTriggeredCreateForDraftState.atom, true);
setAgentChatDraftsByThreadId((prev) => ({
...prev,
[newThreadId]: newDraft,
[AGENT_CHAT_NEW_THREAD_DRAFT_KEY]: '',
}));
store.set(focusEditorAfterMigrateState.atom, true);
store.set(skipMessagesSkeletonUntilLoadedState.atom, true);
store.set(threadIdCreatedFromDraftState.atom, newThreadId);
} else {
setAgentChatDraftsByThreadId((prev) => ({
...prev,
[previousDraftKey]: store.get(agentChatInputState.atom),
}));
}
setCurrentAIChatThread(newThreadId);
setAgentChatInput(newDraft);
setCurrentAIChatThreadTitle(null);
setAgentChatUsage(null);
const newThread = data.createChatThread;
const threadListVariables = {
paging: { first: CHAT_THREADS_PAGE_SIZE },
};
const existing = apolloClient.cache.readQuery<GetChatThreadsQuery>({
query: GetChatThreadsDocument,
variables: threadListVariables,
});
if (isDefined(existing) && isDefined(existing.chatThreads)) {
const newNode = {
__typename: 'AgentChatThread' as const,
...newThread,
totalInputTokens: 0,
totalOutputTokens: 0,
contextWindowTokens: null,
conversationSize: 0,
totalInputCredits: 0,
totalOutputCredits: 0,
};
const newEdge = {
__typename: 'AgentChatThreadEdge' as const,
node: newNode,
cursor: newThread.id,
};
apolloClient.cache.writeQuery({
query: GetChatThreadsDocument,
variables: threadListVariables,
data: {
chatThreads: {
...existing.chatThreads,
edges: [newEdge, ...existing.chatThreads.edges],
},
},
});
}
},
onError: () => {
setIsCreatingChatThread(false);
store.set(isCreatingForFirstSendState.atom, false);
store.set(hasTriggeredCreateForDraftState.atom, false);
},
refetchQueries: [
getOperationName(GetChatThreadsDocument) ?? 'GetChatThreads',
],
});
const { loading: threadsLoading, data: threadsData } = useQuery(
GetChatThreadsDocument,
{
variables: { paging: { first: CHAT_THREADS_PAGE_SIZE } },
skip: isDefined(currentAIChatThread),
},
);
// TODO: Refactor this useEffect to avoid unnecessary re-renders (see PR #18584 review)
useEffect(() => {
if (!threadsData) return;
const threads = threadsData.chatThreads.edges.map((edge) => edge.node);
if (threads.length > 0) {
const firstThread = threads[0];
const newDraft =
store.get(agentChatDraftsByThreadIdState.atom)[firstThread.id] ?? '';
setCurrentAIChatThread(firstThread.id);
setAgentChatInput(newDraft);
setCurrentAIChatThreadTitle(firstThread.title ?? null);
const hasUsageData =
(firstThread.conversationSize ?? 0) > 0 &&
isDefined(firstThread.contextWindowTokens);
setAgentChatUsage(
hasUsageData
? {
lastMessage: null,
conversationSize: firstThread.conversationSize ?? 0,
contextWindowTokens: firstThread.contextWindowTokens ?? 0,
inputTokens: firstThread.totalInputTokens,
outputTokens: firstThread.totalOutputTokens,
inputCredits: firstThread.totalInputCredits,
outputCredits: firstThread.totalOutputCredits,
}
: null,
);
} else {
store.set(hasTriggeredCreateForDraftState.atom, false);
setCurrentAIChatThread(AGENT_CHAT_NEW_THREAD_DRAFT_KEY);
setAgentChatInput(
store.get(agentChatDraftsByThreadIdState.atom)[
AGENT_CHAT_NEW_THREAD_DRAFT_KEY
] ?? '',
);
setCurrentAIChatThreadTitle(null);
setAgentChatUsage(null);
}
}, [
threadsData,
store,
setCurrentAIChatThread,
setAgentChatInput,
setCurrentAIChatThreadTitle,
setAgentChatUsage,
]);
const isNewThread = useMemo(
() => currentAIChatThread === AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
[currentAIChatThread],
);
const { loading: messagesLoading, data } = useQuery(GetChatMessagesDocument, {
variables: { threadId: currentAIChatThread! },
skip: !isDefined(currentAIChatThread) || isNewThread,
});
// TODO: Refactor this useEffect to avoid unnecessary re-renders (see PR #18584 review)
useEffect(() => {
if (data) {
store.set(skipMessagesSkeletonUntilLoadedState.atom, false);
scrollToBottom();
}
}, [data, store, scrollToBottom]);
const ensureThreadForDraft = useCallback(() => {
const current = store.get(currentAIChatThreadState.atom);
if (current !== AGENT_CHAT_NEW_THREAD_DRAFT_KEY) {
return;
}
const draft =
store.get(agentChatDraftsByThreadIdState.atom)[
AGENT_CHAT_NEW_THREAD_DRAFT_KEY
] ?? '';
if (draft.trim() === '') {
return;
}
if (store.get(hasTriggeredCreateForDraftState.atom)) {
return;
}
if (store.get(isCreatingChatThreadState.atom)) {
return;
}
setIsCreatingChatThread(true);
const createPromise = createChatThread();
const threadIdPromise = createPromise.then(
(result) => result?.data?.createChatThread?.id ?? null,
);
setPendingCreateFromDraftPromise(threadIdPromise);
threadIdPromise.finally(() => {
setPendingCreateFromDraftPromise(null);
});
}, [
createChatThread,
setPendingCreateFromDraftPromise,
store,
setIsCreatingChatThread,
]);
const ensureThreadIdForSend = useCallback(async (): Promise<
string | null
> => {
const current = store.get(currentAIChatThreadState.atom);
if (current !== AGENT_CHAT_NEW_THREAD_DRAFT_KEY) {
return current;
}
const inFlightCreate = store.get(pendingCreateFromDraftPromiseState.atom);
if (
store.get(isCreatingChatThreadState.atom) &&
isDefined(inFlightCreate)
) {
try {
const threadId = await inFlightCreate;
return threadId;
} catch {
return null;
}
}
store.set(isCreatingForFirstSendState.atom, true);
setIsCreatingChatThread(true);
try {
const result = await createChatThread();
return result?.data?.createChatThread?.id ?? null;
} catch {
return null;
} finally {
setIsCreatingChatThread(false);
}
}, [createChatThread, store, setIsCreatingChatThread]);
const threadsLoadingMemoized = useMemo(
() => threadsLoading,
[threadsLoading],
);
const messagesLoadingMemoized = useMemo(
() => messagesLoading,
[messagesLoading],
);
const uiMessages = useMemo(
() => mapDBMessagesToUIMessages(data?.chatMessages || []),
[data?.chatMessages],
);
const isLoading = useMemo(
() => messagesLoadingMemoized || threadsLoadingMemoized,
[messagesLoadingMemoized, threadsLoadingMemoized],
);
return {
uiMessages,
isLoading,
threadsLoading: threadsLoadingMemoized,
messagesLoading: messagesLoadingMemoized,
ensureThreadForDraft,
ensureThreadIdForSend,
};
};

View file

@ -1,37 +0,0 @@
import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId';
import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement';
import { scrollWrapperScrollBottomComponentState } from '@/ui/utilities/scroll/states/scrollWrapperScrollBottomComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useCallback, useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
const SCROLL_BOTTOM_THRESHOLD_PX = 10;
export const useAgentChatScrollToBottom = () => {
const { getScrollWrapperElement } = useScrollWrapperHTMLElement(
AI_CHAT_SCROLL_WRAPPER_ID,
);
const scrollWrapperScrollBottom = useAtomComponentStateValue(
scrollWrapperScrollBottomComponentState,
AI_CHAT_SCROLL_WRAPPER_ID,
);
const isNearBottom = useMemo(
() => scrollWrapperScrollBottom <= SCROLL_BOTTOM_THRESHOLD_PX,
[scrollWrapperScrollBottom],
);
const scrollToBottom = useCallback(() => {
const { scrollWrapperElement } = getScrollWrapperElement();
if (!isDefined(scrollWrapperElement)) {
return;
}
scrollWrapperElement.scrollTo({
top: scrollWrapperElement.scrollHeight,
});
}, [getScrollWrapperElement]);
return { scrollToBottom, isNearBottom };
};

View file

@ -0,0 +1,89 @@
import { useStore } from 'jotai';
import {
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
agentChatDraftsByThreadIdState,
} from '@/ai/states/agentChatDraftsByThreadIdState';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState';
import { focusEditorAfterMigrateState } from '@/ai/states/focusEditorAfterMigrateState';
import { hasTriggeredCreateForDraftState } from '@/ai/states/hasTriggeredCreateForDraftState';
import { isCreatingChatThreadState } from '@/ai/states/isCreatingChatThreadState';
import { isCreatingForFirstSendState } from '@/ai/states/isCreatingForFirstSendState';
import { skipMessagesSkeletonUntilLoadedState } from '@/ai/states/skipMessagesSkeletonUntilLoadedState';
import { threadIdCreatedFromDraftState } from '@/ai/states/threadIdCreatedFromDraftState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useMutation } from '@apollo/client/react';
import {
CreateChatThreadDocument,
GetChatThreadsDocument,
} from '~/generated-metadata/graphql';
import { getOperationName } from '~/utils/getOperationName';
export const useCreateAgentChatThread = () => {
const setCurrentAIChatThread = useSetAtomState(currentAIChatThreadState);
const setAgentChatInput = useSetAtomState(agentChatInputState);
const setAgentChatUsage = useSetAtomState(agentChatUsageState);
const setCurrentAIChatThreadTitle = useSetAtomState(
currentAIChatThreadTitleState,
);
const setIsCreatingChatThread = useSetAtomState(isCreatingChatThreadState);
const setAgentChatDraftsByThreadId = useSetAtomState(
agentChatDraftsByThreadIdState,
);
const store = useStore();
const [createChatThread] = useMutation(CreateChatThreadDocument, {
onCompleted: (data) => {
if (store.get(isCreatingForFirstSendState.atom)) {
store.set(isCreatingForFirstSendState.atom, false);
setIsCreatingChatThread(false);
return;
}
const newThreadId = data.createChatThread.id;
const previousDraftKey =
store.get(currentAIChatThreadState.atom) ??
AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
const draftsSnapshot = store.get(agentChatDraftsByThreadIdState.atom);
const newDraft = draftsSnapshot[AGENT_CHAT_NEW_THREAD_DRAFT_KEY] ?? '';
setIsCreatingChatThread(false);
if (previousDraftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY) {
store.set(hasTriggeredCreateForDraftState.atom, true);
setAgentChatDraftsByThreadId((previousDrafts) => ({
...previousDrafts,
[newThreadId]: newDraft,
[AGENT_CHAT_NEW_THREAD_DRAFT_KEY]: '',
}));
store.set(focusEditorAfterMigrateState.atom, true);
store.set(skipMessagesSkeletonUntilLoadedState.atom, true);
store.set(threadIdCreatedFromDraftState.atom, newThreadId);
} else {
setAgentChatDraftsByThreadId((previousDrafts) => ({
...previousDrafts,
[previousDraftKey]: store.get(agentChatInputState.atom),
}));
}
setCurrentAIChatThread(newThreadId);
setAgentChatInput(newDraft);
setCurrentAIChatThreadTitle(null);
setAgentChatUsage(null);
},
onError: () => {
setIsCreatingChatThread(false);
store.set(isCreatingForFirstSendState.atom, false);
store.set(hasTriggeredCreateForDraftState.atom, false);
},
refetchQueries: [
getOperationName(GetChatThreadsDocument) ?? 'GetChatThreads',
],
});
return { createChatThread };
};

View file

@ -0,0 +1,67 @@
import { useStore } from 'jotai';
import { useCallback } from 'react';
import {
AGENT_CHAT_NEW_THREAD_DRAFT_KEY,
agentChatDraftsByThreadIdState,
} from '@/ai/states/agentChatDraftsByThreadIdState';
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
import { hasTriggeredCreateForDraftState } from '@/ai/states/hasTriggeredCreateForDraftState';
import { isCreatingChatThreadState } from '@/ai/states/isCreatingChatThreadState';
import { pendingCreateFromDraftPromiseState } from '@/ai/states/pendingCreateFromDraftPromiseState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export const useEnsureAgentChatThreadExistsForDraft = (
createChatThread: () => Promise<any>,
) => {
const setIsCreatingChatThread = useSetAtomState(isCreatingChatThreadState);
const setPendingCreateFromDraftPromise = useSetAtomState(
pendingCreateFromDraftPromiseState,
);
const store = useStore();
const ensureThreadExistsForDraft = useCallback(() => {
const currentThreadId = store.get(currentAIChatThreadState.atom);
if (currentThreadId !== AGENT_CHAT_NEW_THREAD_DRAFT_KEY) {
return;
}
const draft =
store.get(agentChatDraftsByThreadIdState.atom)[
AGENT_CHAT_NEW_THREAD_DRAFT_KEY
] ?? '';
if (draft.trim() === '') {
return;
}
if (store.get(hasTriggeredCreateForDraftState.atom)) {
return;
}
if (store.get(isCreatingChatThreadState.atom)) {
return;
}
setIsCreatingChatThread(true);
const createPromise = createChatThread();
const threadIdPromise = createPromise.then(
(result) => result?.data?.createChatThread?.id ?? null,
);
setPendingCreateFromDraftPromise(threadIdPromise);
threadIdPromise.finally(() => {
setPendingCreateFromDraftPromise(null);
});
}, [
createChatThread,
setPendingCreateFromDraftPromise,
store,
setIsCreatingChatThread,
]);
return { ensureThreadExistsForDraft };
};

View file

@ -0,0 +1,57 @@
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { AGENT_CHAT_NEW_THREAD_DRAFT_KEY } from '@/ai/states/agentChatDraftsByThreadIdState';
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
import { isCreatingChatThreadState } from '@/ai/states/isCreatingChatThreadState';
import { isCreatingForFirstSendState } from '@/ai/states/isCreatingForFirstSendState';
import { pendingCreateFromDraftPromiseState } from '@/ai/states/pendingCreateFromDraftPromiseState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export const useEnsureAgentChatThreadIdForSend = (
createChatThread: () => Promise<any>,
) => {
const setIsCreatingChatThread = useSetAtomState(isCreatingChatThreadState);
const store = useStore();
const ensureThreadIdForSend = useCallback(async (): Promise<
string | null
> => {
const currentThreadId = store.get(currentAIChatThreadState.atom);
if (currentThreadId !== AGENT_CHAT_NEW_THREAD_DRAFT_KEY) {
return currentThreadId;
}
const inFlightCreatePromise = store.get(
pendingCreateFromDraftPromiseState.atom,
);
if (
store.get(isCreatingChatThreadState.atom) &&
isDefined(inFlightCreatePromise)
) {
try {
const threadId = await inFlightCreatePromise;
return threadId;
} catch {
return null;
}
}
store.set(isCreatingForFirstSendState.atom, true);
setIsCreatingChatThread(true);
try {
const result = await createChatThread();
return result?.data?.createChatThread?.id ?? null;
} catch {
return null;
} finally {
setIsCreatingChatThread(false);
}
}, [createChatThread, store, setIsCreatingChatThread]);
return { ensureThreadIdForSend };
};

View file

@ -6,21 +6,21 @@ import { isNonEmptyString } from '@sniptt/guards';
import { Temporal } from 'temporal-polyfill';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
export const useProcessNewMessageStreamIncrement = () => {
export const useProcessStreamingMessageUpdate = () => {
const agentChatUISessionStartTime = useAtomStateValue(
agentChatUISessionStartTimeState,
);
const { processUIToolCallMessage } = useProcessUIToolCallMessage();
const processNewMessageStreamIncrement = (
messageStreamIncrement: ExtendedUIMessage,
const processStreamingMessageUpdate = (
streamingMessage: ExtendedUIMessage,
) => {
if (agentChatUISessionStartTime === null) {
return false;
}
const messageCreatedAt = messageStreamIncrement.metadata?.createdAt;
const messageCreatedAt = streamingMessage.metadata?.createdAt;
if (isNonEmptyString(messageCreatedAt)) {
const messageCreatedAtInstant = Temporal.Instant.from(messageCreatedAt);
@ -34,14 +34,14 @@ export const useProcessNewMessageStreamIncrement = () => {
}
}
const messageIsUIToolCall = isUIToolCallMessage(messageStreamIncrement);
const messageIsUIToolCall = isUIToolCallMessage(streamingMessage);
if (messageIsUIToolCall) {
processUIToolCallMessage(messageStreamIncrement);
processUIToolCallMessage(streamingMessage);
}
};
return {
processNewMessageStreamIncrement,
processStreamingMessageUpdate,
};
};

View file

@ -14,7 +14,7 @@ import { useOpenAskAIPageInSidePanel } from '@/side-panel/hooks/useOpenAskAIPage
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export const useCreateNewAIChatThread = () => {
export const useSwitchToNewAIChat = () => {
const setThreadIdCreatedFromDraft = useSetAtomState(
threadIdCreatedFromDraftState,
);
@ -34,8 +34,7 @@ export const useCreateNewAIChatThread = () => {
const switchToNewChat = () => {
setThreadIdCreatedFromDraft(null);
const previousDraftKey =
currentAIChatThread ?? AGENT_CHAT_NEW_THREAD_DRAFT_KEY;
const previousDraftKey = currentAIChatThread;
const newChatDraft =
store.get(agentChatDraftsByThreadIdState.atom)[
AGENT_CHAT_NEW_THREAD_DRAFT_KEY

View file

@ -1,4 +1,4 @@
import { useProcessNewMessageStreamIncrement } from '@/ai/hooks/useProcessNewMessageStreamIncrement';
import { useProcessStreamingMessageUpdate } from '@/ai/hooks/useProcessStreamingMessageUpdate';
import { agentChatMessageComponentFamilyState } from '@/ai/states/agentChatMessageComponentFamilyState';
import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
@ -7,25 +7,24 @@ import { type ExtendedUIMessage } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const useProcessIncrementalStreamMessages = () => {
export const useUpdateStreamingPartsWithDiff = () => {
const agentChatMessageFamilyCallbackState =
useAtomComponentFamilyStateCallbackState(
agentChatMessageComponentFamilyState,
);
const { processNewMessageStreamIncrement } =
useProcessNewMessageStreamIncrement();
const { processStreamingMessageUpdate } = useProcessStreamingMessageUpdate();
const processIncrementalStreamMessages = useCallback(
(incrementalStreamMessages: ExtendedUIMessage[]) => {
for (const updatedMessage of incrementalStreamMessages) {
const updateStreamingPartsWithDiff = useCallback(
(incomingMessages: ExtendedUIMessage[]) => {
for (const incomingMessage of incomingMessages) {
const alreadyExistingMessage = jotaiStore.get(
agentChatMessageFamilyCallbackState(updatedMessage.id),
agentChatMessageFamilyCallbackState(incomingMessage.id),
);
const messageContentHasChanged = !isDeeplyEqual(
alreadyExistingMessage,
updatedMessage,
incomingMessage,
);
const messageAlreadyExists = isDefined(alreadyExistingMessage);
@ -37,20 +36,20 @@ export const useProcessIncrementalStreamMessages = () => {
continue;
}
const clonedMessage = structuredClone(updatedMessage);
const clonedMessage = structuredClone(incomingMessage);
jotaiStore.set(
agentChatMessageFamilyCallbackState(updatedMessage.id),
agentChatMessageFamilyCallbackState(incomingMessage.id),
clonedMessage,
);
processNewMessageStreamIncrement(updatedMessage);
processStreamingMessageUpdate(incomingMessage);
}
},
[agentChatMessageFamilyCallbackState, processNewMessageStreamIncrement],
[agentChatMessageFamilyCallbackState, processStreamingMessageUpdate],
);
return {
processIncrementalStreamMessages,
updateStreamingPartsWithDiff,
};
};

View file

@ -0,0 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const agentChatDisplayedThreadState = createAtomState<string>({
key: 'ai/agentChatDisplayedThreadState',
defaultValue: '',
});

View file

@ -0,0 +1,10 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilyState';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
export const agentChatFetchedMessagesComponentFamilyState =
createAtomComponentFamilyState<ExtendedUIMessage[], { threadId: string }>({
key: 'agentChatFetchedMessagesComponentFamilyState',
defaultValue: [],
componentInstanceContext: AgentChatComponentInstanceContext,
});

View file

@ -1,5 +1,6 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { agentChatMessagesComponentState } from '@/ai/states/agentChatMessagesComponentState';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { createAtomComponentSelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentSelector';
import { isNonEmptyArray } from '@sniptt/guards';
@ -10,7 +11,12 @@ export const agentChatHasMessageComponentSelector =
get:
({ instanceId }) =>
({ get }) => {
const messages = get(agentChatMessagesComponentState, { instanceId });
const currentThreadId = get(agentChatDisplayedThreadState);
const messages = get(agentChatMessagesComponentFamilyState, {
instanceId,
familyKey: { threadId: currentThreadId },
});
return isNonEmptyArray(messages);
},

View file

@ -0,0 +1,7 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const agentChatIsInitialScrollPendingOnThreadChangeState =
createAtomState<boolean>({
key: 'ai/agentChatIsInitialScrollPendingOnThreadChangeState',
defaultValue: false,
});

View file

@ -0,0 +1,16 @@
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;
},
});

View file

@ -0,0 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const agentChatLastDiffSyncedThreadState = createAtomState<string>({
key: 'ai/agentChatLastDiffSyncedThreadState',
defaultValue: '',
});

View file

@ -0,0 +1,22 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { createAtomComponentSelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentSelector';
export const agentChatLastMessageIdComponentSelector =
createAtomComponentSelector<string | null>({
key: 'agentChatLastMessageIdComponentSelector',
componentInstanceContext: AgentChatComponentInstanceContext,
get:
({ instanceId }) =>
({ get }) => {
const currentThreadId = get(agentChatDisplayedThreadState);
const messages = get(agentChatMessagesComponentFamilyState, {
instanceId,
familyKey: { threadId: currentThreadId },
});
return messages.at(-1)?.id ?? null;
},
});

View file

@ -1,5 +1,6 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { agentChatMessagesComponentState } from '@/ai/states/agentChatMessagesComponentState';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { createAtomComponentFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilySelector';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
import { type Nullable } from 'twenty-shared/types';
@ -13,7 +14,12 @@ export const agentChatMessageComponentFamilySelector =
get:
({ instanceId, familyKey: { messageId } }) =>
({ get }) => {
const messages = get(agentChatMessagesComponentState, { instanceId });
const currentThreadId = get(agentChatDisplayedThreadState);
const messages = get(agentChatMessagesComponentFamilyState, {
instanceId,
familyKey: { threadId: currentThreadId },
});
return messages.find((message) => message.id === messageId);
},

View file

@ -1,5 +1,6 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { agentChatMessagesComponentState } from '@/ai/states/agentChatMessagesComponentState';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { createAtomComponentSelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentSelector';
export const agentChatMessageIdsComponentSelector = createAtomComponentSelector<
@ -10,7 +11,12 @@ export const agentChatMessageIdsComponentSelector = createAtomComponentSelector<
get:
({ instanceId }) =>
({ get }) => {
const messages = get(agentChatMessagesComponentState, { instanceId });
const currentThreadId = get(agentChatDisplayedThreadState);
const messages = get(agentChatMessagesComponentFamilyState, {
instanceId,
familyKey: { threadId: currentThreadId },
});
return messages.map((message) => message.id);
},

View file

@ -0,0 +1,10 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { createAtomComponentFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilyState';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
export const agentChatMessagesComponentFamilyState =
createAtomComponentFamilyState<ExtendedUIMessage[], { threadId: string }>({
key: 'agentChatMessagesComponentFamilyState',
defaultValue: [],
componentInstanceContext: AgentChatComponentInstanceContext,
});

View file

@ -0,0 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const agentChatMessagesLoadingState = createAtomState({
key: 'agentChatMessagesLoadingState',
defaultValue: false,
});

View file

@ -0,0 +1,25 @@
import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext';
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
import { createAtomComponentSelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentSelector';
export const agentChatNonLastMessageIdsComponentSelector =
createAtomComponentSelector<string[]>({
key: 'agentChatNonLastMessageIdsComponentSelector',
componentInstanceContext: AgentChatComponentInstanceContext,
get:
({ instanceId }) =>
({ get }) => {
const currentThreadId = get(agentChatDisplayedThreadState);
const messages = get(agentChatMessagesComponentFamilyState, {
instanceId,
familyKey: { threadId: currentThreadId },
});
return messages.slice(0, -1).map((message) => message.id);
},
areEqual: (previous, next) =>
previous.length === next.length &&
previous.every((id, index) => id === next[index]),
});

View file

@ -0,0 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const agentChatThreadsLoadingState = createAtomState({
key: 'agentChatThreadsLoadingState',
defaultValue: false,
});

View file

@ -1,6 +1,7 @@
import { AGENT_CHAT_UNKNOWN_THREAD_ID } from '@/ai/constants/AgentChatUnknownThreadId';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const currentAIChatThreadState = createAtomState<string | null>({
export const currentAIChatThreadState = createAtomState<string>({
key: 'ai/currentAIChatThreadState',
defaultValue: null,
defaultValue: AGENT_CHAT_UNKNOWN_THREAD_ID,
});

View file

@ -0,0 +1,7 @@
import { AGENT_CHAT_ENSURE_THREAD_FOR_DRAFT_EVENT_NAME } from '@/ai/constants/AgentChatEnsureThreadForDraftEventName';
export const dispatchAgentChatEnsureThreadForDraftEvent = () => {
window.dispatchEvent(
new CustomEvent(AGENT_CHAT_ENSURE_THREAD_FOR_DRAFT_EVENT_NAME),
);
};

View file

@ -0,0 +1,16 @@
import { type ExtendedUIMessage } from 'twenty-shared/ai';
export const mergeAgentChatFetchedAndStreamingMessages = (
fetchedMessages: ExtendedUIMessage[],
streamingMessages: ExtendedUIMessage[],
): ExtendedUIMessage[] => {
const fetchedMessageIds = new Set(
fetchedMessages.map((message) => message.id),
);
const streamingOnlyMessages = streamingMessages.filter(
(message) => !fetchedMessageIds.has(message.id),
);
return [...fetchedMessages, ...streamingOnlyMessages];
};

View file

@ -0,0 +1,11 @@
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;
}
};

View file

@ -0,0 +1,99 @@
import {
NetworkStatus,
type OperationVariables,
type TypedDocumentNode,
} from '@apollo/client';
import { useQuery } from '@apollo/client/react';
import { useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
export type UseQueryWithCallbacksOptions<
TData,
TVariables extends OperationVariables,
> = useQuery.Options<TData, TVariables> & {
onFirstLoad?: (data: TData) => void;
onSubsequentLoad?: (data: TData) => void;
onDataLoaded?: (data: TData) => void;
onLoadingChange?: (loading: boolean) => void;
};
export const useQueryWithCallbacks = <
TData,
TVariables extends OperationVariables,
>(
document: TypedDocumentNode<TData, TVariables>,
options: UseQueryWithCallbacksOptions<TData, TVariables>,
) => {
const {
onFirstLoad,
onSubsequentLoad,
onDataLoaded,
onLoadingChange,
...queryOptions
} = options;
const { networkStatus, data, loading, refetch } = useQuery(document, {
...queryOptions,
notifyOnNetworkStatusChange: true,
} as useQuery.Options<TData, TVariables>);
const variablesString = JSON.stringify(queryOptions.variables);
const [lastProcessedVariablesString, setLastProcessedVariablesString] =
useState<string | null>(null);
const [hasProcessedCurrentFetchCycle, setHasProcessedCurrentFetchCycle] =
useState(false);
const [hasEverLoaded, setHasEverLoaded] = useState(false);
useEffect(() => {
if (networkStatus !== NetworkStatus.ready) {
setHasProcessedCurrentFetchCycle(false);
return;
}
if (!isDefined(data)) {
return;
}
const variablesChanged = variablesString !== lastProcessedVariablesString;
if (hasProcessedCurrentFetchCycle && !variablesChanged) {
return;
}
setHasProcessedCurrentFetchCycle(true);
setLastProcessedVariablesString(variablesString);
const isFirstLoad = !hasEverLoaded;
setHasEverLoaded(true);
const typedData = data as TData;
onDataLoaded?.(typedData);
if (isFirstLoad) {
onFirstLoad?.(typedData);
} else {
onSubsequentLoad?.(typedData);
}
}, [
networkStatus,
data,
variablesString,
lastProcessedVariablesString,
hasProcessedCurrentFetchCycle,
hasEverLoaded,
onFirstLoad,
onSubsequentLoad,
onDataLoaded,
]);
useEffect(() => {
onLoadingChange?.(loading);
}, [loading, onLoadingChange]);
return { refetch };
};

View file

@ -10,7 +10,7 @@ import { useIsMobile } from 'twenty-ui/utilities';
import { useContext } from 'react';
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
import { useSwitchToNewAIChat } from '@/ai/hooks/useSwitchToNewAIChat';
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { navigationDrawerActiveTabState } from '@/ui/navigation/states/navigationDrawerActiveTabState';
@ -127,7 +127,7 @@ export const MainNavigationDrawerTabsRow = () => {
);
const [navigationDrawerActiveTab, setNavigationDrawerActiveTab] =
useAtomState(navigationDrawerActiveTabState);
const { switchToNewChat } = useCreateNewAIChatThread();
const { switchToNewChat } = useSwitchToNewAIChat();
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
const setIsNavigationDrawerExpanded = useSetAtomState(
isNavigationDrawerExpandedState,

View file

@ -1,15 +1,15 @@
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { useOpenRecordsSearchPageInSidePanel } from '@/side-panel/hooks/useOpenRecordsSearchPageInSidePanel';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { useSwitchToNewAIChat } from '@/ai/hooks/useSwitchToNewAIChat';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath';
import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage';
import { currentMobileNavigationDrawerState } from '@/navigation/states/currentMobileNavigationDrawerState';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useOpenRecordsSearchPageInSidePanel } from '@/side-panel/hooks/useOpenRecordsSearchPageInSidePanel';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
@ -36,12 +36,12 @@ export const MobileNavigationBar = () => {
useAtomState(isNavigationDrawerExpandedState);
const [currentMobileNavigationDrawer, setCurrentMobileNavigationDrawer] =
useAtomState(currentMobileNavigationDrawerState);
const { switchToNewChat } = useCreateNewAIChatThread();
const { switchToNewChat } = useSwitchToNewAIChat();
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
const { alphaSortedActiveNonSystemObjectMetadataItems } =
useFilteredObjectMetadataItems();
const [, setContextStoreCurrentObjectMetadataItemId] = useAtomComponentState(
const setContextStoreCurrentObjectMetadataItemId = useSetAtomComponentState(
contextStoreCurrentObjectMetadataItemIdComponentState,
MAIN_CONTEXT_STORE_INSTANCE_ID,
);

View file

@ -10,7 +10,7 @@ import { IconButton } from 'twenty-ui/input';
import { useIsMobile } from 'twenty-ui/utilities';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
import { useSwitchToNewAIChat } from '@/ai/hooks/useSwitchToNewAIChat';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledIconButtonContainer = styled.div`
@ -22,7 +22,7 @@ export const SidePanelTopBarRightCornerIcon = () => {
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
const sidePanelPage = useAtomStateValue(sidePanelPageState);
const { openAskAIPage } = useOpenAskAIPageInSidePanel();
const { switchToNewChat } = useCreateNewAIChatThread();
const { switchToNewChat } = useSwitchToNewAIChat();
if (isMobile || !isAiEnabled) {
return null;

View file

@ -1,4 +1,5 @@
import { atom, type Atom } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { type ComponentInstanceStateContext } from '@/ui/utilities/state/component-state/types/ComponentInstanceStateContext';
import { globalComponentInstanceContextMap } from '@/ui/utilities/state/component-state/utils/globalComponentInstanceContextMap';
@ -12,12 +13,14 @@ export const createAtomComponentFamilySelector = <ValueType, FamilyKey>({
key,
get,
componentInstanceContext,
areEqual,
}: {
key: string;
get: (
key: ComponentFamilyStateKey<FamilyKey>,
) => (callbacks: SelectorGetter) => ValueType;
componentInstanceContext: ComponentInstanceStateContext<any> | null;
areEqual?: (previous: ValueType, next: ValueType) => boolean;
}): ComponentFamilySelector<ValueType, FamilyKey> => {
if (isDefined(componentInstanceContext)) {
globalComponentInstanceContextMap.set(key, componentInstanceContext);
@ -47,10 +50,14 @@ export const createAtomComponentFamilySelector = <ValueType, FamilyKey>({
return getForKey({ get: getHelper });
});
derivedAtom.debugLabel = `${key}__${cacheKey}`;
atomCache.set(cacheKey, derivedAtom);
const finalAtom = isDefined(areEqual)
? selectAtom(derivedAtom, (value) => value, areEqual)
: derivedAtom;
return derivedAtom;
finalAtom.debugLabel = `${key}__${cacheKey}`;
atomCache.set(cacheKey, finalAtom);
return finalAtom;
};
return {

View file

@ -1,4 +1,5 @@
import { atom, type Atom } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { type ComponentInstanceStateContext } from '@/ui/utilities/state/component-state/types/ComponentInstanceStateContext';
import { type ComponentStateKey } from '@/ui/utilities/state/component-state/types/ComponentStateKey';
@ -12,10 +13,12 @@ export const createAtomComponentSelector = <ValueType>({
key,
get,
componentInstanceContext,
areEqual,
}: {
key: string;
get: (key: ComponentStateKey) => (callbacks: SelectorGetter) => ValueType;
componentInstanceContext: ComponentInstanceStateContext<any> | null;
areEqual?: (previous: ValueType, next: ValueType) => boolean;
}): ComponentSelector<ValueType> => {
if (isDefined(componentInstanceContext)) {
globalComponentInstanceContextMap.set(key, componentInstanceContext);
@ -40,10 +43,14 @@ export const createAtomComponentSelector = <ValueType>({
return getForKey({ get: getHelper });
});
derivedAtom.debugLabel = `${key}__${componentStateKey.instanceId}`;
atomCache.set(componentStateKey.instanceId, derivedAtom);
const finalAtom = isDefined(areEqual)
? selectAtom(derivedAtom, (value) => value, areEqual)
: derivedAtom;
return derivedAtom;
finalAtom.debugLabel = `${key}__${componentStateKey.instanceId}`;
atomCache.set(componentStateKey.instanceId, finalAtom);
return finalAtom;
};
return {

View file

@ -1,15 +1,19 @@
import { atom, type Atom } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { type FamilySelector } from '@/ui/utilities/state/jotai/types/FamilySelector';
import { type SelectorGetter } from '@/ui/utilities/state/jotai/types/SelectorCallbacks';
import { buildGetHelper } from '@/ui/utilities/state/jotai/utils/buildGetHelper';
import { isDefined } from 'twenty-shared/utils';
export const createAtomFamilySelector = <ValueType, FamilyKey>({
key,
get,
areEqual,
}: {
key: string;
get: (familyKey: FamilyKey) => (callbacks: SelectorGetter) => ValueType;
areEqual?: (previous: ValueType, next: ValueType) => boolean;
}): FamilySelector<ValueType, FamilyKey> => {
const atomCache = new Map<string, Atom<ValueType>>();
@ -31,10 +35,14 @@ export const createAtomFamilySelector = <ValueType, FamilyKey>({
return getForKey({ get: getHelper });
});
derivedAtom.debugLabel = `${key}__${cacheKey}`;
atomCache.set(cacheKey, derivedAtom);
const finalAtom = isDefined(areEqual)
? selectAtom(derivedAtom, (value) => value, areEqual)
: derivedAtom;
return derivedAtom;
finalAtom.debugLabel = `${key}__${cacheKey}`;
atomCache.set(cacheKey, finalAtom);
return finalAtom;
};
return {

View file

@ -1,15 +1,19 @@
import { atom } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { type SelectorGetter } from '@/ui/utilities/state/jotai/types/SelectorCallbacks';
import { type Selector } from '@/ui/utilities/state/jotai/types/Selector';
import { buildGetHelper } from '@/ui/utilities/state/jotai/utils/buildGetHelper';
import { isDefined } from 'twenty-shared/utils';
export const createAtomSelector = <ValueType>({
key,
get,
areEqual,
}: {
key: string;
get: (callbacks: SelectorGetter) => ValueType;
areEqual?: (previous: ValueType, next: ValueType) => boolean;
}): Selector<ValueType> => {
const derivedAtom = atom((jotaiGet) => {
const getHelper = buildGetHelper(jotaiGet);
@ -17,11 +21,15 @@ export const createAtomSelector = <ValueType>({
return get({ get: getHelper });
});
derivedAtom.debugLabel = key;
const finalAtom = isDefined(areEqual)
? selectAtom(derivedAtom, (value) => value, areEqual)
: derivedAtom;
finalAtom.debugLabel = key;
return {
type: 'Selector',
key,
atom: derivedAtom,
atom: finalAtom,
};
};

View file

@ -1,4 +1,5 @@
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { type FlatObjectMetadataItem } from '@/metadata-store/types/FlatObjectMetadataItem';
import { type FlatView } from '@/metadata-store/types/FlatView';
import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
import { type FlatViewFieldGroup } from '@/metadata-store/types/FlatViewFieldGroup';
@ -8,11 +9,21 @@ import { type FlatViewGroup } from '@/metadata-store/types/FlatViewGroup';
import { type FlatViewSort } from '@/metadata-store/types/FlatViewSort';
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
import { type ViewWithRelations } from '@/views/types/ViewWithRelations';
import { resolveViewNamePlaceholders } from '@/views/utils/resolveViewNamePlaceholders';
export const viewsSelector = createAtomSelector<ViewWithRelations[]>({
key: 'viewsSelector',
get: ({ get }) => {
const flatViews = get(metadataStoreState, 'views').current as FlatView[];
const flatObjectMetadataItems = get(
metadataStoreState,
'objectMetadataItems',
).current as FlatObjectMetadataItem[];
const objectMetadataItemsById = new Map(
flatObjectMetadataItems.map((item) => [item.id, item]),
);
const flatViewFields = get(metadataStoreState, 'viewFields')
.current as FlatViewField[];
const flatViewFilters = get(metadataStoreState, 'viewFilters')
@ -75,6 +86,10 @@ export const viewsSelector = createAtomSelector<ViewWithRelations[]>({
return flatViews.map((view) => ({
...view,
name: resolveViewNamePlaceholders(
view.name,
objectMetadataItemsById.get(view.objectMetadataId),
),
viewFields: viewFieldsByViewId.get(view.id) ?? [],
viewFilters: viewFiltersByViewId.get(view.id) ?? [],
viewSorts: viewSortsByViewId.get(view.id) ?? [],

View file

@ -0,0 +1,15 @@
import { type FlatObjectMetadataItem } from '@/metadata-store/types/FlatObjectMetadataItem';
import { isDefined } from 'twenty-shared/utils';
export const resolveViewNamePlaceholders = (
viewName: string,
objectMetadataItem: FlatObjectMetadataItem | undefined,
): string => {
if (!isDefined(objectMetadataItem)) {
return viewName;
}
return viewName
.replace('{objectLabelPlural}', objectMetadataItem.labelPlural)
.replace('{objectLabelSingular}', objectMetadataItem.labelSingular);
};

View file

@ -96,8 +96,6 @@ export default defineConfig(({ mode }) => {
'**/testing/cache/**',
'**/*.test.{ts,tsx}',
'**/*.spec.{ts,tsx}',
'**/*.stories.{ts,tsx}',
'**/__stories__/**',
'**/__tests__/**',
'**/__mocks__/**',
'**/types/**',