Auto-start the workspace setup chat with a data model proposal (#23437)
https://github.com/user-attachments/assets/d447372b-c1b4-4c95-bac9-a8f8efa7d4a1 When the workspace creator lands on `/workspace-setup` after onboarding, the AI chat now starts on its own: an invisible first message, built server-side from the company enrichment collected in #23199, asks the assistant to propose a data model tailored to the business. The proposal streams in; the user never sees the prompt. - New `startWorkspaceSetupChat` mutation: creator only, gated on `IS_ONBOARDING_AI_CHAT_ENABLED`, available models and credits. Idempotent per user and workspace via a `keyValuePair` pointing at the thread, so a reload or a second tab joins the same conversation instead of starting a new one. - The thread holds exactly one hidden `USER` message combining the company context and the setup instructions, which keeps the one-hidden-message-per-thread index from #23199 satisfied. It goes through a dedicated streaming path that never queues, so the prompt cannot resurface as a visible message. - The assistant only proposes. It creates nothing until the user approves, then builds the model with the `metadata-building` skill. Objects and fields get English names with labels in the user's language, and the conversation continues in that language. - With no enrichment (consumer email domain, or the integration disabled) the kickoff still runs, and the assistant asks one short question about the business before proposing. - `findLatestSentUserMessage` no longer filters out hidden messages, so a failed kickoff turn stays retryable, and the no-message chat error surface now offers retry for stream errors. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23437?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -6,6 +6,8 @@ import { AiChatSuggestedPrompts } from '@/ai/components/suggested-prompts/AiChat
|
||||
import { AGENT_CHAT_NEW_THREAD_DRAFT_KEY } from '@/ai/states/agentChatDraftsByThreadIdState';
|
||||
import { agentChatErrorComponentFamilyState } from '@/ai/states/agentChatErrorComponentFamilyState';
|
||||
import { agentChatHasMessageComponentSelector } from '@/ai/states/selectors/agentChatHasMessageComponentSelector';
|
||||
import { agentChatIsAwaitingFirstChunkComponentFamilyState } from '@/ai/states/agentChatIsAwaitingFirstChunkComponentFamilyState';
|
||||
import { agentChatIsStreamingComponentFamilyState } from '@/ai/states/agentChatIsStreamingComponentFamilyState';
|
||||
import { agentChatMessagesLoadingState } from '@/ai/states/agentChatMessagesLoadingState';
|
||||
import { agentChatThreadsLoadingState } from '@/ai/states/agentChatThreadsLoadingState';
|
||||
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
|
||||
@@ -32,6 +34,14 @@ export const AiChatEmptyState = ({ editor }: AiChatEmptyStateProps) => {
|
||||
agentChatErrorComponentFamilyState,
|
||||
{ threadId: currentAiChatThread },
|
||||
);
|
||||
const agentChatIsAwaitingFirstChunk = useAtomComponentFamilyStateValue(
|
||||
agentChatIsAwaitingFirstChunkComponentFamilyState,
|
||||
{ threadId: currentAiChatThread },
|
||||
);
|
||||
const agentChatIsStreaming = useAtomComponentFamilyStateValue(
|
||||
agentChatIsStreamingComponentFamilyState,
|
||||
{ threadId: currentAiChatThread },
|
||||
);
|
||||
const agentChatThreadsLoading = useAtomStateValue(
|
||||
agentChatThreadsLoadingState,
|
||||
);
|
||||
@@ -54,7 +64,12 @@ export const AiChatEmptyState = ({ editor }: AiChatEmptyStateProps) => {
|
||||
(agentChatThreadsLoading && isOnNewChatSlot) ||
|
||||
(agentChatMessagesLoading && !skipMessagesSkeletonUntilLoaded);
|
||||
const shouldRender =
|
||||
!isMobile && !hasMessages && !isDefined(agentChatError) && !skeletonShowing;
|
||||
!isMobile &&
|
||||
!hasMessages &&
|
||||
!isDefined(agentChatError) &&
|
||||
!skeletonShowing &&
|
||||
!agentChatIsAwaitingFirstChunk &&
|
||||
!agentChatIsStreaming;
|
||||
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
|
||||
@@ -2,10 +2,11 @@ import { styled } from '@linaria/react';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { AiChatErrorRenderer } from '@/ai/components/AiChatErrorRenderer';
|
||||
import { useRetryChatMessage } from '@/ai/hooks/useRetryChatMessage';
|
||||
import { agentChatErrorComponentFamilyState } from '@/ai/states/agentChatErrorComponentFamilyState';
|
||||
import { agentChatHasMessageComponentSelector } from '@/ai/states/selectors/agentChatHasMessageComponentSelector';
|
||||
import { agentChatIsLoadingState } from '@/ai/states/agentChatIsLoadingState';
|
||||
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
|
||||
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
|
||||
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
|
||||
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
@@ -22,11 +23,14 @@ const StyledErrorContainer = styled.div`
|
||||
|
||||
export const AiChatStandaloneError = () => {
|
||||
const agentChatIsLoading = useAtomStateValue(agentChatIsLoadingState);
|
||||
const { retryChatMessage } = useRetryChatMessage();
|
||||
|
||||
const currentAiChatThread = useAtomStateValue(currentAiChatThreadState);
|
||||
const agentChatDisplayedThread = useAtomStateValue(
|
||||
agentChatDisplayedThreadState,
|
||||
);
|
||||
const agentChatError = useAtomComponentFamilyStateValue(
|
||||
agentChatErrorComponentFamilyState,
|
||||
{ threadId: currentAiChatThread },
|
||||
{ threadId: agentChatDisplayedThread },
|
||||
);
|
||||
|
||||
const hasMessages = useAtomComponentSelectorValue(
|
||||
@@ -42,7 +46,7 @@ export const AiChatStandaloneError = () => {
|
||||
|
||||
return (
|
||||
<StyledErrorContainer>
|
||||
<AiChatErrorRenderer error={agentChatError} />
|
||||
<AiChatErrorRenderer error={agentChatError} onRetry={retryChatMessage} />
|
||||
</StyledErrorContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -58,6 +58,7 @@ export const AiChatTabMessageList = () => {
|
||||
return (
|
||||
<StyledPreambleOutsideScrollContainer>
|
||||
{messageListPreamble}
|
||||
<AiChatPendingResponseIndicator />
|
||||
</StyledPreambleOutsideScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
import { AiChatEmptyState } from '@/ai/components/AiChatEmptyState';
|
||||
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
|
||||
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
|
||||
import { agentChatIsAwaitingFirstChunkComponentFamilyState } from '@/ai/states/agentChatIsAwaitingFirstChunkComponentFamilyState';
|
||||
import { agentChatIsStreamingComponentFamilyState } from '@/ai/states/agentChatIsStreamingComponentFamilyState';
|
||||
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
|
||||
jest.mock('@/ai/components/suggested-prompts/AiChatSuggestedPrompts', () => ({
|
||||
AiChatSuggestedPrompts: () => <div data-testid="suggested-prompts" />,
|
||||
}));
|
||||
|
||||
const INSTANCE_ID = 'aiChatEmptyStateTest';
|
||||
const THREAD_ID = 'thread-1';
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<JotaiProvider store={jotaiStore}>
|
||||
<AgentChatComponentInstanceContext.Provider
|
||||
value={{ instanceId: INSTANCE_ID }}
|
||||
>
|
||||
{children}
|
||||
</AgentChatComponentInstanceContext.Provider>
|
||||
</JotaiProvider>
|
||||
);
|
||||
|
||||
describe('AiChatEmptyState', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
resetJotaiStore();
|
||||
jotaiStore.set(currentAiChatThreadState.atom, THREAD_ID);
|
||||
jotaiStore.set(agentChatDisplayedThreadState.atom, THREAD_ID);
|
||||
});
|
||||
|
||||
it('should render the suggested prompts when there is no message, no error and nothing loading', () => {
|
||||
const { getByTestId } = render(<AiChatEmptyState editor={null} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(getByTestId('suggested-prompts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render nothing when the current thread is awaiting its first chunk', () => {
|
||||
jotaiStore.set(
|
||||
agentChatIsAwaitingFirstChunkComponentFamilyState.atomFamily({
|
||||
instanceId: INSTANCE_ID,
|
||||
familyKey: { threadId: THREAD_ID },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
const { container } = render(<AiChatEmptyState editor={null} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('should render nothing when the current thread is streaming', () => {
|
||||
jotaiStore.set(
|
||||
agentChatIsStreamingComponentFamilyState.atomFamily({
|
||||
instanceId: INSTANCE_ID,
|
||||
familyKey: { threadId: THREAD_ID },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
const { container } = render(<AiChatEmptyState editor={null} />, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
import { AiChatTabMessageList } from '@/ai/components/AiChatTabMessageList';
|
||||
import { AgentChatComponentInstanceContext } from '@/ai/contexts/AgentChatComponentInstanceContext';
|
||||
import { AiChatMessageListPreambleContext } from '@/ai/contexts/AiChatMessageListPreambleContext';
|
||||
import { agentChatDisplayedThreadState } from '@/ai/states/agentChatDisplayedThreadState';
|
||||
import { agentChatIsAwaitingFirstChunkComponentFamilyState } from '@/ai/states/agentChatIsAwaitingFirstChunkComponentFamilyState';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
|
||||
jest.mock('@/ai/components/AiChatInitialLoadingIndicator', () => ({
|
||||
AiChatInitialLoadingIndicator: () => (
|
||||
<div data-testid="initial-loading-indicator" />
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/ui/utilities/scroll/components/ScrollWrapper', () => ({
|
||||
ScrollWrapper: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="scroll-wrapper">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/ai/components/AiChatNonLastMessageIdsList', () => ({
|
||||
AiChatNonLastMessageIdsList: () => null,
|
||||
}));
|
||||
jest.mock('@/ai/components/AiChatLastMessageWithStreamingState', () => ({
|
||||
AiChatLastMessageWithStreamingState: () => null,
|
||||
}));
|
||||
jest.mock('@/ai/components/AiChatErrorUnderMessageList', () => ({
|
||||
AiChatErrorUnderMessageList: () => null,
|
||||
}));
|
||||
jest.mock('@/ai/components/AiChatScrollToBottomButton', () => ({
|
||||
AiChatScrollToBottomButton: () => null,
|
||||
}));
|
||||
jest.mock(
|
||||
'@/ai/components/AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect',
|
||||
() => ({
|
||||
AgentChatScrollToBottomOnDisplayedThreadChangeLayoutEffect: () => null,
|
||||
}),
|
||||
);
|
||||
jest.mock('@/ai/components/AgentChatScrollToBottomOnMountLayoutEffect', () => ({
|
||||
AgentChatScrollToBottomOnMountLayoutEffect: () => null,
|
||||
}));
|
||||
|
||||
const INSTANCE_ID = 'aiChatTabMessageListPreambleTest';
|
||||
const THREAD_ID = 'thread-1';
|
||||
|
||||
const renderPreambleBranch = () =>
|
||||
render(
|
||||
<JotaiProvider store={jotaiStore}>
|
||||
<AgentChatComponentInstanceContext.Provider
|
||||
value={{ instanceId: INSTANCE_ID }}
|
||||
>
|
||||
<AiChatMessageListPreambleContext.Provider
|
||||
value={<div data-testid="preamble" />}
|
||||
>
|
||||
<AiChatTabMessageList />
|
||||
</AiChatMessageListPreambleContext.Provider>
|
||||
</AgentChatComponentInstanceContext.Provider>
|
||||
</JotaiProvider>,
|
||||
);
|
||||
|
||||
describe('AiChatTabMessageList preamble branch', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
resetJotaiStore();
|
||||
jotaiStore.set(agentChatDisplayedThreadState.atom, THREAD_ID);
|
||||
});
|
||||
|
||||
it('should render the preamble with the pending response loader when the displayed thread is awaiting its first chunk', () => {
|
||||
jotaiStore.set(
|
||||
agentChatIsAwaitingFirstChunkComponentFamilyState.atomFamily({
|
||||
instanceId: INSTANCE_ID,
|
||||
familyKey: { threadId: THREAD_ID },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
const { getByTestId, queryByTestId } = renderPreambleBranch();
|
||||
|
||||
expect(getByTestId('preamble')).toBeInTheDocument();
|
||||
expect(getByTestId('initial-loading-indicator')).toBeInTheDocument();
|
||||
expect(queryByTestId('scroll-wrapper')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the preamble without the pending response loader when the displayed thread is not awaiting its first chunk', () => {
|
||||
const { getByTestId, queryByTestId } = renderPreambleBranch();
|
||||
|
||||
expect(getByTestId('preamble')).toBeInTheDocument();
|
||||
expect(queryByTestId('initial-loading-indicator')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const WORKSPACE_SETUP_CHAT_ENRICHMENT_MAX_WAIT_MS = 2500;
|
||||
+9
@@ -7,8 +7,10 @@ import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboard
|
||||
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
|
||||
import { companyEnrichmentState } from '@/onboarding/states/companyEnrichmentState';
|
||||
import { hasAttemptedCompanyEnrichmentFetchState } from '@/onboarding/states/hasAttemptedCompanyEnrichmentFetchState';
|
||||
import { isCompanyEnrichmentFetchInFlightState } from '@/onboarding/states/isCompanyEnrichmentFetchInFlightState';
|
||||
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 {
|
||||
EnrichWorkspaceCompanyDocument,
|
||||
OnboardingStatus,
|
||||
@@ -25,6 +27,9 @@ export const CompanyEnrichmentOnboardingEffect = () => {
|
||||
setHasAttemptedCompanyEnrichmentFetch,
|
||||
] = useAtomState(hasAttemptedCompanyEnrichmentFetchState);
|
||||
const [enrichWorkspaceCompany] = useMutation(EnrichWorkspaceCompanyDocument);
|
||||
const setIsCompanyEnrichmentFetchInFlight = useSetAtomState(
|
||||
isCompanyEnrichmentFetchInFlightState,
|
||||
);
|
||||
const isOnboardingAiChatEnabled = useAtomStateValue(
|
||||
isOnboardingAiChatEnabledState,
|
||||
);
|
||||
@@ -45,6 +50,7 @@ export const CompanyEnrichmentOnboardingEffect = () => {
|
||||
}
|
||||
|
||||
setHasAttemptedCompanyEnrichmentFetch(true);
|
||||
setIsCompanyEnrichmentFetchInFlight(true);
|
||||
|
||||
const fetchCompanyEnrichment = async () => {
|
||||
try {
|
||||
@@ -65,6 +71,8 @@ export const CompanyEnrichmentOnboardingEffect = () => {
|
||||
setCompanyEnrichment(enrichment);
|
||||
} catch {
|
||||
return;
|
||||
} finally {
|
||||
setIsCompanyEnrichmentFetchInFlight(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -75,6 +83,7 @@ export const CompanyEnrichmentOnboardingEffect = () => {
|
||||
isOnboardingInProgress,
|
||||
isOnboardingAiChatEnabled,
|
||||
setHasAttemptedCompanyEnrichmentFetch,
|
||||
setIsCompanyEnrichmentFetchInFlight,
|
||||
setCompanyEnrichment,
|
||||
enrichWorkspaceCompany,
|
||||
]);
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { useStore } from 'jotai';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { AGENT_CHAT_INSTANCE_ID } from '@/ai/constants/AgentChatInstanceId';
|
||||
import { agentChatIsAwaitingFirstChunkComponentFamilyState } from '@/ai/states/agentChatIsAwaitingFirstChunkComponentFamilyState';
|
||||
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
|
||||
import { currentAiChatThreadTitleComponentFamilyState } from '@/ai/states/currentAiChatThreadTitleComponentFamilyState';
|
||||
import { hasInitializedAgentChatThreadsState } from '@/ai/states/hasInitializedAgentChatThreadsState';
|
||||
import { skipMessagesSkeletonUntilLoadedState } from '@/ai/states/skipMessagesSkeletonUntilLoadedState';
|
||||
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
|
||||
import { type FlatAgentChatThread } from '@/metadata-store/types/FlatAgentChatThread';
|
||||
import { WORKSPACE_SETUP_CHAT_ENRICHMENT_MAX_WAIT_MS } from '@/onboarding/constants/WorkspaceSetupChatEnrichmentMaxWaitMs';
|
||||
import { companyEnrichmentState } from '@/onboarding/states/companyEnrichmentState';
|
||||
import { hasRequestedWorkspaceSetupChatState } from '@/onboarding/states/hasRequestedWorkspaceSetupChatState';
|
||||
import { isCompanyEnrichmentFetchInFlightState } from '@/onboarding/states/isCompanyEnrichmentFetchInFlightState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import {
|
||||
StartWorkspaceSetupChatDocument,
|
||||
WorkspaceSetupChatOutcome,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export const WorkspaceSetupChatKickoffEffect = () => {
|
||||
const [startWorkspaceSetupChatMutation] = useMutation(
|
||||
StartWorkspaceSetupChatDocument,
|
||||
);
|
||||
const store = useStore();
|
||||
const { addToDraft, applyChanges } = useUpdateMetadataStoreDraft();
|
||||
const isCompanyEnrichmentFetchInFlight = useAtomStateValue(
|
||||
isCompanyEnrichmentFetchInFlightState,
|
||||
);
|
||||
const [hasWaitedForCompanyEnrichment, setHasWaitedForCompanyEnrichment] =
|
||||
useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const waitTimer = setTimeout(
|
||||
() => setHasWaitedForCompanyEnrichment(true),
|
||||
WORKSPACE_SETUP_CHAT_ENRICHMENT_MAX_WAIT_MS,
|
||||
);
|
||||
|
||||
return () => clearTimeout(waitTimer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldWaitForCompanyEnrichment =
|
||||
isCompanyEnrichmentFetchInFlight && !hasWaitedForCompanyEnrichment;
|
||||
|
||||
if (
|
||||
shouldWaitForCompanyEnrichment ||
|
||||
store.get(hasRequestedWorkspaceSetupChatState.atom)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.set(hasRequestedWorkspaceSetupChatState.atom, true);
|
||||
|
||||
const startWorkspaceSetupChat = async () => {
|
||||
try {
|
||||
const { data } = await startWorkspaceSetupChatMutation({
|
||||
variables: {
|
||||
companyContext: store.get(companyEnrichmentState.atom) ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const result = data?.startWorkspaceSetupChat;
|
||||
const thread = result?.thread;
|
||||
|
||||
if (!isDefined(result)) {
|
||||
store.set(hasRequestedWorkspaceSetupChatState.atom, false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
result.outcome === WorkspaceSetupChatOutcome.UNAVAILABLE ||
|
||||
!isDefined(thread)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceSetupThread: FlatAgentChatThread = {
|
||||
id: thread.id,
|
||||
title: thread.title ?? null,
|
||||
createdAt: thread.createdAt,
|
||||
updatedAt: thread.updatedAt,
|
||||
conversationSize: thread.conversationSize,
|
||||
contextWindowTokens: thread.contextWindowTokens ?? null,
|
||||
totalInputTokens: thread.totalInputTokens,
|
||||
totalOutputTokens: thread.totalOutputTokens,
|
||||
totalInputCredits: thread.totalInputCredits,
|
||||
totalOutputCredits: thread.totalOutputCredits,
|
||||
};
|
||||
|
||||
addToDraft({ key: 'agentChatThreads', items: [workspaceSetupThread] });
|
||||
applyChanges();
|
||||
|
||||
store.set(
|
||||
currentAiChatThreadTitleComponentFamilyState.atomFamily({
|
||||
instanceId: AGENT_CHAT_INSTANCE_ID,
|
||||
familyKey: { threadId: thread.id },
|
||||
}),
|
||||
thread.title ?? null,
|
||||
);
|
||||
|
||||
if (result.outcome === WorkspaceSetupChatOutcome.STARTED) {
|
||||
store.set(
|
||||
agentChatIsAwaitingFirstChunkComponentFamilyState.atomFamily({
|
||||
instanceId: AGENT_CHAT_INSTANCE_ID,
|
||||
familyKey: { threadId: thread.id },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
store.set(hasInitializedAgentChatThreadsState.atom, true);
|
||||
store.set(skipMessagesSkeletonUntilLoadedState.atom, true);
|
||||
store.set(currentAiChatThreadState.atom, thread.id);
|
||||
} catch {
|
||||
store.set(hasRequestedWorkspaceSetupChatState.atom, false);
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
void startWorkspaceSetupChat();
|
||||
}, [
|
||||
startWorkspaceSetupChatMutation,
|
||||
store,
|
||||
addToDraft,
|
||||
applyChanges,
|
||||
isCompanyEnrichmentFetchInFlight,
|
||||
hasWaitedForCompanyEnrichment,
|
||||
]);
|
||||
|
||||
return null;
|
||||
};
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
import { MockedProvider } from '@apollo/client/testing/react';
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { StrictMode } from 'react';
|
||||
import { type WorkspaceCompanyEnrichment } from 'twenty-shared/workspace';
|
||||
|
||||
import { AGENT_CHAT_INSTANCE_ID } from '@/ai/constants/AgentChatInstanceId';
|
||||
import { agentChatIsAwaitingFirstChunkComponentFamilyState } from '@/ai/states/agentChatIsAwaitingFirstChunkComponentFamilyState';
|
||||
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
|
||||
import { currentAiChatThreadTitleComponentFamilyState } from '@/ai/states/currentAiChatThreadTitleComponentFamilyState';
|
||||
import { hasInitializedAgentChatThreadsState } from '@/ai/states/hasInitializedAgentChatThreadsState';
|
||||
import { skipMessagesSkeletonUntilLoadedState } from '@/ai/states/skipMessagesSkeletonUntilLoadedState';
|
||||
import { WorkspaceSetupChatKickoffEffect } from '@/onboarding/effect-components/WorkspaceSetupChatKickoffEffect';
|
||||
import { companyEnrichmentState } from '@/onboarding/states/companyEnrichmentState';
|
||||
import { isCompanyEnrichmentFetchInFlightState } from '@/onboarding/states/isCompanyEnrichmentFetchInFlightState';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
import { StartWorkspaceSetupChatDocument } from '~/generated-metadata/graphql';
|
||||
|
||||
const threadId = '20202020-aaaa-4aaa-8aaa-202020202020';
|
||||
const threadTitle = 'Configuration du workspace';
|
||||
|
||||
const enrichment: WorkspaceCompanyEnrichment = {
|
||||
domain: 'acme.com',
|
||||
enrichedAt: '2026-07-21T10:00:00.000Z',
|
||||
name: 'Acme Inc',
|
||||
website: null,
|
||||
industry: null,
|
||||
employeeCount: null,
|
||||
size: null,
|
||||
founded: null,
|
||||
headline: null,
|
||||
summary: null,
|
||||
tags: [],
|
||||
locality: null,
|
||||
region: null,
|
||||
country: null,
|
||||
};
|
||||
|
||||
const buildKickoffMock = ({
|
||||
outcome,
|
||||
countCall,
|
||||
captureVariables,
|
||||
}: {
|
||||
outcome: 'STARTED' | 'ALREADY_STARTED' | 'UNAVAILABLE';
|
||||
countCall?: () => void;
|
||||
captureVariables?: (variables: Record<string, unknown>) => void;
|
||||
}) => ({
|
||||
request: {
|
||||
query: StartWorkspaceSetupChatDocument,
|
||||
variables: (variables: Record<string, unknown>) => {
|
||||
captureVariables?.(variables);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
maxUsageCount: 2,
|
||||
result: () => {
|
||||
countCall?.();
|
||||
|
||||
return {
|
||||
data: {
|
||||
startWorkspaceSetupChat: {
|
||||
__typename: 'StartWorkspaceSetupChatResult',
|
||||
outcome,
|
||||
thread:
|
||||
outcome === 'UNAVAILABLE'
|
||||
? null
|
||||
: {
|
||||
__typename: 'AgentChatThread',
|
||||
id: threadId,
|
||||
title: threadTitle,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
contextWindowTokens: null,
|
||||
conversationSize: 0,
|
||||
totalInputCredits: 0,
|
||||
totalOutputCredits: 0,
|
||||
deletedAt: null,
|
||||
lastMessageAt: null,
|
||||
createdAt: '2026-07-21T10:00:00.000Z',
|
||||
updatedAt: '2026-07-21T10:00:00.000Z',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const renderKickoffEffect = (mocks: readonly unknown[]) =>
|
||||
render(
|
||||
<MockedProvider mocks={mocks as never}>
|
||||
<JotaiProvider store={jotaiStore}>
|
||||
<StrictMode>
|
||||
<WorkspaceSetupChatKickoffEffect />
|
||||
</StrictMode>
|
||||
</JotaiProvider>
|
||||
</MockedProvider>,
|
||||
);
|
||||
|
||||
const flushMutation = async () => {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
};
|
||||
|
||||
describe('WorkspaceSetupChatKickoffEffect', () => {
|
||||
beforeEach(() => {
|
||||
resetJotaiStore();
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should wait for an in-flight company enrichment before starting the chat', async () => {
|
||||
jotaiStore.set(isCompanyEnrichmentFetchInFlightState.atom, true);
|
||||
|
||||
const capturedVariablesList: Record<string, unknown>[] = [];
|
||||
let callCount = 0;
|
||||
|
||||
renderKickoffEffect([
|
||||
buildKickoffMock({
|
||||
outcome: 'STARTED',
|
||||
countCall: () => {
|
||||
callCount += 1;
|
||||
},
|
||||
captureVariables: (variables) => {
|
||||
capturedVariablesList.push(variables);
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await flushMutation();
|
||||
|
||||
expect(callCount).toBe(0);
|
||||
|
||||
await act(async () => {
|
||||
jotaiStore.set(companyEnrichmentState.atom, enrichment);
|
||||
jotaiStore.set(isCompanyEnrichmentFetchInFlightState.atom, false);
|
||||
});
|
||||
await flushMutation();
|
||||
|
||||
expect(callCount).toBe(1);
|
||||
expect(capturedVariablesList[0].companyContext).toEqual(enrichment);
|
||||
});
|
||||
|
||||
it('should start the workspace setup chat only once when the effect renders twice', async () => {
|
||||
let callCount = 0;
|
||||
const { rerender } = renderKickoffEffect([
|
||||
buildKickoffMock({
|
||||
outcome: 'STARTED',
|
||||
countCall: () => {
|
||||
callCount += 1;
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await flushMutation();
|
||||
|
||||
rerender(
|
||||
<MockedProvider mocks={[] as never}>
|
||||
<JotaiProvider store={jotaiStore}>
|
||||
<StrictMode>
|
||||
<WorkspaceSetupChatKickoffEffect />
|
||||
</StrictMode>
|
||||
</JotaiProvider>
|
||||
</MockedProvider>,
|
||||
);
|
||||
await flushMutation();
|
||||
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should select the thread with its server title and mark it awaiting the first chunk when the chat is started', async () => {
|
||||
renderKickoffEffect([buildKickoffMock({ outcome: 'STARTED' })]);
|
||||
|
||||
await flushMutation();
|
||||
|
||||
expect(jotaiStore.get(currentAiChatThreadState.atom)).toBe(threadId);
|
||||
expect(jotaiStore.get(hasInitializedAgentChatThreadsState.atom)).toBe(true);
|
||||
expect(jotaiStore.get(skipMessagesSkeletonUntilLoadedState.atom)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
jotaiStore.get(
|
||||
agentChatIsAwaitingFirstChunkComponentFamilyState.atomFamily({
|
||||
instanceId: AGENT_CHAT_INSTANCE_ID,
|
||||
familyKey: { threadId },
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
jotaiStore.get(
|
||||
currentAiChatThreadTitleComponentFamilyState.atomFamily({
|
||||
instanceId: AGENT_CHAT_INSTANCE_ID,
|
||||
familyKey: { threadId },
|
||||
}),
|
||||
),
|
||||
).toBe(threadTitle);
|
||||
});
|
||||
|
||||
it('should select the thread without awaiting a first chunk when the chat was already started', async () => {
|
||||
renderKickoffEffect([buildKickoffMock({ outcome: 'ALREADY_STARTED' })]);
|
||||
|
||||
await flushMutation();
|
||||
|
||||
expect(jotaiStore.get(currentAiChatThreadState.atom)).toBe(threadId);
|
||||
expect(
|
||||
jotaiStore.get(
|
||||
agentChatIsAwaitingFirstChunkComponentFamilyState.atomFamily({
|
||||
instanceId: AGENT_CHAT_INSTANCE_ID,
|
||||
familyKey: { threadId },
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should pass the stored enrichment as the companyContext variable when one is stored', async () => {
|
||||
jotaiStore.set(companyEnrichmentState.atom, enrichment);
|
||||
|
||||
const capturedVariablesList: Record<string, unknown>[] = [];
|
||||
renderKickoffEffect([
|
||||
buildKickoffMock({
|
||||
outcome: 'STARTED',
|
||||
captureVariables: (variables) => {
|
||||
capturedVariablesList.push(variables);
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await flushMutation();
|
||||
|
||||
expect(capturedVariablesList[0].companyContext).toEqual(enrichment);
|
||||
});
|
||||
|
||||
it('should send an undefined companyContext variable when no enrichment is stored', async () => {
|
||||
const capturedVariablesList: Record<string, unknown>[] = [];
|
||||
renderKickoffEffect([
|
||||
buildKickoffMock({
|
||||
outcome: 'STARTED',
|
||||
captureVariables: (variables) => {
|
||||
capturedVariablesList.push(variables);
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await flushMutation();
|
||||
|
||||
expect(capturedVariablesList.length).toBeGreaterThan(0);
|
||||
expect(capturedVariablesList[0].companyContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not touch the chat state when the chat is unavailable', async () => {
|
||||
renderKickoffEffect([buildKickoffMock({ outcome: 'UNAVAILABLE' })]);
|
||||
|
||||
await flushMutation();
|
||||
|
||||
expect(jotaiStore.get(currentAiChatThreadState.atom)).toBeNull();
|
||||
expect(jotaiStore.get(hasInitializedAgentChatThreadsState.atom)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(jotaiStore.get(skipMessagesSkeletonUntilLoadedState.atom)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should retry on a later effect run when the mutation fails', async () => {
|
||||
let callCount = 0;
|
||||
|
||||
renderKickoffEffect([
|
||||
{
|
||||
request: { query: StartWorkspaceSetupChatDocument },
|
||||
error: new Error('Network error'),
|
||||
},
|
||||
buildKickoffMock({
|
||||
outcome: 'STARTED',
|
||||
countCall: () => {
|
||||
callCount += 1;
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await flushMutation();
|
||||
|
||||
expect(jotaiStore.get(currentAiChatThreadState.atom)).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
jotaiStore.set(isCompanyEnrichmentFetchInFlightState.atom, true);
|
||||
});
|
||||
await act(async () => {
|
||||
jotaiStore.set(isCompanyEnrichmentFetchInFlightState.atom, false);
|
||||
});
|
||||
await flushMutation();
|
||||
|
||||
expect(callCount).toBe(1);
|
||||
expect(jotaiStore.get(currentAiChatThreadState.atom)).toBe(threadId);
|
||||
});
|
||||
});
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const START_WORKSPACE_SETUP_CHAT = gql`
|
||||
mutation StartWorkspaceSetupChat($companyContext: JSON) {
|
||||
startWorkspaceSetupChat(companyContext: $companyContext) {
|
||||
outcome
|
||||
thread {
|
||||
id
|
||||
title
|
||||
totalInputTokens
|
||||
totalOutputTokens
|
||||
contextWindowTokens
|
||||
conversationSize
|
||||
totalInputCredits
|
||||
totalOutputCredits
|
||||
deletedAt
|
||||
lastMessageAt
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const hasRequestedWorkspaceSetupChatState = createAtomState<boolean>({
|
||||
key: 'hasRequestedWorkspaceSetupChatState',
|
||||
defaultValue: false,
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const isCompanyEnrichmentFetchInFlightState = createAtomState<boolean>({
|
||||
key: 'isCompanyEnrichmentFetchInFlightState',
|
||||
defaultValue: false,
|
||||
});
|
||||
Reference in New Issue
Block a user