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/**',