feat: fix Command Menu Side Panel Layout (#15883)

[Figma
Design](https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=81380-344641&t=FpjWNOK2gZuDQQfr-0)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Adds a side panel layout for the Command Menu, routes modals into a
local container, updates the top bar and context chips, and standardizes
small button sizes.
> 
> - **Command Menu**:
> - **Side Panel Layout**: Introduces `CommandMenuSidePanelLayout` with
animated width, hosts `CommandMenuRouter`, and provides a modal
container via `ModalContainerContext`.
> - **Top Bar**: Redesign (`CommandMenuTopBar`) with back icon, optional
AI sparkles action, compact height
(`COMMAND_MENU_SEARCH_BAR_HEIGHT=40`), and updated placeholder.
> - **Context Chips**: Adds `CommandMenuLastContextChip` and
`CommandMenuRecordInfo`; extends `CommandMenuContextChip` with `page`
prop; updates `CommandMenuContextChipGroups` to render last chip as
record info when applicable.
> - **Container Simplification**: `CommandMenuContainer` simplified to
just provide contexts and `AgentChatProvider`.
> - **Modal System**:
> - Adds `ModalContainerContext` and updates `Modal` to portal into
provided container; `Modal.Backdrop` supports `isInContainer`.
> - Updates usages (e.g., `UserOrMetadataLoader`, `ActionModal`) to
align with new modal behavior.
> - **Page Integration**:
> - Replaces `PageBody` with `CommandMenuSidePanelLayout` in
`RecordShowPage` and `RecordIndexContainerGater`.
> - Removes global `CommandMenuRouter` from `DefaultLayout` (keeps
keyboard shortcuts).
> - **UI/Styling**:
> - Standardizes several buttons to `size="small"` (e.g., command
actions, open record, options, reply, workflow footer).
> - Adjusts `ShowPageSubContainer` styling when rendered inside command
menu.
>   - Storybook tests updated for new placeholder text.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
81fcaa145618a2fa1c3e11e2dc88fe832c51374c. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: Devessier <baptiste@devessier.fr>
Co-authored-by: Aman Raj <92664006+araj00@users.noreply.github.com>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
This commit is contained in:
Abdul Rahman
2025-11-21 20:42:41 +05:30
committed by GitHub
parent 28b8a4f7ec
commit 04b0a65e73
102 changed files with 1913 additions and 1548 deletions
@@ -0,0 +1,151 @@
import { CommandMenuRouter } from '@/command-menu/components/CommandMenuRouter';
import { useCommandMenuHotKeys } from '@/command-menu/hooks/useCommandMenuHotKeys';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { tableWidthResizeIsActiveState } from '@/object-record/record-table/states/tableWidthResizeIsActivedState';
import { ModalContainerContext } from '@/ui/layout/modal/contexts/ModalContainerContext';
import { PageBody } from '@/ui/layout/page/components/PageBody';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { type ReactNode, useCallback, useState } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useIsMobile } from 'twenty-ui/utilities';
type CommandMenuPageLayoutProps = {
children: ReactNode;
isSidePanelOpen?: boolean;
};
const DEFAULT_SIDE_PANEL_WIDTH = 400;
const StyledLayout = styled.div`
display: flex;
flex: 1;
min-height: 0;
padding-bottom: ${({ theme }) => theme.spacing(3)};
padding-right: ${({ theme }) => theme.spacing(3)};
`;
const StyledPageBody = styled(PageBody)`
flex: 1;
min-width: 0;
padding-bottom: 0;
padding-right: 0;
`;
const StyledSidePanelWrapper = styled(motion.div)`
flex-shrink: 0;
min-width: 0;
overflow: hidden;
`;
const StyledSidePanel = styled(motion.aside)`
background: ${({ theme }) => theme.background.primary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
position: relative;
width: ${DEFAULT_SIDE_PANEL_WIDTH}px;
box-sizing: border-box;
`;
const StyledModalContainer = styled.div`
height: 100%;
left: 0;
pointer-events: none;
position: absolute;
top: 0;
width: 100%;
z-index: 1;
`;
export const CommandMenuPageLayout = ({
children,
isSidePanelOpen,
}: CommandMenuPageLayoutProps) => {
const theme = useTheme();
const isMobile = useIsMobile();
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
const [modalContainer, setModalContainer] = useState<HTMLDivElement | null>(
null,
);
const setTableWidthResizeIsActive = useSetRecoilState(
tableWidthResizeIsActiveState,
);
const resolvedIsSidePanelOpen =
isSidePanelOpen ?? isCommandMenuOpened ?? false;
const [shouldRenderContent, setShouldRenderContent] = useState(
resolvedIsSidePanelOpen,
);
const shouldShowContent = resolvedIsSidePanelOpen || shouldRenderContent;
const handleAnimationComplete = () => {
if (!resolvedIsSidePanelOpen) {
setShouldRenderContent(false);
}
setTableWidthResizeIsActive(true);
};
const handleAnimationStart = () => {
if (resolvedIsSidePanelOpen && !shouldRenderContent) {
setShouldRenderContent(true);
}
setTableWidthResizeIsActive(false);
};
const handleModalContainerRef = useCallback(
(element: HTMLDivElement | null) => {
setModalContainer(element);
},
[],
);
useCommandMenuHotKeys();
if (isMobile) {
return <>{children}</>;
}
return (
<StyledLayout>
<StyledPageBody>{children}</StyledPageBody>
<StyledSidePanelWrapper
initial={false}
animate={{
width: resolvedIsSidePanelOpen ? DEFAULT_SIDE_PANEL_WIDTH : 0,
marginLeft: resolvedIsSidePanelOpen ? theme.spacing(2) : 0,
}}
transition={{
duration: theme.animation.duration.normal,
}}
onAnimationStart={handleAnimationStart}
onAnimationComplete={handleAnimationComplete}
>
<StyledSidePanel
initial={false}
animate={{
x: resolvedIsSidePanelOpen ? 0 : DEFAULT_SIDE_PANEL_WIDTH,
}}
transition={{
duration: theme.animation.duration.normal,
}}
>
<StyledModalContainer ref={handleModalContainerRef} />
<ModalContainerContext.Provider value={{ container: modalContainer }}>
{shouldShowContent && <CommandMenuRouter />}
</ModalContainerContext.Provider>
</StyledSidePanel>
</StyledSidePanelWrapper>
</StyledLayout>
);
};
@@ -0,0 +1,144 @@
import { CommandMenuRouter } from '@/command-menu/components/CommandMenuRouter';
import { COMMAND_MENU_SIDE_PANEL_WIDTH } from '@/command-menu/constants/CommandMenuSidePanelWidth';
import { useCommandMenuHotKeys } from '@/command-menu/hooks/useCommandMenuHotKeys';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { tableWidthResizeIsActiveState } from '@/object-record/record-table/states/tableWidthResizeIsActivedState';
import { ModalContainerContext } from '@/ui/layout/modal/contexts/ModalContainerContext';
import { PageBody } from '@/ui/layout/page/components/PageBody';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { type ReactNode, useCallback, useState } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useIsMobile } from 'twenty-ui/utilities';
type CommandMenuPageLayoutProps = {
children: ReactNode;
};
const StyledLayout = styled.div`
display: flex;
flex: 1;
min-height: 0;
padding-bottom: ${({ theme }) => theme.spacing(3)};
padding-right: ${({ theme }) => theme.spacing(3)};
`;
const StyledPageBody = styled(PageBody)`
flex: 1;
min-width: 0;
padding-bottom: 0;
padding-right: 0;
`;
const StyledSidePanelWrapper = styled(motion.div)`
flex-shrink: 0;
min-width: 0;
overflow: hidden;
`;
const StyledSidePanel = styled(motion.aside)`
background: ${({ theme }) => theme.background.primary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
position: relative;
width: ${COMMAND_MENU_SIDE_PANEL_WIDTH}px;
box-sizing: border-box;
`;
const StyledModalContainer = styled.div`
height: 100%;
left: 0;
pointer-events: none;
position: absolute;
top: 0;
width: 100%;
z-index: 1;
`;
export const CommandMenuPageLayout = ({
children,
}: CommandMenuPageLayoutProps) => {
const theme = useTheme();
const isMobile = useIsMobile();
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
const [modalContainer, setModalContainer] = useState<HTMLDivElement | null>(
null,
);
const setTableWidthResizeIsActive = useSetRecoilState(
tableWidthResizeIsActiveState,
);
const [shouldRenderContent, setShouldRenderContent] =
useState(isCommandMenuOpened);
const shouldShowContent = isCommandMenuOpened || shouldRenderContent;
const handleAnimationComplete = () => {
if (!isCommandMenuOpened) {
setShouldRenderContent(false);
}
setTableWidthResizeIsActive(true);
};
const handleAnimationStart = () => {
if (isCommandMenuOpened && !shouldRenderContent) {
setShouldRenderContent(true);
}
setTableWidthResizeIsActive(false);
};
const handleModalContainerRef = useCallback(
(element: HTMLDivElement | null) => {
setModalContainer(element);
},
[],
);
useCommandMenuHotKeys();
if (isMobile) {
return <>{children}</>;
}
return (
<StyledLayout>
<StyledPageBody>{children}</StyledPageBody>
<StyledSidePanelWrapper
initial={false}
animate={{
width: isCommandMenuOpened ? COMMAND_MENU_SIDE_PANEL_WIDTH : 0,
marginLeft: isCommandMenuOpened ? theme.spacing(2) : 0,
}}
transition={{
duration: theme.animation.duration.normal,
}}
onAnimationStart={handleAnimationStart}
onAnimationComplete={handleAnimationComplete}
>
<StyledSidePanel
initial={false}
animate={{
x: isCommandMenuOpened ? 0 : COMMAND_MENU_SIDE_PANEL_WIDTH,
}}
transition={{
duration: theme.animation.duration.normal,
}}
>
<StyledModalContainer ref={handleModalContainerRef} />
<ModalContainerContext.Provider value={{ container: modalContainer }}>
{shouldShowContent && <CommandMenuRouter />}
</ModalContainerContext.Provider>
</StyledSidePanel>
</StyledSidePanelWrapper>
</StyledLayout>
);
};
@@ -3,6 +3,7 @@ import { RecordIndexContextProvider } from '@/object-record/record-index/context
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { getActionMenuIdFromRecordIndexId } from '@/action-menu/utils/getActionMenuIdFromRecordIndexId';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { CommandMenuPageLayout } from '@/object-record/components/CommandMenuPageLayout';
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { lastShowPageRecordIdState } from '@/object-record/record-field/ui/states/lastShowPageRecordId';
@@ -13,7 +14,6 @@ import { RecordIndexPageHeader } from '@/object-record/record-index/components/R
import { useHandleIndexIdentifierClick } from '@/object-record/record-index/hooks/useHandleIndexIdentifierClick';
import { useRecordIndexFieldMetadataDerivedStates } from '@/object-record/record-index/hooks/useRecordIndexFieldMetadataDerivedStates';
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { PageBody } from '@/ui/layout/page/components/PageBody';
import { RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS } from '@/ui/utilities/drag-select/constants/RecordIndecDragSelectBoundaryClass';
import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
@@ -98,14 +98,14 @@ export const RecordIndexContainerGater = () => {
>
<PageTitle title={objectMetadataItem.labelPlural} />
<RecordIndexPageHeader />
<PageBody>
<CommandMenuPageLayout>
<StyledIndexContainer
className={RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS}
>
<RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect />
<RecordIndexContainer />
</StyledIndexContainer>
</PageBody>
</CommandMenuPageLayout>
</ActionMenuComponentInstanceContext.Provider>
</RecordComponentInstanceContextsWrapper>
<RecordIndexLoadBaseOnContextStoreEffect />
@@ -1,8 +1,10 @@
import { recordTableWidthComponentState } from '@/object-record/record-table/states/recordTableWidthComponentState';
import { tableWidthResizeIsActiveState } from '@/object-record/record-table/states/tableWidthResizeIsActivedState';
import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const RecordTableWidthEffect = () => {
@@ -10,33 +12,38 @@ export const RecordTableWidthEffect = () => {
recordTableWidthComponentState,
);
const tableWidthResizeIsActive = useRecoilValue(
tableWidthResizeIsActiveState,
);
const { scrollWrapperHTMLElement } = useScrollWrapperHTMLElement();
useEffect(() => {
const tableWidth = scrollWrapperHTMLElement?.clientWidth ?? 0;
if (tableWidth > 0) {
setRecordTableWidth(tableWidth);
}
const tableResizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target === scrollWrapperHTMLElement) {
const newWidth = scrollWrapperHTMLElement.clientWidth;
setRecordTableWidth(newWidth);
}
if (tableWidthResizeIsActive) {
if (tableWidth > 0) {
setRecordTableWidth(tableWidth);
}
});
if (isDefined(scrollWrapperHTMLElement)) {
tableResizeObserver.observe(scrollWrapperHTMLElement);
const tableResizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target === scrollWrapperHTMLElement) {
const newWidth = scrollWrapperHTMLElement.clientWidth;
setRecordTableWidth(newWidth);
}
}
});
if (isDefined(scrollWrapperHTMLElement)) {
tableResizeObserver.observe(scrollWrapperHTMLElement);
}
return () => {
tableResizeObserver.disconnect();
};
}
return () => {
tableResizeObserver.disconnect();
};
}, [setRecordTableWidth, scrollWrapperHTMLElement]);
}, [setRecordTableWidth, scrollWrapperHTMLElement, tableWidthResizeIsActive]);
return null;
};
@@ -0,0 +1,6 @@
import { createState } from 'twenty-ui/utilities';
export const tableWidthResizeIsActiveState = createState({
key: 'tableWidthResizeIsActiveState',
defaultValue: true,
});
@@ -26,7 +26,7 @@ import { useRecoilCallback } from 'recoil';
type RecordTitleCellProps = {
loading?: boolean;
sizeVariant?: 'xs' | 'md';
sizeVariant?: 'xs' | 'sm' | 'md';
containerType: RecordTitleCellContainerType;
};
@@ -8,7 +8,7 @@ import { RecordTitleFullNameFieldInput } from '@/object-record/record-title-cell
type RecordTitleCellFieldInputProps = {
instanceId: string;
sizeVariant?: 'xs' | 'md';
sizeVariant?: 'xs' | 'sm' | 'md';
};
export const RecordTitleCellFieldInput = ({
@@ -9,7 +9,7 @@ import { turnIntoUndefinedIfWhitespacesOnly } from '~/utils/string/turnIntoUndef
type RecordTitleCellTextFieldInputProps = {
instanceId: string;
sizeVariant?: 'xs' | 'md';
sizeVariant?: 'xs' | 'sm' | 'md';
};
export const RecordTitleCellTextFieldInput = ({
@@ -38,7 +38,7 @@ type RecordTitleDoubleTextInputProps = {
) => void;
onChange?: (newDoubleTextValue: FieldDoubleText) => void;
onPaste?: (newDoubleTextValue: FieldDoubleText) => void;
sizeVariant?: 'xs' | 'md';
sizeVariant?: 'xs' | 'sm' | 'md';
};
export const RecordTitleDoubleTextInput = ({
@@ -9,7 +9,7 @@ import { useContext } from 'react';
import { RecordTitleDoubleTextInput } from './RecordTitleDoubleTextInput';
type RecordTitleFullNameFieldInputProps = {
sizeVariant?: 'xs' | 'md';
sizeVariant?: 'xs' | 'sm' | 'md';
};
export const RecordTitleFullNameFieldInput = ({