[Command menu items] Create engine commands (#18681)

## Description

- Introduces a new engine command execution model that replaces the
previous approach of mapping `EngineComponentKey` to React components.
Instead, engine commands are now mounted headlessly via
`HeadlessEngineCommandMountRoot`, with their execution context populated
synchronously before mounting.
- Creates new headless command components
- Moves error handling from the SDK layer to the host app by wrapping
all mounted commands with a new `CommandMenuItemErrorBoundary`

The new flow works as follows:
- When a command menu item with an `engineComponentKey` is clicked,
`useCommandMenuItemFrontComponentCommands` calls
`useMountEngineCommand`, which synchronously reads the current context
store (object metadata, selected records, filters, view ID, etc.) and
writes a `MountedEngineCommandContext` into
`mountedEngineCommandsState`.
- The command is then mounted into `mountedEngineCommandsState`, which
triggers `HeadlessEngineCommandMountRoot` to render the corresponding
headless component from `ENGINE_COMPONENT_KEY_HEADLESS_COMPONENT_MAP`,
wrapped in `CommandMenuItemErrorBoundary`,
`ContextStoreComponentInstanceContext.Provider`, and
`EngineCommandComponentInstanceContext.Provider`.
- Each command component reads its execution context and delegates to
one of the 4 execution patterns: `HeadlessEngineCommandWrapperEffect`
(simple actions), `HeadlessConfirmationModalEngineCommandEffect`
(destructive actions needing confirmation),
`HeadlessNavigateEngineCommand` (GO_TO_* commands), or
`HeadlessOpenSidePanelPageEngineCommand` (SEARCH_RECORDS, ASK_AI,
VIEW_PREVIOUS_AI_CHATS).
- After execution, the command self-unmounts via
`useUnmountEngineCommand`, which removes the entry from
`mountedEngineCommandsState` and stops rendering the component.
This commit is contained in:
Raphaël Bosi
2026-03-17 17:25:18 +01:00
committed by GitHub
parent bee474afdf
commit abdab2fb7e
83 changed files with 2594 additions and 132 deletions
@@ -2,7 +2,7 @@ import {
CommandMenuContext,
type CommandMenuContextType,
} from '@/command-menu-item/contexts/CommandMenuContext';
import { useCommandMenuItemFrontComponentCommands } from '@/command-menu-item/server-items/hooks/useCommandMenuItemFrontComponentCommands';
import { useCommandMenuItemsFromBackend } from '@/command-menu-item/server-items/hooks/useCommandMenuItemsFromBackend';
import { type CommandMenuContextApi } from 'twenty-shared/types';
type CommandMenuContextProviderServerItemsContentProps = {
@@ -21,8 +21,9 @@ export const CommandMenuContextProviderServerItemsContent = ({
}: CommandMenuContextProviderServerItemsContentProps & {
commandMenuContextApi: CommandMenuContextApi;
}) => {
const commandMenuItemFrontComponentActions =
useCommandMenuItemFrontComponentCommands(commandMenuContextApi);
const commandMenuItems = useCommandMenuItemsFromBackend(
commandMenuContextApi,
);
return (
<CommandMenuContext.Provider
@@ -30,7 +31,7 @@ export const CommandMenuContextProviderServerItemsContent = ({
isInSidePanel,
displayType,
containerType,
commandMenuItems: commandMenuItemFrontComponentActions,
commandMenuItems,
}}
>
{children}
@@ -1,14 +1,16 @@
import { ENGINE_COMPONENT_KEY_COMPONENT_MAP } from '@/command-menu-item/constants/EngineComponentKeyComponentMap';
import { Command } from '@/command-menu-item/display/components/Command';
import { HeadlessFrontComponentCommandMenuItem } from '@/command-menu-item/display/components/HeadlessFrontComponentCommandMenuItem';
import { useMountEngineCommand } from '@/command-menu-item/engine-command/hooks/useMountEngineCommand';
import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope';
import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useMountHeadlessFrontComponent } from '@/front-components/hooks/useMountHeadlessFrontComponent';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { useOpenFrontComponentInSidePanel } from '@/side-panel/hooks/useOpenFrontComponentInSidePanel';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { type CommandMenuContextApi } from 'twenty-shared/types';
@@ -129,6 +131,12 @@ type BuildCommandMenuItemFromStandardKeyParams = {
isPinned: boolean;
getIcon: ReturnType<typeof useIcons>['getIcon'];
commandMenuContextApi: CommandMenuContextApi;
mountEngineCommand: (
engineCommandId: string,
contextStoreInstanceId: string,
engineComponentKey: EngineComponentKey,
) => void;
contextStoreInstanceId: string;
};
const buildCommandItemFromEngineKey = ({
@@ -139,10 +147,14 @@ const buildCommandItemFromEngineKey = ({
isPinned,
getIcon,
commandMenuContextApi,
mountEngineCommand,
contextStoreInstanceId,
}: BuildCommandMenuItemFromStandardKeyParams) => {
const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON);
const component = ENGINE_COMPONENT_KEY_COMPONENT_MAP[engineComponentKey];
const handleClick = () => {
mountEngineCommand(item.id, contextStoreInstanceId, engineComponentKey);
};
return {
type,
@@ -159,16 +171,21 @@ const buildCommandItemFromEngineKey = ({
item.conditionalAvailabilityExpression,
commandMenuContextApi,
),
component,
component: <Command onClick={handleClick} />,
};
};
export const useCommandMenuItemFrontComponentCommands = (
export const useCommandMenuItemsFromBackend = (
commandMenuContextApi: CommandMenuContextApi,
) => {
const { getIcon } = useIcons();
const { openFrontComponentInSidePanel } = useOpenFrontComponentInSidePanel();
const mountHeadlessFrontComponent = useMountHeadlessFrontComponent();
const mountEngineCommand = useMountEngineCommand();
const contextStoreInstanceId = useAvailableComponentInstanceIdOrThrow(
ContextStoreComponentInstanceContext,
);
const contextStoreIsPageInEditMode = useAtomComponentStateValue(
contextStoreIsPageInEditModeComponentState,
@@ -236,6 +253,8 @@ export const useCommandMenuItemFrontComponentCommands = (
isPinned,
getIcon,
commandMenuContextApi,
mountEngineCommand,
contextStoreInstanceId,
});
}