diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 00000000000..d636552bd2b
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,7 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(git stash:*)"
+ ]
+ }
+}
diff --git a/.mcp.json b/.mcp.json
index dfb97841f33..0320cab0d44 100644
--- a/.mcp.json
+++ b/.mcp.json
@@ -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": {
diff --git a/CLAUDE.md b/CLAUDE.md
index 6fe99438691..87660abcad0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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)
diff --git a/packages/twenty-front/src/modules/ai/components/AIChatEmptyState.tsx b/packages/twenty-front/src/modules/ai/components/AIChatEmptyState.tsx
index 30c11db2692..47e62e9a701 100644
--- a/packages/twenty-front/src/modules/ai/components/AIChatEmptyState.tsx
+++ b/packages/twenty-front/src/modules/ai/components/AIChatEmptyState.tsx
@@ -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;
diff --git a/packages/twenty-front/src/modules/ai/components/AIChatLastMessageWithStreamingState.tsx b/packages/twenty-front/src/modules/ai/components/AIChatLastMessageWithStreamingState.tsx
new file mode 100644
index 00000000000..073f7798f6a
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/AIChatLastMessageWithStreamingState.tsx
@@ -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 (
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx b/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx
index d5bc9df84c1..26fe279a5cd 100644
--- a/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx
+++ b/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx
@@ -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 (
-
+
{
))}
)}
- {shouldShowError && }
+ {shouldShowError && isDefined(error) && (
+
+ )}
{agentChatMessage.parts.length > 0 &&
agentChatMessage.metadata?.createdAt && (
diff --git a/packages/twenty-front/src/modules/ai/components/AIChatNonLastMessageIdsList.tsx b/packages/twenty-front/src/modules/ai/components/AIChatNonLastMessageIdsList.tsx
new file mode 100644
index 00000000000..533b5fb7f16
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/AIChatNonLastMessageIdsList.tsx
@@ -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) => (
+
+ ));
+};
diff --git a/packages/twenty-front/src/modules/ai/components/AIChatScrollToBottomButton.tsx b/packages/twenty-front/src/modules/ai/components/AIChatScrollToBottomButton.tsx
new file mode 100644
index 00000000000..4195b698b46
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/AIChatScrollToBottomButton.tsx
@@ -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 (
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx b/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx
index 3ca13884a9b..10f91afc4ac 100644
--- a/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx
+++ b/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx
@@ -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
diff --git a/packages/twenty-front/src/modules/ai/components/AIChatTabMessageList.tsx b/packages/twenty-front/src/modules/ai/components/AIChatTabMessageList.tsx
index e43e3c9b6df..9218131d073 100644
--- a/packages/twenty-front/src/modules/ai/components/AIChatTabMessageList.tsx
+++ b/packages/twenty-front/src/modules/ai/components/AIChatTabMessageList.tsx
@@ -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 (
-
+
- {agentChatMessageIdsComponent.map((messageId) => {
- return ;
- })}
+
+
+
+
);
};
diff --git a/packages/twenty-front/src/modules/ai/components/AIChatThreadsList.tsx b/packages/twenty-front/src/modules/ai/components/AIChatThreadsList.tsx
index 6b69ae48f49..7d7bcdc5b42 100644
--- a/packages/twenty-front/src/modules/ai/components/AIChatThreadsList.tsx
+++ b/packages/twenty-front/src/modules/ai/components/AIChatThreadsList.tsx
@@ -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 (
<>
-
+
{Object.entries(groupedThreads).map(([title, threadsInGroup]) => (
diff --git a/packages/twenty-front/src/modules/ai/components/AIChatThreadsListEffect.tsx b/packages/twenty-front/src/modules/ai/components/AIChatThreadsListFocusEffect.tsx
similarity index 90%
rename from packages/twenty-front/src/modules/ai/components/AIChatThreadsListEffect.tsx
rename to packages/twenty-front/src/modules/ai/components/AIChatThreadsListFocusEffect.tsx
index 5f7630e9032..b3cb4280f93 100644
--- a/packages/twenty-front/src/modules/ai/components/AIChatThreadsListEffect.tsx
+++ b/packages/twenty-front/src/modules/ai/components/AIChatThreadsListFocusEffect.tsx
@@ -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();
diff --git a/packages/twenty-front/src/modules/ai/components/AgentChatAiSdkStreamEffect.tsx b/packages/twenty-front/src/modules/ai/components/AgentChatAiSdkStreamEffect.tsx
new file mode 100644
index 00000000000..b728822d6dc
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/AgentChatAiSdkStreamEffect.tsx
@@ -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;
+};
diff --git a/packages/twenty-front/src/modules/ai/components/AgentChatDataEffect.tsx b/packages/twenty-front/src/modules/ai/components/AgentChatDataEffect.tsx
deleted file mode 100644
index 54ac9935cef..00000000000
--- a/packages/twenty-front/src/modules/ai/components/AgentChatDataEffect.tsx
+++ /dev/null
@@ -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;
-};
diff --git a/packages/twenty-front/src/modules/ai/components/AgentChatMessagesFetchEffect.tsx b/packages/twenty-front/src/modules/ai/components/AgentChatMessagesFetchEffect.tsx
new file mode 100644
index 00000000000..b958e3f37fa
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/AgentChatMessagesFetchEffect.tsx
@@ -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;
+};
diff --git a/packages/twenty-front/src/modules/ai/components/AgentChatProviderContent.tsx b/packages/twenty-front/src/modules/ai/components/AgentChatProviderContent.tsx
index f35c8f3d34d..91f8eef108f 100644
--- a/packages/twenty-front/src/modules/ai/components/AgentChatProviderContent.tsx
+++ b/packages/twenty-front/src/modules/ai/components/AgentChatProviderContent.tsx
@@ -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 (
-
-
-
- {children}
-
-
+
+
+
+
+
+
+
+ {children}
+
);
};
diff --git a/packages/twenty-front/src/modules/ai/components/AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect.tsx b/packages/twenty-front/src/modules/ai/components/AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect.tsx
new file mode 100644
index 00000000000..ce9d288b942
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect.tsx
@@ -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 | 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;
+ };
diff --git a/packages/twenty-front/src/modules/ai/components/AgentChatSessionStartTimeEffect.tsx b/packages/twenty-front/src/modules/ai/components/AgentChatSessionStartTimeEffect.tsx
new file mode 100644
index 00000000000..12b735f3bbe
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/AgentChatSessionStartTimeEffect.tsx
@@ -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;
+};
diff --git a/packages/twenty-front/src/modules/ai/components/AgentChatStreamingAutoScrollEffect.tsx b/packages/twenty-front/src/modules/ai/components/AgentChatStreamingAutoScrollEffect.tsx
new file mode 100644
index 00000000000..54538b154c7
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/AgentChatStreamingAutoScrollEffect.tsx
@@ -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;
+};
diff --git a/packages/twenty-front/src/modules/ai/components/AgentChatStreamingPartsDiffSyncEffect.tsx b/packages/twenty-front/src/modules/ai/components/AgentChatStreamingPartsDiffSyncEffect.tsx
new file mode 100644
index 00000000000..7761ab11ec5
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/AgentChatStreamingPartsDiffSyncEffect.tsx
@@ -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;
+};
diff --git a/packages/twenty-front/src/modules/ai/components/AgentChatThreadInitializationEffect.tsx b/packages/twenty-front/src/modules/ai/components/AgentChatThreadInitializationEffect.tsx
new file mode 100644
index 00000000000..7284e4d7306
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/AgentChatThreadInitializationEffect.tsx
@@ -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;
+};
diff --git a/packages/twenty-front/src/modules/ai/components/__stories__/AIChatMessage.stories.tsx b/packages/twenty-front/src/modules/ai/components/__stories__/AIChatMessage.stories.tsx
index 90a28e68bd9..4e437f9f4dd 100644
--- a/packages/twenty-front/src/modules/ai/components/__stories__/AIChatMessage.stories.tsx
+++ b/packages/twenty-front/src/modules/ai/components/__stories__/AIChatMessage.stories.tsx
@@ -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,
);
diff --git a/packages/twenty-front/src/modules/ai/components/internal/AIChatSkeletonLoader.tsx b/packages/twenty-front/src/modules/ai/components/internal/AIChatSkeletonLoader.tsx
index d0f2e4acda4..4fd4cc8ed11 100644
--- a/packages/twenty-front/src/modules/ai/components/internal/AIChatSkeletonLoader.tsx
+++ b/packages/twenty-front/src/modules/ai/components/internal/AIChatSkeletonLoader.tsx
@@ -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;
diff --git a/packages/twenty-front/src/modules/ai/constants/AgentChatEnsureThreadForDraftEventName.ts b/packages/twenty-front/src/modules/ai/constants/AgentChatEnsureThreadForDraftEventName.ts
new file mode 100644
index 00000000000..18a3c15572f
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/constants/AgentChatEnsureThreadForDraftEventName.ts
@@ -0,0 +1,2 @@
+export const AGENT_CHAT_ENSURE_THREAD_FOR_DRAFT_EVENT_NAME =
+ 'agent-chat-ensure-thread-for-draft' as const;
diff --git a/packages/twenty-front/src/modules/ai/constants/AgentChatRefetchMessagesEventName.ts b/packages/twenty-front/src/modules/ai/constants/AgentChatRefetchMessagesEventName.ts
new file mode 100644
index 00000000000..19fae7314ae
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/constants/AgentChatRefetchMessagesEventName.ts
@@ -0,0 +1,2 @@
+export const AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME =
+ 'agent-chat-refetch-messages' as const;
diff --git a/packages/twenty-front/src/modules/ai/constants/AgentChatUnknownThreadId.ts b/packages/twenty-front/src/modules/ai/constants/AgentChatUnknownThreadId.ts
new file mode 100644
index 00000000000..929f87b1f10
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/constants/AgentChatUnknownThreadId.ts
@@ -0,0 +1 @@
+export const AGENT_CHAT_UNKNOWN_THREAD_ID = 'unknown-thread';
diff --git a/packages/twenty-front/src/modules/ai/hooks/useAIChatEditor.ts b/packages/twenty-front/src/modules/ai/hooks/useAIChatEditor.ts
index 61f5c4b76aa..6612d7dedbe 100644
--- a/packages/twenty-front/src/modules/ai/hooks/useAIChatEditor.ts
+++ b/packages/twenty-front/src/modules/ai/hooks/useAIChatEditor.ts
@@ -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: () => {
diff --git a/packages/twenty-front/src/modules/ai/hooks/useAIChatThreadClick.ts b/packages/twenty-front/src/modules/ai/hooks/useAIChatThreadClick.ts
index 035e54cc688..7640bc39b83 100644
--- a/packages/twenty-front/src/modules/ai/hooks/useAIChatThreadClick.ts
+++ b/packages/twenty-front/src/modules/ai/hooks/useAIChatThreadClick.ts
@@ -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) => ({
diff --git a/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts b/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts
index 06529501160..9bd1d077018 100644
--- a/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts
+++ b/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts
@@ -35,6 +35,7 @@ import { cookieStorage } from '~/utils/cookie-storage';
export const useAgentChat = (
uiMessages: ExtendedUIMessage[],
ensureThreadIdForSend: () => Promise,
+ onStreamingComplete?: () => void,
) => {
const setTokenPair = useSetAtomState(tokenPairState);
const setAgentChatUsage = useSetAtomState(agentChatUsageState);
@@ -206,6 +207,8 @@ export const useAgentChat = (
}
return null;
});
+
+ onStreamingComplete?.();
},
});
diff --git a/packages/twenty-front/src/modules/ai/hooks/useAgentChatData.ts b/packages/twenty-front/src/modules/ai/hooks/useAgentChatData.ts
deleted file mode 100644
index c7e5a19a65b..00000000000
--- a/packages/twenty-front/src/modules/ai/hooks/useAgentChatData.ts
+++ /dev/null
@@ -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({
- 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,
- };
-};
diff --git a/packages/twenty-front/src/modules/ai/hooks/useAgentChatScrollToBottom.ts b/packages/twenty-front/src/modules/ai/hooks/useAgentChatScrollToBottom.ts
deleted file mode 100644
index 9187305611e..00000000000
--- a/packages/twenty-front/src/modules/ai/hooks/useAgentChatScrollToBottom.ts
+++ /dev/null
@@ -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 };
-};
diff --git a/packages/twenty-front/src/modules/ai/hooks/useCreateAgentChatThread.ts b/packages/twenty-front/src/modules/ai/hooks/useCreateAgentChatThread.ts
new file mode 100644
index 00000000000..af025757794
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/hooks/useCreateAgentChatThread.ts
@@ -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 };
+};
diff --git a/packages/twenty-front/src/modules/ai/hooks/useEnsureAgentChatThreadExistsForDraft.ts b/packages/twenty-front/src/modules/ai/hooks/useEnsureAgentChatThreadExistsForDraft.ts
new file mode 100644
index 00000000000..cb1c1b61550
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/hooks/useEnsureAgentChatThreadExistsForDraft.ts
@@ -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,
+) => {
+ 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 };
+};
diff --git a/packages/twenty-front/src/modules/ai/hooks/useEnsureAgentChatThreadIdForSend.ts b/packages/twenty-front/src/modules/ai/hooks/useEnsureAgentChatThreadIdForSend.ts
new file mode 100644
index 00000000000..23af83b7831
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/hooks/useEnsureAgentChatThreadIdForSend.ts
@@ -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,
+) => {
+ 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 };
+};
diff --git a/packages/twenty-front/src/modules/ai/hooks/useProcessNewMessageStreamIncrement.ts b/packages/twenty-front/src/modules/ai/hooks/useProcessStreamingMessageUpdate.ts
similarity index 74%
rename from packages/twenty-front/src/modules/ai/hooks/useProcessNewMessageStreamIncrement.ts
rename to packages/twenty-front/src/modules/ai/hooks/useProcessStreamingMessageUpdate.ts
index 86b38bd5264..058a7310e39 100644
--- a/packages/twenty-front/src/modules/ai/hooks/useProcessNewMessageStreamIncrement.ts
+++ b/packages/twenty-front/src/modules/ai/hooks/useProcessStreamingMessageUpdate.ts
@@ -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,
};
};
diff --git a/packages/twenty-front/src/modules/ai/hooks/useCreateNewAIChatThread.ts b/packages/twenty-front/src/modules/ai/hooks/useSwitchToNewAIChat.ts
similarity index 94%
rename from packages/twenty-front/src/modules/ai/hooks/useCreateNewAIChatThread.ts
rename to packages/twenty-front/src/modules/ai/hooks/useSwitchToNewAIChat.ts
index 367f0ebfd08..33184a84959 100644
--- a/packages/twenty-front/src/modules/ai/hooks/useCreateNewAIChatThread.ts
+++ b/packages/twenty-front/src/modules/ai/hooks/useSwitchToNewAIChat.ts
@@ -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
diff --git a/packages/twenty-front/src/modules/ai/hooks/useProcessIncrementalStreamMessages.ts b/packages/twenty-front/src/modules/ai/hooks/useUpdateStreamingPartsWithDiff.ts
similarity index 58%
rename from packages/twenty-front/src/modules/ai/hooks/useProcessIncrementalStreamMessages.ts
rename to packages/twenty-front/src/modules/ai/hooks/useUpdateStreamingPartsWithDiff.ts
index d89cc8ae495..07dede10f86 100644
--- a/packages/twenty-front/src/modules/ai/hooks/useProcessIncrementalStreamMessages.ts
+++ b/packages/twenty-front/src/modules/ai/hooks/useUpdateStreamingPartsWithDiff.ts
@@ -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,
};
};
diff --git a/packages/twenty-front/src/modules/ai/states/agentChatDisplayedThreadState.ts b/packages/twenty-front/src/modules/ai/states/agentChatDisplayedThreadState.ts
new file mode 100644
index 00000000000..782a3ca9390
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/states/agentChatDisplayedThreadState.ts
@@ -0,0 +1,6 @@
+import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
+
+export const agentChatDisplayedThreadState = createAtomState({
+ key: 'ai/agentChatDisplayedThreadState',
+ defaultValue: '',
+});
diff --git a/packages/twenty-front/src/modules/ai/states/agentChatFetchedMessagesComponentFamilyState.ts b/packages/twenty-front/src/modules/ai/states/agentChatFetchedMessagesComponentFamilyState.ts
new file mode 100644
index 00000000000..9c439698ec8
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/states/agentChatFetchedMessagesComponentFamilyState.ts
@@ -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({
+ key: 'agentChatFetchedMessagesComponentFamilyState',
+ defaultValue: [],
+ componentInstanceContext: AgentChatComponentInstanceContext,
+ });
diff --git a/packages/twenty-front/src/modules/ai/states/agentChatHasMessageComponentSelector.ts b/packages/twenty-front/src/modules/ai/states/agentChatHasMessageComponentSelector.ts
index 92f30eba848..0f48533a2f1 100644
--- a/packages/twenty-front/src/modules/ai/states/agentChatHasMessageComponentSelector.ts
+++ b/packages/twenty-front/src/modules/ai/states/agentChatHasMessageComponentSelector.ts
@@ -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);
},
diff --git a/packages/twenty-front/src/modules/ai/states/agentChatIsInitialScrollPendingOnThreadChangeState.ts b/packages/twenty-front/src/modules/ai/states/agentChatIsInitialScrollPendingOnThreadChangeState.ts
new file mode 100644
index 00000000000..04bf3291224
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/states/agentChatIsInitialScrollPendingOnThreadChangeState.ts
@@ -0,0 +1,7 @@
+import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
+
+export const agentChatIsInitialScrollPendingOnThreadChangeState =
+ createAtomState({
+ key: 'ai/agentChatIsInitialScrollPendingOnThreadChangeState',
+ defaultValue: false,
+ });
diff --git a/packages/twenty-front/src/modules/ai/states/agentChatIsScrolledToBottomSelector.ts b/packages/twenty-front/src/modules/ai/states/agentChatIsScrolledToBottomSelector.ts
new file mode 100644
index 00000000000..03e8a6c10f6
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/states/agentChatIsScrolledToBottomSelector.ts
@@ -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({
+ key: 'agentChatIsScrolledToBottomSelector',
+ get: ({ get }) => {
+ const scrollBottom = get(scrollWrapperScrollBottomComponentState, {
+ instanceId: AI_CHAT_SCROLL_WRAPPER_ID,
+ });
+
+ return scrollBottom <= SCROLL_BOTTOM_THRESHOLD_PX;
+ },
+});
diff --git a/packages/twenty-front/src/modules/ai/states/agentChatLastDiffSyncedThreadState.ts b/packages/twenty-front/src/modules/ai/states/agentChatLastDiffSyncedThreadState.ts
new file mode 100644
index 00000000000..f6db87d4d6d
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/states/agentChatLastDiffSyncedThreadState.ts
@@ -0,0 +1,6 @@
+import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
+
+export const agentChatLastDiffSyncedThreadState = createAtomState({
+ key: 'ai/agentChatLastDiffSyncedThreadState',
+ defaultValue: '',
+});
diff --git a/packages/twenty-front/src/modules/ai/states/agentChatLastMessageIdComponentSelector.ts b/packages/twenty-front/src/modules/ai/states/agentChatLastMessageIdComponentSelector.ts
new file mode 100644
index 00000000000..c6feed8a161
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/states/agentChatLastMessageIdComponentSelector.ts
@@ -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({
+ 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;
+ },
+ });
diff --git a/packages/twenty-front/src/modules/ai/states/agentChatMessageComponentFamilySelector.ts b/packages/twenty-front/src/modules/ai/states/agentChatMessageComponentFamilySelector.ts
index 94c67e56e4d..25398ecddd4 100644
--- a/packages/twenty-front/src/modules/ai/states/agentChatMessageComponentFamilySelector.ts
+++ b/packages/twenty-front/src/modules/ai/states/agentChatMessageComponentFamilySelector.ts
@@ -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);
},
diff --git a/packages/twenty-front/src/modules/ai/states/agentChatMessageIdsComponentSelector.ts b/packages/twenty-front/src/modules/ai/states/agentChatMessageIdsComponentSelector.ts
index 6469deeb823..b6daaf34aca 100644
--- a/packages/twenty-front/src/modules/ai/states/agentChatMessageIdsComponentSelector.ts
+++ b/packages/twenty-front/src/modules/ai/states/agentChatMessageIdsComponentSelector.ts
@@ -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);
},
diff --git a/packages/twenty-front/src/modules/ai/states/agentChatMessagesComponentFamilyState.ts b/packages/twenty-front/src/modules/ai/states/agentChatMessagesComponentFamilyState.ts
new file mode 100644
index 00000000000..fcf2944f4ba
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/states/agentChatMessagesComponentFamilyState.ts
@@ -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({
+ key: 'agentChatMessagesComponentFamilyState',
+ defaultValue: [],
+ componentInstanceContext: AgentChatComponentInstanceContext,
+ });
diff --git a/packages/twenty-front/src/modules/ai/states/agentChatMessagesLoadingState.ts b/packages/twenty-front/src/modules/ai/states/agentChatMessagesLoadingState.ts
new file mode 100644
index 00000000000..940beea1db1
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/states/agentChatMessagesLoadingState.ts
@@ -0,0 +1,6 @@
+import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
+
+export const agentChatMessagesLoadingState = createAtomState({
+ key: 'agentChatMessagesLoadingState',
+ defaultValue: false,
+});
diff --git a/packages/twenty-front/src/modules/ai/states/agentChatNonLastMessageIdsComponentSelector.ts b/packages/twenty-front/src/modules/ai/states/agentChatNonLastMessageIdsComponentSelector.ts
new file mode 100644
index 00000000000..c074f9424db
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/states/agentChatNonLastMessageIdsComponentSelector.ts
@@ -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({
+ 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]),
+ });
diff --git a/packages/twenty-front/src/modules/ai/states/agentChatThreadsLoadingState.ts b/packages/twenty-front/src/modules/ai/states/agentChatThreadsLoadingState.ts
new file mode 100644
index 00000000000..e58b497dfdf
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/states/agentChatThreadsLoadingState.ts
@@ -0,0 +1,6 @@
+import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
+
+export const agentChatThreadsLoadingState = createAtomState({
+ key: 'agentChatThreadsLoadingState',
+ defaultValue: false,
+});
diff --git a/packages/twenty-front/src/modules/ai/states/currentAIChatThreadState.ts b/packages/twenty-front/src/modules/ai/states/currentAIChatThreadState.ts
index 20b27940c70..5b326f5ca21 100644
--- a/packages/twenty-front/src/modules/ai/states/currentAIChatThreadState.ts
+++ b/packages/twenty-front/src/modules/ai/states/currentAIChatThreadState.ts
@@ -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({
+export const currentAIChatThreadState = createAtomState({
key: 'ai/currentAIChatThreadState',
- defaultValue: null,
+ defaultValue: AGENT_CHAT_UNKNOWN_THREAD_ID,
});
diff --git a/packages/twenty-front/src/modules/ai/utils/dispatchAgentChatEnsureThreadForDraftEvent.ts b/packages/twenty-front/src/modules/ai/utils/dispatchAgentChatEnsureThreadForDraftEvent.ts
new file mode 100644
index 00000000000..463b9d7e0f6
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/dispatchAgentChatEnsureThreadForDraftEvent.ts
@@ -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),
+ );
+};
diff --git a/packages/twenty-front/src/modules/ai/utils/mergeAgentChatFetchedAndStreamingMessages.ts b/packages/twenty-front/src/modules/ai/utils/mergeAgentChatFetchedAndStreamingMessages.ts
new file mode 100644
index 00000000000..fd2a287dc45
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/mergeAgentChatFetchedAndStreamingMessages.ts
@@ -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];
+};
diff --git a/packages/twenty-front/src/modules/ai/utils/scrollAIChatToBottom.ts b/packages/twenty-front/src/modules/ai/utils/scrollAIChatToBottom.ts
new file mode 100644
index 00000000000..f34fe4b2b4c
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/scrollAIChatToBottom.ts
@@ -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;
+ }
+};
diff --git a/packages/twenty-front/src/modules/apollo/hooks/useQueryWithCallbacks.ts b/packages/twenty-front/src/modules/apollo/hooks/useQueryWithCallbacks.ts
new file mode 100644
index 00000000000..3403b4d8968
--- /dev/null
+++ b/packages/twenty-front/src/modules/apollo/hooks/useQueryWithCallbacks.ts
@@ -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 & {
+ 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,
+ options: UseQueryWithCallbacksOptions,
+) => {
+ const {
+ onFirstLoad,
+ onSubsequentLoad,
+ onDataLoaded,
+ onLoadingChange,
+ ...queryOptions
+ } = options;
+
+ const { networkStatus, data, loading, refetch } = useQuery(document, {
+ ...queryOptions,
+ notifyOnNetworkStatusChange: true,
+ } as useQuery.Options);
+
+ const variablesString = JSON.stringify(queryOptions.variables);
+
+ const [lastProcessedVariablesString, setLastProcessedVariablesString] =
+ useState(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 };
+};
diff --git a/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawerTabsRow.tsx b/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawerTabsRow.tsx
index b8726785af3..c10f31b0b6b 100644
--- a/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawerTabsRow.tsx
+++ b/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawerTabsRow.tsx
@@ -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,
diff --git a/packages/twenty-front/src/modules/navigation/components/MobileNavigationBar.tsx b/packages/twenty-front/src/modules/navigation/components/MobileNavigationBar.tsx
index b1cc732c053..0c6d1c1ee0a 100644
--- a/packages/twenty-front/src/modules/navigation/components/MobileNavigationBar.tsx
+++ b/packages/twenty-front/src/modules/navigation/components/MobileNavigationBar.tsx
@@ -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,
);
diff --git a/packages/twenty-front/src/modules/side-panel/components/SidePanelTopBarRightCornerIcon.tsx b/packages/twenty-front/src/modules/side-panel/components/SidePanelTopBarRightCornerIcon.tsx
index 8ee82b05feb..09256089ba1 100644
--- a/packages/twenty-front/src/modules/side-panel/components/SidePanelTopBarRightCornerIcon.tsx
+++ b/packages/twenty-front/src/modules/side-panel/components/SidePanelTopBarRightCornerIcon.tsx
@@ -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;
diff --git a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomComponentFamilySelector.ts b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomComponentFamilySelector.ts
index b4909b2cb9d..1d8fc7443a9 100644
--- a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomComponentFamilySelector.ts
+++ b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomComponentFamilySelector.ts
@@ -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 = ({
key,
get,
componentInstanceContext,
+ areEqual,
}: {
key: string;
get: (
key: ComponentFamilyStateKey,
) => (callbacks: SelectorGetter) => ValueType;
componentInstanceContext: ComponentInstanceStateContext | null;
+ areEqual?: (previous: ValueType, next: ValueType) => boolean;
}): ComponentFamilySelector => {
if (isDefined(componentInstanceContext)) {
globalComponentInstanceContextMap.set(key, componentInstanceContext);
@@ -47,10 +50,14 @@ export const createAtomComponentFamilySelector = ({
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 {
diff --git a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomComponentSelector.ts b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomComponentSelector.ts
index b9cfd363154..9c3e5d746d4 100644
--- a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomComponentSelector.ts
+++ b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomComponentSelector.ts
@@ -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 = ({
key,
get,
componentInstanceContext,
+ areEqual,
}: {
key: string;
get: (key: ComponentStateKey) => (callbacks: SelectorGetter) => ValueType;
componentInstanceContext: ComponentInstanceStateContext | null;
+ areEqual?: (previous: ValueType, next: ValueType) => boolean;
}): ComponentSelector => {
if (isDefined(componentInstanceContext)) {
globalComponentInstanceContextMap.set(key, componentInstanceContext);
@@ -40,10 +43,14 @@ export const createAtomComponentSelector = ({
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 {
diff --git a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomFamilySelector.ts b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomFamilySelector.ts
index 48db765ed6e..5a09831d476 100644
--- a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomFamilySelector.ts
+++ b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomFamilySelector.ts
@@ -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 = ({
key,
get,
+ areEqual,
}: {
key: string;
get: (familyKey: FamilyKey) => (callbacks: SelectorGetter) => ValueType;
+ areEqual?: (previous: ValueType, next: ValueType) => boolean;
}): FamilySelector => {
const atomCache = new Map>();
@@ -31,10 +35,14 @@ export const createAtomFamilySelector = ({
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 {
diff --git a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomSelector.ts b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomSelector.ts
index f5aadf0ee68..b1223fb9a20 100644
--- a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomSelector.ts
+++ b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomSelector.ts
@@ -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 = ({
key,
get,
+ areEqual,
}: {
key: string;
get: (callbacks: SelectorGetter) => ValueType;
+ areEqual?: (previous: ValueType, next: ValueType) => boolean;
}): Selector => {
const derivedAtom = atom((jotaiGet) => {
const getHelper = buildGetHelper(jotaiGet);
@@ -17,11 +21,15 @@ export const createAtomSelector = ({
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,
};
};
diff --git a/packages/twenty-front/src/modules/views/states/selectors/viewsSelector.ts b/packages/twenty-front/src/modules/views/states/selectors/viewsSelector.ts
index 6eed7653bd7..e66d7284641 100644
--- a/packages/twenty-front/src/modules/views/states/selectors/viewsSelector.ts
+++ b/packages/twenty-front/src/modules/views/states/selectors/viewsSelector.ts
@@ -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({
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({
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) ?? [],
diff --git a/packages/twenty-front/src/modules/views/utils/resolveViewNamePlaceholders.ts b/packages/twenty-front/src/modules/views/utils/resolveViewNamePlaceholders.ts
new file mode 100644
index 00000000000..c5ed486072d
--- /dev/null
+++ b/packages/twenty-front/src/modules/views/utils/resolveViewNamePlaceholders.ts
@@ -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);
+};
diff --git a/packages/twenty-front/vite.config.ts b/packages/twenty-front/vite.config.ts
index 6bcf5ab7dcc..1018faba39c 100644
--- a/packages/twenty-front/vite.config.ts
+++ b/packages/twenty-front/vite.config.ts
@@ -96,8 +96,6 @@ export default defineConfig(({ mode }) => {
'**/testing/cache/**',
'**/*.test.{ts,tsx}',
'**/*.spec.{ts,tsx}',
- '**/*.stories.{ts,tsx}',
- '**/__stories__/**',
'**/__tests__/**',
'**/__mocks__/**',
'**/types/**',