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
@@ -7,6 +7,7 @@ import { type AppPath, type EnqueueSnackbarParams } from 'twenty-shared/types';
import { currentUserState } from '@/auth/states/currentUserState';
import { useCommandMenuConfirmationModal } from '@/command-menu-item/confirmation-modal/hooks/useCommandMenuConfirmationModal';
import { commandMenuItemProgressFamilyState } from '@/command-menu-item/states/commandMenuItemProgressFamilyState';
import { useRequestApplicationTokenRefresh } from '@/front-components/hooks/useRequestApplicationTokenRefresh';
import { useUnmountHeadlessFrontComponent } from '@/front-components/hooks/useUnmountHeadlessFrontComponent';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
@@ -15,14 +16,17 @@ import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { assertUnreachable } from 'twenty-shared/utils';
import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { useNavigateApp } from '~/hooks/useNavigateApp';
export const useFrontComponentExecutionContext = ({
frontComponentId,
commandMenuItemId,
}: {
frontComponentId: string;
commandMenuItemId?: string;
}): {
executionContext: FrontComponentExecutionContext;
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
@@ -44,6 +48,10 @@ export const useFrontComponentExecutionContext = ({
enqueueWarningSnackBar,
} = useSnackBar();
const { closeSidePanelMenu } = useSidePanelMenu();
const setCommandMenuItemProgress = useSetAtomFamilyState(
commandMenuItemProgressFamilyState,
commandMenuItemId ?? '',
);
const navigate: FrontComponentHostCommunicationApi['navigate'] = async (
to,
@@ -133,6 +141,15 @@ export const useFrontComponentExecutionContext = ({
closeSidePanelMenu();
};
const updateProgress: FrontComponentHostCommunicationApi['updateProgress'] =
async (progress) => {
if (!isDefined(commandMenuItemId)) {
return;
}
setCommandMenuItemProgress(Math.max(0, Math.min(100, progress)));
};
const frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi =
{
navigate,
@@ -142,6 +159,7 @@ export const useFrontComponentExecutionContext = ({
enqueueSnackbar,
unmountFrontComponent,
closeSidePanel,
updateProgress,
};
return {
@@ -9,13 +9,10 @@ import { useStore } from 'jotai';
export const useMountHeadlessFrontComponent = () => {
const store = useStore();
const mountHeadlessFrontComponent = useCallback(
(
frontComponentId: string,
context?: HeadlessFrontComponentMountContext,
) => {
(frontComponentId: string, context: HeadlessFrontComponentMountContext) => {
store.set(mountedHeadlessFrontComponentMapsState.atom, (previousMap) => {
const next = new Map(previousMap);
next.set(frontComponentId, context ?? undefined);
next.set(frontComponentId, context);
return next;
});
},
@@ -1,5 +1,6 @@
import { useCallback } from 'react';
import { commandMenuItemProgressFamilyState } from '@/command-menu-item/states/commandMenuItemProgressFamilyState';
import { mountedHeadlessFrontComponentMapsState } from '@/front-components/states/mountedHeadlessFrontComponentMapsState';
import { useStore } from 'jotai';
@@ -7,11 +8,23 @@ export const useUnmountHeadlessFrontComponent = () => {
const store = useStore();
const unmountHeadlessFrontComponent = useCallback(
(frontComponentId: string) => {
const currentMap = store.get(mountedHeadlessFrontComponentMapsState.atom);
const mountContext = currentMap.get(frontComponentId);
store.set(mountedHeadlessFrontComponentMapsState.atom, (previousMap) => {
const next = new Map(previousMap);
next.delete(frontComponentId);
return next;
});
if (mountContext) {
store.set(
commandMenuItemProgressFamilyState.atomFamily(
mountContext.commandMenuItemId,
),
undefined,
);
}
},
[store],
);