From f5a9adcb76aa8dbefcd871ed661913d4a36fd046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:16:11 +0200 Subject: [PATCH] Add post-onboarding AI chat setup behind a feature flag (#23120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/user-attachments/assets/fec7076f-4e46-4c39-84d7-68e4340244ac After finishing onboarding, users now land in a full-screen AI chat that helps them set up their workspace, instead of going straight to their default view. The welcome overlay's title flies into the chat's first message so the handoff reads as one continuous motion: the slide plays alone, the title swaps in place pixel-exactly (a regular-weight clone of the target line is crossfaded in mid-flight to morph the font weight), then the rest of the text fades in. All of it sits behind `IS_ONBOARDING_AI_CHAT_ENABLED` (default off, not registered as a public flag). With the flag off, onboarding behaves exactly as it does today — the welcome overlay still plays and the user lands on their home view. Layout follows the Figma: the nav drawer stays visible and the chat renders in a panel-styled container with an "Onboarding" header, matching the expanded side panel. Also fixes two pre-existing bugs the feature surfaced: - On billing instances the completion redirect raced the lazy `PaymentSuccess` page, which silently skipped the welcome animation on the no-card trial path. The redirect now defers while a checkout is pending, and `PaymentSuccess` always confirms through `useLoadCurrentUser` so freshly served feature flags are respected. - `useDefaultHomePagePath` could conclude its `/settings/profile` empty-workspace fallback from a transiently empty metadata store and strand the user there; it now waits for both object metadata and navigation menu items before deciding. Reviewer notes: - `AgentChatRuntimeEffects` no longer keys off side-panel state, so `modules/ai` stops importing `modules/side-panel`. The two visibility-scoped effects moved into `AiChatTab`. - `/workspace-setup` is deliberately URL-addressable rather than onboarding-only: the collapse control in the header is a general expand/collapse toggle (paired with a new expand button in the side panel top bar), and gating the route would break refresh and browser-back. It is still authenticated-only. - The design's second, LLM-authored paragraph is not implemented — starting an assistant turn with no user message needs server-side work. Review in cubic --- .../src/metadata/generated/schema.graphql | 1 + .../src/metadata/generated/schema.ts | 3 +- .../src/generated-admin/graphql.ts | 1 + .../src/generated-metadata/graphql.ts | 1 + ...sePageChangeEffectNavigateLocation.test.ts | 38 +++- .../usePageChangeEffectNavigateLocation.ts | 22 +- .../AgentChatHasBeenOpenedEffect.tsx | 16 ++ .../ai/components/AgentChatRuntimeEffects.tsx | 29 +-- .../ai/components/AiChatCloseButton.tsx | 28 +++ .../ai/components/AiChatCollapseButton.tsx | 28 +++ .../src/modules/ai/components/AiChatTab.tsx | 6 + .../ai/components/AiChatTabMessageList.tsx | 22 +- .../AgentChatRuntimeEffects.test.tsx | 60 +++++ .../__tests__/AiChatTabMessageList.test.tsx | 91 ++++++++ .../AiChatMessageListPreambleContext.ts | 3 + .../ai/hooks/useReturnFromExpandedAiChat.ts | 44 ++++ .../aiChatExpandedReturnLocationState.ts | 8 + .../effect-components/PageChangeEffect.tsx | 9 + .../app/hooks/useCreateWorkspaceAppRouter.tsx | 15 ++ .../__tests__/isValidReturnToPath.test.ts | 4 + .../__tests__/useDefaultHomePagePath.test.ts | 13 ++ .../hooks/useDefaultHomePagePath.ts | 23 +- .../WelcomeAnimationAutoLeaveEffect.tsx | 58 +++-- .../WelcomeAnimationForcedTeardownEffect.tsx | 36 +++ .../WelcomeOverlay/WelcomeOverlay.tsx | 215 ++++++++++++++---- .../WelcomeOverlay/WelcomePersonChip.tsx | 35 ++- .../WelcomeAnimationAutoLeaveEffect.test.tsx | 88 +++++++ .../__tests__/WelcomeOverlay.test.tsx | 15 +- .../components/WorkspaceSetupChatPreamble.tsx | 101 ++++++++ .../components/WorkspaceSetupHeader.tsx | 47 ++++ .../WorkspaceSetupChatPreamble.test.tsx | 46 ++++ .../WelcomeTitleHandoffTargetElementId.ts | 2 + .../constants/WelcomeTitleMessage.ts | 3 + .../constants/WelcomeTitleSourceElementId.ts | 1 + .../useSetNextOnboardingStatus.test.ts | 162 +++++++++---- .../hooks/useSetNextOnboardingStatus.ts | 19 +- ...WelcomeAnimationAfterOnboardingCheckout.ts | 14 +- .../states/isWelcomeAnimationLeavingState.ts | 6 + .../shouldOpenAiChatAfterOnboardingState.ts | 7 + .../states/welcomeTitleFlightState.ts | 8 + .../onboarding/types/WelcomeTitleFlight.ts | 6 + .../__tests__/getWelcomeTitleFlight.test.ts | 80 +++++++ .../measureWelcomeTitleFlight.test.ts | 68 ++++++ .../onboarding/utils/getWelcomeTitleFlight.ts | 28 +++ .../utils/measureWelcomeTitleFlight.ts | 44 ++++ .../SidePanelExpandAiChatButton.tsx | 50 ++++ .../side-panel/components/SidePanelTopBar.tsx | 2 + .../__tests__/SidePanelTopBar.test.tsx | 4 + .../hooks/useOpenAskAiPageInSidePanel.ts | 9 +- .../src/pages/onboarding/PaymentSuccess.tsx | 29 +-- .../src/pages/onboarding/WorkspaceSetup.tsx | 68 ++++++ .../__tests__/WorkspaceSetup.test.tsx | 113 +++++++++ .../workspace-entity-manager.spec.ts | 1 + .../core/utils/seed-feature-flags.util.ts | 5 + packages/twenty-shared/src/types/AppPath.ts | 1 + .../twenty-shared/src/types/FeatureFlagKey.ts | 1 + 56 files changed, 1646 insertions(+), 191 deletions(-) create mode 100644 packages/twenty-front/src/modules/ai/components/AgentChatHasBeenOpenedEffect.tsx create mode 100644 packages/twenty-front/src/modules/ai/components/AiChatCloseButton.tsx create mode 100644 packages/twenty-front/src/modules/ai/components/AiChatCollapseButton.tsx create mode 100644 packages/twenty-front/src/modules/ai/components/__tests__/AgentChatRuntimeEffects.test.tsx create mode 100644 packages/twenty-front/src/modules/ai/components/__tests__/AiChatTabMessageList.test.tsx create mode 100644 packages/twenty-front/src/modules/ai/contexts/AiChatMessageListPreambleContext.ts create mode 100644 packages/twenty-front/src/modules/ai/hooks/useReturnFromExpandedAiChat.ts create mode 100644 packages/twenty-front/src/modules/ai/states/aiChatExpandedReturnLocationState.ts create mode 100644 packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomeAnimationForcedTeardownEffect.tsx create mode 100644 packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/__tests__/WelcomeAnimationAutoLeaveEffect.test.tsx create mode 100644 packages/twenty-front/src/modules/onboarding/components/WorkspaceSetupChatPreamble.tsx create mode 100644 packages/twenty-front/src/modules/onboarding/components/WorkspaceSetupHeader.tsx create mode 100644 packages/twenty-front/src/modules/onboarding/components/__tests__/WorkspaceSetupChatPreamble.test.tsx create mode 100644 packages/twenty-front/src/modules/onboarding/constants/WelcomeTitleHandoffTargetElementId.ts create mode 100644 packages/twenty-front/src/modules/onboarding/constants/WelcomeTitleMessage.ts create mode 100644 packages/twenty-front/src/modules/onboarding/constants/WelcomeTitleSourceElementId.ts create mode 100644 packages/twenty-front/src/modules/onboarding/states/isWelcomeAnimationLeavingState.ts create mode 100644 packages/twenty-front/src/modules/onboarding/states/shouldOpenAiChatAfterOnboardingState.ts create mode 100644 packages/twenty-front/src/modules/onboarding/states/welcomeTitleFlightState.ts create mode 100644 packages/twenty-front/src/modules/onboarding/types/WelcomeTitleFlight.ts create mode 100644 packages/twenty-front/src/modules/onboarding/utils/__tests__/getWelcomeTitleFlight.test.ts create mode 100644 packages/twenty-front/src/modules/onboarding/utils/__tests__/measureWelcomeTitleFlight.test.ts create mode 100644 packages/twenty-front/src/modules/onboarding/utils/getWelcomeTitleFlight.ts create mode 100644 packages/twenty-front/src/modules/onboarding/utils/measureWelcomeTitleFlight.ts create mode 100644 packages/twenty-front/src/modules/side-panel/components/SidePanelExpandAiChatButton.tsx create mode 100644 packages/twenty-front/src/pages/onboarding/WorkspaceSetup.tsx create mode 100644 packages/twenty-front/src/pages/onboarding/__tests__/WorkspaceSetup.test.tsx 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', }