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.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23744?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Raphaël Bosi
2026-08-05 10:09:36 +02:00
committed by GitHub
parent 8bfa9c4adb
commit 7c9ec6a770
12 changed files with 579 additions and 13 deletions
@@ -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 }) => (
<JotaiProvider store={jotaiStore}>{children}</JotaiProvider>
);
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,
);
});
});
@@ -3,6 +3,7 @@ import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { aiChatExpandedReturnLocationState } from '@/ai/states/aiChatExpandedReturnLocationState'; import { aiChatExpandedReturnLocationState } from '@/ai/states/aiChatExpandedReturnLocationState';
import { shouldContinueAiChatInSidePanelState } from '@/ai/states/shouldContinueAiChatInSidePanelState';
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath'; import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath';
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState'; import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
import { useOpenAskAiPageInSidePanel } from '@/side-panel/hooks/useOpenAskAiPageInSidePanel'; import { useOpenAskAiPageInSidePanel } from '@/side-panel/hooks/useOpenAskAiPageInSidePanel';
@@ -25,6 +26,7 @@ export const useReturnFromExpandedAiChat = ({
if (reopenSidePanel) { if (reopenSidePanel) {
openAskAiPage({ resetNavigationStack: true }); openAskAiPage({ resetNavigationStack: true });
} else { } else {
store.set(shouldContinueAiChatInSidePanelState.atom, false);
void closeSidePanelMenu(); void closeSidePanelMenu();
} }
@@ -0,0 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const shouldContinueAiChatInSidePanelState = createAtomState<boolean>({
key: 'shouldContinueAiChatInSidePanelState',
defaultValue: false,
});
@@ -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;
};
@@ -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;
};
@@ -1,8 +1,10 @@
import { tableWidthResizeIsActiveState } from '@/object-record/record-table/states/tableWidthResizeIsActivedState'; import { tableWidthResizeIsActiveState } from '@/object-record/record-table/states/tableWidthResizeIsActivedState';
import { SidePanelAskAiHandoffEffect } from '@/side-panel/components/SidePanelAskAiHandoffEffect';
import { SidePanelRouter } from '@/side-panel/components/SidePanelRouter'; import { SidePanelRouter } from '@/side-panel/components/SidePanelRouter';
import { SidePanelWidthEffect } from '@/side-panel/components/SidePanelWidthEffect'; import { SidePanelWidthEffect } from '@/side-panel/components/SidePanelWidthEffect';
import { SIDE_PANEL_CLICK_OUTSIDE_ID } from '@/side-panel/constants/SidePanelClickOutsideId'; import { SIDE_PANEL_CLICK_OUTSIDE_ID } from '@/side-panel/constants/SidePanelClickOutsideId';
import { SIDE_PANEL_CONSTRAINTS } from '@/side-panel/constants/SidePanelConstraints'; import { SIDE_PANEL_CONSTRAINTS } from '@/side-panel/constants/SidePanelConstraints';
import { useShouldShrinkSidePanelFromFullWidth } from '@/side-panel/hooks/useShouldShrinkSidePanelFromFullWidth';
import { useSidePanelCloseAnimationCompleteCleanup } from '@/side-panel/hooks/useSidePanelCloseAnimationCompleteCleanup'; import { useSidePanelCloseAnimationCompleteCleanup } from '@/side-panel/hooks/useSidePanelCloseAnimationCompleteCleanup';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { isSidePanelClosingState } from '@/side-panel/states/isSidePanelClosingState'; 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 { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { styled } from '@linaria/react'; 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'; import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledSidePanelWrapper = styled.div<{ const StyledSidePanelWrapper = styled.div<{
@@ -33,9 +36,20 @@ const StyledSidePanelWrapper = styled.div<{
? 'none' ? 'none'
: `width calc(${themeCssVariables.animation.duration.normal} * 1s)`}; : `width calc(${themeCssVariables.animation.duration.normal} * 1s)`};
width: ${({ isOpen }) => (isOpen ? `var(${SIDE_PANEL_WIDTH_VAR})` : '0px')}; 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}; background: ${themeCssVariables.background.primary};
border-left: 1px solid ${themeCssVariables.border.color.medium}; border-left: 1px solid ${themeCssVariables.border.color.medium};
box-sizing: border-box; box-sizing: border-box;
@@ -44,7 +58,8 @@ const StyledSidePanel = styled.aside`
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
width: var(${SIDE_PANEL_WIDTH_VAR}); width: ${({ isShrinkingFromFullWidth }) =>
isShrinkingFromFullWidth ? '100%' : `var(${SIDE_PANEL_WIDTH_VAR})`};
`; `;
const StyledModalContainer = styled.div` const StyledModalContainer = styled.div`
@@ -58,12 +73,13 @@ const StyledModalContainer = styled.div`
`; `;
export const SidePanelForDesktop = () => { export const SidePanelForDesktop = () => {
const store = useStore();
const isSidePanelOpened = useAtomStateValue(isSidePanelOpenedState); const isSidePanelOpened = useAtomStateValue(isSidePanelOpenedState);
const isSidePanelClosing = useAtomStateValue(isSidePanelClosingState);
const [sidePanelWidth, setSidePanelWidth] = useAtomState(sidePanelWidthState); const [sidePanelWidth, setSidePanelWidth] = useAtomState(sidePanelWidthState);
const { closeSidePanelMenu } = useSidePanelMenu(); const { closeSidePanelMenu } = useSidePanelMenu();
const { sidePanelCloseAnimationCompleteCleanup } = const { sidePanelCloseAnimationCompleteCleanup } =
useSidePanelCloseAnimationCompleteCleanup(); useSidePanelCloseAnimationCompleteCleanup();
const shouldShrinkFromFullWidth = useShouldShrinkSidePanelFromFullWidth();
const [modalContainer, setModalContainer] = useState<HTMLDivElement | null>( const [modalContainer, setModalContainer] = useState<HTMLDivElement | null>(
null, null,
@@ -71,6 +87,9 @@ export const SidePanelForDesktop = () => {
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const [shouldRenderContent, setShouldRenderContent] = const [shouldRenderContent, setShouldRenderContent] =
useState(isSidePanelOpened); useState(isSidePanelOpened);
const [isShrinkingFromFullWidth, setIsShrinkingFromFullWidth] = useState(
shouldShrinkFromFullWidth,
);
const setTableWidthResizeIsActive = useSetAtomState( const setTableWidthResizeIsActive = useSetAtomState(
tableWidthResizeIsActiveState, tableWidthResizeIsActiveState,
@@ -78,17 +97,29 @@ export const SidePanelForDesktop = () => {
const shouldShowContent = isSidePanelOpened || shouldRenderContent; const shouldShowContent = isSidePanelOpened || shouldRenderContent;
if (isSidePanelOpened && !shouldRenderContent) {
setShouldRenderContent(true);
}
const handleTransitionEnd = () => { const handleTransitionEnd = () => {
if (isSidePanelOpened) { if (isSidePanelOpened) {
// Open animation completed - ensure content persists for close animation return;
setShouldRenderContent(true);
} else {
// Close animation completed
setShouldRenderContent(false);
if (isSidePanelClosing) {
sidePanelCloseAnimationCompleteCleanup();
}
} }
setShouldRenderContent(false);
if (store.get(isSidePanelClosingState.atom)) {
sidePanelCloseAnimationCompleteCleanup();
}
};
const handleAnimationEnd = (event: AnimationEvent<HTMLDivElement>) => {
if (event.target !== event.currentTarget) {
return;
}
setIsShrinkingFromFullWidth(false);
handleTransitionEnd();
}; };
const handleModalContainerRef = useCallback( const handleModalContainerRef = useCallback(
@@ -121,6 +152,7 @@ export const SidePanelForDesktop = () => {
return ( return (
<> <>
<SidePanelWidthEffect /> <SidePanelWidthEffect />
<SidePanelAskAiHandoffEffect />
<ResizablePanelGap <ResizablePanelGap
side="left" side="left"
constraints={SIDE_PANEL_CONSTRAINTS} constraints={SIDE_PANEL_CONSTRAINTS}
@@ -136,10 +168,12 @@ export const SidePanelForDesktop = () => {
isOpen={isSidePanelOpened} isOpen={isSidePanelOpened}
isResizing={isResizing} isResizing={isResizing}
onTransitionEnd={handleTransitionEnd} onTransitionEnd={handleTransitionEnd}
onAnimationEnd={handleAnimationEnd}
data-shrink-from-full-width={isShrinkingFromFullWidth}
data-side-panel="" data-side-panel=""
data-click-outside-id={SIDE_PANEL_CLICK_OUTSIDE_ID} data-click-outside-id={SIDE_PANEL_CLICK_OUTSIDE_ID}
> >
<StyledSidePanel> <StyledSidePanel isShrinkingFromFullWidth={isShrinkingFromFullWidth}>
<StyledModalContainer ref={handleModalContainerRef} /> <StyledModalContainer ref={handleModalContainerRef} />
<ModalContainerContext.Provider value={{ container: modalContainer }}> <ModalContainerContext.Provider value={{ container: modalContainer }}>
<ParentClickOutsideIdContext.Provider <ParentClickOutsideIdContext.Provider
@@ -0,0 +1,102 @@
import { act, render } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom';
import { aiChatExpandedReturnLocationState } from '@/ai/states/aiChatExpandedReturnLocationState';
import { shouldContinueAiChatInSidePanelState } from '@/ai/states/shouldContinueAiChatInSidePanelState';
import { WorkspaceSetupChatSidePanelHandoffEffect } from '@/onboarding/effect-components/WorkspaceSetupChatSidePanelHandoffEffect';
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
import { SidePanelAskAiHandoffEffect } from '@/side-panel/components/SidePanelAskAiHandoffEffect';
import { useShouldShrinkSidePanelFromFullWidth } from '@/side-panel/hooks/useShouldShrinkSidePanelFromFullWidth';
import {
jotaiStore,
resetJotaiStore,
} from '@/ui/utilities/state/jotai/jotaiStore';
const openAskAiPageMock = jest.fn();
jest.mock('@/side-panel/hooks/useOpenAskAiPageInSidePanel', () => ({
useOpenAskAiPageInSidePanel: () => ({ openAskAiPage: openAskAiPageMock }),
}));
jest.mock('framer-motion', () => ({
useReducedMotion: () => false,
}));
let navigateAwayFromWorkspaceSetup: (() => void) | undefined;
const WorkspaceSetupRoute = () => {
const navigate = useNavigate();
navigateAwayFromWorkspaceSetup = () => navigate('/objects/companies');
return <WorkspaceSetupChatSidePanelHandoffEffect />;
};
const SidePanelRoute = () => {
const shouldShrinkFromFullWidth = useShouldShrinkSidePanelFromFullWidth();
return (
<>
<SidePanelAskAiHandoffEffect />
<div data-testid="side-panel">{String(shouldShrinkFromFullWidth)}</div>
</>
);
};
const RouterUnderTest = () => (
<JotaiProvider store={jotaiStore}>
<MemoryRouter initialEntries={['/workspace-setup']}>
<Routes>
<Route path="/workspace-setup" element={<WorkspaceSetupRoute />} />
<Route path="/objects/companies" element={<SidePanelRoute />} />
</Routes>
</MemoryRouter>
</JotaiProvider>
);
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(<RouterUnderTest />);
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(
<JotaiProvider store={jotaiStore}>
<SidePanelAskAiHandoffEffect />
</JotaiProvider>,
);
expect(openAskAiPageMock).not.toHaveBeenCalled();
});
});
@@ -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: () => <div data-testid="side-panel-content" />,
}));
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 }) => (
<JotaiProvider store={jotaiStore}>{children}</JotaiProvider>
);
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(<SidePanelForDesktop />, {
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(<SidePanelForDesktop />, { 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(<SidePanelForDesktop />, {
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(<SidePanelForDesktop />, { 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(<SidePanelForDesktop />, { wrapper: Wrapper });
expect(container.querySelector('[data-side-panel]')).toHaveAttribute(
'data-shrink-from-full-width',
'false',
);
});
});
@@ -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 }) => (
<JotaiProvider store={jotaiStore}>{children}</JotaiProvider>
);
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);
});
});
@@ -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;
};
@@ -10,6 +10,7 @@ import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePat
import { WorkspaceSetupChatPreamble } from '@/onboarding/components/WorkspaceSetupChatPreamble'; import { WorkspaceSetupChatPreamble } from '@/onboarding/components/WorkspaceSetupChatPreamble';
import { WorkspaceSetupHeader } from '@/onboarding/components/WorkspaceSetupHeader'; import { WorkspaceSetupHeader } from '@/onboarding/components/WorkspaceSetupHeader';
import { WorkspaceSetupChatKickoffEffect } from '@/onboarding/effect-components/WorkspaceSetupChatKickoffEffect'; import { WorkspaceSetupChatKickoffEffect } from '@/onboarding/effect-components/WorkspaceSetupChatKickoffEffect';
import { WorkspaceSetupChatSidePanelHandoffEffect } from '@/onboarding/effect-components/WorkspaceSetupChatSidePanelHandoffEffect';
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState'; import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -59,6 +60,7 @@ export const WorkspaceSetup = () => {
<StyledPanel> <StyledPanel>
<WorkspaceSetupHeader title={title} /> <WorkspaceSetupHeader title={title} />
<StyledContent> <StyledContent>
<WorkspaceSetupChatSidePanelHandoffEffect />
{shouldOpenAiChatAfterOnboarding && <WorkspaceSetupChatKickoffEffect />} {shouldOpenAiChatAfterOnboarding && <WorkspaceSetupChatKickoffEffect />}
<AiChatMessageListPreambleContext.Provider value={preamble}> <AiChatMessageListPreambleContext.Provider value={preamble}>
<AiChatTab /> <AiChatTab />
@@ -5,6 +5,7 @@ import { Provider as JotaiProvider } from 'jotai';
import { type ReactNode } from 'react'; import { type ReactNode } from 'react';
import { SOURCE_LOCALE } from 'twenty-shared/translations'; import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { shouldContinueAiChatInSidePanelState } from '@/ai/states/shouldContinueAiChatInSidePanelState';
import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboardingAiChatEnabledState'; import { isOnboardingAiChatEnabledState } from '@/client-config/states/isOnboardingAiChatEnabledState';
import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState'; import { shouldOpenAiChatAfterOnboardingState } from '@/onboarding/states/shouldOpenAiChatAfterOnboardingState';
import { import {
@@ -121,6 +122,32 @@ describe('WorkspaceSetup', () => {
expect(queryByTestId('chat-kickoff-effect')).not.toBeInTheDocument(); expect(queryByTestId('chat-kickoff-effect')).not.toBeInTheDocument();
}); });
it('should mark the chat for side panel continuation while mounted', () => {
setIsOnboardingAiChatEnabled(true);
const { unmount } = render(<WorkspaceSetup />, { 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(<WorkspaceSetup />, { wrapper: Wrapper });
expect(jotaiStore.get(shouldContinueAiChatInSidePanelState.atom)).toBe(
false,
);
});
it('should redirect home when the onboarding ai chat is disabled', () => { it('should redirect home when the onboarding ai chat is disabled', () => {
setIsOnboardingAiChatEnabled(false); setIsOnboardingAiChatEnabled(false);