From 7c9ec6a7709c8e33e0449e0c4bb93331fee68927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:09:36 +0200 Subject: [PATCH] Continue the workspace setup chat in the side panel when navigating away (#23744) https://github.com/user-attachments/assets/579a8d41-901e-41c0-85a4-f23e6bc3da8d The /workspace-setup full-page chat shows the nav drawer, so users can navigate away mid-conversation and lose sight of the chat. Leaving the page by any means (drawer link, browser back) now opens the same conversation in the Ask AI side panel, with the full-page chat visually shrinking into the panel via the panel's existing width transition. The page marks a handoff atom while mounted; the side panel consumes it in a mount layout effect (pre-paint, so no flash frame), opens the Ask AI page, and enters at full width before shrinking. The Close button still exits without reopening the panel, the Collapse button keeps its behavior and gains the same animation, and prefers-reduced-motion skips it. Mobile is unchanged since the full-screen panel would cover the destination page. Review in cubic --- .../useReturnFromExpandedAiChat.test.tsx | 95 ++++++++++ .../ai/hooks/useReturnFromExpandedAiChat.ts | 2 + .../shouldContinueAiChatInSidePanelState.ts | 6 + ...rkspaceSetupChatSidePanelHandoffEffect.tsx | 20 +++ .../SidePanelAskAiHandoffEffect.tsx | 26 +++ .../components/SidePanelForDesktop.tsx | 60 +++++-- .../SidePanelAskAiHandoffEffect.test.tsx | 102 +++++++++++ .../__tests__/SidePanelForDesktop.test.tsx | 164 ++++++++++++++++++ ...houldShrinkSidePanelFromFullWidth.test.tsx | 72 ++++++++ .../useShouldShrinkSidePanelFromFullWidth.ts | 16 ++ .../src/pages/onboarding/WorkspaceSetup.tsx | 2 + .../__tests__/WorkspaceSetup.test.tsx | 27 +++ 12 files changed, 579 insertions(+), 13 deletions(-) create mode 100644 packages/twenty-front/src/modules/ai/hooks/__tests__/useReturnFromExpandedAiChat.test.tsx create mode 100644 packages/twenty-front/src/modules/ai/states/shouldContinueAiChatInSidePanelState.ts create mode 100644 packages/twenty-front/src/modules/onboarding/effect-components/WorkspaceSetupChatSidePanelHandoffEffect.tsx create mode 100644 packages/twenty-front/src/modules/side-panel/components/SidePanelAskAiHandoffEffect.tsx create mode 100644 packages/twenty-front/src/modules/side-panel/components/__tests__/SidePanelAskAiHandoffEffect.test.tsx create mode 100644 packages/twenty-front/src/modules/side-panel/components/__tests__/SidePanelForDesktop.test.tsx create mode 100644 packages/twenty-front/src/modules/side-panel/hooks/__tests__/useShouldShrinkSidePanelFromFullWidth.test.tsx create mode 100644 packages/twenty-front/src/modules/side-panel/hooks/useShouldShrinkSidePanelFromFullWidth.ts 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);