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:
@@ -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 />
|
||||
|
||||
+60
@@ -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();
|
||||
});
|
||||
});
|
||||
+91
@@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user