diff --git a/packages/twenty-front/src/modules/ai/hooks/__tests__/useReturnFromExpandedAiChat.test.tsx b/packages/twenty-front/src/modules/ai/hooks/__tests__/useReturnFromExpandedAiChat.test.tsx new file mode 100644 index 0000000000..21db51822c --- /dev/null +++ b/packages/twenty-front/src/modules/ai/hooks/__tests__/useReturnFromExpandedAiChat.test.tsx @@ -0,0 +1,95 @@ +import { act, renderHook } from '@testing-library/react'; +import { Provider as JotaiProvider } from 'jotai'; +import { type ReactNode } from 'react'; + +import { useReturnFromExpandedAiChat } from '@/ai/hooks/useReturnFromExpandedAiChat'; +import { aiChatExpandedReturnLocationState } from '@/ai/states/aiChatExpandedReturnLocationState'; +import { shouldContinueAiChatInSidePanelState } from '@/ai/states/shouldContinueAiChatInSidePanelState'; +import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState'; +import { + jotaiStore, + resetJotaiStore, +} from '@/ui/utilities/state/jotai/jotaiStore'; + +const navigateMock = jest.fn(); + +jest.mock('react-router-dom', () => ({ + useNavigate: () => navigateMock, +})); + +const defaultHomePagePath = '/objects/companies'; + +jest.mock('@/navigation/hooks/useDefaultHomePagePath', () => ({ + useDefaultHomePagePath: () => ({ defaultHomePagePath }), +})); + +const openAskAiPageMock = jest.fn(); + +jest.mock('@/side-panel/hooks/useOpenAskAiPageInSidePanel', () => ({ + useOpenAskAiPageInSidePanel: () => ({ openAskAiPage: openAskAiPageMock }), +})); + +const closeSidePanelMenuMock = jest.fn(); + +jest.mock('@/side-panel/hooks/useSidePanelMenu', () => ({ + useSidePanelMenu: () => ({ closeSidePanelMenu: closeSidePanelMenuMock }), +})); + +const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe('useReturnFromExpandedAiChat', () => { + beforeEach(() => { + jest.clearAllMocks(); + sessionStorage.clear(); + resetJotaiStore(); + }); + + it('should reopen the side panel and keep the side panel continuation when collapsing', () => { + jotaiStore.set(shouldContinueAiChatInSidePanelState.atom, true); + jotaiStore.set(aiChatExpandedReturnLocationState.atom, '/objects/people'); + + const { result } = renderHook( + () => useReturnFromExpandedAiChat({ reopenSidePanel: true }), + { wrapper: Wrapper }, + ); + + act(() => { + result.current(); + }); + + expect(openAskAiPageMock).toHaveBeenCalledWith({ + resetNavigationStack: true, + }); + expect(closeSidePanelMenuMock).not.toHaveBeenCalled(); + expect(navigateMock).toHaveBeenCalledWith('/objects/people'); + expect(jotaiStore.get(shouldContinueAiChatInSidePanelState.atom)).toBe( + true, + ); + expect(jotaiStore.get(aiChatExpandedReturnLocationState.atom)).toBeNull(); + expect(jotaiStore.get(shouldOpenAiChatAfterOnboardingState.atom)).toBe( + false, + ); + }); + + it('should cancel the side panel continuation when closing', () => { + jotaiStore.set(shouldContinueAiChatInSidePanelState.atom, true); + + const { result } = renderHook( + () => useReturnFromExpandedAiChat({ reopenSidePanel: false }), + { wrapper: Wrapper }, + ); + + act(() => { + result.current(); + }); + + expect(openAskAiPageMock).not.toHaveBeenCalled(); + expect(closeSidePanelMenuMock).toHaveBeenCalled(); + expect(navigateMock).toHaveBeenCalledWith(defaultHomePagePath); + expect(jotaiStore.get(shouldContinueAiChatInSidePanelState.atom)).toBe( + false, + ); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/hooks/useReturnFromExpandedAiChat.ts b/packages/twenty-front/src/modules/ai/hooks/useReturnFromExpandedAiChat.ts index 5e07b08048..313ee50e9b 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useReturnFromExpandedAiChat.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useReturnFromExpandedAiChat.ts @@ -3,6 +3,7 @@ import { useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { aiChatExpandedReturnLocationState } from '@/ai/states/aiChatExpandedReturnLocationState'; +import { shouldContinueAiChatInSidePanelState } from '@/ai/states/shouldContinueAiChatInSidePanelState'; import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath'; import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState'; import { useOpenAskAiPageInSidePanel } from '@/side-panel/hooks/useOpenAskAiPageInSidePanel'; @@ -25,6 +26,7 @@ export const useReturnFromExpandedAiChat = ({ if (reopenSidePanel) { openAskAiPage({ resetNavigationStack: true }); } else { + store.set(shouldContinueAiChatInSidePanelState.atom, false); void closeSidePanelMenu(); } diff --git a/packages/twenty-front/src/modules/ai/states/shouldContinueAiChatInSidePanelState.ts b/packages/twenty-front/src/modules/ai/states/shouldContinueAiChatInSidePanelState.ts new file mode 100644 index 0000000000..a42c2ecc4c --- /dev/null +++ b/packages/twenty-front/src/modules/ai/states/shouldContinueAiChatInSidePanelState.ts @@ -0,0 +1,6 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const shouldContinueAiChatInSidePanelState = createAtomState({ + key: 'shouldContinueAiChatInSidePanelState', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/onboarding/effect-components/WorkspaceSetupChatSidePanelHandoffEffect.tsx b/packages/twenty-front/src/modules/onboarding/effect-components/WorkspaceSetupChatSidePanelHandoffEffect.tsx new file mode 100644 index 0000000000..0839cc096b --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/effect-components/WorkspaceSetupChatSidePanelHandoffEffect.tsx @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; + +import { shouldContinueAiChatInSidePanelState } from '@/ai/states/shouldContinueAiChatInSidePanelState'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; + +export const WorkspaceSetupChatSidePanelHandoffEffect = () => { + const setShouldContinueAiChatInSidePanel = useSetAtomState( + shouldContinueAiChatInSidePanelState, + ); + + useEffect(() => { + setShouldContinueAiChatInSidePanel(true); + + return () => { + setShouldContinueAiChatInSidePanel(false); + }; + }, [setShouldContinueAiChatInSidePanel]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/side-panel/components/SidePanelAskAiHandoffEffect.tsx b/packages/twenty-front/src/modules/side-panel/components/SidePanelAskAiHandoffEffect.tsx new file mode 100644 index 0000000000..7412e52a05 --- /dev/null +++ b/packages/twenty-front/src/modules/side-panel/components/SidePanelAskAiHandoffEffect.tsx @@ -0,0 +1,26 @@ +import { useStore } from 'jotai'; +import { useLayoutEffect } from 'react'; + +import { aiChatExpandedReturnLocationState } from '@/ai/states/aiChatExpandedReturnLocationState'; +import { shouldContinueAiChatInSidePanelState } from '@/ai/states/shouldContinueAiChatInSidePanelState'; +import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState'; +import { useOpenAskAiPageInSidePanel } from '@/side-panel/hooks/useOpenAskAiPageInSidePanel'; + +export const SidePanelAskAiHandoffEffect = () => { + const store = useStore(); + const { openAskAiPage } = useOpenAskAiPageInSidePanel(); + + useLayoutEffect(() => { + if (!store.get(shouldContinueAiChatInSidePanelState.atom)) { + return; + } + + store.set(shouldContinueAiChatInSidePanelState.atom, false); + store.set(shouldOpenAiChatAfterOnboardingState.atom, false); + store.set(aiChatExpandedReturnLocationState.atom, null); + + openAskAiPage({ resetNavigationStack: true }); + }, [store, openAskAiPage]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/side-panel/components/SidePanelForDesktop.tsx b/packages/twenty-front/src/modules/side-panel/components/SidePanelForDesktop.tsx index 2b8ef964fe..6b93955aa9 100644 --- a/packages/twenty-front/src/modules/side-panel/components/SidePanelForDesktop.tsx +++ b/packages/twenty-front/src/modules/side-panel/components/SidePanelForDesktop.tsx @@ -1,8 +1,10 @@ import { tableWidthResizeIsActiveState } from '@/object-record/record-table/states/tableWidthResizeIsActivedState'; +import { SidePanelAskAiHandoffEffect } from '@/side-panel/components/SidePanelAskAiHandoffEffect'; import { SidePanelRouter } from '@/side-panel/components/SidePanelRouter'; import { SidePanelWidthEffect } from '@/side-panel/components/SidePanelWidthEffect'; import { SIDE_PANEL_CLICK_OUTSIDE_ID } from '@/side-panel/constants/SidePanelClickOutsideId'; import { SIDE_PANEL_CONSTRAINTS } from '@/side-panel/constants/SidePanelConstraints'; +import { useShouldShrinkSidePanelFromFullWidth } from '@/side-panel/hooks/useShouldShrinkSidePanelFromFullWidth'; import { useSidePanelCloseAnimationCompleteCleanup } from '@/side-panel/hooks/useSidePanelCloseAnimationCompleteCleanup'; import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; import { isSidePanelClosingState } from '@/side-panel/states/isSidePanelClosingState'; @@ -18,7 +20,8 @@ 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'; import { styled } from '@linaria/react'; -import { useCallback, useState } from 'react'; +import { useStore } from 'jotai'; +import { type AnimationEvent, useCallback, useState } from 'react'; import { themeCssVariables } from 'twenty-ui/theme-constants'; const StyledSidePanelWrapper = styled.div<{ @@ -33,9 +36,20 @@ const StyledSidePanelWrapper = styled.div<{ ? 'none' : `width calc(${themeCssVariables.animation.duration.normal} * 1s)`}; width: ${({ isOpen }) => (isOpen ? `var(${SIDE_PANEL_WIDTH_VAR})` : '0px')}; + + @keyframes sidePanelShrinkFromFullWidth { + from { + width: 100%; + } + } + + &[data-shrink-from-full-width='true'] { + animation: sidePanelShrinkFromFullWidth + calc(${themeCssVariables.animation.duration.normal} * 1s); + } `; -const StyledSidePanel = styled.aside` +const StyledSidePanel = styled.aside<{ isShrinkingFromFullWidth: boolean }>` background: ${themeCssVariables.background.primary}; border-left: 1px solid ${themeCssVariables.border.color.medium}; box-sizing: border-box; @@ -44,7 +58,8 @@ const StyledSidePanel = styled.aside` height: 100%; overflow: hidden; position: relative; - width: var(${SIDE_PANEL_WIDTH_VAR}); + width: ${({ isShrinkingFromFullWidth }) => + isShrinkingFromFullWidth ? '100%' : `var(${SIDE_PANEL_WIDTH_VAR})`}; `; const StyledModalContainer = styled.div` @@ -58,12 +73,13 @@ const StyledModalContainer = styled.div` `; export const SidePanelForDesktop = () => { + const store = useStore(); const isSidePanelOpened = useAtomStateValue(isSidePanelOpenedState); - const isSidePanelClosing = useAtomStateValue(isSidePanelClosingState); const [sidePanelWidth, setSidePanelWidth] = useAtomState(sidePanelWidthState); const { closeSidePanelMenu } = useSidePanelMenu(); const { sidePanelCloseAnimationCompleteCleanup } = useSidePanelCloseAnimationCompleteCleanup(); + const shouldShrinkFromFullWidth = useShouldShrinkSidePanelFromFullWidth(); const [modalContainer, setModalContainer] = useState( null, @@ -71,6 +87,9 @@ export const SidePanelForDesktop = () => { const [isResizing, setIsResizing] = useState(false); const [shouldRenderContent, setShouldRenderContent] = useState(isSidePanelOpened); + const [isShrinkingFromFullWidth, setIsShrinkingFromFullWidth] = useState( + shouldShrinkFromFullWidth, + ); const setTableWidthResizeIsActive = useSetAtomState( tableWidthResizeIsActiveState, @@ -78,17 +97,29 @@ export const SidePanelForDesktop = () => { const shouldShowContent = isSidePanelOpened || shouldRenderContent; + if (isSidePanelOpened && !shouldRenderContent) { + setShouldRenderContent(true); + } + const handleTransitionEnd = () => { if (isSidePanelOpened) { - // Open animation completed - ensure content persists for close animation - setShouldRenderContent(true); - } else { - // Close animation completed - setShouldRenderContent(false); - if (isSidePanelClosing) { - sidePanelCloseAnimationCompleteCleanup(); - } + return; } + + setShouldRenderContent(false); + + if (store.get(isSidePanelClosingState.atom)) { + sidePanelCloseAnimationCompleteCleanup(); + } + }; + + const handleAnimationEnd = (event: AnimationEvent) => { + if (event.target !== event.currentTarget) { + return; + } + + setIsShrinkingFromFullWidth(false); + handleTransitionEnd(); }; const handleModalContainerRef = useCallback( @@ -121,6 +152,7 @@ export const SidePanelForDesktop = () => { return ( <> + { isOpen={isSidePanelOpened} isResizing={isResizing} onTransitionEnd={handleTransitionEnd} + onAnimationEnd={handleAnimationEnd} + data-shrink-from-full-width={isShrinkingFromFullWidth} data-side-panel="" data-click-outside-id={SIDE_PANEL_CLICK_OUTSIDE_ID} > - + ({ + 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);