({
+ useOpenAskAiPageInSidePanel: () => ({ openAskAiPage: openAskAiPageMock }),
+}));
+
+jest.mock('framer-motion', () => ({
+ useReducedMotion: () => false,
+}));
+
+let navigateAwayFromWorkspaceSetup: (() => void) | undefined;
+
+const WorkspaceSetupRoute = () => {
+ const navigate = useNavigate();
+
+ navigateAwayFromWorkspaceSetup = () => navigate('/objects/companies');
+
+ return ;
+};
+
+const SidePanelRoute = () => {
+ const shouldShrinkFromFullWidth = useShouldShrinkSidePanelFromFullWidth();
+
+ return (
+ <>
+
+ {String(shouldShrinkFromFullWidth)}
+ >
+ );
+};
+
+const RouterUnderTest = () => (
+
+
+
+ } />
+ } />
+
+
+
+);
+
+describe('SidePanelAskAiHandoffEffect', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ sessionStorage.clear();
+ resetJotaiStore();
+ navigateAwayFromWorkspaceSetup = undefined;
+ });
+
+ it('should consume the marker and open the ask ai page when the workspace setup page unmounts in the same commit', () => {
+ jotaiStore.set(shouldOpenAiChatAfterOnboardingState.atom, true);
+ jotaiStore.set(aiChatExpandedReturnLocationState.atom, '/objects/people');
+
+ const { getByTestId } = render();
+
+ expect(jotaiStore.get(shouldContinueAiChatInSidePanelState.atom)).toBe(
+ true,
+ );
+
+ act(() => {
+ navigateAwayFromWorkspaceSetup?.();
+ });
+
+ expect(openAskAiPageMock).toHaveBeenCalledWith({
+ resetNavigationStack: true,
+ });
+ expect(getByTestId('side-panel')).toHaveTextContent('true');
+ expect(jotaiStore.get(shouldContinueAiChatInSidePanelState.atom)).toBe(
+ false,
+ );
+ expect(jotaiStore.get(shouldOpenAiChatAfterOnboardingState.atom)).toBe(
+ false,
+ );
+ expect(jotaiStore.get(aiChatExpandedReturnLocationState.atom)).toBeNull();
+ });
+
+ it('should do nothing when the marker is not set', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(openAskAiPageMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/twenty-front/src/modules/side-panel/components/__tests__/SidePanelForDesktop.test.tsx b/packages/twenty-front/src/modules/side-panel/components/__tests__/SidePanelForDesktop.test.tsx
new file mode 100644
index 0000000000..26bbb6f7ba
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/components/__tests__/SidePanelForDesktop.test.tsx
@@ -0,0 +1,164 @@
+import { act, fireEvent, render } from '@testing-library/react';
+import { Provider as JotaiProvider } from 'jotai';
+import { type ReactNode } from 'react';
+
+import { SidePanelForDesktop } from '@/side-panel/components/SidePanelForDesktop';
+import { isSidePanelClosingState } from '@/side-panel/states/isSidePanelClosingState';
+import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
+import {
+ jotaiStore,
+ resetJotaiStore,
+} from '@/ui/utilities/state/jotai/jotaiStore';
+
+const shouldShrinkFromFullWidthMock = jest.fn();
+const sidePanelCloseAnimationCompleteCleanupMock = jest.fn();
+
+jest.mock('@/side-panel/hooks/useShouldShrinkSidePanelFromFullWidth', () => ({
+ useShouldShrinkSidePanelFromFullWidth: () => shouldShrinkFromFullWidthMock(),
+}));
+
+jest.mock('@/side-panel/components/SidePanelAskAiHandoffEffect', () => ({
+ SidePanelAskAiHandoffEffect: () => null,
+}));
+
+jest.mock('@/side-panel/components/SidePanelRouter', () => ({
+ SidePanelRouter: () => ,
+}));
+
+jest.mock('@/side-panel/components/SidePanelWidthEffect', () => ({
+ SidePanelWidthEffect: () => null,
+}));
+
+jest.mock('@/ui/layout/resizable-panel/components/ResizablePanelGap', () => ({
+ ResizablePanelGap: () => null,
+}));
+
+jest.mock(
+ '@/side-panel/hooks/useSidePanelCloseAnimationCompleteCleanup',
+ () => ({
+ useSidePanelCloseAnimationCompleteCleanup: () => ({
+ sidePanelCloseAnimationCompleteCleanup:
+ sidePanelCloseAnimationCompleteCleanupMock,
+ }),
+ }),
+);
+
+jest.mock('@/side-panel/hooks/useSidePanelMenu', () => ({
+ useSidePanelMenu: () => ({ closeSidePanelMenu: jest.fn() }),
+}));
+
+const Wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+);
+
+describe('SidePanelForDesktop', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ resetJotaiStore();
+ shouldShrinkFromFullWidthMock.mockReturnValue(false);
+ sidePanelCloseAnimationCompleteCleanupMock.mockImplementation(() => {
+ jotaiStore.set(isSidePanelClosingState.atom, false);
+ });
+ });
+
+ it('should keep the content mounted while closing after a handoff entrance', () => {
+ shouldShrinkFromFullWidthMock.mockReturnValue(true);
+ jotaiStore.set(isSidePanelOpenedState.atom, true);
+
+ const { queryByTestId } = render(, {
+ wrapper: Wrapper,
+ });
+
+ expect(queryByTestId('side-panel-content')).toBeInTheDocument();
+
+ act(() => {
+ jotaiStore.set(isSidePanelOpenedState.atom, false);
+ });
+
+ expect(queryByTestId('side-panel-content')).toBeInTheDocument();
+ });
+
+ it('should stop shrinking from full width once the entrance animation ends', () => {
+ shouldShrinkFromFullWidthMock.mockReturnValue(true);
+ jotaiStore.set(isSidePanelOpenedState.atom, true);
+
+ const { container } = render(, { wrapper: Wrapper });
+
+ const wrapperElement = container.querySelector('[data-side-panel]');
+
+ if (wrapperElement === null) {
+ throw new Error('side panel wrapper not found');
+ }
+
+ expect(wrapperElement).toHaveAttribute(
+ 'data-shrink-from-full-width',
+ 'true',
+ );
+
+ fireEvent.animationEnd(wrapperElement);
+
+ expect(wrapperElement).toHaveAttribute(
+ 'data-shrink-from-full-width',
+ 'false',
+ );
+ });
+
+ it('should complete the close lifecycle when closed while the entrance animation is still running', () => {
+ shouldShrinkFromFullWidthMock.mockReturnValue(true);
+ jotaiStore.set(isSidePanelOpenedState.atom, true);
+
+ const { container, queryByTestId } = render(, {
+ wrapper: Wrapper,
+ });
+
+ const wrapperElement = container.querySelector('[data-side-panel]');
+
+ if (wrapperElement === null) {
+ throw new Error('side panel wrapper not found');
+ }
+
+ act(() => {
+ jotaiStore.set(isSidePanelOpenedState.atom, false);
+ jotaiStore.set(isSidePanelClosingState.atom, true);
+ });
+
+ fireEvent.animationEnd(wrapperElement);
+
+ expect(sidePanelCloseAnimationCompleteCleanupMock).toHaveBeenCalled();
+ expect(queryByTestId('side-panel-content')).not.toBeInTheDocument();
+ });
+
+ it('should run the close cleanup once when both the animation and the transition end', () => {
+ shouldShrinkFromFullWidthMock.mockReturnValue(true);
+ jotaiStore.set(isSidePanelOpenedState.atom, true);
+
+ const { container } = render(, { wrapper: Wrapper });
+
+ const wrapperElement = container.querySelector('[data-side-panel]');
+
+ if (wrapperElement === null) {
+ throw new Error('side panel wrapper not found');
+ }
+
+ act(() => {
+ jotaiStore.set(isSidePanelOpenedState.atom, false);
+ jotaiStore.set(isSidePanelClosingState.atom, true);
+ });
+
+ fireEvent.animationEnd(wrapperElement);
+ fireEvent.transitionEnd(wrapperElement);
+
+ expect(sidePanelCloseAnimationCompleteCleanupMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not shrink from full width on a normal open', () => {
+ jotaiStore.set(isSidePanelOpenedState.atom, true);
+
+ const { container } = render(, { wrapper: Wrapper });
+
+ expect(container.querySelector('[data-side-panel]')).toHaveAttribute(
+ 'data-shrink-from-full-width',
+ 'false',
+ );
+ });
+});
diff --git a/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useShouldShrinkSidePanelFromFullWidth.test.tsx b/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useShouldShrinkSidePanelFromFullWidth.test.tsx
new file mode 100644
index 0000000000..6ef0984d3f
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useShouldShrinkSidePanelFromFullWidth.test.tsx
@@ -0,0 +1,72 @@
+import { renderHook } from '@testing-library/react';
+import { Provider as JotaiProvider } from 'jotai';
+import { type ReactNode } from 'react';
+
+import { shouldContinueAiChatInSidePanelState } from '@/ai/states/shouldContinueAiChatInSidePanelState';
+import { useShouldShrinkSidePanelFromFullWidth } from '@/side-panel/hooks/useShouldShrinkSidePanelFromFullWidth';
+import {
+ jotaiStore,
+ resetJotaiStore,
+} from '@/ui/utilities/state/jotai/jotaiStore';
+
+const useReducedMotionMock = jest.fn();
+
+jest.mock('framer-motion', () => ({
+ useReducedMotion: () => useReducedMotionMock(),
+}));
+
+const Wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+);
+
+describe('useShouldShrinkSidePanelFromFullWidth', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ resetJotaiStore();
+ useReducedMotionMock.mockReturnValue(false);
+ });
+
+ it('should shrink from full width when arriving from the workspace setup page', () => {
+ jotaiStore.set(shouldContinueAiChatInSidePanelState.atom, true);
+
+ const { result } = renderHook(
+ () => useShouldShrinkSidePanelFromFullWidth(),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current).toBe(true);
+ });
+
+ it('should keep the marker untouched so the handoff effect can consume it', () => {
+ jotaiStore.set(shouldContinueAiChatInSidePanelState.atom, true);
+
+ renderHook(() => useShouldShrinkSidePanelFromFullWidth(), {
+ wrapper: Wrapper,
+ });
+
+ expect(jotaiStore.get(shouldContinueAiChatInSidePanelState.atom)).toBe(
+ true,
+ );
+ });
+
+ it('should not shrink from full width when not arriving from the workspace setup page', () => {
+ const { result } = renderHook(
+ () => useShouldShrinkSidePanelFromFullWidth(),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current).toBe(false);
+ });
+
+ it('should not shrink from full width when reduced motion is preferred', () => {
+ useReducedMotionMock.mockReturnValue(true);
+ jotaiStore.set(shouldContinueAiChatInSidePanelState.atom, true);
+
+ const { result } = renderHook(
+ () => useShouldShrinkSidePanelFromFullWidth(),
+ { wrapper: Wrapper },
+ );
+
+ expect(result.current).toBe(false);
+ });
+});
diff --git a/packages/twenty-front/src/modules/side-panel/hooks/useShouldShrinkSidePanelFromFullWidth.ts b/packages/twenty-front/src/modules/side-panel/hooks/useShouldShrinkSidePanelFromFullWidth.ts
new file mode 100644
index 0000000000..3f9b80af46
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/hooks/useShouldShrinkSidePanelFromFullWidth.ts
@@ -0,0 +1,16 @@
+import { useReducedMotion } from 'framer-motion';
+import { useStore } from 'jotai';
+import { useState } from 'react';
+
+import { shouldContinueAiChatInSidePanelState } from '@/ai/states/shouldContinueAiChatInSidePanelState';
+
+export const useShouldShrinkSidePanelFromFullWidth = () => {
+ const store = useStore();
+ const shouldReduceMotion = useReducedMotion();
+
+ const [isContinuingChatFromWorkspaceSetup] = useState(() =>
+ store.get(shouldContinueAiChatInSidePanelState.atom),
+ );
+
+ return isContinuingChatFromWorkspaceSetup && !shouldReduceMotion;
+};
diff --git a/packages/twenty-front/src/pages/onboarding/WorkspaceSetup.tsx b/packages/twenty-front/src/pages/onboarding/WorkspaceSetup.tsx
index bd7e863181..dae2ebc13c 100644
--- a/packages/twenty-front/src/pages/onboarding/WorkspaceSetup.tsx
+++ b/packages/twenty-front/src/pages/onboarding/WorkspaceSetup.tsx
@@ -10,6 +10,7 @@ import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePat
import { WorkspaceSetupChatPreamble } from '@/onboarding/components/WorkspaceSetupChatPreamble';
import { WorkspaceSetupHeader } from '@/onboarding/components/WorkspaceSetupHeader';
import { WorkspaceSetupChatKickoffEffect } from '@/onboarding/effect-components/WorkspaceSetupChatKickoffEffect';
+import { WorkspaceSetupChatSidePanelHandoffEffect } from '@/onboarding/effect-components/WorkspaceSetupChatSidePanelHandoffEffect';
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -59,6 +60,7 @@ export const WorkspaceSetup = () => {
+
{shouldOpenAiChatAfterOnboarding && }
diff --git a/packages/twenty-front/src/pages/onboarding/__tests__/WorkspaceSetup.test.tsx b/packages/twenty-front/src/pages/onboarding/__tests__/WorkspaceSetup.test.tsx
index 393abf9dd3..8a768e9262 100644
--- a/packages/twenty-front/src/pages/onboarding/__tests__/WorkspaceSetup.test.tsx
+++ b/packages/twenty-front/src/pages/onboarding/__tests__/WorkspaceSetup.test.tsx
@@ -5,6 +5,7 @@ import { Provider as JotaiProvider } from 'jotai';
import { type ReactNode } from 'react';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
+import { shouldContinueAiChatInSidePanelState } from '@/ai/states/shouldContinueAiChatInSidePanelState';
import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboardingAiChatEnabledState';
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
import {
@@ -121,6 +122,32 @@ describe('WorkspaceSetup', () => {
expect(queryByTestId('chat-kickoff-effect')).not.toBeInTheDocument();
});
+ it('should mark the chat for side panel continuation while mounted', () => {
+ setIsOnboardingAiChatEnabled(true);
+
+ const { unmount } = render(, { wrapper: Wrapper });
+
+ expect(jotaiStore.get(shouldContinueAiChatInSidePanelState.atom)).toBe(
+ true,
+ );
+
+ unmount();
+
+ expect(jotaiStore.get(shouldContinueAiChatInSidePanelState.atom)).toBe(
+ false,
+ );
+ });
+
+ it('should not mark the chat for side panel continuation when the onboarding ai chat is disabled', () => {
+ setIsOnboardingAiChatEnabled(false);
+
+ render(, { wrapper: Wrapper });
+
+ expect(jotaiStore.get(shouldContinueAiChatInSidePanelState.atom)).toBe(
+ false,
+ );
+ });
+
it('should redirect home when the onboarding ai chat is disabled', () => {
setIsOnboardingAiChatEnabled(false);