Add post-onboarding AI chat setup behind a feature flag (#23120)

https://github.com/user-attachments/assets/fec7076f-4e46-4c39-84d7-68e4340244ac



After finishing onboarding, users now land in a full-screen AI chat that
helps them set up their workspace, instead of going straight to their
default view. The welcome overlay's title flies into the chat's first
message so the handoff reads as one continuous motion: the slide plays
alone, the title swaps in place pixel-exactly (a regular-weight clone of
the target line is crossfaded in mid-flight to morph the font weight),
then the rest of the text fades in.

All of it sits behind `IS_ONBOARDING_AI_CHAT_ENABLED` (default off, not
registered as a public flag). With the flag off, onboarding behaves
exactly as it does today — the welcome overlay still plays and the user
lands on their home view.

Layout follows the Figma: the nav drawer stays visible and the chat
renders in a panel-styled container with an "Onboarding" header,
matching the expanded side panel.

Also fixes two pre-existing bugs the feature surfaced:
- On billing instances the completion redirect raced the lazy
`PaymentSuccess` page, which silently skipped the welcome animation on
the no-card trial path. The redirect now defers while a checkout is
pending, and `PaymentSuccess` always confirms through
`useLoadCurrentUser` so freshly served feature flags are respected.
- `useDefaultHomePagePath` could conclude its `/settings/profile`
empty-workspace fallback from a transiently empty metadata store and
strand the user there; it now waits for both object metadata and
navigation menu items before deciding.

Reviewer notes:
- `AgentChatRuntimeEffects` no longer keys off side-panel state, so
`modules/ai` stops importing `modules/side-panel`. The two
visibility-scoped effects moved into `AiChatTab`.
- `/workspace-setup` is deliberately URL-addressable rather than
onboarding-only: the collapse control in the header is a general
expand/collapse toggle (paired with a new expand button in the side
panel top bar), and gating the route would break refresh and
browser-back. It is still authenticated-only.
- The design's second, LLM-authored paragraph is not implemented —
starting an assistant turn with no user message needs server-side work.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23120?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-23 12:16:11 +02:00
committed by GitHub
parent e15b9efc2d
commit f5a9adcb76
56 changed files with 1646 additions and 191 deletions
@@ -0,0 +1,16 @@
import { useEffect } from 'react';
import { hasAgentChatBeenOpenedState } from '@/ai/states/hasAgentChatBeenOpenedState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export const AgentChatHasBeenOpenedEffect = () => {
const setHasAgentChatBeenOpened = useSetAtomState(
hasAgentChatBeenOpenedState,
);
useEffect(() => {
setHasAgentChatBeenOpened(true);
}, [setHasAgentChatBeenOpened]);
return null;
};
@@ -3,32 +3,11 @@ import { AgentChatPrepromptEffect } from '@/ai/components/AgentChatPrepromptEffe
import { AgentChatSessionStartTimeEffect } from '@/ai/components/AgentChatSessionStartTimeEffect';
import { AgentChatStreamKeepAliveEffect } from '@/ai/components/AgentChatStreamKeepAliveEffect';
import { AgentChatStreamSubscriptionEffect } from '@/ai/components/AgentChatStreamSubscriptionEffect';
import { AgentChatStreamingAutoScrollEffect } from '@/ai/components/AgentChatStreamingAutoScrollEffect';
import { AgentChatStreamingPartsDiffSyncEffect } from '@/ai/components/AgentChatStreamingPartsDiffSyncEffect';
import { hasAgentChatBeenOpenedState } from '@/ai/states/hasAgentChatBeenOpenedState';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useEffect } from 'react';
import { SidePanelPages } from 'twenty-shared/types';
export const AgentChatRuntimeEffects = () => {
const isSidePanelOpened = useAtomStateValue(isSidePanelOpenedState);
const sidePanelPage = useAtomStateValue(sidePanelPageState);
const [hasAgentChatBeenOpened, setHasAgentChatBeenOpened] = useAtomState(
hasAgentChatBeenOpenedState,
);
const isAgentChatOpen =
isSidePanelOpened && sidePanelPage === SidePanelPages.AskAI;
useEffect(() => {
if (isAgentChatOpen && !hasAgentChatBeenOpened) {
setHasAgentChatBeenOpened(true);
}
}, [isAgentChatOpen, hasAgentChatBeenOpened, setHasAgentChatBeenOpened]);
const hasAgentChatBeenOpened = useAtomStateValue(hasAgentChatBeenOpenedState);
if (!hasAgentChatBeenOpened) {
return null;
@@ -41,12 +20,6 @@ export const AgentChatRuntimeEffects = () => {
<AgentChatPrepromptEffect />
<AgentChatStreamKeepAliveEffect />
<AgentChatSessionStartTimeEffect />
{isAgentChatOpen && (
<>
<AgentChatStreamingPartsDiffSyncEffect />
<AgentChatStreamingAutoScrollEffect />
</>
)}
</>
);
};
@@ -0,0 +1,28 @@
import { useLingui } from '@lingui/react/macro';
import { IconX } from 'twenty-ui/icon';
import { IconButton } from 'twenty-ui/input';
import { useReturnFromExpandedAiChat } from '@/ai/hooks/useReturnFromExpandedAiChat';
import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const AiChatCloseButton = () => {
const { t } = useLingui();
const returnFromExpandedAiChat = useReturnFromExpandedAiChat({
reopenSidePanel: false,
});
const isWelcomeAnimationVisible = useAtomStateValue(
isWelcomeAnimationVisibleState,
);
return (
<IconButton
Icon={IconX}
size="small"
variant="secondary"
disabled={isWelcomeAnimationVisible}
onClick={returnFromExpandedAiChat}
ariaLabel={t`Close`}
/>
);
};
@@ -0,0 +1,28 @@
import { useLingui } from '@lingui/react/macro';
import { IconLayoutSidebarRightCollapse } from 'twenty-ui/icon';
import { IconButton } from 'twenty-ui/input';
import { useReturnFromExpandedAiChat } from '@/ai/hooks/useReturnFromExpandedAiChat';
import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const AiChatCollapseButton = () => {
const { t } = useLingui();
const returnFromExpandedAiChat = useReturnFromExpandedAiChat({
reopenSidePanel: true,
});
const isWelcomeAnimationVisible = useAtomStateValue(
isWelcomeAnimationVisibleState,
);
return (
<IconButton
Icon={IconLayoutSidebarRightCollapse}
size="small"
variant="tertiary"
disabled={isWelcomeAnimationVisible}
onClick={returnFromExpandedAiChat}
ariaLabel={t`Collapse to side panel`}
/>
);
};
@@ -3,6 +3,9 @@ import { useState } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { DropZone } from '@/activities/files/components/DropZone';
import { AgentChatHasBeenOpenedEffect } from '@/ai/components/AgentChatHasBeenOpenedEffect';
import { AgentChatStreamingAutoScrollEffect } from '@/ai/components/AgentChatStreamingAutoScrollEffect';
import { AgentChatStreamingPartsDiffSyncEffect } from '@/ai/components/AgentChatStreamingPartsDiffSyncEffect';
import { AiChatEditorSection } from '@/ai/components/AiChatEditorSection';
import { useAiChatFileUpload } from '@/ai/hooks/useAiChatFileUpload';
import { AGENT_CHAT_NEW_THREAD_DRAFT_KEY } from '@/ai/states/agentChatDraftsByThreadIdState';
@@ -44,6 +47,9 @@ export const AiChatTab = () => {
onDragEnter={() => setIsDraggingFile(true)}
onDragLeave={() => setIsDraggingFile(false)}
>
<AgentChatHasBeenOpenedEffect />
<AgentChatStreamingPartsDiffSyncEffect />
<AgentChatStreamingAutoScrollEffect />
{isDraggingFile && (
<DropZone
setIsDraggingFile={setIsDraggingFile}
@@ -8,10 +8,13 @@ import { AgentChatScrollToBottomOnMountLayoutEffect } from '@/ai/components/Agen
import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId';
import { agentChatHasMessageComponentSelector } from '@/ai/states/selectors/agentChatHasMessageComponentSelector';
import { agentChatIsInitialScrollPendingOnThreadChangeState } from '@/ai/states/agentChatIsInitialScrollPendingOnThreadChangeState';
import { AiChatMessageListPreambleContext } from '@/ai/contexts/AiChatMessageListPreambleContext';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledScrollWrapperContainer = styled.div`
@@ -23,6 +26,14 @@ const StyledScrollWrapperContainer = styled.div`
width: 100%;
`;
const StyledPreambleOutsideScrollContainer = styled.div`
display: flex;
flex-direction: column;
flex-shrink: 0;
padding: ${themeCssVariables.spacing[4]};
width: 100%;
`;
const StyledMessageListContent = styled.div`
display: flex;
flex-direction: column;
@@ -31,6 +42,7 @@ const StyledMessageListContent = styled.div`
`;
export const AiChatTabMessageList = () => {
const messageListPreamble = useContext(AiChatMessageListPreambleContext);
const agentChatHasMessage = useAtomComponentSelectorValue(
agentChatHasMessageComponentSelector,
);
@@ -40,7 +52,14 @@ export const AiChatTabMessageList = () => {
);
if (!agentChatHasMessage) {
return null;
if (!isDefined(messageListPreamble)) {
return null;
}
return (
<StyledPreambleOutsideScrollContainer>
{messageListPreamble}
</StyledPreambleOutsideScrollContainer>
);
}
return (
@@ -53,6 +72,7 @@ export const AiChatTabMessageList = () => {
>
<ScrollWrapper componentInstanceId={AI_CHAT_SCROLL_WRAPPER_ID}>
<StyledMessageListContent>
{messageListPreamble}
<AiChatNonLastMessageIdsList />
<AiChatLastMessageWithStreamingState />
<AiChatPendingResponseIndicator />
@@ -0,0 +1,60 @@
import { render } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import { type ReactNode } from 'react';
import { AgentChatRuntimeEffects } from '@/ai/components/AgentChatRuntimeEffects';
import { hasAgentChatBeenOpenedState } from '@/ai/states/hasAgentChatBeenOpenedState';
import {
jotaiStore,
resetJotaiStore,
} from '@/ui/utilities/state/jotai/jotaiStore';
jest.mock('@/ai/components/AgentChatMessagesFetchEffect', () => ({
AgentChatMessagesFetchEffect: () => <div data-testid="messages-fetch" />,
}));
jest.mock('@/ai/components/AgentChatStreamSubscriptionEffect', () => ({
AgentChatStreamSubscriptionEffect: () => (
<div data-testid="stream-subscription" />
),
}));
jest.mock('@/ai/components/AgentChatPrepromptEffect', () => ({
AgentChatPrepromptEffect: () => <div data-testid="preprompt" />,
}));
jest.mock('@/ai/components/AgentChatStreamKeepAliveEffect', () => ({
AgentChatStreamKeepAliveEffect: () => <div data-testid="keep-alive" />,
}));
jest.mock('@/ai/components/AgentChatSessionStartTimeEffect', () => ({
AgentChatSessionStartTimeEffect: () => <div data-testid="session-start" />,
}));
const Wrapper = ({ children }: { children: ReactNode }) => (
<JotaiProvider store={jotaiStore}>{children}</JotaiProvider>
);
describe('AgentChatRuntimeEffects', () => {
beforeEach(() => {
resetJotaiStore();
});
it('should render nothing until the chat has been opened once', () => {
const { container } = render(<AgentChatRuntimeEffects />, {
wrapper: Wrapper,
});
expect(container).toBeEmptyDOMElement();
});
it('should run the chat runtime once the chat has been opened, regardless of the side panel', () => {
jotaiStore.set(hasAgentChatBeenOpenedState.atom, true);
const { getByTestId } = render(<AgentChatRuntimeEffects />, {
wrapper: Wrapper,
});
expect(getByTestId('messages-fetch')).toBeInTheDocument();
expect(getByTestId('stream-subscription')).toBeInTheDocument();
expect(getByTestId('preprompt')).toBeInTheDocument();
expect(getByTestId('keep-alive')).toBeInTheDocument();
expect(getByTestId('session-start')).toBeInTheDocument();
});
});
@@ -0,0 +1,91 @@
import { render } from '@testing-library/react';
import { type ReactNode } from 'react';
import { AiChatTabMessageList } from '@/ai/components/AiChatTabMessageList';
import { AiChatMessageListPreambleContext } from '@/ai/contexts/AiChatMessageListPreambleContext';
const renderWithPreamble = (preamble: ReactNode) =>
render(
<AiChatMessageListPreambleContext.Provider value={preamble}>
<AiChatTabMessageList />
</AiChatMessageListPreambleContext.Provider>,
);
const mockUseAtomComponentSelectorValue = jest.fn();
jest.mock(
'@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue',
() => ({
useAtomComponentSelectorValue: () => mockUseAtomComponentSelectorValue(),
}),
);
jest.mock('@/ui/utilities/state/jotai/hooks/useAtomStateValue', () => ({
useAtomStateValue: () => false,
}));
jest.mock('@/ui/utilities/scroll/components/ScrollWrapper', () => ({
ScrollWrapper: ({ children }: { children: React.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/AiChatPendingResponseIndicator', () => ({
AiChatPendingResponseIndicator: () => 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,
}));
describe('AiChatTabMessageList', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render nothing with no messages and no preamble', () => {
mockUseAtomComponentSelectorValue.mockReturnValue(false);
const { container } = render(<AiChatTabMessageList />);
expect(container).toBeEmptyDOMElement();
});
it('should render the preamble outside the scroll container with no messages', () => {
mockUseAtomComponentSelectorValue.mockReturnValue(false);
const { getByTestId, queryByTestId } = renderWithPreamble(
<div data-testid="preamble" />,
);
expect(getByTestId('preamble')).toBeInTheDocument();
expect(queryByTestId('scroll-wrapper')).not.toBeInTheDocument();
});
it('should render the preamble inside the message list once messages exist', () => {
mockUseAtomComponentSelectorValue.mockReturnValue(true);
const { getByTestId } = renderWithPreamble(<div data-testid="preamble" />);
expect(getByTestId('scroll-wrapper')).toContainElement(
getByTestId('preamble'),
);
});
});