Add progress tracking to command menu items (#18732)

Introduces a progress indicator for command menu items (both engine
commands and front components). When a command is running, the menu item
now displays a percentage alongside the loader spinner instead of just a
spinner.

- Added `commandMenuItemProgressFamilyState` to track per-item progress
and `CommandListItemLoader` to render it
- Exposed `updateProgress` in the twenty-sdk public API so front
components can report execution progress back to the host
- Wired progress reporting into `ExportMultipleRecordsCommand` as the
first consumer, showing CSV export progress
- Progress state is cleaned up on unmount for both engine commands and
headless front components
- Refactor
This commit is contained in:
Raphaël Bosi
2026-03-18 15:37:27 +01:00
committed by GitHub
parent eb51f8e6da
commit f2fa958f20
23 changed files with 348 additions and 110 deletions
@@ -1,16 +1,15 @@
import { Command } from '@/command-menu-item/display/components/Command';
import { EngineCommandMenuItem } from '@/command-menu-item/display/components/EngineCommandMenuItem';
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';
@@ -21,7 +20,6 @@ import {
} from 'twenty-shared/utils';
import { type IconComponent, useIcons } from 'twenty-ui/display';
import { type HeadlessFrontComponentMountContext } from '@/front-components/states/mountedHeadlessFrontComponentMapsState';
import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon';
import { useQuery } from '@apollo/client/react';
import {
@@ -55,11 +53,8 @@ type BuildCommandMenuItemFromFrontComponentParams = {
objectNameSingular: string;
};
}) => void;
mountHeadlessFrontComponent: (
frontComponentId: string,
context?: HeadlessFrontComponentMountContext,
) => void;
mountContext?: HeadlessFrontComponentMountContext;
recordId?: string;
objectNameSingular?: string;
commandMenuContextApi: CommandMenuContextApi;
};
@@ -70,8 +65,8 @@ const buildCommandMenuItemFromFrontComponent = ({
isPinned,
getIcon,
openFrontComponentInSidePanel,
mountHeadlessFrontComponent,
mountContext,
recordId,
objectNameSingular,
commandMenuContextApi,
}: BuildCommandMenuItemFromFrontComponentParams) => {
const displayLabel = interpolateCommandMenuItemLabel({
@@ -88,22 +83,16 @@ const buildCommandMenuItemFromFrontComponent = ({
const isHeadless = item.frontComponent?.isHeadless === true;
const handleClick = () => {
if (isHeadless) {
mountHeadlessFrontComponent(item.frontComponentId, mountContext);
} else {
openFrontComponentInSidePanel({
frontComponentId: item.frontComponentId,
pageTitle: displayLabel ?? '',
pageIcon: Icon,
recordContext: isDefined(mountContext)
? {
recordId: mountContext.recordId,
objectNameSingular: mountContext.objectNameSingular,
}
const handleNonHeadlessClick = () => {
openFrontComponentInSidePanel({
frontComponentId: item.frontComponentId,
pageTitle: displayLabel ?? '',
pageIcon: Icon,
recordContext:
isDefined(recordId) && isDefined(objectNameSingular)
? { recordId, objectNameSingular }
: undefined,
});
}
});
};
return {
@@ -124,10 +113,12 @@ const buildCommandMenuItemFromFrontComponent = ({
component: isHeadless ? (
<HeadlessFrontComponentCommandMenuItem
frontComponentId={item.frontComponentId}
onClick={handleClick}
commandMenuItemId={item.id}
recordId={recordId}
objectNameSingular={objectNameSingular}
/>
) : (
<Command onClick={handleClick} />
<Command onClick={handleNonHeadlessClick} />
),
};
};
@@ -140,12 +131,6 @@ type BuildCommandMenuItemFromStandardKeyParams = {
isPinned: boolean;
getIcon: ReturnType<typeof useIcons>['getIcon'];
commandMenuContextApi: CommandMenuContextApi;
mountEngineCommand: (
engineCommandId: string,
contextStoreInstanceId: string,
engineComponentKey: EngineComponentKey,
) => void;
contextStoreInstanceId: string;
};
const buildCommandItemFromEngineKey = ({
@@ -156,15 +141,9 @@ const buildCommandItemFromEngineKey = ({
isPinned,
getIcon,
commandMenuContextApi,
mountEngineCommand,
contextStoreInstanceId,
}: BuildCommandMenuItemFromStandardKeyParams) => {
const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON);
const handleClick = () => {
mountEngineCommand(item.id, contextStoreInstanceId, engineComponentKey);
};
return {
type,
key: `command-menu-item-engine-${item.id}`,
@@ -186,7 +165,12 @@ const buildCommandItemFromEngineKey = ({
item.conditionalAvailabilityExpression,
commandMenuContextApi,
),
component: <Command onClick={handleClick} />,
component: (
<EngineCommandMenuItem
commandMenuItemId={item.id}
engineComponentKey={engineComponentKey}
/>
),
};
};
@@ -195,12 +179,6 @@ export const useCommandMenuItemsFromBackend = (
) => {
const { getIcon } = useIcons();
const { openFrontComponentInSidePanel } = useOpenFrontComponentInSidePanel();
const mountHeadlessFrontComponent = useMountHeadlessFrontComponent();
const mountEngineCommand = useMountEngineCommand();
const contextStoreInstanceId = useAvailableComponentInstanceIdOrThrow(
ContextStoreComponentInstanceContext,
);
const contextStoreIsPageInEditMode = useAtomComponentStateValue(
contextStoreIsPageInEditModeComponentState,
@@ -229,13 +207,10 @@ export const useCommandMenuItemsFromBackend = (
selectedRecordIds.length >= 1 ||
contextStoreTargetedRecordsRule.mode === 'exclusion';
const mountContext: HeadlessFrontComponentMountContext | undefined =
selectedRecordIds.length === 1 && isDefined(currentObjectMetadataItem)
? {
recordId: selectedRecordIds[0],
objectNameSingular: currentObjectMetadataItem.nameSingular,
}
: undefined;
const recordId =
selectedRecordIds.length === 1 ? selectedRecordIds[0] : undefined;
const objectNameSingular = currentObjectMetadataItem?.nameSingular;
const { data } = useQuery(FindManyCommandMenuItemsDocument);
@@ -268,8 +243,6 @@ export const useCommandMenuItemsFromBackend = (
isPinned,
getIcon,
commandMenuContextApi,
mountEngineCommand,
contextStoreInstanceId,
});
}
@@ -281,9 +254,9 @@ export const useCommandMenuItemsFromBackend = (
isPinned,
getIcon,
openFrontComponentInSidePanel,
mountHeadlessFrontComponent,
commandMenuContextApi,
mountContext,
recordId,
objectNameSingular,
});
}