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:
Raphaël Bosi
2026-07-30 14:06:13 +02:00
committed by GitHub
parent 014b3cdc67
commit 38ad13655c
39 changed files with 3068 additions and 278 deletions
@@ -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();
});
});
@@ -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();
});
});
@@ -0,0 +1 @@
export const WORKSPACE_SETUP_CHAT_ENRICHMENT_MAX_WAIT_MS = 2500;
@@ -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,
]);
@@ -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;
};
@@ -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);
});
});
@@ -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
}
}
}
`;
@@ -0,0 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const hasRequestedWorkspaceSetupChatState = createAtomState<boolean>({
key: 'hasRequestedWorkspaceSetupChatState',
defaultValue: false,
});
@@ -0,0 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const isCompanyEnrichmentFetchInFlightState = createAtomState<boolean>({
key: 'isCompanyEnrichmentFetchInFlightState',
defaultValue: false,
});