[FRONT COMPONENTS] Headless components (#18096)

## Description

- Add `isHeadless` field to `FrontComponent` entity so front components
can run without rendering UI in the command menu
- Introduce headless front component mounting logic:
`HeadlessFrontComponentMountRoot` at the application root,
`useMountHeadlessFrontComponent`, and `useUnmountHeadlessFrontComponent`
hooks to mount/unmount headless components
- Expand the SDK with new action components (`Action`, `ActionLink`,
`ActionOpenSidePanelPage`) and host communication functions
(`openSidePanelPage`, `unmountFrontComponent`)
- Move `CommandMenuPages` type to twenty-shared so the SDK can reference
it for side panel navigation

## Video QA



https://github.com/user-attachments/assets/4f9e3bb1-fcd1-42be-b3f4-a97e80c2add2
This commit is contained in:
Raphaël Bosi
2026-02-20 15:14:42 +01:00
committed by GitHub
parent d444648cc0
commit 7da8450075
100 changed files with 518 additions and 143 deletions
@@ -6,6 +6,7 @@ import { useOpenFrontComponentInCommandMenu } from '@/command-menu/hooks/useOpen
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useMountHeadlessFrontComponent } from '@/front-components/hooks/useMountHeadlessFrontComponent';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useContext } from 'react';
@@ -35,6 +36,7 @@ type BuildActionFromItemParams = {
pageTitle: string;
pageIcon: IconComponent;
}) => void;
mountHeadlessFrontComponent: (frontComponentId: string) => void;
};
const buildActionFromItem = ({
@@ -44,11 +46,26 @@ const buildActionFromItem = ({
isPinned,
getIcon,
openFrontComponentInCommandMenu,
mountHeadlessFrontComponent,
}: BuildActionFromItemParams) => {
const displayLabel = item.label;
const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON);
const isHeadless = item.frontComponent?.isHeadless === true;
const handleClick = () => {
if (isHeadless) {
mountHeadlessFrontComponent(item.frontComponentId);
} else {
openFrontComponentInCommandMenu({
frontComponentId: item.frontComponentId,
pageTitle: displayLabel,
pageIcon: Icon,
});
}
};
return {
type: ActionType.FrontComponent,
key: `command-menu-item-front-component-${item.id}`,
@@ -61,14 +78,8 @@ const buildActionFromItem = ({
shouldBeRegistered: () => true,
component: (
<Action
onClick={() =>
openFrontComponentInCommandMenu({
frontComponentId: item.frontComponentId,
pageTitle: displayLabel,
pageIcon: Icon,
})
}
closeSidePanelOnCommandMenuListActionExecution={false}
onClick={handleClick}
closeSidePanelOnCommandMenuListActionExecution={isHeadless}
/>
),
};
@@ -78,6 +89,7 @@ export const useCommandMenuItemFrontComponentActions = () => {
const { getIcon } = useIcons();
const { openFrontComponentInCommandMenu } =
useOpenFrontComponentInCommandMenu();
const mountHeadlessFrontComponent = useMountHeadlessFrontComponent();
const isPageInEditMode = useRecoilComponentValue(
contextStoreIsPageInEditModeComponentState,
@@ -140,6 +152,7 @@ export const useCommandMenuItemFrontComponentActions = () => {
isPinned: !isPageInEditMode && item.isPinned,
getIcon,
openFrontComponentInCommandMenu,
mountHeadlessFrontComponent,
}),
);
@@ -151,6 +164,7 @@ export const useCommandMenuItemFrontComponentActions = () => {
isPinned: !isPageInEditMode && item.isPinned,
getIcon,
openFrontComponentInCommandMenu,
mountHeadlessFrontComponent,
}),
);