diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index d8874d79ff..67ffdc9966 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -1742,6 +1742,7 @@ enum FeatureFlagKey { IS_JUNCTION_RELATIONS_ENABLED IS_REST_METADATA_API_NEW_FORMAT_DIRECT IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED + IS_ONBOARDING_AI_CHAT_ENABLED IS_SETTINGS_DISCOVERY_HERO_ENABLED IS_WORKFLOW_VERSION_IN_CORE_ENABLED } diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 91076b4105..8f54946ec6 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -1397,7 +1397,7 @@ export interface FeatureFlag { __typename: 'FeatureFlag' } -export type FeatureFlagKey = 'IS_APP_CLAIMING_ENABLED' | 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_CALENDAR_WEEK_VIEW_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' | 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' | 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' | 'IS_WORKFLOW_VERSION_IN_CORE_ENABLED' +export type FeatureFlagKey = 'IS_APP_CLAIMING_ENABLED' | 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_CALENDAR_WEEK_VIEW_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' | 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' | 'IS_ONBOARDING_AI_CHAT_ENABLED' | 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' | 'IS_WORKFLOW_VERSION_IN_CORE_ENABLED' export interface WorkspaceUrls { customUrl?: Scalars['String'] @@ -9277,6 +9277,7 @@ export const enumFeatureFlagKey = { IS_JUNCTION_RELATIONS_ENABLED: 'IS_JUNCTION_RELATIONS_ENABLED' as const, IS_REST_METADATA_API_NEW_FORMAT_DIRECT: 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' as const, IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED: 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' as const, + IS_ONBOARDING_AI_CHAT_ENABLED: 'IS_ONBOARDING_AI_CHAT_ENABLED' as const, IS_SETTINGS_DISCOVERY_HERO_ENABLED: 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' as const, IS_WORKFLOW_VERSION_IN_CORE_ENABLED: 'IS_WORKFLOW_VERSION_IN_CORE_ENABLED' as const } diff --git a/packages/twenty-front/src/generated-admin/graphql.ts b/packages/twenty-front/src/generated-admin/graphql.ts index 5d14057f8f..447187c6d1 100644 --- a/packages/twenty-front/src/generated-admin/graphql.ts +++ b/packages/twenty-front/src/generated-admin/graphql.ts @@ -326,6 +326,7 @@ export enum FeatureFlagKey { IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED', IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED = 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED', + IS_ONBOARDING_AI_CHAT_ENABLED = 'IS_ONBOARDING_AI_CHAT_ENABLED', IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT', IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED', IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED', diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index baf41cbb58..e42d347671 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1770,6 +1770,7 @@ export enum FeatureFlagKey { IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED', IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED = 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED', + IS_ONBOARDING_AI_CHAT_ENABLED = 'IS_ONBOARDING_AI_CHAT_ENABLED', IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT', IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED', IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED', diff --git a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts index d8b882c7ae..1b3468bf23 100644 --- a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts +++ b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts @@ -86,6 +86,8 @@ const setupMockState = ( currentWorkspace: object | null = { id: 'mock-workspace-id' }, isBillingEnabled: boolean = true, isMinimalMetadataReady: boolean = true, + shouldOpenAiChatAfterOnboarding: boolean = false, + isOnboardingCheckoutPending: boolean = false, ) => { jest .mocked(useAtomStateValue) @@ -94,7 +96,9 @@ const setupMockState = ( .mockReturnValueOnce([{ namePlural: objectNamePlural ?? '' }]) .mockReturnValueOnce(isMinimalMetadataReady) .mockReturnValueOnce(verifyEmailRedirectPath) - .mockReturnValueOnce(returnToPath ?? ''); + .mockReturnValueOnce(returnToPath ?? '') + .mockReturnValueOnce(shouldOpenAiChatAfterOnboarding) + .mockReturnValueOnce(isOnboardingCheckoutPending); }; // prettier-ignore @@ -113,7 +117,19 @@ const testCases: { useQueryResult?: { data?: unknown; loading?: boolean }; isBillingEnabled?: boolean; isMinimalMetadataReady?: boolean; + shouldOpenAiChatAfterOnboarding?: boolean; + isOnboardingCheckoutPending?: boolean; }[] = [ + { loc: AppPath.WorkspaceSetup, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, + { loc: AppPath.WorkspaceSetup, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, + { loc: AppPath.WorkspaceSetup, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp }, + { loc: AppPath.WorkspaceSetup, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.WorkspaceActivation }, + { loc: AppPath.WorkspaceSetup, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile }, + { loc: AppPath.WorkspaceSetup, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails }, + { loc: AppPath.WorkspaceSetup, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.APPS_INSTALLATION, res: AppPath.InstallApps }, + { loc: AppPath.WorkspaceSetup, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam }, + { loc: AppPath.WorkspaceSetup, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined }, + { loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired }, { loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: getSettingsPath(SettingsPath.Billing) }, { loc: AppPath.Verify, hasAccessTokenPair: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: undefined }, @@ -372,6 +388,14 @@ const testCases: { { loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, returnToPath: '/objects/tasks', res: '/objects/tasks' }, { loc: AppPath.Index, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, returnToPath: '/settings/api-keys', res: '/settings/api-keys' }, + { loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isBillingEnabled: false, shouldOpenAiChatAfterOnboarding: true, res: AppPath.WorkspaceSetup }, + { loc: AppPath.InviteTeam, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isBillingEnabled: false, shouldOpenAiChatAfterOnboarding: false, res: defaultHomePagePath }, + { loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, shouldOpenAiChatAfterOnboarding: true, res: AppPath.WorkspaceSetup }, + { loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, shouldOpenAiChatAfterOnboarding: true, returnToPath: '/objects/tasks', res: '/objects/tasks' }, + + { loc: AppPath.PlanRequiredSuccess, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isOnboardingCheckoutPending: true, res: undefined }, + { loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isOnboardingCheckoutPending: true, res: defaultHomePagePath }, + // isOnAWorkspace:false — on default domain, don't redirect to returnToPath or defaultHomePagePath from auth pages { loc: AppPath.Verify, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isOnAWorkspace: false, res: undefined }, { loc: AppPath.SignInUp, hasAccessTokenPair: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, isOnAWorkspace: false, res: undefined }, @@ -394,6 +418,8 @@ describe('usePageChangeEffectNavigateLocation', () => { useQueryResult, isBillingEnabled, isMinimalMetadataReady, + shouldOpenAiChatAfterOnboarding, + isOnboardingCheckoutPending, res, }) => { setupMockIsMatchingLocation(loc); @@ -410,6 +436,8 @@ describe('usePageChangeEffectNavigateLocation', () => { undefined, isBillingEnabled ?? true, isMinimalMetadataReady ?? true, + shouldOpenAiChatAfterOnboarding ?? false, + isOnboardingCheckoutPending ?? false, ); expect(usePageChangeEffectNavigateLocation()).toEqual(res); @@ -439,6 +467,14 @@ describe('usePageChangeEffectNavigateLocation', () => { [ 'billingDisabled:inviteTeamCompleted', 'billingDisabled:planRequiredCompleted', + ].length + + [ + 'workspaceSetupPending:inviteTeamCompleted', + 'workspaceSetupNotPending:inviteTeamCompleted', + 'workspaceSetupPending:paymentSuccessCompleted', + 'workspaceSetupPending:returnToPathWins', + 'checkoutPending:paymentSuccessDefersRedirect', + 'checkoutPending:verifyStillRedirects', ].length, ); }); diff --git a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts index 4525324cc5..6a54062aed 100644 --- a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts +++ b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts @@ -10,6 +10,8 @@ import { isMinimalMetadataReadyState } from '@/metadata-store/states/isMinimalMe import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath'; import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector'; import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus'; +import { isOnboardingCheckoutPendingState } from '@/onboarding/states/isOnboardingCheckoutPendingState'; +import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo'; import { isValidReturnToPath } from '@/auth/utils/isValidReturnToPath'; @@ -79,6 +81,17 @@ export const usePageChangeEffectNavigateLocation = () => { ? returnToPath : readReturnToPathFromUrlSearchParams(); + const shouldOpenAiChatAfterOnboarding = useAtomStateValue( + shouldOpenAiChatAfterOnboardingState, + ); + const onboardingCompletedPath = shouldOpenAiChatAfterOnboarding + ? AppPath.WorkspaceSetup + : defaultHomePagePath; + + const isOnboardingCheckoutPending = useAtomStateValue( + isOnboardingCheckoutPendingState, + ); + if ( (!hasAccessTokenPair || !isOnAWorkspace || !isDefined(currentWorkspace)) && !someMatchingLocationOf([ @@ -170,7 +183,14 @@ export const usePageChangeEffectNavigateLocation = () => { hasAccessTokenPair && isOnAWorkspace ) { - return resolvedReturnToPath ?? defaultHomePagePath; + if ( + isMatchingLocation(location, AppPath.PlanRequiredSuccess) && + isOnboardingCheckoutPending + ) { + return; + } + + return resolvedReturnToPath ?? onboardingCompletedPath; } if (isMatchingLocation(location, AppPath.Index) && hasAccessTokenPair) { diff --git a/packages/twenty-front/src/modules/ai/components/AgentChatHasBeenOpenedEffect.tsx b/packages/twenty-front/src/modules/ai/components/AgentChatHasBeenOpenedEffect.tsx new file mode 100644 index 0000000000..b843c540ab --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/AgentChatHasBeenOpenedEffect.tsx @@ -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; +}; diff --git a/packages/twenty-front/src/modules/ai/components/AgentChatRuntimeEffects.tsx b/packages/twenty-front/src/modules/ai/components/AgentChatRuntimeEffects.tsx index a74d632ef9..98d041d97f 100644 --- a/packages/twenty-front/src/modules/ai/components/AgentChatRuntimeEffects.tsx +++ b/packages/twenty-front/src/modules/ai/components/AgentChatRuntimeEffects.tsx @@ -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 = () => { - {isAgentChatOpen && ( - <> - - - - )} ); }; diff --git a/packages/twenty-front/src/modules/ai/components/AiChatCloseButton.tsx b/packages/twenty-front/src/modules/ai/components/AiChatCloseButton.tsx new file mode 100644 index 0000000000..ec4b0e454a --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/AiChatCloseButton.tsx @@ -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 ( + + ); +}; diff --git a/packages/twenty-front/src/modules/ai/components/AiChatCollapseButton.tsx b/packages/twenty-front/src/modules/ai/components/AiChatCollapseButton.tsx new file mode 100644 index 0000000000..eef75b040f --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/AiChatCollapseButton.tsx @@ -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 ( + + ); +}; diff --git a/packages/twenty-front/src/modules/ai/components/AiChatTab.tsx b/packages/twenty-front/src/modules/ai/components/AiChatTab.tsx index e2a3ce6d3d..ffec3ce5b5 100644 --- a/packages/twenty-front/src/modules/ai/components/AiChatTab.tsx +++ b/packages/twenty-front/src/modules/ai/components/AiChatTab.tsx @@ -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)} > + + + {isDraggingFile && ( { + 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 ( + + {messageListPreamble} + + ); } return ( @@ -53,6 +72,7 @@ export const AiChatTabMessageList = () => { > + {messageListPreamble} diff --git a/packages/twenty-front/src/modules/ai/components/__tests__/AgentChatRuntimeEffects.test.tsx b/packages/twenty-front/src/modules/ai/components/__tests__/AgentChatRuntimeEffects.test.tsx new file mode 100644 index 0000000000..2f572bc369 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/__tests__/AgentChatRuntimeEffects.test.tsx @@ -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: () =>
, +})); +jest.mock('@/ai/components/AgentChatStreamSubscriptionEffect', () => ({ + AgentChatStreamSubscriptionEffect: () => ( +
+ ), +})); +jest.mock('@/ai/components/AgentChatPrepromptEffect', () => ({ + AgentChatPrepromptEffect: () =>
, +})); +jest.mock('@/ai/components/AgentChatStreamKeepAliveEffect', () => ({ + AgentChatStreamKeepAliveEffect: () =>
, +})); +jest.mock('@/ai/components/AgentChatSessionStartTimeEffect', () => ({ + AgentChatSessionStartTimeEffect: () =>
, +})); + +const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe('AgentChatRuntimeEffects', () => { + beforeEach(() => { + resetJotaiStore(); + }); + + it('should render nothing until the chat has been opened once', () => { + const { container } = render(, { + 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(, { + 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(); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/components/__tests__/AiChatTabMessageList.test.tsx b/packages/twenty-front/src/modules/ai/components/__tests__/AiChatTabMessageList.test.tsx new file mode 100644 index 0000000000..5561df2fc9 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/__tests__/AiChatTabMessageList.test.tsx @@ -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( + + + , + ); + +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 }) => ( +
{children}
+ ), +})); + +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(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('should render the preamble outside the scroll container with no messages', () => { + mockUseAtomComponentSelectorValue.mockReturnValue(false); + + const { getByTestId, queryByTestId } = renderWithPreamble( +
, + ); + + 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(
); + + expect(getByTestId('scroll-wrapper')).toContainElement( + getByTestId('preamble'), + ); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/contexts/AiChatMessageListPreambleContext.ts b/packages/twenty-front/src/modules/ai/contexts/AiChatMessageListPreambleContext.ts new file mode 100644 index 0000000000..de6fcc6500 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/contexts/AiChatMessageListPreambleContext.ts @@ -0,0 +1,3 @@ +import { createContext, type ReactNode } from 'react'; + +export const AiChatMessageListPreambleContext = createContext(null); diff --git a/packages/twenty-front/src/modules/ai/hooks/useReturnFromExpandedAiChat.ts b/packages/twenty-front/src/modules/ai/hooks/useReturnFromExpandedAiChat.ts new file mode 100644 index 0000000000..5e07b08048 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/hooks/useReturnFromExpandedAiChat.ts @@ -0,0 +1,44 @@ +import { useStore } from 'jotai'; +import { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { aiChatExpandedReturnLocationState } from '@/ai/states/aiChatExpandedReturnLocationState'; +import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath'; +import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState'; +import { useOpenAskAiPageInSidePanel } from '@/side-panel/hooks/useOpenAskAiPageInSidePanel'; +import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; + +type UseReturnFromExpandedAiChatParams = { + reopenSidePanel: boolean; +}; + +export const useReturnFromExpandedAiChat = ({ + reopenSidePanel, +}: UseReturnFromExpandedAiChatParams) => { + const store = useStore(); + const navigate = useNavigate(); + const { defaultHomePagePath } = useDefaultHomePagePath(); + const { openAskAiPage } = useOpenAskAiPageInSidePanel(); + const { closeSidePanelMenu } = useSidePanelMenu(); + + return useCallback(() => { + if (reopenSidePanel) { + openAskAiPage({ resetNavigationStack: true }); + } else { + void closeSidePanelMenu(); + } + + const returnLocation = store.get(aiChatExpandedReturnLocationState.atom); + navigate(returnLocation ?? defaultHomePagePath); + + store.set(aiChatExpandedReturnLocationState.atom, null); + store.set(shouldOpenAiChatAfterOnboardingState.atom, false); + }, [ + reopenSidePanel, + openAskAiPage, + closeSidePanelMenu, + store, + navigate, + defaultHomePagePath, + ]); +}; diff --git a/packages/twenty-front/src/modules/ai/states/aiChatExpandedReturnLocationState.ts b/packages/twenty-front/src/modules/ai/states/aiChatExpandedReturnLocationState.ts new file mode 100644 index 0000000000..aa6122696f --- /dev/null +++ b/packages/twenty-front/src/modules/ai/states/aiChatExpandedReturnLocationState.ts @@ -0,0 +1,8 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const aiChatExpandedReturnLocationState = createAtomState( + { + key: 'aiChatExpandedReturnLocationState', + defaultValue: null, + }, +); diff --git a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx index 6e277d833a..8928c2b2bd 100644 --- a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx +++ b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx @@ -10,6 +10,7 @@ import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/ import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState'; import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType'; import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural'; +import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState'; import { useActiveRecordBoardCard } from '@/object-record/record-board/hooks/useActiveRecordBoardCard'; import { useFocusedRecordBoardCard } from '@/object-record/record-board/hooks/useFocusedRecordBoardCard'; import { useResetRecordBoardSelection } from '@/object-record/record-board/hooks/useResetRecordBoardSelection'; @@ -157,6 +158,13 @@ export const PageChangeEffect = () => { if (consumedReturnToPath) { clearReturnToPath(); } + + if ( + store.get(shouldOpenAiChatAfterOnboardingState.atom) && + pageChangeEffectNavigateLocation !== AppPath.WorkspaceSetup + ) { + store.set(shouldOpenAiChatAfterOnboardingState.atom, false); + } } }, [ navigate, @@ -166,6 +174,7 @@ export const PageChangeEffect = () => { saveReturnToPath, getReturnToPath, clearReturnToPath, + store, ]); useEffect(() => { diff --git a/packages/twenty-front/src/modules/app/hooks/useCreateWorkspaceAppRouter.tsx b/packages/twenty-front/src/modules/app/hooks/useCreateWorkspaceAppRouter.tsx index 977f105b86..2aa9c1a8a7 100644 --- a/packages/twenty-front/src/modules/app/hooks/useCreateWorkspaceAppRouter.tsx +++ b/packages/twenty-front/src/modules/app/hooks/useCreateWorkspaceAppRouter.tsx @@ -111,6 +111,12 @@ const StandalonePageLayoutPage = lazy(() => })), ); +const WorkspaceSetup = lazyWithPreload(() => + import('~/pages/onboarding/WorkspaceSetup').then((module) => ({ + default: module.WorkspaceSetup, + })), +); + const NotFound = lazy(() => import('~/pages/not-found/NotFound').then((module) => ({ default: module.NotFound, @@ -124,6 +130,7 @@ const preloadOnboardingPages = () => { void InstallApps.preload(); void InviteTeam.preload(); void ChooseYourPlan.preload(); + void WorkspaceSetup.preload(); return null; }; @@ -140,6 +147,14 @@ const createWorkspaceAppRouter = ( > }> }> + + + + } + /> }> { expect(isValidReturnToPath('/reset-password')).toBe(false); }); + it('should return true for the workspace setup path', () => { + expect(isValidReturnToPath('/workspace-setup')).toBe(true); + }); + it('should return true for valid application paths', () => { expect(isValidReturnToPath('/objects/people')).toBe(true); expect(isValidReturnToPath('/settings/accounts')).toBe(true); diff --git a/packages/twenty-front/src/modules/navigation/hooks/__tests__/useDefaultHomePagePath.test.ts b/packages/twenty-front/src/modules/navigation/hooks/__tests__/useDefaultHomePagePath.test.ts index 30794a0b07..8f37eaffdb 100644 --- a/packages/twenty-front/src/modules/navigation/hooks/__tests__/useDefaultHomePagePath.test.ts +++ b/packages/twenty-front/src/modules/navigation/hooks/__tests__/useDefaultHomePagePath.test.ts @@ -314,6 +314,19 @@ describe('useDefaultHomePagePath', () => { withNavigationMenuItemsLoaded: false, }); + await waitFor(() => { + expect(result.current.defaultHomePagePath).toEqual(AppPath.Index); + }); + }); + it('should defer to AppPath.Index when object metadata is loaded but empty while navigation menu items are not loaded yet', async () => { + const { result } = renderHooks({ + withCurrentUser: true, + withExistingView: false, + objectMetadataItems: [], + navigationMenuItems: [], + withNavigationMenuItemsLoaded: false, + }); + await waitFor(() => { expect(result.current.defaultHomePagePath).toEqual(AppPath.Index); }); diff --git a/packages/twenty-front/src/modules/navigation/hooks/useDefaultHomePagePath.ts b/packages/twenty-front/src/modules/navigation/hooks/useDefaultHomePagePath.ts index 1dfc428d01..597a2a3052 100644 --- a/packages/twenty-front/src/modules/navigation/hooks/useDefaultHomePagePath.ts +++ b/packages/twenty-front/src/modules/navigation/hooks/useDefaultHomePagePath.ts @@ -90,24 +90,15 @@ export const useDefaultHomePagePath = () => { return AppPath.SignInUp; } - if (isEmpty(readableNonSystemObjectMetadataItems)) { - // Object metadata may legitimately be empty for a user with no readable - // objects, in which case /settings/profile is the intended fallback. - // It can also be transiently empty during the post-login window before - // workspace metadata has finished loading. Defer to AppPath.Index in - // that case so the user isn't stranded on /settings/profile once - // metadata becomes available. - if (!areObjectMetadataItemsLoaded) { - return AppPath.Index; - } - return getSettingsPath(SettingsPath.ProfilePage); + // Both stores are transiently empty during the post-login window; + // deciding the redirect before they are loaded could strand users on a + // wrong fallback (/settings/profile or the alphabetically-first object). + if (!areObjectMetadataItemsLoaded || !areNavigationMenuItemsLoaded) { + return AppPath.Index; } - // The navigation menu drives the redirect and loads after the minimal- - // metadata fast path. Wait for it instead of falling back to the - // alphabetically-first object during the post-login window. - if (!areNavigationMenuItemsLoaded) { - return AppPath.Index; + if (isEmpty(readableNonSystemObjectMetadataItems)) { + return getSettingsPath(SettingsPath.ProfilePage); } if (isDefined(firstNavigationMenuItemLink)) { diff --git a/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomeAnimationAutoLeaveEffect.tsx b/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomeAnimationAutoLeaveEffect.tsx index 6483abae10..07a289bb70 100644 --- a/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomeAnimationAutoLeaveEffect.tsx +++ b/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomeAnimationAutoLeaveEffect.tsx @@ -1,24 +1,56 @@ import { useEffect } from 'react'; +import { isDefined } from 'twenty-shared/utils'; -const WELCOME_HOLD_DURATION_MS = 2900; +import { WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID } from '@/onboarding/constants/WelcomeTitleHandoffTargetElementId'; +import { isWelcomeAnimationLeavingState } from '@/onboarding/states/isWelcomeAnimationLeavingState'; +import { welcomeTitleFlightState } from '@/onboarding/states/welcomeTitleFlightState'; +import { measureWelcomeTitleFlight } from '@/onboarding/utils/measureWelcomeTitleFlight'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; -type WelcomeAnimationAutoLeaveEffectProps = { - onAutoLeave: () => void; -}; +const WELCOME_HOLD_MIN_DURATION_MS = 2900; +const WELCOME_HOLD_MAX_DURATION_MS = 5000; + +export const WelcomeAnimationAutoLeaveEffect = () => { + const setIsWelcomeAnimationLeaving = useSetAtomState( + isWelcomeAnimationLeavingState, + ); + const setWelcomeTitleFlight = useSetAtomState(welcomeTitleFlightState); -export const WelcomeAnimationAutoLeaveEffect = ({ - onAutoLeave, -}: WelcomeAnimationAutoLeaveEffectProps) => { useEffect(() => { - const autoLeaveTimeoutId = setTimeout( - onAutoLeave, - WELCOME_HOLD_DURATION_MS, - ); + let hasFired = false; + + const leave = () => { + if (hasFired) { + return; + } + hasFired = true; + + setWelcomeTitleFlight(measureWelcomeTitleFlight()); + setIsWelcomeAnimationLeaving(true); + }; + + const capTimeoutId = setTimeout(leave, WELCOME_HOLD_MAX_DURATION_MS); + const minHoldTimeoutId = setTimeout(() => { + const hasHandoffTarget = isDefined( + document.getElementById(WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID), + ); + if (!hasHandoffTarget) { + return; + } + + if (isDefined(document.fonts)) { + void document.fonts.ready.then(leave); + } else { + leave(); + } + }, WELCOME_HOLD_MIN_DURATION_MS); return () => { - clearTimeout(autoLeaveTimeoutId); + clearTimeout(capTimeoutId); + clearTimeout(minHoldTimeoutId); + hasFired = true; }; - }, [onAutoLeave]); + }, [setIsWelcomeAnimationLeaving, setWelcomeTitleFlight]); return null; }; diff --git a/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomeAnimationForcedTeardownEffect.tsx b/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomeAnimationForcedTeardownEffect.tsx new file mode 100644 index 0000000000..7328f94c7f --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomeAnimationForcedTeardownEffect.tsx @@ -0,0 +1,36 @@ +import { useEffect } from 'react'; + +import { isWelcomeAnimationLeavingState } from '@/onboarding/states/isWelcomeAnimationLeavingState'; +import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState'; +import { welcomeTitleFlightState } from '@/onboarding/states/welcomeTitleFlightState'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; + +const FORCED_TEARDOWN_DELAY_IN_MS = 1100; + +export const WelcomeAnimationForcedTeardownEffect = () => { + const setIsWelcomeAnimationVisible = useSetAtomState( + isWelcomeAnimationVisibleState, + ); + const setIsWelcomeAnimationLeaving = useSetAtomState( + isWelcomeAnimationLeavingState, + ); + const setWelcomeTitleFlight = useSetAtomState(welcomeTitleFlightState); + + useEffect(() => { + const forcedTeardownTimeoutId = setTimeout(() => { + setIsWelcomeAnimationVisible(false); + setIsWelcomeAnimationLeaving(false); + setWelcomeTitleFlight(null); + }, FORCED_TEARDOWN_DELAY_IN_MS); + + return () => { + clearTimeout(forcedTeardownTimeoutId); + }; + }, [ + setIsWelcomeAnimationVisible, + setIsWelcomeAnimationLeaving, + setWelcomeTitleFlight, + ]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomeOverlay.tsx b/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomeOverlay.tsx index f3312eb5d8..244e1bdfe1 100644 --- a/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomeOverlay.tsx +++ b/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomeOverlay.tsx @@ -1,22 +1,22 @@ import { styled } from '@linaria/react'; -import { - type AnimationEvent, - type CSSProperties, - useCallback, - useState, -} from 'react'; +import { useLingui } from '@lingui/react/macro'; +import { type CSSProperties } from 'react'; import { createPortal } from 'react-dom'; +import { isDefined } from 'twenty-shared/utils'; import { themeCssVariables } from 'twenty-ui/theme-constants'; import { WelcomeAnimationAutoLeaveEffect } from '@/onboarding/components/WelcomeOverlay/WelcomeAnimationAutoLeaveEffect'; +import { WelcomeAnimationForcedTeardownEffect } from '@/onboarding/components/WelcomeOverlay/WelcomeAnimationForcedTeardownEffect'; import { WelcomeHalftoneCanvas } from '@/onboarding/components/WelcomeOverlay/WelcomeHalftoneCanvas'; import { WelcomePersonChip } from '@/onboarding/components/WelcomeOverlay/WelcomePersonChip'; +import { WELCOME_TITLE_MESSAGE } from '@/onboarding/constants/WelcomeTitleMessage'; +import { WELCOME_TITLE_SOURCE_ELEMENT_ID } from '@/onboarding/constants/WelcomeTitleSourceElementId'; +import { isWelcomeAnimationLeavingState } from '@/onboarding/states/isWelcomeAnimationLeavingState'; import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState'; +import { welcomeTitleFlightState } from '@/onboarding/states/welcomeTitleFlightState'; import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices'; +import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; -import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; - -const WELCOME_TITLE_WORDS = ['Welcome', 'to', 'your', 'workspace']; const StyledOverlay = styled.div` align-items: center; @@ -25,6 +25,7 @@ const StyledOverlay = styled.div` justify-content: center; left: 0; overflow: hidden; + pointer-events: none; position: fixed; right: 0; top: 0; @@ -66,8 +67,6 @@ const StyledCanvasLayer = styled.div` const StyledTitle = styled.div` align-items: center; animation: welcomeTitleIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) 0.8s both; - background: ${themeCssVariables.background.primary}; - border-radius: ${themeCssVariables.border.radius.pill}; color: ${themeCssVariables.font.color.primary}; display: flex; flex-wrap: nowrap; @@ -106,11 +105,32 @@ const StyledTitle = styled.div` } } + &.is-flying { + animation: + welcomeTitleFlight 0.62s cubic-bezier(0.16, 1, 0.3, 1) forwards, + welcomeTitleFlightOut 0.14s ease-out 0.66s forwards; + transform-origin: var(--welcome-flight-origin-x) center; + } + + @keyframes welcomeTitleFlight { + to { + transform: translate(var(--welcome-flight-x), var(--welcome-flight-y)) + scale(var(--welcome-flight-scale)); + } + } + + @keyframes welcomeTitleFlightOut { + to { + opacity: 0; + } + } + @media (prefers-reduced-motion: reduce) { animation: none; - &.is-leaving { - animation-name: welcomeTitleFadeOut; + &.is-leaving, + &.is-flying { + animation: welcomeTitleFadeOut 0.34s ease-out forwards; } @keyframes welcomeTitleFadeOut { @@ -121,10 +141,89 @@ const StyledTitle = styled.div` } `; +const StyledTitleSurface = styled.div` + background: ${themeCssVariables.background.primary}; + border-radius: ${themeCssVariables.border.radius.pill}; + inset: 0; + position: absolute; + + .is-flying & { + animation: welcomeTitleSurfaceOut 0.24s ease-out forwards; + } + + @keyframes welcomeTitleSurfaceOut { + to { + opacity: 0; + } + } +`; + +const StyledTitleBoldRun = styled.span` + align-items: center; + display: inline-flex; + gap: ${themeCssVariables.spacing[2]}; + white-space: nowrap; + + .is-flying & { + animation: welcomeTitleBoldRunOut 0.38s ease 0.12s forwards; + } + + @keyframes welcomeTitleBoldRunOut { + to { + opacity: 0; + } + } + + @media (prefers-reduced-motion: reduce) { + .is-flying & { + animation: none; + } + } +`; + +const StyledTitleRegularRun = styled.span` + align-items: center; + display: inline-flex; + font-weight: ${themeCssVariables.font.weight.regular}; + gap: calc(${themeCssVariables.spacing[2]} * 2); + left: ${themeCssVariables.spacing[8]}; + line-height: 1.4em; + opacity: 0; + position: absolute; + top: 50%; + transform: translateY(-50%); + white-space: nowrap; + + .is-flying & { + animation: welcomeTitleRegularRunIn 0.38s ease 0.12s forwards; + } + + @keyframes welcomeTitleRegularRunIn { + to { + opacity: 1; + } + } + + @media (prefers-reduced-motion: reduce) { + .is-flying & { + animation: none; + } + } +`; + +const StyledTargetScaleChip = styled.span` + display: inline-flex; + font-size: 0.5em; + line-height: 1.4em; + transform: scale(2); + transform-origin: left center; +`; + const StyledWord = styled.span` animation: welcomeWordIn 0.6s cubic-bezier(0.16, 1, 0.3, 1) both; animation-delay: calc(1.1s + var(--word-index) * 0.07s); display: inline-flex; + position: relative; @keyframes welcomeWordIn { from { @@ -153,57 +252,81 @@ const StyledWord = styled.span` `; export const WelcomeOverlay = () => { + const { t } = useLingui(); const isWelcomeAnimationVisible = useAtomStateValue( isWelcomeAnimationVisibleState, ); - const setIsWelcomeAnimationVisible = useSetAtomState( - isWelcomeAnimationVisibleState, + const isWelcomeAnimationLeaving = useAtomStateValue( + isWelcomeAnimationLeavingState, ); - const [isLeaving, setIsLeaving] = useState(false); - - const startLeaving = useCallback(() => setIsLeaving(true), []); + const welcomeTitleFlight = useAtomStateValue(welcomeTitleFlightState); + const isMobile = useIsMobile(); if (!isWelcomeAnimationVisible) { return null; } - const handleBackdropAnimationEnd = ( - event: AnimationEvent, - ) => { - if (event.target === event.currentTarget && isLeaving) { - setIsWelcomeAnimationVisible(false); - setIsLeaving(false); - } - }; + const activeWelcomeTitleFlight = isMobile ? null : welcomeTitleFlight; - const leavingClassName = isLeaving ? 'is-leaving' : undefined; + const welcomeTitle = t(WELCOME_TITLE_MESSAGE); + const welcomeTitleWords = welcomeTitle.split(' '); + + const leavingClassName = isWelcomeAnimationLeaving ? 'is-leaving' : undefined; + const titleClassName = isWelcomeAnimationLeaving + ? isDefined(activeWelcomeTitleFlight) + ? 'is-flying' + : 'is-leaving' + : undefined; + + const titleStyle = isDefined(activeWelcomeTitleFlight) + ? ({ + '--welcome-flight-x': `${activeWelcomeTitleFlight.translateXInPx}px`, + '--welcome-flight-y': `${activeWelcomeTitleFlight.translateYInPx}px`, + '--welcome-flight-scale': activeWelcomeTitleFlight.scale, + '--welcome-flight-origin-x': `${activeWelcomeTitleFlight.transformOriginXInPx}px`, + } as CSSProperties) + : undefined; return createPortal( - - + {isWelcomeAnimationLeaving ? ( + + ) : ( + + )} + - + - - {WELCOME_TITLE_WORDS.map((word, index) => ( + + + + {welcomeTitleWords.map((word, index) => ( + + {word} + + ))} - {word} + - ))} - - - + + + {welcomeTitle} + + + + , document.body, diff --git a/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomePersonChip.tsx b/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomePersonChip.tsx index 0b23772585..1c0261e11b 100644 --- a/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomePersonChip.tsx +++ b/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomePersonChip.tsx @@ -1,18 +1,29 @@ import { styled } from '@linaria/react'; -import { Avatar } from 'twenty-ui/data-display'; +import { Avatar, type AvatarSize } from 'twenty-ui/data-display'; import { themeCssVariables } from 'twenty-ui/theme-constants'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl'; -const StyledChip = styled.div` +type WelcomePersonChipSizeVariant = 'default' | 'compact'; + +const StyledChip = styled.div<{ sizeVariant: WelcomePersonChipSizeVariant }>` align-items: center; background: ${themeCssVariables.background.transparent.light}; - border-radius: ${themeCssVariables.border.radius.md}; + border-radius: ${({ sizeVariant }) => + sizeVariant === 'compact' + ? themeCssVariables.border.radius.sm + : themeCssVariables.border.radius.md}; display: inline-flex; - gap: ${themeCssVariables.spacing[2]}; - padding: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[2]}; + gap: ${({ sizeVariant }) => + sizeVariant === 'compact' + ? themeCssVariables.spacing[1] + : themeCssVariables.spacing[2]}; + padding: ${({ sizeVariant }) => + sizeVariant === 'compact' + ? `${themeCssVariables.spacing['0.5']} ${themeCssVariables.spacing[1]}` + : `${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[2]}`}; `; const StyledPersonName = styled.span` @@ -24,17 +35,25 @@ const StyledPersonName = styled.span` white-space: nowrap; `; -export const WelcomePersonChip = () => { +type WelcomePersonChipProps = { + avatarSize?: AvatarSize; + sizeVariant?: WelcomePersonChipSizeVariant; +}; + +export const WelcomePersonChip = ({ + avatarSize = 'lg', + sizeVariant = 'default', +}: WelcomePersonChipProps) => { const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState); const firstName = currentWorkspaceMember?.name?.firstName ?? ''; const lastName = currentWorkspaceMember?.name?.lastName ?? ''; const fullName = `${firstName} ${lastName}`.trim(); return ( - + ( + {children} +); + +const addHandoffTarget = () => { + const target = document.createElement('div'); + target.id = WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID; + document.body.appendChild(target); +}; + +const isLeaving = () => jotaiStore.get(isWelcomeAnimationLeavingState.atom); + +describe('WelcomeAnimationAutoLeaveEffect', () => { + beforeEach(() => { + resetJotaiStore(); + jest.useFakeTimers(); + document.body.innerHTML = ''; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should leave at the minimum hold once the handoff target exists', () => { + addHandoffTarget(); + render(, { wrapper: Wrapper }); + + act(() => { + jest.advanceTimersByTime(2899); + }); + expect(isLeaving()).toBe(false); + + act(() => { + jest.advanceTimersByTime(1); + }); + expect(isLeaving()).toBe(true); + }); + + it('should hold for the minimum until the target appears', () => { + render(, { wrapper: Wrapper }); + + act(() => { + jest.advanceTimersByTime(2900); + }); + expect(isLeaving()).toBe(false); + }); + + it('should leave at the cap when the target never appears', () => { + render(, { wrapper: Wrapper }); + + act(() => { + jest.advanceTimersByTime(4999); + }); + expect(isLeaving()).toBe(false); + + act(() => { + jest.advanceTimersByTime(1); + }); + expect(isLeaving()).toBe(true); + }); + + it('should not fire again after the cap once it has already left', () => { + addHandoffTarget(); + render(, { wrapper: Wrapper }); + + act(() => { + jest.advanceTimersByTime(2900); + }); + jotaiStore.set(isWelcomeAnimationLeavingState.atom, false); + + act(() => { + jest.advanceTimersByTime(5000); + }); + expect(isLeaving()).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/__tests__/WelcomeOverlay.test.tsx b/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/__tests__/WelcomeOverlay.test.tsx index 9bd5dd9201..3e944d030b 100644 --- a/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/__tests__/WelcomeOverlay.test.tsx +++ b/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/__tests__/WelcomeOverlay.test.tsx @@ -1,6 +1,9 @@ +import { i18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; import { render, screen } from '@testing-library/react'; import { Provider as JotaiProvider } from 'jotai'; import { createElement } from 'react'; +import { SOURCE_LOCALE } from 'twenty-shared/translations'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { WelcomeOverlay } from '@/onboarding/components/WelcomeOverlay/WelcomeOverlay'; @@ -10,10 +13,18 @@ import { resetJotaiStore, } from '@/ui/utilities/state/jotai/jotaiStore'; +import { messages } from '~/locales/generated/en'; import { mockedWorkspaceMemberData } from '~/testing/mock-data/users'; +i18n.load({ [SOURCE_LOCALE]: messages }); +i18n.activate(SOURCE_LOCALE); + const Wrapper = ({ children }: { children: React.ReactNode }) => - createElement(JotaiProvider, { store: jotaiStore }, children); + createElement( + JotaiProvider, + { store: jotaiStore }, + createElement(I18nProvider, { i18n }, children), + ); describe('WelcomeOverlay', () => { beforeEach(() => { @@ -37,6 +48,6 @@ describe('WelcomeOverlay', () => { expect(screen.getByText('Welcome')).toBeInTheDocument(); expect(screen.getByText('workspace')).toBeInTheDocument(); - expect(screen.getByText('Marie Curie')).toBeInTheDocument(); + expect(screen.getAllByText('Marie Curie').length).toBeGreaterThan(0); }); }); diff --git a/packages/twenty-front/src/modules/onboarding/components/WorkspaceSetupChatPreamble.tsx b/packages/twenty-front/src/modules/onboarding/components/WorkspaceSetupChatPreamble.tsx new file mode 100644 index 0000000000..70eb02d4fc --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/components/WorkspaceSetupChatPreamble.tsx @@ -0,0 +1,101 @@ +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +import { WelcomePersonChip } from '@/onboarding/components/WelcomeOverlay/WelcomePersonChip'; +import { WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID } from '@/onboarding/constants/WelcomeTitleHandoffTargetElementId'; +import { WELCOME_TITLE_MESSAGE } from '@/onboarding/constants/WelcomeTitleMessage'; +import { isWelcomeAnimationLeavingState } from '@/onboarding/states/isWelcomeAnimationLeavingState'; +import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; + +const StyledPreamble = styled.div<{ + isHiddenBehindOverlay: boolean; +}>` + color: ${themeCssVariables.font.color.primary}; + font-weight: ${themeCssVariables.font.weight.regular}; + line-height: 1.4em; + opacity: ${({ isHiddenBehindOverlay }) => (isHiddenBehindOverlay ? 0 : 1)}; + overflow-wrap: break-word; + width: 100%; +`; + +const StyledSingleLineHandoffRun = styled.span` + align-items: center; + display: inline-flex; + gap: ${themeCssVariables.spacing[2]}; + white-space: nowrap; + + &.is-revealing-after-flight { + animation: workspaceSetupHandoffRunIn 0.12s ease-out 0.62s both; + } + + @keyframes workspaceSetupHandoffRunIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + @media (prefers-reduced-motion: reduce) { + &.is-revealing-after-flight { + animation-delay: 0s; + } + } +`; + +const StyledContinuation = styled.span` + &.is-revealing-after-flight { + animation: workspaceSetupContinuationIn 0.2s ease-out 0.8s both; + } + + @keyframes workspaceSetupContinuationIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + @media (prefers-reduced-motion: reduce) { + &.is-revealing-after-flight { + animation-delay: 0s; + } + } +`; + +export const WorkspaceSetupChatPreamble = () => { + const { t } = useLingui(); + const isWelcomeAnimationVisible = useAtomStateValue( + isWelcomeAnimationVisibleState, + ); + const isWelcomeAnimationLeaving = useAtomStateValue( + isWelcomeAnimationLeavingState, + ); + + const revealClassName = isWelcomeAnimationLeaving + ? 'is-revealing-after-flight' + : undefined; + + return ( + + + {t(WELCOME_TITLE_MESSAGE)} + + {' '} + + {t`It natively comes with 7 standard objects.`} + + + ); +}; diff --git a/packages/twenty-front/src/modules/onboarding/components/WorkspaceSetupHeader.tsx b/packages/twenty-front/src/modules/onboarding/components/WorkspaceSetupHeader.tsx new file mode 100644 index 0000000000..ab214dec16 --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/components/WorkspaceSetupHeader.tsx @@ -0,0 +1,47 @@ +import { styled } from '@linaria/react'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +import { AiChatCloseButton } from '@/ai/components/AiChatCloseButton'; +import { AiChatCollapseButton } from '@/ai/components/AiChatCollapseButton'; +import { SIDE_PANEL_TOP_BAR_HEIGHT } from '@/side-panel/constants/SidePanelTopBarHeight'; + +const StyledHeader = styled.header` + align-items: center; + background-color: ${themeCssVariables.background.secondary}; + border-bottom: 1px solid ${themeCssVariables.border.color.light}; + box-sizing: border-box; + display: flex; + flex-shrink: 0; + gap: ${themeCssVariables.spacing['0.5']}; + height: ${SIDE_PANEL_TOP_BAR_HEIGHT}px; + padding: 0 ${themeCssVariables.spacing[2]}; +`; + +const StyledHeaderTitle = styled.div` + align-items: center; + border-radius: ${themeCssVariables.border.radius.sm}; + color: ${themeCssVariables.font.color.primary}; + display: flex; + flex: 1; + font-size: ${themeCssVariables.font.size.md}; + font-weight: ${themeCssVariables.font.weight.semiBold}; + height: ${themeCssVariables.spacing[6]}; + line-height: 1.4; + min-width: 0; + overflow: hidden; + padding: 0 ${themeCssVariables.spacing[1]}; + text-overflow: ellipsis; + white-space: nowrap; +`; + +type WorkspaceSetupHeaderProps = { + title: string; +}; + +export const WorkspaceSetupHeader = ({ title }: WorkspaceSetupHeaderProps) => ( + + {title} + + + +); diff --git a/packages/twenty-front/src/modules/onboarding/components/__tests__/WorkspaceSetupChatPreamble.test.tsx b/packages/twenty-front/src/modules/onboarding/components/__tests__/WorkspaceSetupChatPreamble.test.tsx new file mode 100644 index 0000000000..7ada2c49a6 --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/components/__tests__/WorkspaceSetupChatPreamble.test.tsx @@ -0,0 +1,46 @@ +import { i18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { render } from '@testing-library/react'; +import { Provider as JotaiProvider } from 'jotai'; +import { type ReactNode } from 'react'; +import { SOURCE_LOCALE } from 'twenty-shared/translations'; + +import { WorkspaceSetupChatPreamble } from '@/onboarding/components/WorkspaceSetupChatPreamble'; +import { WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID } from '@/onboarding/constants/WelcomeTitleHandoffTargetElementId'; +import { + jotaiStore, + resetJotaiStore, +} from '@/ui/utilities/state/jotai/jotaiStore'; +import { messages } from '~/locales/generated/en'; + +jest.mock('@/onboarding/components/WelcomeOverlay/WelcomePersonChip', () => ({ + WelcomePersonChip: () => , +})); + +i18n.load({ [SOURCE_LOCALE]: messages }); +i18n.activate(SOURCE_LOCALE); + +const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +describe('WorkspaceSetupChatPreamble', () => { + beforeEach(() => { + resetJotaiStore(); + }); + + it('should carry the handoff target id on a single non-wrapping run', () => { + const { container } = render(, { + wrapper: Wrapper, + }); + + const handoffRun = container.querySelector( + `#${WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID}`, + ); + + expect(handoffRun).toBeInTheDocument(); + expect(handoffRun?.textContent).toContain('Welcome to your workspace'); + }); +}); diff --git a/packages/twenty-front/src/modules/onboarding/constants/WelcomeTitleHandoffTargetElementId.ts b/packages/twenty-front/src/modules/onboarding/constants/WelcomeTitleHandoffTargetElementId.ts new file mode 100644 index 0000000000..17f4ec1322 --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/constants/WelcomeTitleHandoffTargetElementId.ts @@ -0,0 +1,2 @@ +export const WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID = + 'welcome-title-handoff-target'; diff --git a/packages/twenty-front/src/modules/onboarding/constants/WelcomeTitleMessage.ts b/packages/twenty-front/src/modules/onboarding/constants/WelcomeTitleMessage.ts new file mode 100644 index 0000000000..48a770ce5f --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/constants/WelcomeTitleMessage.ts @@ -0,0 +1,3 @@ +import { msg } from '@lingui/core/macro'; + +export const WELCOME_TITLE_MESSAGE = msg`Welcome to your workspace`; diff --git a/packages/twenty-front/src/modules/onboarding/constants/WelcomeTitleSourceElementId.ts b/packages/twenty-front/src/modules/onboarding/constants/WelcomeTitleSourceElementId.ts new file mode 100644 index 0000000000..726b1570db --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/constants/WelcomeTitleSourceElementId.ts @@ -0,0 +1 @@ +export const WELCOME_TITLE_SOURCE_ELEMENT_ID = 'welcome-title-source'; diff --git a/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useSetNextOnboardingStatus.test.ts b/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useSetNextOnboardingStatus.test.ts index 2b696b42dc..26f56c498d 100644 --- a/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useSetNextOnboardingStatus.test.ts +++ b/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useSetNextOnboardingStatus.test.ts @@ -8,6 +8,7 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { billingState } from '@/client-config/states/billingState'; import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus'; import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState'; +import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState'; 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'; @@ -16,7 +17,7 @@ import { resetJotaiStore, } from '@/ui/utilities/state/jotai/jotaiStore'; -import { OnboardingStatus } from '~/generated-metadata/graphql'; +import { FeatureFlagKey, OnboardingStatus } from '~/generated-metadata/graphql'; import { mockCurrentWorkspace, mockedUserData, @@ -29,6 +30,7 @@ type RenderHooksOptions = { withSubscription?: boolean; isBillingEnabled?: boolean; withOneWorkspaceMember?: boolean; + isOnboardingAiChatEnabled?: boolean; }; const renderHooks = ( @@ -37,6 +39,7 @@ const renderHooks = ( withSubscription = false, isBillingEnabled = false, withOneWorkspaceMember = true, + isOnboardingAiChatEnabled = false, }: RenderHooksOptions = {}, ) => { const { result } = renderHook( @@ -51,6 +54,9 @@ const renderHooks = ( const isWelcomeAnimationVisible = useAtomStateValue( isWelcomeAnimationVisibleState, ); + const shouldOpenAiChatAfterOnboarding = useAtomStateValue( + shouldOpenAiChatAfterOnboardingState, + ); return { currentUser, setCurrentUser, @@ -59,6 +65,7 @@ const renderHooks = ( setBilling, setNextOnboardingStatus, isWelcomeAnimationVisible, + shouldOpenAiChatAfterOnboarding, }; }, { @@ -74,6 +81,12 @@ const renderHooks = ( ? mockCurrentWorkspace.billingSubscriptions : [], workspaceMembersCount: withOneWorkspaceMember ? 1 : 2, + featureFlags: [ + { + key: FeatureFlagKey.IS_ONBOARDING_AI_CHAT_ENABLED, + value: isOnboardingAiChatEnabled, + }, + ], }); result.current.setBilling({ __typename: 'Billing', @@ -87,114 +100,165 @@ const renderHooks = ( return { nextOnboardingStatus: result.current.currentUser?.onboardingStatus, isWelcomeAnimationVisible: result.current.isWelcomeAnimationVisible, + shouldOpenAiChatAfterOnboarding: + result.current.shouldOpenAiChatAfterOnboarding, }; }; describe('useSetNextOnboardingStatus', () => { beforeEach(() => { + sessionStorage.clear(); resetJotaiStore(); }); it('should sync emails right after workspace activation', () => { - const { nextOnboardingStatus, isWelcomeAnimationVisible } = renderHooks( - OnboardingStatus.WORKSPACE_ACTIVATION, - ); + const { + nextOnboardingStatus, + isWelcomeAnimationVisible, + shouldOpenAiChatAfterOnboarding, + } = renderHooks(OnboardingStatus.WORKSPACE_ACTIVATION); expect(nextOnboardingStatus).toEqual(OnboardingStatus.SYNC_EMAIL); expect(isWelcomeAnimationVisible).toBe(false); + expect(shouldOpenAiChatAfterOnboarding).toBe(false); }); it('should install apps after syncing emails', () => { - const { nextOnboardingStatus, isWelcomeAnimationVisible } = renderHooks( - OnboardingStatus.SYNC_EMAIL, - ); + const { + nextOnboardingStatus, + isWelcomeAnimationVisible, + shouldOpenAiChatAfterOnboarding, + } = renderHooks(OnboardingStatus.SYNC_EMAIL); expect(nextOnboardingStatus).toEqual(OnboardingStatus.APPS_INSTALLATION); expect(isWelcomeAnimationVisible).toBe(false); + expect(shouldOpenAiChatAfterOnboarding).toBe(false); }); it('should create profile after installing apps', () => { - const { nextOnboardingStatus, isWelcomeAnimationVisible } = renderHooks( - OnboardingStatus.APPS_INSTALLATION, - ); + const { + nextOnboardingStatus, + isWelcomeAnimationVisible, + shouldOpenAiChatAfterOnboarding, + } = renderHooks(OnboardingStatus.APPS_INSTALLATION); expect(nextOnboardingStatus).toEqual(OnboardingStatus.PROFILE_CREATION); expect(isWelcomeAnimationVisible).toBe(false); + expect(shouldOpenAiChatAfterOnboarding).toBe(false); }); it('should invite the team right after profile creation', () => { - const { nextOnboardingStatus, isWelcomeAnimationVisible } = renderHooks( - OnboardingStatus.PROFILE_CREATION, - ); + const { + nextOnboardingStatus, + isWelcomeAnimationVisible, + shouldOpenAiChatAfterOnboarding, + } = renderHooks(OnboardingStatus.PROFILE_CREATION); expect(nextOnboardingStatus).toEqual(OnboardingStatus.INVITE_TEAM); expect(isWelcomeAnimationVisible).toBe(false); + expect(shouldOpenAiChatAfterOnboarding).toBe(false); }); it('should complete after profile creation when more than 1 workspaceMember exist', () => { - const { nextOnboardingStatus, isWelcomeAnimationVisible } = renderHooks( - OnboardingStatus.PROFILE_CREATION, - { - withOneWorkspaceMember: false, - }, - ); + const { + nextOnboardingStatus, + isWelcomeAnimationVisible, + shouldOpenAiChatAfterOnboarding, + } = renderHooks(OnboardingStatus.PROFILE_CREATION, { + withOneWorkspaceMember: false, + }); expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED); expect(isWelcomeAnimationVisible).toBe(true); + expect(shouldOpenAiChatAfterOnboarding).toBe(false); }); it('should require a plan after profile creation when billing is enabled and the workspace has no subscription', () => { - const { nextOnboardingStatus, isWelcomeAnimationVisible } = renderHooks( - OnboardingStatus.PROFILE_CREATION, - { - withOneWorkspaceMember: false, - isBillingEnabled: true, - withSubscription: false, - }, - ); + const { + nextOnboardingStatus, + isWelcomeAnimationVisible, + shouldOpenAiChatAfterOnboarding, + } = renderHooks(OnboardingStatus.PROFILE_CREATION, { + withOneWorkspaceMember: false, + isBillingEnabled: true, + withSubscription: false, + }); expect(nextOnboardingStatus).toEqual(OnboardingStatus.PLAN_REQUIRED); expect(isWelcomeAnimationVisible).toBe(false); + expect(shouldOpenAiChatAfterOnboarding).toBe(false); }); it('should complete after inviting the team when billing is disabled', () => { - const { nextOnboardingStatus, isWelcomeAnimationVisible } = renderHooks( - OnboardingStatus.INVITE_TEAM, - ); + const { + nextOnboardingStatus, + isWelcomeAnimationVisible, + shouldOpenAiChatAfterOnboarding, + } = renderHooks(OnboardingStatus.INVITE_TEAM); expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED); expect(isWelcomeAnimationVisible).toBe(true); + expect(shouldOpenAiChatAfterOnboarding).toBe(false); }); it('should complete after inviting the team when the workspace already has a subscription', () => { - const { nextOnboardingStatus, isWelcomeAnimationVisible } = renderHooks( - OnboardingStatus.INVITE_TEAM, - { - isBillingEnabled: true, - withSubscription: true, - }, - ); + const { + nextOnboardingStatus, + isWelcomeAnimationVisible, + shouldOpenAiChatAfterOnboarding, + } = renderHooks(OnboardingStatus.INVITE_TEAM, { + isBillingEnabled: true, + withSubscription: true, + }); expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED); expect(isWelcomeAnimationVisible).toBe(true); + expect(shouldOpenAiChatAfterOnboarding).toBe(false); }); it('should require a plan after inviting the team when billing is enabled and the workspace has no subscription', () => { - const { nextOnboardingStatus, isWelcomeAnimationVisible } = renderHooks( - OnboardingStatus.INVITE_TEAM, - { - isBillingEnabled: true, - withSubscription: false, - }, - ); + const { + nextOnboardingStatus, + isWelcomeAnimationVisible, + shouldOpenAiChatAfterOnboarding, + } = renderHooks(OnboardingStatus.INVITE_TEAM, { + isBillingEnabled: true, + withSubscription: false, + }); expect(nextOnboardingStatus).toEqual(OnboardingStatus.PLAN_REQUIRED); expect(isWelcomeAnimationVisible).toBe(false); + expect(shouldOpenAiChatAfterOnboarding).toBe(false); }); it('should not show the welcome animation when the onboarding was already completed', () => { - const { nextOnboardingStatus, isWelcomeAnimationVisible } = renderHooks( - OnboardingStatus.COMPLETED, - ); + const { + nextOnboardingStatus, + isWelcomeAnimationVisible, + shouldOpenAiChatAfterOnboarding, + } = renderHooks(OnboardingStatus.COMPLETED); expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED); expect(isWelcomeAnimationVisible).toBe(false); + expect(shouldOpenAiChatAfterOnboarding).toBe(false); }); it('should not show the welcome animation when the onboarding status is unknown', () => { - const { nextOnboardingStatus, isWelcomeAnimationVisible } = - renderHooks(null); + const { + nextOnboardingStatus, + isWelcomeAnimationVisible, + shouldOpenAiChatAfterOnboarding, + } = renderHooks(null); expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED); expect(isWelcomeAnimationVisible).toBe(false); + expect(shouldOpenAiChatAfterOnboarding).toBe(false); + }); + + it('should open the ai chat after onboarding when the feature flag is enabled', () => { + const { isWelcomeAnimationVisible, shouldOpenAiChatAfterOnboarding } = + renderHooks(OnboardingStatus.INVITE_TEAM, { + isOnboardingAiChatEnabled: true, + }); + expect(isWelcomeAnimationVisible).toBe(true); + expect(shouldOpenAiChatAfterOnboarding).toBe(true); + }); + + it('should still show the welcome animation when the ai chat feature flag is disabled', () => { + const { isWelcomeAnimationVisible, shouldOpenAiChatAfterOnboarding } = + renderHooks(OnboardingStatus.INVITE_TEAM, { + isOnboardingAiChatEnabled: false, + }); + expect(isWelcomeAnimationVisible).toBe(true); + expect(shouldOpenAiChatAfterOnboarding).toBe(false); }); }); diff --git a/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts b/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts index 691ec6e399..2c93d9ee25 100644 --- a/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts +++ b/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts @@ -10,12 +10,14 @@ import { } from '@/auth/states/currentWorkspaceState'; import { billingState } from '@/client-config/states/billingState'; import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState'; +import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState'; import { getHasJustCompletedOnboarding } from '@/onboarding/utils/getHasJustCompletedOnboarding'; import { getIsPlanRequired } from '@/onboarding/utils/getIsPlanRequired'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useCallback } from 'react'; -import { OnboardingStatus } from '~/generated-metadata/graphql'; +import { FeatureFlagKey, OnboardingStatus } from '~/generated-metadata/graphql'; import { useStore } from 'jotai'; type GetNextOnboardingStatusArgs = { @@ -68,6 +70,9 @@ export const useSetNextOnboardingStatus = () => { const currentWorkspace = useAtomStateValue(currentWorkspaceState); const billing = useAtomStateValue(billingState); const isBillingEnabled = billing?.isBillingEnabled ?? false; + const isOnboardingAiChatEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_ONBOARDING_AI_CHAT_ENABLED, + ); return useCallback(() => { const nextOnboardingStatus = getNextOnboardingStatus({ @@ -92,6 +97,16 @@ export const useSetNextOnboardingStatus = () => { }) ) { store.set(isWelcomeAnimationVisibleState.atom, true); + store.set( + shouldOpenAiChatAfterOnboardingState.atom, + isOnboardingAiChatEnabled, + ); } - }, [currentUser, currentWorkspace, isBillingEnabled, store]); + }, [ + currentUser, + currentWorkspace, + isBillingEnabled, + isOnboardingAiChatEnabled, + store, + ]); }; diff --git a/packages/twenty-front/src/modules/onboarding/hooks/useShowWelcomeAnimationAfterOnboardingCheckout.ts b/packages/twenty-front/src/modules/onboarding/hooks/useShowWelcomeAnimationAfterOnboardingCheckout.ts index 4127cc8113..fbde78ce97 100644 --- a/packages/twenty-front/src/modules/onboarding/hooks/useShowWelcomeAnimationAfterOnboardingCheckout.ts +++ b/packages/twenty-front/src/modules/onboarding/hooks/useShowWelcomeAnimationAfterOnboardingCheckout.ts @@ -1,8 +1,11 @@ import { currentUserState } from '@/auth/states/currentUserState'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { checkIfFeatureFlagIsEnabledOnWorkspace } from '@/workspace/utils/checkIfFeatureFlagIsEnabledOnWorkspace'; import { isOnboardingCheckoutPendingState } from '@/onboarding/states/isOnboardingCheckoutPendingState'; import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState'; +import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState'; import { useStore } from 'jotai'; -import { OnboardingStatus } from '~/generated-metadata/graphql'; +import { FeatureFlagKey, OnboardingStatus } from '~/generated-metadata/graphql'; export const useShowWelcomeAnimationAfterOnboardingCheckout = () => { const store = useStore(); @@ -18,7 +21,16 @@ export const useShowWelcomeAnimationAfterOnboardingCheckout = () => { return; } + const isOnboardingAiChatEnabled = checkIfFeatureFlagIsEnabledOnWorkspace( + FeatureFlagKey.IS_ONBOARDING_AI_CHAT_ENABLED, + store.get(currentWorkspaceState.atom), + ); + store.set(isOnboardingCheckoutPendingState.atom, false); store.set(isWelcomeAnimationVisibleState.atom, true); + store.set( + shouldOpenAiChatAfterOnboardingState.atom, + isOnboardingAiChatEnabled, + ); }; }; diff --git a/packages/twenty-front/src/modules/onboarding/states/isWelcomeAnimationLeavingState.ts b/packages/twenty-front/src/modules/onboarding/states/isWelcomeAnimationLeavingState.ts new file mode 100644 index 0000000000..a863afca1c --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/states/isWelcomeAnimationLeavingState.ts @@ -0,0 +1,6 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const isWelcomeAnimationLeavingState = createAtomState({ + key: 'isWelcomeAnimationLeavingState', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/onboarding/states/shouldOpenAiChatAfterOnboardingState.ts b/packages/twenty-front/src/modules/onboarding/states/shouldOpenAiChatAfterOnboardingState.ts new file mode 100644 index 0000000000..8706e54c7b --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/states/shouldOpenAiChatAfterOnboardingState.ts @@ -0,0 +1,7 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const shouldOpenAiChatAfterOnboardingState = createAtomState({ + key: 'shouldOpenAiChatAfterOnboardingState', + defaultValue: false, + useSessionStorage: true, +}); diff --git a/packages/twenty-front/src/modules/onboarding/states/welcomeTitleFlightState.ts b/packages/twenty-front/src/modules/onboarding/states/welcomeTitleFlightState.ts new file mode 100644 index 0000000000..b841e93a6e --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/states/welcomeTitleFlightState.ts @@ -0,0 +1,8 @@ +import { type WelcomeTitleFlight } from '@/onboarding/types/WelcomeTitleFlight'; +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const welcomeTitleFlightState = + createAtomState({ + key: 'welcomeTitleFlightState', + defaultValue: null, + }); diff --git a/packages/twenty-front/src/modules/onboarding/types/WelcomeTitleFlight.ts b/packages/twenty-front/src/modules/onboarding/types/WelcomeTitleFlight.ts new file mode 100644 index 0000000000..5042923bc9 --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/types/WelcomeTitleFlight.ts @@ -0,0 +1,6 @@ +export type WelcomeTitleFlight = { + translateXInPx: number; + translateYInPx: number; + scale: number; + transformOriginXInPx: number; +}; diff --git a/packages/twenty-front/src/modules/onboarding/utils/__tests__/getWelcomeTitleFlight.test.ts b/packages/twenty-front/src/modules/onboarding/utils/__tests__/getWelcomeTitleFlight.test.ts new file mode 100644 index 0000000000..f0fb93a59e --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/utils/__tests__/getWelcomeTitleFlight.test.ts @@ -0,0 +1,80 @@ +import { getWelcomeTitleFlight } from '@/onboarding/utils/getWelcomeTitleFlight'; + +const buildRect = ( + left: number, + top: number, + width: number, + height: number, +): DOMRect => + ({ + left, + top, + width, + height, + right: left + width, + bottom: top + height, + x: left, + y: top, + }) as DOMRect; + +describe('getWelcomeTitleFlight', () => { + it('should translate from where the text starts, not from the pill edge', () => { + const flight = getWelcomeTitleFlight({ + sourceRect: buildRect(100, 200, 400, 80), + sourcePaddingLeftInPx: 32, + sourceFontSizeInPx: 26, + targetRect: buildRect(24, 40, 300, 24), + targetFontSizeInPx: 26, + }); + + expect(flight.translateXInPx).toBe(24 - (100 + 32)); + }); + + it('should align the vertical centers of source and target', () => { + const flight = getWelcomeTitleFlight({ + sourceRect: buildRect(0, 200, 400, 80), + sourcePaddingLeftInPx: 0, + sourceFontSizeInPx: 26, + targetRect: buildRect(0, 40, 300, 24), + targetFontSizeInPx: 26, + }); + + expect(flight.translateYInPx).toBe(52 - 240); + }); + + it('should scale by the font size ratio', () => { + const flight = getWelcomeTitleFlight({ + sourceRect: buildRect(0, 0, 400, 80), + sourcePaddingLeftInPx: 0, + sourceFontSizeInPx: 26, + targetRect: buildRect(0, 0, 300, 24), + targetFontSizeInPx: 13, + }); + + expect(flight.scale).toBe(0.5); + }); + + it('should anchor the transform origin on the text start', () => { + const flight = getWelcomeTitleFlight({ + sourceRect: buildRect(0, 0, 400, 80), + sourcePaddingLeftInPx: 32, + sourceFontSizeInPx: 26, + targetRect: buildRect(0, 0, 300, 24), + targetFontSizeInPx: 26, + }); + + expect(flight.transformOriginXInPx).toBe(32); + }); + + it('should fall back to a neutral scale when the source font size is unreadable', () => { + const flight = getWelcomeTitleFlight({ + sourceRect: buildRect(0, 0, 400, 80), + sourcePaddingLeftInPx: 0, + sourceFontSizeInPx: 0, + targetRect: buildRect(0, 0, 300, 24), + targetFontSizeInPx: 16, + }); + + expect(flight.scale).toBe(1); + }); +}); diff --git a/packages/twenty-front/src/modules/onboarding/utils/__tests__/measureWelcomeTitleFlight.test.ts b/packages/twenty-front/src/modules/onboarding/utils/__tests__/measureWelcomeTitleFlight.test.ts new file mode 100644 index 0000000000..98622e7a10 --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/utils/__tests__/measureWelcomeTitleFlight.test.ts @@ -0,0 +1,68 @@ +import { WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID } from '@/onboarding/constants/WelcomeTitleHandoffTargetElementId'; +import { WELCOME_TITLE_SOURCE_ELEMENT_ID } from '@/onboarding/constants/WelcomeTitleSourceElementId'; +import { measureWelcomeTitleFlight } from '@/onboarding/utils/measureWelcomeTitleFlight'; + +const buildSourceElement = () => { + const source = document.createElement('div'); + source.id = WELCOME_TITLE_SOURCE_ELEMENT_ID; + source.getBoundingClientRect = () => + ({ left: 100, top: 200, width: 400, height: 80 }) as DOMRect; + document.body.appendChild(source); + return source; +}; + +const buildTargetElement = (rect: Partial, visibility = 'visible') => { + const target = document.createElement('span'); + target.id = WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID; + target.style.visibility = visibility; + target.getBoundingClientRect = () => + ({ left: 24, top: 40, width: 200, height: 20, ...rect }) as DOMRect; + document.body.appendChild(target); + return target; +}; + +describe('measureWelcomeTitleFlight', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('should return null when the source element is missing', () => { + buildTargetElement({}); + + expect(measureWelcomeTitleFlight()).toBeNull(); + }); + + it('should return null when the target element is missing', () => { + buildSourceElement(); + + expect(measureWelcomeTitleFlight()).toBeNull(); + }); + + it('should return null when the target has no width', () => { + buildSourceElement(); + buildTargetElement({ width: 0 }); + + expect(measureWelcomeTitleFlight()).toBeNull(); + }); + + it('should return null when the target has no height', () => { + buildSourceElement(); + buildTargetElement({ height: 0 }); + + expect(measureWelcomeTitleFlight()).toBeNull(); + }); + + it('should return null when the target is not visible', () => { + buildSourceElement(); + buildTargetElement({}, 'hidden'); + + expect(measureWelcomeTitleFlight()).toBeNull(); + }); + + it('should measure a flight when both ends are laid out and visible', () => { + buildSourceElement(); + buildTargetElement({}); + + expect(measureWelcomeTitleFlight()).not.toBeNull(); + }); +}); diff --git a/packages/twenty-front/src/modules/onboarding/utils/getWelcomeTitleFlight.ts b/packages/twenty-front/src/modules/onboarding/utils/getWelcomeTitleFlight.ts new file mode 100644 index 0000000000..f1194f3250 --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/utils/getWelcomeTitleFlight.ts @@ -0,0 +1,28 @@ +import { type WelcomeTitleFlight } from '@/onboarding/types/WelcomeTitleFlight'; + +type GetWelcomeTitleFlightArgs = { + sourceRect: DOMRect; + sourcePaddingLeftInPx: number; + sourceFontSizeInPx: number; + targetRect: DOMRect; + targetFontSizeInPx: number; +}; + +export const getWelcomeTitleFlight = ({ + sourceRect, + sourcePaddingLeftInPx, + sourceFontSizeInPx, + targetRect, + targetFontSizeInPx, +}: GetWelcomeTitleFlightArgs): WelcomeTitleFlight => { + const sourceTextLeft = sourceRect.left + sourcePaddingLeftInPx; + const sourceCenterY = sourceRect.top + sourceRect.height / 2; + const targetCenterY = targetRect.top + targetRect.height / 2; + + return { + translateXInPx: targetRect.left - sourceTextLeft, + translateYInPx: targetCenterY - sourceCenterY, + scale: sourceFontSizeInPx > 0 ? targetFontSizeInPx / sourceFontSizeInPx : 1, + transformOriginXInPx: sourcePaddingLeftInPx, + }; +}; diff --git a/packages/twenty-front/src/modules/onboarding/utils/measureWelcomeTitleFlight.ts b/packages/twenty-front/src/modules/onboarding/utils/measureWelcomeTitleFlight.ts new file mode 100644 index 0000000000..03bf5fd969 --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/utils/measureWelcomeTitleFlight.ts @@ -0,0 +1,44 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID } from '@/onboarding/constants/WelcomeTitleHandoffTargetElementId'; +import { WELCOME_TITLE_SOURCE_ELEMENT_ID } from '@/onboarding/constants/WelcomeTitleSourceElementId'; +import { type WelcomeTitleFlight } from '@/onboarding/types/WelcomeTitleFlight'; +import { getWelcomeTitleFlight } from '@/onboarding/utils/getWelcomeTitleFlight'; + +const isElementLaidOutAndVisible = ( + rect: DOMRect, + style: CSSStyleDeclaration, +) => rect.width > 0 && rect.height > 0 && style.visibility === 'visible'; + +export const measureWelcomeTitleFlight = (): WelcomeTitleFlight | null => { + const sourceElement = document.getElementById( + WELCOME_TITLE_SOURCE_ELEMENT_ID, + ); + const targetElement = document.getElementById( + WELCOME_TITLE_HANDOFF_TARGET_ELEMENT_ID, + ); + + if (!isDefined(sourceElement) || !isDefined(targetElement)) { + return null; + } + + const sourceStyle = window.getComputedStyle(sourceElement); + const targetStyle = window.getComputedStyle(targetElement); + const sourceRect = sourceElement.getBoundingClientRect(); + const targetRect = targetElement.getBoundingClientRect(); + + if ( + !isElementLaidOutAndVisible(sourceRect, sourceStyle) || + !isElementLaidOutAndVisible(targetRect, targetStyle) + ) { + return null; + } + + return getWelcomeTitleFlight({ + sourceRect, + sourcePaddingLeftInPx: parseFloat(sourceStyle.paddingLeft), + sourceFontSizeInPx: parseFloat(sourceStyle.fontSize), + targetRect, + targetFontSizeInPx: parseFloat(targetStyle.fontSize), + }); +}; diff --git a/packages/twenty-front/src/modules/side-panel/components/SidePanelExpandAiChatButton.tsx b/packages/twenty-front/src/modules/side-panel/components/SidePanelExpandAiChatButton.tsx new file mode 100644 index 0000000000..edc0c9b3c7 --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/components/SidePanelExpandAiChatButton.tsx @@ -0,0 +1,50 @@ +import { useLingui } from '@lingui/react/macro'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { AppPath, SidePanelPages } from 'twenty-shared/types'; +import { IconLayoutSidebarRightExpand } from 'twenty-ui/icon'; +import { IconButton } from 'twenty-ui/input'; +import { useIsMobile } from 'twenty-ui/utilities'; + +import { aiChatExpandedReturnLocationState } from '@/ai/states/aiChatExpandedReturnLocationState'; +import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { FeatureFlagKey } from '~/generated-metadata/graphql'; + +export const SidePanelExpandAiChatButton = () => { + const { t } = useLingui(); + const navigate = useNavigate(); + const location = useLocation(); + const isMobile = useIsMobile(); + const sidePanelPage = useAtomStateValue(sidePanelPageState); + const isOnboardingAiChatEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_ONBOARDING_AI_CHAT_ENABLED, + ); + const setAiChatExpandedReturnLocation = useSetAtomState( + aiChatExpandedReturnLocationState, + ); + + const isOnAskAiPage = sidePanelPage === SidePanelPages.AskAI; + + if (!isOnboardingAiChatEnabled || isMobile || !isOnAskAiPage) { + return null; + } + + const handleClick = () => { + setAiChatExpandedReturnLocation( + `${location.pathname}${location.search}${location.hash}`, + ); + navigate(AppPath.WorkspaceSetup); + }; + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/side-panel/components/SidePanelTopBar.tsx b/packages/twenty-front/src/modules/side-panel/components/SidePanelTopBar.tsx index 9a4b677cb9..7c9000eab4 100644 --- a/packages/twenty-front/src/modules/side-panel/components/SidePanelTopBar.tsx +++ b/packages/twenty-front/src/modules/side-panel/components/SidePanelTopBar.tsx @@ -1,6 +1,7 @@ import { SidePanelBackButton } from '@/side-panel/components/SidePanelBackButton'; import { SidePanelPageInfo } from '@/side-panel/components/SidePanelPageInfo'; import { SidePanelTopBarInputFocusEffect } from '@/side-panel/components/SidePanelTopBarInputFocusEffect'; +import { SidePanelExpandAiChatButton } from '@/side-panel/components/SidePanelExpandAiChatButton'; import { SidePanelTopBarRightCornerIcon } from '@/side-panel/components/SidePanelTopBarRightCornerIcon'; import { COMMAND_MENU_SIDE_PANEL_PAGES } from '@/side-panel/constants/CommandMenuSidePanelPages'; import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId'; @@ -211,6 +212,7 @@ export const SidePanelTopBar = () => { + {!shouldHideCloseButton && ( ({ SidePanelTopBarRightCornerIcon: () => null, })); +jest.mock('@/side-panel/components/SidePanelExpandAiChatButton', () => ({ + SidePanelExpandAiChatButton: () => null, +})); + const mockCloseSidePanelMenu = jest.fn(); jest.mock('@/side-panel/hooks/useSidePanelContextChips', () => ({ diff --git a/packages/twenty-front/src/modules/side-panel/hooks/useOpenAskAiPageInSidePanel.ts b/packages/twenty-front/src/modules/side-panel/hooks/useOpenAskAiPageInSidePanel.ts index 55c86bfd5f..3ba6258973 100644 --- a/packages/twenty-front/src/modules/side-panel/hooks/useOpenAskAiPageInSidePanel.ts +++ b/packages/twenty-front/src/modules/side-panel/hooks/useOpenAskAiPageInSidePanel.ts @@ -1,6 +1,8 @@ +import { hasAgentChatBeenOpenedState } from '@/ai/states/hasAgentChatBeenOpenedState'; import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { t } from '@lingui/core/macro'; import { useCallback } from 'react'; import { SidePanelPages } from 'twenty-shared/types'; @@ -10,6 +12,9 @@ import { v4 } from 'uuid'; export const useOpenAskAiPageInSidePanel = () => { const { navigateSidePanelMenu } = useSidePanelMenu(); const isSidePanelOpened = useAtomStateValue(isSidePanelOpenedState); + const setHasAgentChatBeenOpened = useSetAtomState( + hasAgentChatBeenOpenedState, + ); const openAskAiPage = useCallback( ({ @@ -22,6 +27,8 @@ export const useOpenAskAiPageInSidePanel = () => { ? resetNavigationStack : isSidePanelOpened; + setHasAgentChatBeenOpened(true); + navigateSidePanelMenu({ page: SidePanelPages.AskAI, pageTitle: t`Ask AI`, @@ -30,7 +37,7 @@ export const useOpenAskAiPageInSidePanel = () => { resetNavigationStack: shouldReset, }); }, - [navigateSidePanelMenu, isSidePanelOpened], + [navigateSidePanelMenu, isSidePanelOpened, setHasAgentChatBeenOpened], ); return { diff --git a/packages/twenty-front/src/pages/onboarding/PaymentSuccess.tsx b/packages/twenty-front/src/pages/onboarding/PaymentSuccess.tsx index 499e1fee34..d6ee76c6fc 100644 --- a/packages/twenty-front/src/pages/onboarding/PaymentSuccess.tsx +++ b/packages/twenty-front/src/pages/onboarding/PaymentSuccess.tsx @@ -1,13 +1,10 @@ import { SubTitle } from '@/auth/components/SubTitle'; -import { currentUserState } from '@/auth/states/currentUserState'; import { OnboardingAnimatedReveal } from '@/onboarding/components/OnboardingAnimatedReveal'; import { OnboardingVerifyLayout } from '@/onboarding/components/OnboardingVerifyLayout'; import { useOnboardingMotionTransition } from '@/onboarding/hooks/useOnboardingMotionTransition'; import { useShowWelcomeAnimationAfterOnboardingCheckout } from '@/onboarding/hooks/useShowWelcomeAnimationAfterOnboardingCheckout'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; -import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus'; -import { useLazyQuery } from '@apollo/client/react'; +import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser'; import { t } from '@lingui/core/macro'; import { styled } from '@linaria/react'; import { AnimatePresence, motion } from 'framer-motion'; @@ -15,7 +12,6 @@ import { useEffect, useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { MainButton } from 'twenty-ui/input'; import { themeCssVariables } from 'twenty-ui/theme-constants'; -import { GetCurrentUserDocument } from '~/generated-metadata/graphql'; const SUBSCRIPTION_CONFIRMATION_POLL_INTERVAL_MS = 2000; const SUBSCRIPTION_CONFIRMATION_MAX_ATTEMPTS = 30; @@ -26,11 +22,7 @@ const StyledRetryButtonContainer = styled.div` `; export const PaymentSuccess = () => { - const subscriptionStatus = useSubscriptionStatus(); - const [getCurrentUser] = useLazyQuery(GetCurrentUserDocument, { - fetchPolicy: 'network-only', - }); - const setCurrentUser = useSetAtomState(currentUserState); + const { loadCurrentUser } = useLoadCurrentUser(); const showWelcomeAnimationAfterOnboardingCheckout = useShowWelcomeAnimationAfterOnboardingCheckout(); const { enqueueErrorSnackBar } = useSnackBar(); @@ -47,23 +39,18 @@ export const PaymentSuccess = () => { return; } - if (isDefined(subscriptionStatus)) { - showWelcomeAnimationAfterOnboardingCheckout(); - return; - } - - const result = await getCurrentUser(); + const refreshedWorkspace = await loadCurrentUser() + .then(({ workspace }) => workspace) + .catch(() => null); if (cancelled) { return; } - const currentUser = result.data?.currentUser; const refreshedSubscriptionStatus = - currentUser?.currentWorkspace?.currentBillingSubscription?.status; + refreshedWorkspace?.currentBillingSubscription?.status; - if (isDefined(currentUser) && isDefined(refreshedSubscriptionStatus)) { - setCurrentUser(currentUser); + if (isDefined(refreshedSubscriptionStatus)) { showWelcomeAnimationAfterOnboardingCheckout(); return; } @@ -93,7 +80,7 @@ export const PaymentSuccess = () => { } }; // oxlint-disable-next-line react-hooks/exhaustive-deps - }, [confirmationRunIndex, subscriptionStatus]); + }, [confirmationRunIndex]); const handleRetry = () => { setHasTimedOut(false); diff --git a/packages/twenty-front/src/pages/onboarding/WorkspaceSetup.tsx b/packages/twenty-front/src/pages/onboarding/WorkspaceSetup.tsx new file mode 100644 index 0000000000..e640945a9f --- /dev/null +++ b/packages/twenty-front/src/pages/onboarding/WorkspaceSetup.tsx @@ -0,0 +1,68 @@ +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { Navigate } from 'react-router-dom'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +import { AiChatMessageListPreambleContext } from '@/ai/contexts/AiChatMessageListPreambleContext'; +import { AiChatTab } from '@/ai/components/AiChatTab'; +import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath'; +import { WorkspaceSetupChatPreamble } from '@/onboarding/components/WorkspaceSetupChatPreamble'; +import { WorkspaceSetupHeader } from '@/onboarding/components/WorkspaceSetupHeader'; +import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { FeatureFlagKey } from '~/generated-metadata/graphql'; + +const PANEL_CORNER_RADIUS_DERIVED_FROM_THEME_SCALE = `calc(${themeCssVariables.border.radius.md} + ${themeCssVariables.spacing[1]})`; + +const StyledPanel = styled.div` + background: ${themeCssVariables.background.primary}; + border-left: 1px solid ${themeCssVariables.border.color.medium}; + border-radius: ${PANEL_CORNER_RADIUS_DERIVED_FROM_THEME_SCALE} 0 0 + ${PANEL_CORNER_RADIUS_DERIVED_FROM_THEME_SCALE}; + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + overflow: hidden; +`; + +const StyledContent = styled.div` + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow-x: clip; + width: 100%; +`; + +export const WorkspaceSetup = () => { + const { t } = useLingui(); + const { defaultHomePagePath } = useDefaultHomePagePath(); + const isOnboardingAiChatEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_ONBOARDING_AI_CHAT_ENABLED, + ); + const shouldOpenAiChatAfterOnboarding = useAtomStateValue( + shouldOpenAiChatAfterOnboardingState, + ); + + if (!isOnboardingAiChatEnabled) { + return ; + } + + const title = shouldOpenAiChatAfterOnboarding ? t`Onboarding` : t`Ask AI`; + const preamble = shouldOpenAiChatAfterOnboarding ? ( + + ) : null; + + return ( + + + + + + + + + ); +}; diff --git a/packages/twenty-front/src/pages/onboarding/__tests__/WorkspaceSetup.test.tsx b/packages/twenty-front/src/pages/onboarding/__tests__/WorkspaceSetup.test.tsx new file mode 100644 index 0000000000..59b3998244 --- /dev/null +++ b/packages/twenty-front/src/pages/onboarding/__tests__/WorkspaceSetup.test.tsx @@ -0,0 +1,113 @@ +import { i18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { render } from '@testing-library/react'; +import { Provider as JotaiProvider } from 'jotai'; +import { type ReactNode } from 'react'; +import { SOURCE_LOCALE } from 'twenty-shared/translations'; + +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState'; +import { + jotaiStore, + resetJotaiStore, +} from '@/ui/utilities/state/jotai/jotaiStore'; +import { FeatureFlagKey } from '~/generated-metadata/graphql'; +import { messages } from '~/locales/generated/en'; +import { WorkspaceSetup } from '~/pages/onboarding/WorkspaceSetup'; +import { mockCurrentWorkspace } from '~/testing/mock-data/users'; + +i18n.load({ [SOURCE_LOCALE]: messages }); +i18n.activate(SOURCE_LOCALE); + +const defaultHomePagePath = '/objects/companies'; + +jest.mock('@/navigation/hooks/useDefaultHomePagePath', () => ({ + useDefaultHomePagePath: () => ({ defaultHomePagePath }), +})); + +jest.mock('@/ai/components/AiChatTab', () => { + const { useContext } = jest.requireActual('react'); + const { AiChatMessageListPreambleContext } = jest.requireActual( + '@/ai/contexts/AiChatMessageListPreambleContext', + ); + return { + AiChatTab: () => ( +
+ {useContext(AiChatMessageListPreambleContext)} +
+ ), + }; +}); + +jest.mock('@/onboarding/components/WorkspaceSetupHeader', () => ({ + WorkspaceSetupHeader: ({ title }: { title: string }) => ( +
{title}
+ ), +})); + +jest.mock('@/onboarding/components/WorkspaceSetupChatPreamble', () => ({ + WorkspaceSetupChatPreamble: () =>
, +})); + +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + Navigate: (props: { to: string }) => { + mockNavigate(props.to); + return
; + }, +})); + +const setOnboardingAiChatFeatureFlag = (value: boolean) => { + jotaiStore.set(currentWorkspaceState.atom, { + ...mockCurrentWorkspace, + featureFlags: [ + { key: FeatureFlagKey.IS_ONBOARDING_AI_CHAT_ENABLED, value }, + ], + }); +}; + +const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +describe('WorkspaceSetup', () => { + beforeEach(() => { + sessionStorage.clear(); + resetJotaiStore(); + mockNavigate.mockClear(); + }); + + it('should dress the chat for onboarding when the post-onboarding hint is set', () => { + setOnboardingAiChatFeatureFlag(true); + jotaiStore.set(shouldOpenAiChatAfterOnboardingState.atom, true); + + const { getByTestId } = render(, { wrapper: Wrapper }); + + expect(getByTestId('ai-chat-tab')).toBeInTheDocument(); + expect(getByTestId('preamble')).toBeInTheDocument(); + expect(getByTestId('header-title')).toHaveTextContent('Onboarding'); + }); + + it('should render a plain chat when the post-onboarding hint is not set', () => { + setOnboardingAiChatFeatureFlag(true); + + const { getByTestId, queryByTestId } = render(, { + wrapper: Wrapper, + }); + + expect(getByTestId('ai-chat-tab')).toBeInTheDocument(); + expect(queryByTestId('preamble')).not.toBeInTheDocument(); + expect(getByTestId('header-title')).toHaveTextContent('Ask AI'); + }); + + it('should redirect home when the onboarding ai chat feature flag is disabled', () => { + setOnboardingAiChatFeatureFlag(false); + + const { queryByTestId } = render(, { wrapper: Wrapper }); + + expect(queryByTestId('ai-chat-tab')).not.toBeInTheDocument(); + expect(mockNavigate).toHaveBeenCalledWith(defaultHomePagePath); + }); +}); diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index 0c66c3bb01..4b4de3dc59 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -247,6 +247,7 @@ describe('WorkspaceEntityManager', () => { IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED: false, IS_SETTINGS_DISCOVERY_HERO_ENABLED: false, IS_WORKFLOW_VERSION_IN_CORE_ENABLED: false, + IS_ONBOARDING_AI_CHAT_ENABLED: false, }, userWorkspaceRoleMap: {}, apiKeyRoleMap: {}, diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts index 13f94e9218..88fa3e7399 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts @@ -50,6 +50,11 @@ export const seedFeatureFlags = async ({ workspaceId: workspaceId, value: false, }, + { + key: FeatureFlagKey.IS_ONBOARDING_AI_CHAT_ENABLED, + workspaceId: workspaceId, + value: false, + }, ]) .execute(); }; diff --git a/packages/twenty-shared/src/types/AppPath.ts b/packages/twenty-shared/src/types/AppPath.ts index e407e178ab..561eb7a974 100644 --- a/packages/twenty-shared/src/types/AppPath.ts +++ b/packages/twenty-shared/src/types/AppPath.ts @@ -17,6 +17,7 @@ export enum AppPath { BookCall = '/book-call', // Onboarded + WorkspaceSetup = '/workspace-setup', Index = '/', TasksPage = '/objects/tasks', OpportunitiesPage = '/objects/opportunities', diff --git a/packages/twenty-shared/src/types/FeatureFlagKey.ts b/packages/twenty-shared/src/types/FeatureFlagKey.ts index 2e441a870f..3ccc7d2707 100644 --- a/packages/twenty-shared/src/types/FeatureFlagKey.ts +++ b/packages/twenty-shared/src/types/FeatureFlagKey.ts @@ -7,6 +7,7 @@ export enum FeatureFlagKey { IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED', IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT', IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED = 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED', + IS_ONBOARDING_AI_CHAT_ENABLED = 'IS_ONBOARDING_AI_CHAT_ENABLED', IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED', IS_WORKFLOW_VERSION_IN_CORE_ENABLED = 'IS_WORKFLOW_VERSION_IN_CORE_ENABLED', }