diff --git a/packages/twenty-front/src/modules/ai/components/AiChatThreadsList.tsx b/packages/twenty-front/src/modules/ai/components/AiChatThreadsList.tsx
index 750fac186b..0ed7d3106c 100644
--- a/packages/twenty-front/src/modules/ai/components/AiChatThreadsList.tsx
+++ b/packages/twenty-front/src/modules/ai/components/AiChatThreadsList.tsx
@@ -7,6 +7,7 @@ import { AiChatThreadListItem } from '@/ai/components/AiChatThreadListItem';
import { AiChatThreadsListFocusEffect } from '@/ai/components/AiChatThreadsListFocusEffect';
import { AiChatSkeletonLoader } from '@/ai/components/internal/AiChatSkeletonLoader';
import { AGENT_CHAT_THREAD_GROUP_BY } from '@/ai/constants/AgentChatThreadGroupBy';
+import { AI_CHAT_THREADS_LIST_FOCUS_ID } from '@/ai/constants/AiChatThreadsListFocusId';
import { AI_CHAT_THREAD_ACTIONS_SURFACE } from '@/ai/constants/AiChatThreadActionsSurface';
import { useChatThreads } from '@/ai/hooks/useChatThreads';
import { useSwitchToNewAiChat } from '@/ai/hooks/useSwitchToNewAiChat';
@@ -51,12 +52,10 @@ const StyledButtonsContainer = styled.div`
export const AiChatThreadsList = () => {
const { switchToNewChat } = useSwitchToNewAiChat();
- const focusId = 'threads-list';
-
useHotkeysOnFocusedElement({
keys: [`${Key.Control}+${Key.Enter}`, `${Key.Meta}+${Key.Enter}`],
callback: () => switchToNewChat(),
- focusId,
+ focusId: AI_CHAT_THREADS_LIST_FOCUS_ID,
dependencies: [switchToNewChat],
});
@@ -79,7 +78,7 @@ export const AiChatThreadsList = () => {
return (
<>
-
+
{shouldRenderDateGroups ? (
diff --git a/packages/twenty-front/src/modules/ai/components/AiChatThreadsListFocusEffect.tsx b/packages/twenty-front/src/modules/ai/components/AiChatThreadsListFocusEffect.tsx
index ed33ea8cc4..480bb8c723 100644
--- a/packages/twenty-front/src/modules/ai/components/AiChatThreadsListFocusEffect.tsx
+++ b/packages/twenty-front/src/modules/ai/components/AiChatThreadsListFocusEffect.tsx
@@ -1,7 +1,8 @@
+import { useEffect } from 'react';
+
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
-import { useEffect } from 'react';
export const AiChatThreadsListFocusEffect = ({
focusId,
@@ -19,6 +20,9 @@ export const AiChatThreadsListFocusEffect = ({
type: FocusComponentType.SIDE_PANEL,
instanceId: focusId,
},
+ globalHotkeysConfig: {
+ enableGlobalHotkeysConflictingWithKeyboard: false,
+ },
});
return () => {
@@ -26,5 +30,5 @@ export const AiChatThreadsListFocusEffect = ({
};
}, [pushFocusItemToFocusStack, removeFocusItemFromFocusStackById, focusId]);
- return <>>;
+ return null;
};
diff --git a/packages/twenty-front/src/modules/ai/components/__tests__/AiChatThreadsList.test.tsx b/packages/twenty-front/src/modules/ai/components/__tests__/AiChatThreadsList.test.tsx
new file mode 100644
index 0000000000..e1c2471756
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/__tests__/AiChatThreadsList.test.tsx
@@ -0,0 +1,98 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { createStore, Provider as JotaiProvider } from 'jotai';
+import { act } from 'react';
+import { MemoryRouter } from 'react-router-dom';
+
+import { AiChatThreadsList } from '@/ai/components/AiChatThreadsList';
+import { AI_CHAT_THREADS_LIST_FOCUS_ID } from '@/ai/constants/AiChatThreadsListFocusId';
+import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
+import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
+import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
+
+const mockSwitchToNewChat = jest.fn();
+
+jest.mock('@/ai/hooks/useSwitchToNewAiChat', () => ({
+ useSwitchToNewAiChat: () => ({
+ switchToNewChat: mockSwitchToNewChat,
+ }),
+}));
+
+jest.mock('@/ai/hooks/useChatThreads', () => ({
+ useChatThreads: () => ({
+ threads: [],
+ hasNextPage: false,
+ loading: false,
+ fetchMoreRef: jest.fn(),
+ }),
+}));
+
+jest.mock('@/ai/components/AiChatThreadFilterDropdown', () => ({
+ AiChatThreadFilterDropdown: () => null,
+}));
+
+jest.mock('@/ai/components/AiChatThreadDeleteConfirmationModal', () => ({
+ AiChatThreadDeleteConfirmationModal: () => null,
+}));
+
+describe('AiChatThreadsList', () => {
+ beforeEach(() => {
+ mockSwitchToNewChat.mockClear();
+ });
+
+ it('keeps a distinct side panel focus for the new chat hotkey', async () => {
+ const store = createStore();
+
+ store.set(focusStackState.atom, [
+ {
+ focusId: SIDE_PANEL_FOCUS_ID,
+ componentInstance: {
+ componentType: FocusComponentType.SIDE_PANEL,
+ componentInstanceId: SIDE_PANEL_FOCUS_ID,
+ },
+ globalHotkeysConfig: {
+ enableGlobalHotkeysWithModifiers: true,
+ enableGlobalHotkeysConflictingWithKeyboard: false,
+ },
+ },
+ ]);
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByText('New chat')).toBeInTheDocument();
+
+ await waitFor(() => {
+ expect(store.get(focusStackState.atom).at(-1)?.focusId).toBe(
+ AI_CHAT_THREADS_LIST_FOCUS_ID,
+ );
+ });
+
+ act(() => {
+ store.set(
+ focusStackState.atom,
+ store
+ .get(focusStackState.atom)
+ .filter((item) => item.focusId !== SIDE_PANEL_FOCUS_ID),
+ );
+ });
+
+ expect(store.get(focusStackState.atom).at(-1)?.focusId).toBe(
+ AI_CHAT_THREADS_LIST_FOCUS_ID,
+ );
+
+ act(() => {
+ fireEvent.keyDown(document, {
+ key: 'Enter',
+ code: 'Enter',
+ metaKey: true,
+ });
+ });
+
+ expect(mockSwitchToNewChat).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/twenty-front/src/modules/ai/constants/AiChatThreadsListFocusId.ts b/packages/twenty-front/src/modules/ai/constants/AiChatThreadsListFocusId.ts
new file mode 100644
index 0000000000..04dbd082cd
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/constants/AiChatThreadsListFocusId.ts
@@ -0,0 +1 @@
+export const AI_CHAT_THREADS_LIST_FOCUS_ID = 'ai-chat-threads-list';
diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuHotKeys.ts b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuHotKeys.ts
index 6ed3d30893..f7955a8d44 100644
--- a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuHotKeys.ts
+++ b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuHotKeys.ts
@@ -1,17 +1,13 @@
+import { AI_CHAT_THREADS_LIST_FOCUS_ID } from '@/ai/constants/AiChatThreadsListFocusId';
import { useKeyboardShortcutMenu } from '@/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu';
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
+import { useHandleSidePanelEscape } from '@/side-panel/hooks/useHandleSidePanelEscape';
import { useOpenAskAiPageInSidePanel } from '@/side-panel/hooks/useOpenAskAiPageInSidePanel';
import { useOpenRecordsSearchPageInSidePanel } from '@/side-panel/hooks/useOpenRecordsSearchPageInSidePanel';
-import { useSidePanelHistory } from '@/side-panel/hooks/useSidePanelHistory';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
-import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
-import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
import { useGlobalHotkeys } from '@/ui/utilities/hotkey/hooks/useGlobalHotkeys';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
-import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
-import { isNonEmptyString } from '@sniptt/guards';
import { Key } from 'ts-key-enum';
-import { SidePanelPages } from 'twenty-shared/types';
export const useCommandMenuHotKeys = () => {
const { toggleSidePanelMenu } = useSidePanelMenu();
@@ -20,15 +16,10 @@ export const useCommandMenuHotKeys = () => {
const { openAskAiPage } = useOpenAskAiPageInSidePanel();
- const { goBackFromSidePanel, goBackOneSubPageOrMainPage } =
- useSidePanelHistory();
-
- const sidePanelSearch = useAtomStateValue(sidePanelSearchState);
+ const handleSidePanelEscape = useHandleSidePanelEscape();
const { closeKeyboardShortcutMenu } = useKeyboardShortcutMenu();
- const sidePanelPage = useAtomStateValue(sidePanelPageState);
-
useGlobalHotkeys({
keys: ['ctrl+k', 'meta+k'],
callback: () => {
@@ -66,30 +57,23 @@ export const useCommandMenuHotKeys = () => {
useHotkeysOnFocusedElement({
keys: [Key.Escape],
callback: () => {
- goBackFromSidePanel();
+ handleSidePanelEscape();
},
focusId: SIDE_PANEL_FOCUS_ID,
- dependencies: [goBackFromSidePanel],
+ dependencies: [handleSidePanelEscape],
options: {
enableOnFormTags: false,
},
});
useHotkeysOnFocusedElement({
- keys: [Key.Backspace, Key.Delete],
+ keys: [Key.Escape],
callback: () => {
- if (isNonEmptyString(sidePanelSearch)) {
- return;
- }
-
- if (sidePanelPage !== SidePanelPages.CommandMenuDisplay) {
- goBackOneSubPageOrMainPage();
- }
+ handleSidePanelEscape();
},
- focusId: SIDE_PANEL_FOCUS_ID,
- dependencies: [sidePanelPage, sidePanelSearch, goBackOneSubPageOrMainPage],
+ focusId: AI_CHAT_THREADS_LIST_FOCUS_ID,
+ dependencies: [handleSidePanelEscape],
options: {
- preventDefault: false,
enableOnFormTags: false,
},
});
diff --git a/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenuOpenContent.tsx b/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenuOpenContent.tsx
index 7d412c7793..bf0eeee21b 100644
--- a/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenuOpenContent.tsx
+++ b/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenuOpenContent.tsx
@@ -1,6 +1,7 @@
import { Key } from 'ts-key-enum';
import { KEYBOARD_SHORTCUTS_GENERAL } from '@/keyboard-shortcut-menu/constants/KeyboardShortcutsGeneral';
+import { KEYBOARD_SHORTCUTS_SIDE_PANEL } from '@/keyboard-shortcut-menu/constants/KeyboardShortcutsSidePanel';
import { KEYBOARD_SHORTCUTS_TABLE } from '@/keyboard-shortcut-menu/constants/KeyboardShortcutsTable';
import {
@@ -35,9 +36,14 @@ export const KeyboardShortcutMenuOpenContent = () => {
))}
+
+ {KEYBOARD_SHORTCUTS_SIDE_PANEL.map((SidePanelShortcut, index) => (
+
+ ))}
+
- {KEYBOARD_SHORTCUTS_GENERAL.map((GeneralShortcut) => (
-
+ {KEYBOARD_SHORTCUTS_GENERAL.map((GeneralShortcut, index) => (
+
))}
diff --git a/packages/twenty-front/src/modules/keyboard-shortcut-menu/constants/KeyboardShortcutsSidePanel.ts b/packages/twenty-front/src/modules/keyboard-shortcut-menu/constants/KeyboardShortcutsSidePanel.ts
new file mode 100644
index 0000000000..c07ffbf7ee
--- /dev/null
+++ b/packages/twenty-front/src/modules/keyboard-shortcut-menu/constants/KeyboardShortcutsSidePanel.ts
@@ -0,0 +1,32 @@
+import {
+ type Shortcut,
+ ShortcutType,
+} from '@/keyboard-shortcut-menu/types/Shortcut';
+
+export const KEYBOARD_SHORTCUTS_SIDE_PANEL: Shortcut[] = [
+ {
+ label: 'Clear search, go back, or close',
+ type: ShortcutType.SidePanel,
+ firstHotKey: 'esc',
+ areSimultaneous: true,
+ },
+ {
+ label: 'Go back when search is empty',
+ type: ShortcutType.SidePanel,
+ firstHotKey: '⌫',
+ areSimultaneous: true,
+ },
+ {
+ label: 'Move through list items',
+ type: ShortcutType.SidePanel,
+ firstHotKey: '↑',
+ secondHotKey: '↓',
+ areSimultaneous: true,
+ },
+ {
+ label: 'Open selected list item',
+ type: ShortcutType.SidePanel,
+ firstHotKey: '↵',
+ areSimultaneous: true,
+ },
+];
diff --git a/packages/twenty-front/src/modules/keyboard-shortcut-menu/types/Shortcut.ts b/packages/twenty-front/src/modules/keyboard-shortcut-menu/types/Shortcut.ts
index baa7cf1f67..6f17e2a290 100644
--- a/packages/twenty-front/src/modules/keyboard-shortcut-menu/types/Shortcut.ts
+++ b/packages/twenty-front/src/modules/keyboard-shortcut-menu/types/Shortcut.ts
@@ -1,11 +1,12 @@
export enum ShortcutType {
Table = 'Table',
General = 'General',
+ SidePanel = 'SidePanel',
}
export type Shortcut = {
label: string;
- type: ShortcutType.Table | ShortcutType.General;
+ type: ShortcutType;
firstHotKey?: string;
secondHotKey?: string;
areSimultaneous: boolean;
diff --git a/packages/twenty-front/src/modules/side-panel/components/SidePanelToggleButton.tsx b/packages/twenty-front/src/modules/side-panel/components/SidePanelToggleButton.tsx
index 4f0c4d7f71..bd0dcac87f 100644
--- a/packages/twenty-front/src/modules/side-panel/components/SidePanelToggleButton.tsx
+++ b/packages/twenty-front/src/modules/side-panel/components/SidePanelToggleButton.tsx
@@ -13,7 +13,7 @@ import { t } from '@lingui/core/macro';
import { IconDotsVertical } from 'twenty-ui/icon';
import { IconButton } from 'twenty-ui/input';
import { AppTooltip, TooltipDelay, TooltipPosition } from 'twenty-ui/surfaces';
-import { useIsMobile } from 'twenty-ui/utilities';
+import { getOsControlSymbol, useIsMobile } from 'twenty-ui/utilities';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledButtonWrapper = styled.div<{ alignToTop: boolean }>`
@@ -64,6 +64,7 @@ export const SidePanelToggleButton = () => {
}
const ariaLabel = t`Command Menu`;
+ const tooltipContent = t`Command menu | ${getOsControlSymbol()}K`;
return (
@@ -85,7 +86,7 @@ export const SidePanelToggleButton = () => {
{
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
+ const handleSidePanelBackspace = useHandleSidePanelBackspace();
+ const handleSidePanelEscape = useHandleSidePanelEscape();
const handleInputFocus = () => {
pushFocusItemToFocusStack({
@@ -130,6 +135,29 @@ export const SidePanelTopBar = () => {
});
};
+ const handleInputKeyDownCapture = (
+ event: React.KeyboardEvent,
+ ) => {
+ if (event.nativeEvent.isComposing || event.keyCode === 229) {
+ return;
+ }
+
+ if (event.key === Key.Escape) {
+ event.preventDefault();
+ event.stopPropagation();
+ event.nativeEvent.stopImmediatePropagation();
+
+ handleSidePanelEscape();
+ return;
+ }
+
+ if (event.key === Key.Backspace && handleSidePanelBackspace()) {
+ event.preventDefault();
+ event.stopPropagation();
+ event.nativeEvent.stopImmediatePropagation();
+ }
+ };
+
const currentPage = sidePanelNavigationStack.at(-1)?.page;
const previousPage = sidePanelNavigationStack.at(-2)?.page;
@@ -173,6 +201,7 @@ export const SidePanelTopBar = () => {
value={sidePanelSearch}
placeholder={t`Type anything...`}
onChange={handleSearchChange}
+ onKeyDownCapture={handleInputKeyDownCapture}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
diff --git a/packages/twenty-front/src/modules/side-panel/components/__tests__/SidePanelToggleButton.test.tsx b/packages/twenty-front/src/modules/side-panel/components/__tests__/SidePanelToggleButton.test.tsx
index 0a5c821763..0eb3ff6ffd 100644
--- a/packages/twenty-front/src/modules/side-panel/components/__tests__/SidePanelToggleButton.test.tsx
+++ b/packages/twenty-front/src/modules/side-panel/components/__tests__/SidePanelToggleButton.test.tsx
@@ -14,13 +14,20 @@ import { PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID } from '@/ui/layout/page
import { SidePanelPages } from 'twenty-shared/types';
import { IconDotsVertical } from 'twenty-ui/icon';
+const mockAppTooltip = jest.fn();
+
jest.mock('twenty-ui/utilities', () => ({
useIsMobile: () => false,
+ getOsControlSymbol: () => '⌘',
}));
jest.mock('twenty-ui/surfaces', () => ({
...jest.requireActual('twenty-ui/surfaces'),
- AppTooltip: () => null,
+ AppTooltip: (props: { content: string }) => {
+ mockAppTooltip(props);
+
+ return null;
+ },
}));
const renderSidePanelToggleButton = ({
@@ -68,6 +75,10 @@ const renderSidePanelToggleButton = ({
};
describe('SidePanelToggleButton', () => {
+ beforeEach(() => {
+ mockAppTooltip.mockClear();
+ });
+
it('opens the command menu when the side panel is closed', () => {
const { store } = renderSidePanelToggleButton();
@@ -147,6 +158,16 @@ describe('SidePanelToggleButton', () => {
expect(screen.getByTestId('page-header-side-panel-button')).toBeVisible();
});
+ it('shows the command menu keyboard shortcut in the tooltip', () => {
+ renderSidePanelToggleButton();
+
+ expect(mockAppTooltip).toHaveBeenCalledWith(
+ expect.objectContaining({
+ content: 'Command menu | ⌘K',
+ }),
+ );
+ });
+
it('marks the command menu button as a click-outside exclusion', () => {
renderSidePanelToggleButton();
diff --git a/packages/twenty-front/src/modules/side-panel/components/__tests__/SidePanelTopBar.test.tsx b/packages/twenty-front/src/modules/side-panel/components/__tests__/SidePanelTopBar.test.tsx
index d04bfddeac..107e813092 100644
--- a/packages/twenty-front/src/modules/side-panel/components/__tests__/SidePanelTopBar.test.tsx
+++ b/packages/twenty-front/src/modules/side-panel/components/__tests__/SidePanelTopBar.test.tsx
@@ -9,7 +9,9 @@ import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { SidePanelTopBar } from '@/side-panel/components/SidePanelTopBar';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { sidePanelNavigationStackState } from '@/side-panel/states/sidePanelNavigationStackState';
+import { sidePanelPageInfoState } from '@/side-panel/states/sidePanelPageInfoState';
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
+import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { PageFocusId } from '@/types/PageFocusId';
@@ -26,13 +28,15 @@ jest.mock('@/side-panel/components/SidePanelTopBarRightCornerIcon', () => ({
SidePanelTopBarRightCornerIcon: () => null,
}));
+const mockCloseSidePanelMenu = jest.fn();
+
jest.mock('@/side-panel/hooks/useSidePanelContextChips', () => ({
useSidePanelContextChips: () => ({ contextChips: [] }),
}));
jest.mock('@/side-panel/hooks/useSidePanelMenu', () => ({
useSidePanelMenu: () => ({
- closeSidePanelMenu: jest.fn(),
+ closeSidePanelMenu: mockCloseSidePanelMenu,
}),
}));
@@ -78,6 +82,11 @@ const createSidePanelTopBarStore = ({
store.set(isSidePanelOpenedState.atom, true);
store.set(sidePanelPageState.atom, sidePanelPage);
store.set(sidePanelNavigationStackState.atom, sidePanelNavigationStack);
+ store.set(sidePanelPageInfoState.atom, {
+ title: sidePanelNavigationStack.at(-1)?.pageTitle,
+ Icon: sidePanelNavigationStack.at(-1)?.pageIcon,
+ instanceId: sidePanelNavigationStack.at(-1)?.pageId ?? '',
+ });
store.set(focusStackState.atom, [recordIndexFocusItem]);
return store;
@@ -105,6 +114,7 @@ const renderSidePanelCommandMenu = (store = createSidePanelTopBarStore()) => {
describe('SidePanelTopBar', () => {
beforeEach(() => {
+ mockCloseSidePanelMenu.mockClear();
mockIsMobile = false;
});
@@ -150,6 +160,123 @@ describe('SidePanelTopBar', () => {
});
});
+ it('clears search with Escape without navigating', () => {
+ const { store } = renderSidePanelCommandMenu();
+
+ const input = screen.getByTestId(SIDE_PANEL_FOCUS_ID);
+
+ fireEvent.change(input, {
+ target: { value: 'company' },
+ });
+
+ fireEvent.keyDown(input, {
+ key: 'Escape',
+ code: 'Escape',
+ });
+
+ expect(store.get(sidePanelSearchState.atom)).toBe('');
+ expect(store.get(sidePanelNavigationStackState.atom)).toHaveLength(1);
+ expect(mockCloseSidePanelMenu).not.toHaveBeenCalled();
+ });
+
+ it('closes the side panel with Escape from an empty root search', () => {
+ renderSidePanelCommandMenu();
+
+ const input = screen.getByTestId(SIDE_PANEL_FOCUS_ID);
+
+ fireEvent.keyDown(input, {
+ key: 'Escape',
+ code: 'Escape',
+ });
+
+ expect(mockCloseSidePanelMenu).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not navigate with Backspace while search has text', () => {
+ const { store } = renderSidePanelCommandMenu(
+ createSidePanelTopBarStore({
+ sidePanelPage: SidePanelPages.SearchRecords,
+ sidePanelNavigationStack: [
+ {
+ page: SidePanelPages.CommandMenuDisplay,
+ pageTitle: 'Command Menu',
+ pageIcon: IconDotsVertical,
+ pageId: 'command-menu',
+ },
+ {
+ page: SidePanelPages.SearchRecords,
+ pageTitle: 'Search',
+ pageIcon: IconDotsVertical,
+ pageId: 'search-records',
+ },
+ ],
+ }),
+ );
+
+ const input = screen.getByTestId(SIDE_PANEL_FOCUS_ID);
+
+ fireEvent.change(input, {
+ target: { value: 'company' },
+ });
+
+ fireEvent.keyDown(input, {
+ key: 'Backspace',
+ code: 'Backspace',
+ });
+
+ expect(store.get(sidePanelSearchState.atom)).toBe('company');
+ expect(store.get(sidePanelNavigationStackState.atom)).toHaveLength(2);
+ });
+
+ it('goes back with Backspace from an empty search when side panel history exists', () => {
+ const { store } = renderSidePanelCommandMenu(
+ createSidePanelTopBarStore({
+ sidePanelPage: SidePanelPages.SearchRecords,
+ sidePanelNavigationStack: [
+ {
+ page: SidePanelPages.CommandMenuDisplay,
+ pageTitle: 'Command Menu',
+ pageIcon: IconDotsVertical,
+ pageId: 'command-menu',
+ },
+ {
+ page: SidePanelPages.SearchRecords,
+ pageTitle: 'Search',
+ pageIcon: IconDotsVertical,
+ pageId: 'search-records',
+ },
+ ],
+ }),
+ );
+
+ const input = screen.getByTestId(SIDE_PANEL_FOCUS_ID);
+
+ fireEvent.keyDown(input, {
+ key: 'Backspace',
+ code: 'Backspace',
+ });
+
+ expect(store.get(sidePanelNavigationStackState.atom)).toHaveLength(1);
+ expect(store.get(sidePanelPageState.atom)).toBe(
+ SidePanelPages.CommandMenuDisplay,
+ );
+ expect(mockCloseSidePanelMenu).not.toHaveBeenCalled();
+ });
+
+ it('does not close the root side panel with Backspace from an empty search', () => {
+ const { store } = renderSidePanelCommandMenu();
+
+ const input = screen.getByTestId(SIDE_PANEL_FOCUS_ID);
+
+ fireEvent.keyDown(input, {
+ key: 'Backspace',
+ code: 'Backspace',
+ });
+
+ expect(store.get(sidePanelNavigationStackState.atom)).toHaveLength(1);
+ expect(mockCloseSidePanelMenu).not.toHaveBeenCalled();
+ });
+
it('renders the close button after the command menu content', () => {
renderSidePanelCommandMenu();
diff --git a/packages/twenty-front/src/modules/side-panel/hooks/useCanGoBackOneSidePanelStep.ts b/packages/twenty-front/src/modules/side-panel/hooks/useCanGoBackOneSidePanelStep.ts
new file mode 100644
index 0000000000..afeaeb23da
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/hooks/useCanGoBackOneSidePanelStep.ts
@@ -0,0 +1,18 @@
+import { sidePanelNavigationStackState } from '@/side-panel/states/sidePanelNavigationStackState';
+import { sidePanelSubPageStackForActiveSidePanelPageSelector } from '@/side-panel/states/sidePanelSubPageStackForActiveSidePanelPageSelector';
+import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
+import { isNonEmptyArray } from '@sniptt/guards';
+
+export const useCanGoBackOneSidePanelStep = () => {
+ const sidePanelNavigationStack = useAtomStateValue(
+ sidePanelNavigationStackState,
+ );
+ const sidePanelSubPageStackForActiveSidePanelPage = useAtomStateValue(
+ sidePanelSubPageStackForActiveSidePanelPageSelector,
+ );
+
+ return (
+ isNonEmptyArray(sidePanelSubPageStackForActiveSidePanelPage) ||
+ sidePanelNavigationStack.length > 1
+ );
+};
diff --git a/packages/twenty-front/src/modules/side-panel/hooks/useHandleSidePanelBackspace.ts b/packages/twenty-front/src/modules/side-panel/hooks/useHandleSidePanelBackspace.ts
new file mode 100644
index 0000000000..80af101b19
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/hooks/useHandleSidePanelBackspace.ts
@@ -0,0 +1,22 @@
+import { useCanGoBackOneSidePanelStep } from '@/side-panel/hooks/useCanGoBackOneSidePanelStep';
+import { useSidePanelHistory } from '@/side-panel/hooks/useSidePanelHistory';
+import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
+import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
+import { isNonEmptyString } from '@sniptt/guards';
+
+export const useHandleSidePanelBackspace = () => {
+ const sidePanelSearch = useAtomStateValue(sidePanelSearchState);
+ const canGoBackOneSidePanelStep = useCanGoBackOneSidePanelStep();
+
+ const { goBackOneSubPageOrMainPage } = useSidePanelHistory();
+
+ return () => {
+ if (!canGoBackOneSidePanelStep || isNonEmptyString(sidePanelSearch)) {
+ return false;
+ }
+
+ goBackOneSubPageOrMainPage();
+
+ return true;
+ };
+};
diff --git a/packages/twenty-front/src/modules/side-panel/hooks/useHandleSidePanelEscape.ts b/packages/twenty-front/src/modules/side-panel/hooks/useHandleSidePanelEscape.ts
new file mode 100644
index 0000000000..15d894efcb
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/hooks/useHandleSidePanelEscape.ts
@@ -0,0 +1,28 @@
+import { COMMAND_MENU_SIDE_PANEL_PAGES } from '@/side-panel/constants/CommandMenuSidePanelPages';
+import { useSidePanelHistory } from '@/side-panel/hooks/useSidePanelHistory';
+import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
+import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
+import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
+import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
+import { isNonEmptyString } from '@sniptt/guards';
+
+export const useHandleSidePanelEscape = () => {
+ const [sidePanelSearch, setSidePanelSearch] =
+ useAtomState(sidePanelSearchState);
+ const sidePanelPage = useAtomStateValue(sidePanelPageState);
+
+ const { goBackOneSubPageOrMainPage } = useSidePanelHistory();
+
+ return () => {
+ const canClearSidePanelSearch =
+ COMMAND_MENU_SIDE_PANEL_PAGES.includes(sidePanelPage) &&
+ isNonEmptyString(sidePanelSearch);
+
+ if (canClearSidePanelSearch) {
+ setSidePanelSearch('');
+ return;
+ }
+
+ goBackOneSubPageOrMainPage();
+ };
+};