diff --git a/packages/twenty-front/jest.config.mjs b/packages/twenty-front/jest.config.mjs index 6b964fe6f3..92aaab3558 100644 --- a/packages/twenty-front/jest.config.mjs +++ b/packages/twenty-front/jest.config.mjs @@ -61,8 +61,8 @@ const jestConfig = { extensionsToTreatAsEsm: ['.ts', '.tsx'], coverageThreshold: { global: { - statements: 49.3, - lines: 47.9, + statements: 49.1, + lines: 47.7, functions: 39.5, }, }, diff --git a/packages/twenty-front/src/modules/ai/components/AIChatEditorSection.tsx b/packages/twenty-front/src/modules/ai/components/AIChatEditorSection.tsx new file mode 100644 index 0000000000..c04638e3bd --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/AIChatEditorSection.tsx @@ -0,0 +1,152 @@ +import { styled } from '@linaria/react'; +import { EditorContent } from '@tiptap/react'; +import { LightButton } from 'twenty-ui/input'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +import { AIChatEmptyState } from '@/ai/components/AIChatEmptyState'; +import { AIChatStandaloneError } from '@/ai/components/AIChatStandaloneError'; +import { AgentChatContextPreview } from '@/ai/components/internal/AgentChatContextPreview'; +import { AgentChatFileUploadButton } from '@/ai/components/internal/AgentChatFileUploadButton'; +import { AIChatContextUsageButton } from '@/ai/components/internal/AIChatContextUsageButton'; +import { AIChatEditorFocusEffect } from '@/ai/components/internal/AIChatEditorFocusEffect'; +import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader'; +import { SendMessageButton } from '@/ai/components/internal/SendMessageButton'; +import { useAIChatEditor } from '@/ai/hooks/useAIChatEditor'; +import { useAiModelLabel } from '@/ai/hooks/useAiModelOptions'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; + +const StyledInputArea = styled.div<{ isMobile: boolean }>` + align-items: flex-end; + background: ${themeCssVariables.background.primary}; + display: flex; + flex-direction: column; + flex-shrink: 0; + gap: ${themeCssVariables.spacing[2]}; + padding-block: ${({ isMobile }) => + isMobile ? '0' : themeCssVariables.spacing[3]}; + padding-inline: ${themeCssVariables.spacing[3]}; +`; + +const StyledInputBox = styled.div` + background-color: ${themeCssVariables.background.transparent.lighter}; + border: 1px solid ${themeCssVariables.border.color.medium}; + border-radius: ${themeCssVariables.border.radius.sm}; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: ${themeCssVariables.spacing[2]}; + min-height: 140px; + padding: ${themeCssVariables.spacing[2]}; + width: 100%; + + &:focus-within { + border-color: ${themeCssVariables.color.blue}; + box-shadow: 0px 0px 0px 3px ${themeCssVariables.color.transparent.blue2}; + } +`; + +const StyledEditorWrapper = styled.div` + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + + .tiptap { + background: transparent; + border: none; + box-shadow: none; + color: ${themeCssVariables.font.color.primary}; + font-family: inherit; + font-size: ${themeCssVariables.font.size.md}; + font-weight: ${themeCssVariables.font.weight.regular}; + line-height: 16px; + max-height: 320px; + min-height: 48px; + outline: none; + overflow-y: auto; + padding: 0; + + p { + margin: 0; + } + + p.is-editor-empty:first-of-type::before { + color: ${themeCssVariables.font.color.light}; + content: attr(data-placeholder); + float: left; + font-weight: ${themeCssVariables.font.weight.regular}; + height: 0; + pointer-events: none; + } + } +`; + +const StyledButtonsContainer = styled.div` + align-items: center; + display: flex; + justify-content: space-between; + width: 100%; +`; + +const StyledLeftButtonsContainer = styled.div` + align-items: center; + display: flex; + gap: ${themeCssVariables.spacing['0.5']}; +`; + +const StyledRightButtonsContainer = styled.div` + align-items: center; + display: flex; + gap: ${themeCssVariables.spacing[1]}; +`; + +const StyledReadOnlyModelButtonContainer = styled.div` + > * { + cursor: default; + + &:hover, + &:active { + background: transparent; + } + } +`; + +export const AIChatEditorSection = () => { + const isMobile = useIsMobile(); + const currentWorkspace = useAtomStateValue(currentWorkspaceState); + const smartModelLabel = useAiModelLabel(currentWorkspace?.smartModel, false); + + const { editor, handleSendAndClear } = useAIChatEditor(); + + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/ai/components/AIChatEmptyState.tsx b/packages/twenty-front/src/modules/ai/components/AIChatEmptyState.tsx index 6432e83f4e..30c11db269 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatEmptyState.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatEmptyState.tsx @@ -1,13 +1,16 @@ import { styled } from '@linaria/react'; 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 { agentChatErrorState } from '@/ai/states/agentChatErrorState'; import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector'; -import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState'; +import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState'; +import { skipMessagesSkeletonUntilLoadedState } from '@/ai/states/skipMessagesSkeletonUntilLoadedState'; import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; -import { isDefined } from 'twenty-shared/utils'; const StyledEmptyState = styled.div` display: flex; @@ -22,16 +25,25 @@ type AIChatEmptyStateProps = { }; export const AIChatEmptyState = ({ editor }: AIChatEmptyStateProps) => { - const agentChatIsLoading = useAtomStateValue(agentChatIsLoadingState); - const agentChatError = useAtomStateValue(agentChatErrorState); + const { threadsLoading, messagesLoading } = useAgentChatContext(); + const skipMessagesSkeletonUntilLoaded = useAtomStateValue( + skipMessagesSkeletonUntilLoadedState, + ); + const currentAIChatThread = useAtomStateValue(currentAIChatThreadState); const hasMessages = useAtomComponentSelectorValue( agentChatHasMessageComponentSelector, ); + const isOnNewChatSlot = + !isDefined(currentAIChatThread) || + currentAIChatThread === AGENT_CHAT_NEW_THREAD_DRAFT_KEY; + const skeletonShowing = + (threadsLoading && isOnNewChatSlot) || + (messagesLoading && !skipMessagesSkeletonUntilLoaded); const shouldRender = - !hasMessages && !isDefined(agentChatError) && !agentChatIsLoading; + !hasMessages && !isDefined(agentChatError) && !skeletonShowing; if (!shouldRender) { return null; diff --git a/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx b/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx index d125a4c298..3ca13884a9 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx @@ -1,26 +1,17 @@ import { styled } from '@linaria/react'; -import { EditorContent } from '@tiptap/react'; import { useState } from 'react'; -import { LightButton } from 'twenty-ui/input'; import { themeCssVariables } from 'twenty-ui/theme-constants'; import { DropZone } from '@/activities/files/components/DropZone'; -import { AgentChatFileUploadButton } from '@/ai/components/internal/AgentChatFileUploadButton'; -import { useAiModelLabel } from '@/ai/hooks/useAiModelOptions'; - -import { AIChatEmptyState } from '@/ai/components/AIChatEmptyState'; -import { AIChatStandaloneError } from '@/ai/components/AIChatStandaloneError'; -import { AIChatTabMessageList } from '@/ai/components/AIChatTabMessageList'; -import { AIChatContextUsageButton } from '@/ai/components/internal/AIChatContextUsageButton'; -import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader'; -import { AgentChatContextPreview } from '@/ai/components/internal/AgentChatContextPreview'; -import { SendMessageButton } from '@/ai/components/internal/SendMessageButton'; -import { useAIChatEditor } from '@/ai/hooks/useAIChatEditor'; +import { AIChatEditorSection } from '@/ai/components/AIChatEditorSection'; import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload'; -import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; -import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; +import { AGENT_CHAT_NEW_THREAD_DRAFT_KEY } from '@/ai/states/agentChatDraftsByThreadIdState'; +import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState'; +import { threadIdCreatedFromDraftState } from '@/ai/states/threadIdCreatedFromDraftState'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { AIChatTabMessageList } from '@/ai/components/AIChatTabMessageList'; + const StyledContainer = styled.div<{ isDraggingFile: boolean }>` background: ${themeCssVariables.background.primary}; display: flex; @@ -31,110 +22,20 @@ const StyledContainer = styled.div<{ isDraggingFile: boolean }>` isDraggingFile ? themeCssVariables.spacing[3] : '0'}; `; -const StyledInputArea = styled.div<{ isMobile: boolean }>` - align-items: flex-end; - background: ${themeCssVariables.background.primary}; - display: flex; - flex-direction: column; - gap: ${themeCssVariables.spacing[2]}; - padding-block: ${({ isMobile }) => - isMobile ? '0' : themeCssVariables.spacing[3]}; - padding-inline: ${themeCssVariables.spacing[3]}; -`; - -const StyledInputBox = styled.div` - background-color: ${themeCssVariables.background.transparent.lighter}; - border: 1px solid ${themeCssVariables.border.color.medium}; - border-radius: ${themeCssVariables.border.radius.sm}; - box-sizing: border-box; - display: flex; - flex-direction: column; - gap: ${themeCssVariables.spacing[2]}; - min-height: 140px; - padding: ${themeCssVariables.spacing[2]}; - width: 100%; - - &:focus-within { - border-color: ${themeCssVariables.color.blue}; - box-shadow: 0px 0px 0px 3px ${themeCssVariables.color.transparent.blue2}; - } -`; - -const StyledEditorWrapper = styled.div` - display: flex; - flex: 1; - flex-direction: column; - min-height: 0; - - .tiptap { - background: transparent; - border: none; - box-shadow: none; - color: ${themeCssVariables.font.color.primary}; - font-family: inherit; - font-size: ${themeCssVariables.font.size.md}; - font-weight: ${themeCssVariables.font.weight.regular}; - line-height: 16px; - max-height: 320px; - min-height: 48px; - outline: none; - overflow-y: auto; - padding: 0; - - p { - margin: 0; - } - - p.is-editor-empty:first-of-type::before { - color: ${themeCssVariables.font.color.light}; - content: attr(data-placeholder); - float: left; - font-weight: ${themeCssVariables.font.weight.regular}; - height: 0; - pointer-events: none; - } - } -`; - -const StyledButtonsContainer = styled.div` - align-items: center; - display: flex; - justify-content: space-between; - width: 100%; -`; - -const StyledLeftButtonsContainer = styled.div` - align-items: center; - display: flex; - gap: ${themeCssVariables.spacing['0.5']}; -`; - -const StyledRightButtonsContainer = styled.div` - align-items: center; - display: flex; - gap: ${themeCssVariables.spacing[1]}; -`; - -const StyledReadOnlyModelButtonContainer = styled.div` - > * { - cursor: default; - - &:hover, - &:active { - background: transparent; - } - } -`; - export const AIChatTab = () => { const [isDraggingFile, setIsDraggingFile] = useState(false); - const isMobile = useIsMobile(); + const currentAIChatThread = useAtomStateValue(currentAIChatThreadState); + const threadIdCreatedFromDraft = useAtomStateValue( + threadIdCreatedFromDraftState, + ); + const draftKey = currentAIChatThread ?? AGENT_CHAT_NEW_THREAD_DRAFT_KEY; + const editorSectionKey = + draftKey !== AGENT_CHAT_NEW_THREAD_DRAFT_KEY && + draftKey === threadIdCreatedFromDraft + ? AGENT_CHAT_NEW_THREAD_DRAFT_KEY + : draftKey; const { uploadFiles } = useAIChatFileUpload(); - const currentWorkspace = useAtomStateValue(currentWorkspaceState); - const smartModelLabel = useAiModelLabel(currentWorkspace?.smartModel, false); - - const { editor, handleSendAndClear } = useAIChatEditor(); return ( { {!isDraggingFile && ( <> - - - - - - - - - - - - - - - - - - - - - - - + )} diff --git a/packages/twenty-front/src/modules/ai/components/AIChatThreadsList.tsx b/packages/twenty-front/src/modules/ai/components/AIChatThreadsList.tsx index 7f0703247c..6b69ae48f4 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatThreadsList.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatThreadsList.tsx @@ -36,15 +36,15 @@ const StyledButtonsContainer = styled.div` `; export const AIChatThreadsList = () => { - const { createChatThread } = useCreateNewAIChatThread(); + const { switchToNewChat } = useCreateNewAIChatThread(); const focusId = 'threads-list'; useHotkeysOnFocusedElement({ keys: [`${Key.Control}+${Key.Enter}`, `${Key.Meta}+${Key.Enter}`], - callback: () => createChatThread(), + callback: () => switchToNewChat(), focusId, - dependencies: [createChatThread], + dependencies: [switchToNewChat], }); const { threads, hasNextPage, loading, fetchMoreRef } = useChatThreads(); @@ -77,7 +77,7 @@ export const AIChatThreadsList = () => { accent="blue" size="medium" title={t`New chat`} - onClick={() => createChatThread()} + onClick={() => switchToNewChat()} hotkeys={[getOsControlSymbol(), '⏎']} /> diff --git a/packages/twenty-front/src/modules/ai/components/AgentChatDataEffect.tsx b/packages/twenty-front/src/modules/ai/components/AgentChatDataEffect.tsx index 3dbad9f67a..54ac9935ce 100644 --- a/packages/twenty-front/src/modules/ai/components/AgentChatDataEffect.tsx +++ b/packages/twenty-front/src/modules/ai/components/AgentChatDataEffect.tsx @@ -14,14 +14,13 @@ import { useEffect } from 'react'; import { Temporal } from 'temporal-polyfill'; export const AgentChatDataEffect = () => { - const { uiMessages, isLoading } = useAgentChatData(); - const chatState = useAgentChat(uiMessages); + 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] = diff --git a/packages/twenty-front/src/modules/ai/components/AgentChatProvider.tsx b/packages/twenty-front/src/modules/ai/components/AgentChatProvider.tsx index 412685c518..6318382aca 100644 --- a/packages/twenty-front/src/modules/ai/components/AgentChatProvider.tsx +++ b/packages/twenty-front/src/modules/ai/components/AgentChatProvider.tsx @@ -1,8 +1,5 @@ -import { AgentChatDataEffect } from '@/ai/components/AgentChatDataEffect'; -import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext'; - +import { AgentChatProviderContent } from '@/ai/components/AgentChatProviderContent'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; -import { Suspense } from 'react'; import { FeatureFlagKey } from '~/generated-metadata/graphql'; export const AgentChatProvider = ({ @@ -16,14 +13,5 @@ export const AgentChatProvider = ({ return <>{children}; } - return ( - - - - {children} - - - ); + return {children}; }; diff --git a/packages/twenty-front/src/modules/ai/components/AgentChatProviderContent.tsx b/packages/twenty-front/src/modules/ai/components/AgentChatProviderContent.tsx new file mode 100644 index 0000000000..f35c8f3d34 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/AgentChatProviderContent.tsx @@ -0,0 +1,33 @@ +import { AgentChatDataEffect } from '@/ai/components/AgentChatDataEffect'; +import { AgentChatContext } from '@/ai/contexts/AgentChatContext'; +import { useAgentChatData } from '@/ai/hooks/useAgentChatData'; +import { AgentChatComponentInstanceContext } from '@/ai/states/AgentChatComponentInstanceContext'; +import { Suspense } from 'react'; + +export const AgentChatProviderContent = ({ + children, +}: { + children: React.ReactNode; +}) => { + const { ensureThreadForDraft, threadsLoading, messagesLoading } = + useAgentChatData(); + + const contextValue = { + ensureThreadForDraft, + threadsLoading, + messagesLoading, + }; + + return ( + + + + + {children} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/ai/components/internal/AIChatEditorFocusEffect.tsx b/packages/twenty-front/src/modules/ai/components/internal/AIChatEditorFocusEffect.tsx new file mode 100644 index 0000000000..3964c1f9f6 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/internal/AIChatEditorFocusEffect.tsx @@ -0,0 +1,34 @@ +import { type Editor } from '@tiptap/react'; +import { useEffect } from 'react'; + +import { focusEditorAfterMigrateState } from '@/ai/states/focusEditorAfterMigrateState'; +import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; + +type AIChatEditorFocusEffectProps = { + editor: Editor | null; +}; + +export const AIChatEditorFocusEffect = ({ + editor, +}: AIChatEditorFocusEffectProps) => { + const [focusEditorAfterMigrate, setFocusEditorAfterMigrate] = useAtomState( + focusEditorAfterMigrateState, + ); + + useEffect(() => { + if (!focusEditorAfterMigrate || !editor) { + return; + } + + const rafId = requestAnimationFrame(() => { + editor.commands.focus('end'); + setFocusEditorAfterMigrate(false); + }); + + return () => { + cancelAnimationFrame(rafId); + }; + }, [focusEditorAfterMigrate, editor, setFocusEditorAfterMigrate]); + + return null; +}; 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 91aa50c04b..d0f2e4acda 100644 --- a/packages/twenty-front/src/modules/ai/components/internal/AIChatSkeletonLoader.tsx +++ b/packages/twenty-front/src/modules/ai/components/internal/AIChatSkeletonLoader.tsx @@ -1,12 +1,17 @@ -import { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector'; -import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState'; -import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; -import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; 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 { agentChatHasMessageComponentSelector } from '@/ai/states/agentChatHasMessageComponentSelector'; +import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState'; +import { skipMessagesSkeletonUntilLoadedState } from '@/ai/states/skipMessagesSkeletonUntilLoadedState'; +import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; + const StyledSkeletonContainer = styled.div` display: flex; flex: 1; @@ -29,14 +34,24 @@ const NUMBER_OF_SKELETONS = 6; export const AIChatSkeletonLoader = () => { const { theme } = useContext(ThemeContext); - - const agentChatIsLoading = useAtomStateValue(agentChatIsLoadingState); + const { threadsLoading, messagesLoading } = useAgentChatContext(); + const skipMessagesSkeletonUntilLoaded = useAtomStateValue( + skipMessagesSkeletonUntilLoadedState, + ); + const currentAIChatThread = useAtomStateValue(currentAIChatThreadState); const hasMessages = useAtomComponentSelectorValue( agentChatHasMessageComponentSelector, ); - const shouldRender = agentChatIsLoading && !hasMessages; + const isOnNewChatSlot = + !isDefined(currentAIChatThread) || + currentAIChatThread === AGENT_CHAT_NEW_THREAD_DRAFT_KEY; + const showForMessagesLoading = + messagesLoading && !skipMessagesSkeletonUntilLoaded; + const shouldRender = + !hasMessages && + ((threadsLoading && isOnNewChatSlot) || showForMessagesLoading); if (!shouldRender) { return null; diff --git a/packages/twenty-front/src/modules/ai/contexts/AgentChatContext.tsx b/packages/twenty-front/src/modules/ai/contexts/AgentChatContext.tsx new file mode 100644 index 0000000000..92a04966e8 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/contexts/AgentChatContext.tsx @@ -0,0 +1,15 @@ +import { createContext, useContext } from 'react'; + +export type AgentChatContextValue = { + ensureThreadForDraft: (() => void) | undefined; + threadsLoading: boolean; + messagesLoading: boolean; +}; + +export const AgentChatContext = createContext({ + ensureThreadForDraft: undefined, + threadsLoading: false, + messagesLoading: false, +}); + +export const useAgentChatContext = () => useContext(AgentChatContext); diff --git a/packages/twenty-front/src/modules/ai/hooks/useAIChatEditor.ts b/packages/twenty-front/src/modules/ai/hooks/useAIChatEditor.ts index 5c82572d97..61f5c4b76a 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useAIChatEditor.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useAIChatEditor.ts @@ -5,11 +5,17 @@ import { Paragraph } from '@tiptap/extension-paragraph'; import { Text } from '@tiptap/extension-text'; import { Placeholder } from '@tiptap/extensions/placeholder'; import { useEditor } from '@tiptap/react'; -import { useCallback, useMemo } from '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, + agentChatDraftsByThreadIdState, +} from '@/ai/states/agentChatDraftsByThreadIdState'; import { agentChatInputState } from '@/ai/states/agentChatInputState'; +import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState'; import { dispatchAgentChatSendMessageEvent } from '@/ai/utils/dispatchAgentChatSendMessageEvent'; import { MENTION_SUGGESTION_PLUGIN_KEY } from '@/mention/constants/MentionSuggestionPluginKey'; import { MentionSuggestion } from '@/mention/extensions/MentionSuggestion'; @@ -18,16 +24,37 @@ import { useMentionSearch } from '@/mention/hooks/useMentionSearch'; import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack'; import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById'; import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; +import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly'; +const textToTiptapContent = (text: string) => ({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: text ? [{ type: 'text', text }] : [], + }, + ], +}); + export const useAIChatEditor = () => { const setAgentChatInput = useSetAtomState(agentChatInputState); + 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 initialDraft = agentChatDraftsByThreadId[draftKey] ?? ''; + const initialContent = textToTiptapContent(initialDraft); + const extensions = useMemo( () => [ Document, @@ -46,6 +73,7 @@ export const useAIChatEditor = () => { ); const editor = useEditor({ + content: initialContent, extensions, editorProps: { handleKeyDown: (view, event) => { @@ -72,6 +100,10 @@ export const useAIChatEditor = () => { currentEditor.getText({ blockSeparator: '\n' }), ); setAgentChatInput(text); + setAgentChatDraftsByThreadId((prev) => ({ ...prev, [draftKey]: text })); + if (draftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY && text.trim() !== '') { + ensureThreadForDraft?.(); + } }, onFocus: () => { pushFocusItemToFocusStack({ @@ -104,10 +136,10 @@ export const useAIChatEditor = () => { mentionStorage.searchMentionRecords = searchMentionRecords; } - const handleSendAndClear = useCallback(() => { + const handleSendAndClear = () => { dispatchAgentChatSendMessageEvent(); editor?.commands.clearContent(); - }, [editor]); + }; return { editor, handleSendAndClear }; }; diff --git a/packages/twenty-front/src/modules/ai/hooks/useAIChatThreadClick.ts b/packages/twenty-front/src/modules/ai/hooks/useAIChatThreadClick.ts index 5a5a99a89b..035e54cc68 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useAIChatThreadClick.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useAIChatThreadClick.ts @@ -1,9 +1,16 @@ +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 { threadIdCreatedFromDraftState } from '@/ai/states/threadIdCreatedFromDraftState'; import { useOpenAskAIPageInSidePanel } from '@/side-panel/hooks/useOpenAskAIPageInSidePanel'; import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; +import { useStore } from 'jotai'; import { isDefined } from 'twenty-shared/utils'; import { type AgentChatThread } from '~/generated-metadata/graphql'; @@ -15,21 +22,46 @@ export const useAIChatThreadClick = ( options: UseAIChatThreadClickOptions = {}, ) => { const { resetNavigationStack = false } = options; - const [, setCurrentAIChatThread] = useAtomState(currentAIChatThreadState); + const setThreadIdCreatedFromDraft = useSetAtomState( + threadIdCreatedFromDraftState, + ); + const [currentAIChatThread, setCurrentAIChatThread] = useAtomState( + currentAIChatThreadState, + ); + const setAgentChatInput = useSetAtomState(agentChatInputState); const setCurrentAIChatThreadTitle = useSetAtomState( currentAIChatThreadTitleState, ); const setAgentChatUsage = useSetAtomState(agentChatUsageState); + const setAgentChatDraftsByThreadId = useSetAtomState( + agentChatDraftsByThreadIdState, + ); + const store = useStore(); const { openAskAIPage } = useOpenAskAIPageInSidePanel(); const handleThreadClick = (thread: AgentChatThread) => { + setThreadIdCreatedFromDraft(null); + const previousDraftKey = + currentAIChatThread ?? AGENT_CHAT_NEW_THREAD_DRAFT_KEY; + const isSameThread = thread.id === currentAIChatThread; + + setAgentChatDraftsByThreadId((prev) => ({ + ...prev, + [previousDraftKey]: store.get(agentChatInputState.atom), + })); setCurrentAIChatThread(thread.id); + + if (!isSameThread) { + const newDraft = + store.get(agentChatDraftsByThreadIdState.atom)[thread.id] ?? ''; + setAgentChatInput(newDraft); + } + setCurrentAIChatThreadTitle(thread.title ?? null); const hasUsageData = (thread.conversationSize ?? 0) > 0 && isDefined(thread.contextWindowTokens); - setAgentChatUsage( hasUsageData ? { diff --git a/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts b/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts index 71674cb3d2..b17e2f237f 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts @@ -10,6 +10,10 @@ import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTi import { AGENT_CHAT_RETRY_EVENT_NAME } from '@/ai/constants/AgentChatRetryEventName'; import { AGENT_CHAT_STOP_EVENT_NAME } from '@/ai/constants/AgentChatStopEventName'; +import { + AGENT_CHAT_NEW_THREAD_DRAFT_KEY, + agentChatDraftsByThreadIdState, +} from '@/ai/states/agentChatDraftsByThreadIdState'; import { agentChatInputState } from '@/ai/states/agentChatInputState'; import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url'; import { getTokenPair } from '@/apollo/utils/getTokenPair'; @@ -21,13 +25,17 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; -import { useCallback } from 'react'; +import { useStore } from 'jotai'; +import { useCallback, useState } from 'react'; import { type ExtendedUIMessage } from 'twenty-shared/ai'; import { isDefined } from 'twenty-shared/utils'; import { REACT_APP_SERVER_BASE_URL } from '~/config'; import { cookieStorage } from '~/utils/cookie-storage'; -export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => { +export const useAgentChat = ( + uiMessages: ExtendedUIMessage[], + ensureThreadIdForSend: () => Promise, +) => { const setTokenPair = useSetAtomState(tokenPairState); const setAgentChatUsage = useSetAtomState(agentChatUsageState); @@ -35,17 +43,24 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => { const setCurrentAIChatThreadTitle = useSetAtomState( currentAIChatThreadTitleState, ); + const setCurrentAIChatThread = useSetAtomState(currentAIChatThreadState); const apolloClient = useApolloClient(); + const store = useStore(); const agentChatSelectedFiles = useAtomStateValue(agentChatSelectedFilesState); const currentAIChatThread = useAtomStateValue(currentAIChatThreadState); + const [, setPendingThreadIdAfterFirstSend] = useState(null); + const [agentChatUploadedFiles, setAgentChatUploadedFiles] = useAtomState( agentChatUploadedFilesState, ); - const [agentChatInput, setAgentChatInput] = useAtomState(agentChatInputState); + const [, setAgentChatInput] = useAtomState(agentChatInputState); + const setAgentChatDraftsByThreadId = useSetAtomState( + agentChatDraftsByThreadIdState, + ); const retryFetchWithRenewedToken = async ( input: RequestInfo | URL, @@ -167,23 +182,30 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => { (part) => part.type === 'data-thread-title', ); - if (isDefined(titlePart) && titlePart.type === 'data-thread-title') { - setCurrentAIChatThreadTitle(titlePart.data.title); - if (isDefined(currentAIChatThread)) { - const threadRef = apolloClient.cache.identify({ - __typename: 'AgentChatThread', - id: currentAIChatThread, - }); - if (isDefined(threadRef)) { - apolloClient.cache.modify({ - id: threadRef, - fields: { - title: () => titlePart.data.title, - }, + setPendingThreadIdAfterFirstSend((pendingId) => { + const threadIdForTitle = pendingId ?? currentAIChatThread; + if (isDefined(titlePart) && titlePart.type === 'data-thread-title') { + setCurrentAIChatThreadTitle(titlePart.data.title); + if (isDefined(threadIdForTitle)) { + const threadRef = apolloClient.cache.identify({ + __typename: 'AgentChatThread', + id: threadIdForTitle, }); + if (isDefined(threadRef)) { + apolloClient.cache.modify({ + id: threadRef, + fields: { + title: () => titlePart.data.title, + }, + }); + } } } - } + if (isDefined(pendingId)) { + setCurrentAIChatThread(pendingId); + } + return null; + }); }, }); @@ -191,24 +213,47 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => { const isLoading = isStreaming || agentChatSelectedFiles.length > 0; const handleSendMessage = useCallback(async () => { - if (agentChatInput.trim() === '' || isLoading || !currentAIChatThread) { + const draftKey = + store.get(currentAIChatThreadState.atom) ?? + AGENT_CHAT_NEW_THREAD_DRAFT_KEY; + const contentToSend = + draftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY + ? ( + store.get(agentChatDraftsByThreadIdState.atom)[ + AGENT_CHAT_NEW_THREAD_DRAFT_KEY + ] ?? store.get(agentChatInputState.atom) + ).trim() + : store.get(agentChatInputState.atom).trim(); + + if (contentToSend === '' || isLoading) { return; } - const content = agentChatInput.trim(); + const threadId = await ensureThreadIdForSend(); + if (!threadId) { + return; + } + + if (draftKey === AGENT_CHAT_NEW_THREAD_DRAFT_KEY) { + setPendingThreadIdAfterFirstSend(threadId); + } setAgentChatInput(''); + setAgentChatDraftsByThreadId((prev) => ({ + ...prev, + [draftKey]: '', + })); const browsingContext = getBrowsingContext(); sendMessage( { - text: content, + text: contentToSend, files: agentChatUploadedFiles, }, { body: { - threadId: currentAIChatThread, + threadId, browsingContext, }, }, @@ -216,14 +261,15 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => { setAgentChatUploadedFiles([]); }, [ - agentChatInput, + store, isLoading, - currentAIChatThread, + ensureThreadIdForSend, setAgentChatInput, getBrowsingContext, sendMessage, agentChatUploadedFiles, setAgentChatUploadedFiles, + setAgentChatDraftsByThreadId, ]); useListenToBrowserEvent({ diff --git a/packages/twenty-front/src/modules/ai/hooks/useAgentChatData.ts b/packages/twenty-front/src/modules/ai/hooks/useAgentChatData.ts index ca0d77ba8f..bf1a814261 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useAgentChatData.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useAgentChatData.ts @@ -1,75 +1,135 @@ +import { useApolloClient } from '@apollo/client'; import { getOperationName } from '@apollo/client/utilities'; -import { type SetStateAction } from 'jotai'; +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 { - agentChatUsageState, - type AgentChatUsageState, -} from '@/ai/states/agentChatUsageState'; + 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 AgentChatThread, + type GetChatThreadsQuery, GetChatThreadsDocument, useCreateChatThreadMutation, useGetChatMessagesQuery, useGetChatThreadsQuery, } from '~/generated-metadata/graphql'; -const setUsageFromThread = ( - thread: AgentChatThread, - setAgentChatUsage: ( - update: SetStateAction, - ) => void, -) => { - const hasUsageData = - (thread.conversationSize ?? 0) > 0 && isDefined(thread.contextWindowTokens); - - setAgentChatUsage( - hasUsageData - ? { - lastMessage: null, - conversationSize: thread.conversationSize ?? 0, - contextWindowTokens: thread.contextWindowTokens ?? 0, - inputTokens: thread.totalInputTokens, - outputTokens: thread.totalOutputTokens, - inputCredits: thread.totalInputCredits, - outputCredits: thread.totalOutputCredits, - } - : null, - ); -}; - export const useAgentChatData = () => { const [currentAIChatThread, setCurrentAIChatThread] = useAtomState( currentAIChatThreadState, ); + const setAgentChatInput = useSetAtomState(agentChatInputState); const setAgentChatUsage = useSetAtomState(agentChatUsageState); const setCurrentAIChatThreadTitle = useSetAtomState( currentAIChatThreadTitleState, ); - const [isCreatingChatThread, setIsCreatingChatThread] = useAtomState( - isCreatingChatThreadState, + const [, setIsCreatingChatThread] = useAtomState(isCreatingChatThreadState); + const setAgentChatDraftsByThreadId = useSetAtomState( + agentChatDraftsByThreadIdState, ); + const setPendingCreateFromDraftPromise = useSetAtomState( + pendingCreateFromDraftPromiseState, + ); + const store = useStore(); + const apolloClient = useApolloClient(); const { scrollToBottom } = useAgentChatScrollToBottom(); const [createChatThread] = useCreateChatThreadMutation({ 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); - setCurrentAIChatThread(data.createChatThread.id); + 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', @@ -80,32 +140,124 @@ export const useAgentChatData = () => { variables: { paging: { first: CHAT_THREADS_PAGE_SIZE } }, skip: isDefined(currentAIChatThread), onCompleted: (data) => { - const edges = data?.chatThreads?.edges ?? []; - const threads = edges.map((edge) => edge.node); + const threads = data.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); - setUsageFromThread(firstThread, setAgentChatUsage); - } else if (!isCreatingChatThread) { - setIsCreatingChatThread(true); - createChatThread(); + + 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); } }, }); + const isNewThread = currentAIChatThread === AGENT_CHAT_NEW_THREAD_DRAFT_KEY; const { loading: messagesLoading, data } = useGetChatMessagesQuery({ variables: { threadId: currentAIChatThread! }, - skip: !isDefined(currentAIChatThread), - onCompleted: scrollToBottom, + skip: !isDefined(currentAIChatThread) || isNewThread, + onCompleted: () => { + store.set(skipMessagesSkeletonUntilLoadedState.atom, false); + scrollToBottom(); + }, }); + const ensureThreadForDraft = () => { + 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); + }); + }; + + const ensureThreadIdForSend = async (): Promise => { + 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); + } + }; + const uiMessages = mapDBMessagesToUIMessages(data?.chatMessages || []); const isLoading = messagesLoading || threadsLoading; return { uiMessages, isLoading, + threadsLoading, + messagesLoading, + ensureThreadForDraft, + ensureThreadIdForSend, }; }; diff --git a/packages/twenty-front/src/modules/ai/hooks/useCreateNewAIChatThread.ts b/packages/twenty-front/src/modules/ai/hooks/useCreateNewAIChatThread.ts index 81feeb37c6..367f0ebfd0 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useCreateNewAIChatThread.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useCreateNewAIChatThread.ts @@ -1,74 +1,56 @@ -import { useApolloClient } from '@apollo/client'; +import { useStore } from 'jotai'; -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 { agentChatUsageState } from '@/ai/states/agentChatUsageState'; import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState'; import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState'; +import { hasTriggeredCreateForDraftState } from '@/ai/states/hasTriggeredCreateForDraftState'; +import { threadIdCreatedFromDraftState } from '@/ai/states/threadIdCreatedFromDraftState'; import { useOpenAskAIPageInSidePanel } from '@/side-panel/hooks/useOpenAskAIPageInSidePanel'; import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; -import { isDefined } from 'twenty-shared/utils'; - -import { - type GetChatThreadsQuery, - GetChatThreadsDocument, - useCreateChatThreadMutation, -} from '~/generated-metadata/graphql'; export const useCreateNewAIChatThread = () => { - const apolloClient = useApolloClient(); - const [, setCurrentAIChatThread] = useAtomState(currentAIChatThreadState); + const setThreadIdCreatedFromDraft = useSetAtomState( + threadIdCreatedFromDraftState, + ); + const [currentAIChatThread, setCurrentAIChatThread] = useAtomState( + currentAIChatThreadState, + ); + const setAgentChatInput = useSetAtomState(agentChatInputState); const setAgentChatUsage = useSetAtomState(agentChatUsageState); const setCurrentAIChatThreadTitle = useSetAtomState( currentAIChatThreadTitleState, ); - + const setAgentChatDraftsByThreadId = useSetAtomState( + agentChatDraftsByThreadIdState, + ); + const store = useStore(); const { openAskAIPage } = useOpenAskAIPageInSidePanel(); - const [createChatThread] = useCreateChatThreadMutation({ - onCompleted: (data) => { - setCurrentAIChatThread(data.createChatThread.id); - setCurrentAIChatThreadTitle(null); - setAgentChatUsage(null); - openAskAIPage({ resetNavigationStack: false }); + const switchToNewChat = () => { + setThreadIdCreatedFromDraft(null); + const previousDraftKey = + currentAIChatThread ?? AGENT_CHAT_NEW_THREAD_DRAFT_KEY; + const newChatDraft = + store.get(agentChatDraftsByThreadIdState.atom)[ + AGENT_CHAT_NEW_THREAD_DRAFT_KEY + ] ?? ''; + setAgentChatDraftsByThreadId((prev) => ({ + ...prev, + [previousDraftKey]: store.get(agentChatInputState.atom), + })); + store.set(hasTriggeredCreateForDraftState.atom, false); + setCurrentAIChatThread(AGENT_CHAT_NEW_THREAD_DRAFT_KEY); + setAgentChatInput(newChatDraft); + setCurrentAIChatThreadTitle(null); + setAgentChatUsage(null); + openAskAIPage({ resetNavigationStack: false }); + }; - 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], - }, - }, - }); - } - }, - }); - - return { createChatThread }; + return { switchToNewChat }; }; diff --git a/packages/twenty-front/src/modules/ai/states/agentChatDraftsByThreadIdState.ts b/packages/twenty-front/src/modules/ai/states/agentChatDraftsByThreadIdState.ts new file mode 100644 index 0000000000..3a51c77f4a --- /dev/null +++ b/packages/twenty-front/src/modules/ai/states/agentChatDraftsByThreadIdState.ts @@ -0,0 +1,14 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const AGENT_CHAT_NEW_THREAD_DRAFT_KEY = '__new__'; + +const DRAFTS_STORAGE_KEY = 'ai/agentChatDraftsByThreadIdState'; + +export const agentChatDraftsByThreadIdState = createAtomState< + Record +>({ + key: DRAFTS_STORAGE_KEY, + defaultValue: {}, + useLocalStorage: true, + localStorageOptions: { getOnInit: true }, +}); diff --git a/packages/twenty-front/src/modules/ai/states/focusEditorAfterMigrateState.ts b/packages/twenty-front/src/modules/ai/states/focusEditorAfterMigrateState.ts new file mode 100644 index 0000000000..c24b44b0a1 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/states/focusEditorAfterMigrateState.ts @@ -0,0 +1,6 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const focusEditorAfterMigrateState = createAtomState({ + key: 'ai/focusEditorAfterMigrateState', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/ai/states/hasTriggeredCreateForDraftState.ts b/packages/twenty-front/src/modules/ai/states/hasTriggeredCreateForDraftState.ts new file mode 100644 index 0000000000..cc31b971b2 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/states/hasTriggeredCreateForDraftState.ts @@ -0,0 +1,6 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const hasTriggeredCreateForDraftState = createAtomState({ + key: 'ai/hasTriggeredCreateForDraftState', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/ai/states/isCreatingForFirstSendState.ts b/packages/twenty-front/src/modules/ai/states/isCreatingForFirstSendState.ts new file mode 100644 index 0000000000..b41152c3a3 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/states/isCreatingForFirstSendState.ts @@ -0,0 +1,6 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const isCreatingForFirstSendState = createAtomState({ + key: 'ai/isCreatingForFirstSendState', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/ai/states/pendingCreateFromDraftPromiseState.ts b/packages/twenty-front/src/modules/ai/states/pendingCreateFromDraftPromiseState.ts new file mode 100644 index 0000000000..0f847e8cb2 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/states/pendingCreateFromDraftPromiseState.ts @@ -0,0 +1,8 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const pendingCreateFromDraftPromiseState = createAtomState | null>({ + key: 'ai/pendingCreateFromDraftPromiseState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/ai/states/skipMessagesSkeletonUntilLoadedState.ts b/packages/twenty-front/src/modules/ai/states/skipMessagesSkeletonUntilLoadedState.ts new file mode 100644 index 0000000000..90d5d5ef1f --- /dev/null +++ b/packages/twenty-front/src/modules/ai/states/skipMessagesSkeletonUntilLoadedState.ts @@ -0,0 +1,6 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const skipMessagesSkeletonUntilLoadedState = createAtomState({ + key: 'ai/skipMessagesSkeletonUntilLoadedState', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/ai/states/threadIdCreatedFromDraftState.ts b/packages/twenty-front/src/modules/ai/states/threadIdCreatedFromDraftState.ts new file mode 100644 index 0000000000..8c585450ac --- /dev/null +++ b/packages/twenty-front/src/modules/ai/states/threadIdCreatedFromDraftState.ts @@ -0,0 +1,6 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const threadIdCreatedFromDraftState = createAtomState({ + key: 'ai/threadIdCreatedFromDraftState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawerTabsRow.tsx b/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawerTabsRow.tsx index 953525278d..b8726785af 100644 --- a/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawerTabsRow.tsx +++ b/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawerTabsRow.tsx @@ -127,7 +127,7 @@ export const MainNavigationDrawerTabsRow = () => { ); const [navigationDrawerActiveTab, setNavigationDrawerActiveTab] = useAtomState(navigationDrawerActiveTabState); - const { createChatThread } = useCreateNewAIChatThread(); + const { switchToNewChat } = useCreateNewAIChatThread(); const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED); const setIsNavigationDrawerExpanded = useSetAtomState( isNavigationDrawerExpandedState, @@ -155,7 +155,7 @@ export const MainNavigationDrawerTabsRow = () => { if (isMobile) { setIsNavigationDrawerExpanded(false); } - createChatThread(); + switchToNewChat(); }; const handleNewChatKeyDown = (event: React.KeyboardEvent) => { diff --git a/packages/twenty-front/src/modules/navigation/components/MobileNavigationBar.tsx b/packages/twenty-front/src/modules/navigation/components/MobileNavigationBar.tsx index 7be1c23635..b1cc732c05 100644 --- a/packages/twenty-front/src/modules/navigation/components/MobileNavigationBar.tsx +++ b/packages/twenty-front/src/modules/navigation/components/MobileNavigationBar.tsx @@ -36,7 +36,7 @@ export const MobileNavigationBar = () => { useAtomState(isNavigationDrawerExpandedState); const [currentMobileNavigationDrawer, setCurrentMobileNavigationDrawer] = useAtomState(currentMobileNavigationDrawerState); - const { createChatThread } = useCreateNewAIChatThread(); + const { switchToNewChat } = useCreateNewAIChatThread(); const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED); const { alphaSortedActiveNonSystemObjectMetadataItems } = useFilteredObjectMetadataItems(); @@ -100,7 +100,7 @@ export const MobileNavigationBar = () => { onClick: () => { setIsNavigationDrawerExpanded(false); closeSidePanelMenu(); - createChatThread(); + switchToNewChat(); }, }, ] 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 846289656b..8ee82b05fe 100644 --- a/packages/twenty-front/src/modules/side-panel/components/SidePanelTopBarRightCornerIcon.tsx +++ b/packages/twenty-front/src/modules/side-panel/components/SidePanelTopBarRightCornerIcon.tsx @@ -22,7 +22,7 @@ export const SidePanelTopBarRightCornerIcon = () => { const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED); const sidePanelPage = useAtomStateValue(sidePanelPageState); const { openAskAIPage } = useOpenAskAIPageInSidePanel(); - const { createChatThread } = useCreateNewAIChatThread(); + const { switchToNewChat } = useCreateNewAIChatThread(); if (isMobile || !isAiEnabled) { return null; @@ -52,7 +52,7 @@ export const SidePanelTopBarRightCornerIcon = () => { Icon={IconEdit} size="small" variant="tertiary" - onClick={() => createChatThread()} + onClick={() => switchToNewChat()} ariaLabel={t`New conversation`} /> diff --git a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomState.ts b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomState.ts index 6b98475626..c9cdac315c 100644 --- a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomState.ts +++ b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createAtomState.ts @@ -22,15 +22,19 @@ type StateAtom = WritableAtom< void >; +type LocalStorageOptions = { getOnInit?: boolean }; + export const createAtomState = ({ key, defaultValue, useLocalStorage = false, + localStorageOptions, useCookieStorage, }: { key: string; defaultValue: ValueType; useLocalStorage?: boolean; + localStorageOptions?: LocalStorageOptions; useCookieStorage?: CookieStorageConfig; }): State => { let baseAtom: StateAtom; @@ -51,6 +55,8 @@ export const createAtomState = ({ baseAtom = atomWithStorage( key, defaultValue, + undefined, + localStorageOptions ?? undefined, ) as StateAtom; } else { baseAtom = atom(defaultValue);