feat(ui): enforce loader when switching tabs

This commit is contained in:
psychedelicious
2025-07-04 16:46:32 +10:00
parent 69a56aafed
commit d94aa4abf7
30 changed files with 202 additions and 186 deletions

View File

@@ -17,6 +17,15 @@ module.exports = {
'no-promise-executor-return': 'error',
// https://eslint.org/docs/latest/rules/require-await
'require-await': 'error',
// Restrict setActiveTab calls to only use-navigation-api.tsx
'no-restricted-syntax': [
'error',
{
selector: 'CallExpression[callee.name="setActiveTab"]',
message:
'setActiveTab() can only be called from use-navigation-api.tsx. Use navigationApi.switchToTab() instead.',
},
],
// TODO: ENABLE THIS RULE BEFORE v6.0.0
'react/display-name': 'off',
'no-restricted-properties': [
@@ -56,6 +65,15 @@ module.exports = {
],
},
overrides: [
/**
* Allow setActiveTab calls only in use-navigation-api.tsx
*/
{
files: ['**/use-navigation-api.tsx'],
rules: {
'no-restricted-syntax': 'off',
},
},
/**
* Overrides for stories
*/

View File

@@ -11,6 +11,7 @@ import type { PartialAppConfig } from 'app/types/invokeai';
import { useFocusRegionWatcher } from 'common/hooks/focus';
import { useCloseChakraTooltipsOnDragFix } from 'common/hooks/useCloseChakraTooltipsOnDragFix';
import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys';
import { useDndMonitor } from 'features/dnd/useDndMonitor';
import { useDynamicPromptsWatcher } from 'features/dynamicPrompts/hooks/useDynamicPromptsWatcher';
import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast';
import { useWorkflowBuilderWatcher } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
@@ -45,6 +46,7 @@ export const GlobalHookIsolator = memo(
useSyncLoggingConfig();
useCloseChakraTooltipsOnDragFix();
useNavigationApi();
useDndMonitor();
// Persistent subscription to the queue counts query - canvas relies on this to know if there are pending
// and/or in progress canvas sessions.

View File

@@ -19,7 +19,8 @@ import {
} from 'features/nodes/store/workflowLibrarySlice';
import { $isStylePresetsMenuOpen, activeStylePresetIdChanged } from 'features/stylePresets/store/stylePresetSlice';
import { toast } from 'features/toast/toast';
import { activeTabCanvasRightPanelChanged, setActiveTab } from 'features/ui/store/uiSlice';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice';
import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
import { atom } from 'nanostores';
import { useCallback, useEffect } from 'react';
@@ -122,17 +123,17 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
);
const handleLoadWorkflow = useCallback(
async (workflowId: string) => {
(workflowId: string) => {
// This shows a toast
await loadWorkflowWithDialog({
loadWorkflowWithDialog({
type: 'library',
data: workflowId,
onSuccess: () => {
store.dispatch(setActiveTab('workflows'));
navigationApi.switchToTab('workflows');
},
});
},
[loadWorkflowWithDialog, store]
[loadWorkflowWithDialog]
);
const handleSelectStylePreset = useCallback(
@@ -146,7 +147,7 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
return;
}
store.dispatch(activeStylePresetIdChanged(stylePresetId));
store.dispatch(setActiveTab('canvas'));
navigationApi.switchToTab('canvas');
toast({
title: t('toast.stylePresetLoaded'),
status: 'info',
@@ -169,20 +170,20 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
break;
case 'workflows':
// Go to the workflows tab
store.dispatch(setActiveTab('workflows'));
navigationApi.switchToTab('workflows');
break;
case 'upscaling':
// Go to the upscaling tab
store.dispatch(setActiveTab('upscaling'));
navigationApi.switchToTab('upscaling');
break;
case 'viewAllWorkflows':
// Go to the workflows tab and open the workflow library modal
store.dispatch(setActiveTab('workflows'));
navigationApi.switchToTab('workflows');
$isWorkflowLibraryModalOpen.set(true);
break;
case 'viewAllWorkflowsRecommended':
// Go to the workflows tab and open the workflow library modal with the recommended workflows view
store.dispatch(setActiveTab('workflows'));
navigationApi.switchToTab('workflows');
$isWorkflowLibraryModalOpen.set(true);
store.dispatch(workflowLibraryViewChanged('defaults'));
store.dispatch(workflowLibraryTagsReset());
@@ -194,7 +195,7 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
break;
case 'viewAllStylePresets':
// Go to the canvas tab and open the style presets menu
store.dispatch(setActiveTab('canvas'));
navigationApi.switchToTab('canvas');
$isStylePresetsMenuOpen.set(true);
break;
}

View File

@@ -6,7 +6,7 @@ import { useDeleteCurrentQueueItem } from 'features/queue/hooks/useDeleteCurrent
import { useInvoke } from 'features/queue/hooks/useInvoke';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { getFocusedRegion } from './focus';
@@ -69,7 +69,7 @@ export const useGlobalHotkeys = () => {
id: 'selectGenerateTab',
category: 'app',
callback: () => {
dispatch(setActiveTab('generate'));
navigationApi.switchToTab('generate');
},
dependencies: [dispatch],
});
@@ -78,7 +78,7 @@ export const useGlobalHotkeys = () => {
id: 'selectCanvasTab',
category: 'app',
callback: () => {
dispatch(setActiveTab('canvas'));
navigationApi.switchToTab('canvas');
},
dependencies: [dispatch],
});
@@ -87,7 +87,7 @@ export const useGlobalHotkeys = () => {
id: 'selectUpscalingTab',
category: 'app',
callback: () => {
dispatch(setActiveTab('upscaling'));
navigationApi.switchToTab('upscaling');
},
dependencies: [dispatch],
});
@@ -96,7 +96,7 @@ export const useGlobalHotkeys = () => {
id: 'selectWorkflowsTab',
category: 'app',
callback: () => {
dispatch(setActiveTab('workflows'));
navigationApi.switchToTab('workflows');
},
dependencies: [dispatch],
});
@@ -105,7 +105,7 @@ export const useGlobalHotkeys = () => {
id: 'selectModelsTab',
category: 'app',
callback: () => {
dispatch(setActiveTab('models'));
navigationApi.switchToTab('models');
},
options: {
enabled: isModelManagerEnabled,
@@ -117,7 +117,7 @@ export const useGlobalHotkeys = () => {
id: 'selectQueueTab',
category: 'app',
callback: () => {
dispatch(setActiveTab('queue'));
navigationApi.switchToTab('queue');
},
dependencies: [dispatch, isModelManagerEnabled],
});

View File

@@ -1,17 +1,15 @@
import { Alert, Button, Flex, Grid, Heading, Text } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { InitialStateMainModelPicker } from 'features/controlLayers/components/SimpleSession/InitialStateMainModelPicker';
import { LaunchpadAddStyleReference } from 'features/controlLayers/components/SimpleSession/LaunchpadAddStyleReference';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { memo, useCallback } from 'react';
import { LaunchpadGenerateFromTextButton } from './LaunchpadGenerateFromTextButton';
export const GenerateLaunchpadPanel = memo(() => {
const dispatch = useAppDispatch();
const newCanvasSession = useCallback(() => {
dispatch(setActiveTab('canvas'));
}, [dispatch]);
navigationApi.switchToTab('canvas');
}, []);
return (
<Flex flexDir="column" h="full" w="full" alignItems="center" gap={2}>

View File

@@ -20,8 +20,8 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => {
const onClickNewCanvasWithRasterLayerFromImage = useCallback(async () => {
const { dispatch, getState } = store;
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
await newCanvasFromImage({ imageDTO, withResize: false, type: 'raster_layer', dispatch, getState });
navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
@@ -31,8 +31,8 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => {
const onClickNewCanvasWithControlLayerFromImage = useCallback(async () => {
const { dispatch, getState } = store;
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
await newCanvasFromImage({ imageDTO, withResize: false, type: 'control_layer', dispatch, getState });
navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
@@ -42,8 +42,8 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => {
const onClickNewCanvasWithRasterLayerFromImageWithResize = useCallback(async () => {
const { dispatch, getState } = store;
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
await newCanvasFromImage({ imageDTO, withResize: true, type: 'raster_layer', dispatch, getState });
navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
@@ -53,8 +53,8 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => {
const onClickNewCanvasWithControlLayerFromImageWithResize = useCallback(async () => {
const { dispatch, getState } = store;
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
await newCanvasFromImage({ imageDTO, withResize: true, type: 'control_layer', dispatch, getState });
navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),

View File

@@ -20,11 +20,11 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
const imageDTO = useImageDTOContext();
const isBusy = useCanvasIsBusySafe();
const onClickNewRasterLayerFromImage = useCallback(() => {
const onClickNewRasterLayerFromImage = useCallback(async () => {
const { dispatch, getState } = store;
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
createNewCanvasEntityFromImage({ imageDTO, type: 'raster_layer', dispatch, getState });
dispatch(sentImageToCanvas());
navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
@@ -32,11 +32,11 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
});
}, [imageDTO, store, t]);
const onClickNewControlLayerFromImage = useCallback(() => {
const onClickNewControlLayerFromImage = useCallback(async () => {
const { dispatch, getState } = store;
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
createNewCanvasEntityFromImage({ imageDTO, type: 'control_layer', dispatch, getState });
dispatch(sentImageToCanvas());
navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
@@ -44,11 +44,11 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
});
}, [imageDTO, store, t]);
const onClickNewInpaintMaskFromImage = useCallback(() => {
const onClickNewInpaintMaskFromImage = useCallback(async () => {
const { dispatch, getState } = store;
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
createNewCanvasEntityFromImage({ imageDTO, type: 'inpaint_mask', dispatch, getState });
dispatch(sentImageToCanvas());
navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
@@ -56,11 +56,11 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
});
}, [imageDTO, store, t]);
const onClickNewRegionalGuidanceFromImage = useCallback(() => {
const onClickNewRegionalGuidanceFromImage = useCallback(async () => {
const { dispatch, getState } = store;
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
createNewCanvasEntityFromImage({ imageDTO, type: 'regional_guidance', dispatch, getState });
dispatch(sentImageToCanvas());
navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),
@@ -68,11 +68,11 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => {
});
}, [imageDTO, store, t]);
const onClickNewRegionalReferenceImageFromImage = useCallback(() => {
const onClickNewRegionalReferenceImageFromImage = useCallback(async () => {
const { dispatch, getState } = store;
await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
createNewCanvasEntityFromImage({ imageDTO, type: 'regional_guidance_with_reference_image', dispatch, getState });
dispatch(sentImageToCanvas());
navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID);
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToCanvas'),

View File

@@ -3,7 +3,7 @@ import { useAppDispatch } from 'app/store/storeHooks';
import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice';
import { toast } from 'features/toast/toast';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiShareFatBold } from 'react-icons/pi';
@@ -15,7 +15,7 @@ export const ImageMenuItemSendToUpscale = memo(() => {
const handleSendToCanvas = useCallback(() => {
dispatch(upscaleInitialImageChanged(imageDTO));
dispatch(setActiveTab('upscaling'));
navigationApi.switchToTab('upscaling');
toast({
id: 'SENT_TO_CANVAS',
title: t('toast.sentToUpscale'),

View File

@@ -1,14 +1,14 @@
import type { ButtonProps } from '@invoke-ai/ui-library';
import { Alert, AlertDescription, AlertIcon, Button, Divider, Flex, Link, Spinner, Text } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { InvokeLogoIcon } from 'common/components/InvokeLogoIcon';
import { LOADING_SYMBOL, useHasImages } from 'features/gallery/hooks/useHasImages';
import { setInstallModelsTabByName } from 'features/modelManagerV2/store/installModelsStore';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { selectIsLocal } from 'features/system/store/configSlice';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { setActiveTab } from 'features/ui/store/uiSlice';
import type { PropsWithChildren } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
@@ -129,17 +129,15 @@ const GettingStartedVideosCallout = () => {
};
const StarterBundlesCallout = () => {
const dispatch = useAppDispatch();
const handleClickDownloadStarterModels = useCallback(() => {
dispatch(setActiveTab('models'));
navigationApi.switchToTab('models');
setInstallModelsTabByName('starterModels');
}, [dispatch]);
}, []);
const handleClickImportModels = useCallback(() => {
dispatch(setActiveTab('models'));
navigationApi.switchToTab('models');
setInstallModelsTabByName('urlOrLocal');
}, [dispatch]);
}, []);
return (
<Text fontSize="md" color="base.200">

View File

@@ -1,8 +1,7 @@
import { Button, Text, useToast } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { setInstallModelsTabByName } from 'features/modelManagerV2/store/installModelsStore';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMainModels } from 'services/api/hooks/modelsByType';
@@ -40,14 +39,13 @@ export const useStarterModelsToast = () => {
const ToastDescription = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const toast = useToast();
const onClick = useCallback(() => {
dispatch(setActiveTab('models'));
navigationApi.switchToTab('models');
setInstallModelsTabByName('launchpad');
toast.close(TOAST_ID);
}, [dispatch, toast]);
}, [toast]);
return (
<Text fontSize="md">

View File

@@ -3,7 +3,6 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectWorkflowMode, workflowModeChanged } from 'features/nodes/store/workflowLibrarySlice';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { VIEWER_PANEL_ID, WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared';
import { setActiveTab } from 'features/ui/store/uiSlice';
import type { MouseEventHandler } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -18,7 +17,6 @@ export const WorkflowViewEditToggleButton = memo(() => {
(e) => {
e.stopPropagation();
// Navigate to workflows tab and focus the Workflow Editor panel
dispatch(setActiveTab('workflows'));
dispatch(workflowModeChanged('edit'));
// Focus the Workflow Editor panel
navigationApi.focusPanel('workflows', WORKSPACE_PANEL_ID);
@@ -30,7 +28,6 @@ export const WorkflowViewEditToggleButton = memo(() => {
(e) => {
e.stopPropagation();
// Navigate to workflows tab and focus the Image Viewer panel
dispatch(setActiveTab('workflows'));
dispatch(workflowModeChanged('view'));
// Focus the Image Viewer panel
navigationApi.focusPanel('workflows', VIEWER_PANEL_ID);

View File

@@ -2,9 +2,9 @@ import type { IconButtonProps } from '@invoke-ai/ui-library';
import { IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $onClickGoToModelManager } from 'app/store/nanostores/onClickGoToModelManager';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAppSelector } from 'app/store/storeHooks';
import { selectIsModelsTabDisabled } from 'features/system/store/configSlice';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCubeBold } from 'react-icons/pi';
@@ -14,11 +14,10 @@ export const NavigateToModelManagerButton = memo((props: Omit<IconButtonProps, '
const onClickGoToModelManager = useStore($onClickGoToModelManager);
const { t } = useTranslation();
const dispatch = useAppDispatch();
const onClick = useCallback(() => {
dispatch(setActiveTab('models'));
}, [dispatch]);
navigationApi.switchToTab('models');
}, []);
if (isModelsTabDisabled && !onClickGoToModelManager) {
return null;

View File

@@ -16,7 +16,7 @@ import { useStore } from '@nanostores/react';
import { EMPTY_ARRAY } from 'app/store/constants';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { $onClickGoToModelManager } from 'app/store/nanostores/onClickGoToModelManager';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAppSelector } from 'app/store/storeHooks';
import type { Group, PickerContextState } from 'common/components/Picker/Picker';
import { buildGroup, getRegex, isOption, Picker, usePickerContext } from 'common/components/Picker/Picker';
import { useDisclosure } from 'common/hooks/useBoolean';
@@ -31,7 +31,7 @@ import { NavigateToModelManagerButton } from 'features/parameters/components/Mai
import { API_BASE_MODELS, MODEL_TYPE_MAP, MODEL_TYPE_SHORT_MAP } from 'features/parameters/types/constants';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { selectIsModelsTabDisabled } from 'features/system/store/configSlice';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { filesize } from 'filesize';
import { memo, useCallback, useMemo, useRef } from 'react';
import { Trans, useTranslation } from 'react-i18next';
@@ -72,11 +72,10 @@ const getOptionId = <T extends AnyModelConfig>(modelConfig: WithStarred<T>) => m
const ModelManagerLink = memo((props: ButtonProps) => {
const onClickGoToModelManager = useStore($onClickGoToModelManager);
const dispatch = useAppDispatch();
const onClick = useCallback(() => {
dispatch(setActiveTab('models'));
navigationApi.switchToTab('models');
setInstallModelsTabByName('launchpad');
}, [dispatch]);
}, []);
return (
<Button

View File

@@ -15,7 +15,7 @@ import { setInstallModelsTabByName } from 'features/modelManagerV2/store/install
import ParamPostProcessingModel from 'features/parameters/components/PostProcessing/ParamPostProcessingModel';
import { selectPostProcessingModel } from 'features/parameters/store/upscaleSlice';
import { useIsQueueMutationInProgress } from 'features/queue/hooks/useIsQueueMutationInProgress';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { memo, useCallback } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { PiFrameCornersBold } from 'react-icons/pi';
@@ -74,12 +74,10 @@ export const PostProcessingPopover = memo((props: Props) => {
PostProcessingPopover.displayName = 'PostProcessingPopover';
const MissingModelWarning = () => {
const dispatch = useAppDispatch();
const handleGoToModelManager = useCallback(() => {
dispatch(setActiveTab('models'));
navigationApi.switchToTab('models');
setInstallModelsTabByName('launchpad');
}, [dispatch]);
}, []);
return (
<Flex bg="error.500" borderRadius="base" padding={4} direction="column" fontSize="sm" gap={2}>

View File

@@ -1,5 +1,4 @@
import { IconButton, Menu, MenuButton, MenuGroup, MenuItem, MenuList } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { SessionMenuItems } from 'common/components/SessionMenuItems';
import { useDeleteAllExceptCurrentQueueItemDialog } from 'features/queue/components/DeleteAllExceptCurrentQueueItemConfirmationAlertDialog';
import { QueueCountBadge } from 'features/queue/components/QueueCountBadge';
@@ -7,14 +6,13 @@ import { useDeleteCurrentQueueItem } from 'features/queue/hooks/useDeleteCurrent
import { usePauseProcessor } from 'features/queue/hooks/usePauseProcessor';
import { useResumeProcessor } from 'features/queue/hooks/useResumeProcessor';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { PiListBold, PiPauseFill, PiPlayFill, PiQueueBold, PiXBold, PiXCircle } from 'react-icons/pi';
export const QueueActionsMenuButton = memo(() => {
const ref = useRef<HTMLDivElement>(null);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const isPauseEnabled = useFeatureStatus('pauseQueue');
const isResumeEnabled = useFeatureStatus('resumeQueue');
@@ -23,8 +21,8 @@ export const QueueActionsMenuButton = memo(() => {
const resumeProcessor = useResumeProcessor();
const pauseProcessor = usePauseProcessor();
const openQueue = useCallback(() => {
dispatch(setActiveTab('queue'));
}, [dispatch]);
navigationApi.switchToTab('queue');
}, []);
return (
<>

View File

@@ -10,7 +10,7 @@ import {
tileControlnetModelChanged,
} from 'features/parameters/store/upscaleSlice';
import { selectIsModelsTabDisabled, selectMaxUpscaleDimension } from 'features/system/store/configSlice';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { useCallback, useEffect, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useControlNetModels } from 'services/api/hooks/modelsByType';
@@ -68,9 +68,9 @@ export const UpscaleWarning = () => {
const allWarnings = useMemo(() => [...modelWarnings, ...otherWarnings], [modelWarnings, otherWarnings]);
const handleGoToModelManager = useCallback(() => {
dispatch(setActiveTab('models'));
navigationApi.switchToTab('models');
setInstallModelsTabByName('launchpad');
}, [dispatch]);
}, []);
if (isBaseModelCompatible && modelWarnings.length > 0 && isModelsTabDisabled) {
return null;

View File

@@ -2,9 +2,9 @@ import 'dockview/dist/styles/dockview.css';
import 'features/ui/styles/dockview-theme-invoke.css';
import { Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import Loading from 'common/components/Loading/Loading';
import { useDndMonitor } from 'features/dnd/useDndMonitor';
import {
selectWithCanvasTab,
selectWithGenerateTab,
@@ -17,16 +17,25 @@ import { VerticalNavBar } from 'features/ui/components/VerticalNavBar';
import { CanvasTabAutoLayout } from 'features/ui/layouts/canvas-tab-auto-layout';
import { GenerateTabAutoLayout } from 'features/ui/layouts/generate-tab-auto-layout';
import { ModelsTabAutoLayout } from 'features/ui/layouts/models-tab-auto-layout';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { QueueTabAutoLayout } from 'features/ui/layouts/queue-tab-auto-layout';
import { UpscalingTabAutoLayout } from 'features/ui/layouts/upscaling-tab-auto-layout';
import { WorkflowsTabAutoLayout } from 'features/ui/layouts/workflows-tab-auto-layout';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { memo, useState } from 'react';
import { memo } from 'react';
export const AppContent = memo(() => {
useDndMonitor();
return (
<Flex position="relative" w="full" h="full" overflow="hidden">
<VerticalNavBar />
<TabContent />
</Flex>
);
});
AppContent.displayName = 'AppContent';
const TabContent = memo(() => {
const tab = useAppSelector(selectActiveTab);
const [isLoading, setIsLoading] = useState(true);
const withGenerateTab = useAppSelector(selectWithGenerateTab);
const withCanvasTab = useAppSelector(selectWithCanvasTab);
const withUpscalingTab = useAppSelector(selectWithUpscalingTab);
@@ -36,17 +45,25 @@ export const AppContent = memo(() => {
return (
<Flex position="relative" w="full" h="full" overflow="hidden">
<VerticalNavBar />
<Flex position="relative" w="full" h="full" overflow="hidden">
{withGenerateTab && tab === 'generate' && <GenerateTabAutoLayout setIsLoading={setIsLoading} />}
{withCanvasTab && tab === 'canvas' && <CanvasTabAutoLayout setIsLoading={setIsLoading} />}
{withUpscalingTab && tab === 'upscaling' && <UpscalingTabAutoLayout setIsLoading={setIsLoading} />}
{withWorkflowsTab && tab === 'workflows' && <WorkflowsTabAutoLayout setIsLoading={setIsLoading} />}
{withModelsTab && tab === 'models' && <ModelsTabAutoLayout setIsLoading={setIsLoading} />}
{withQueueTab && tab === 'queue' && <QueueTabAutoLayout setIsLoading={setIsLoading} />}
{isLoading && <Loading />}
</Flex>
{withGenerateTab && tab === 'generate' && <GenerateTabAutoLayout />}
{withCanvasTab && tab === 'canvas' && <CanvasTabAutoLayout />}
{withUpscalingTab && tab === 'upscaling' && <UpscalingTabAutoLayout />}
{withWorkflowsTab && tab === 'workflows' && <WorkflowsTabAutoLayout />}
{withModelsTab && tab === 'models' && <ModelsTabAutoLayout />}
{withQueueTab && tab === 'queue' && <QueueTabAutoLayout />}
<SwitchingTabsLoader />
</Flex>
);
});
AppContent.displayName = 'AppContent';
TabContent.displayName = 'TabContent';
const SwitchingTabsLoader = memo(() => {
const isSwitchingTabs = useStore(navigationApi.$isSwitchingTabs);
if (isSwitchingTabs) {
return <Loading />;
}
return null;
});
SwitchingTabsLoader.displayName = 'SwitchingTabsLoader';

View File

@@ -1,9 +1,9 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { IconButton, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAppSelector } from 'app/store/storeHooks';
import { useCallbackOnDragEnter } from 'common/hooks/useCallbackOnDragEnter';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import { setActiveTab } from 'features/ui/store/uiSlice';
import type { TabName } from 'features/ui/store/uiTypes';
import type { ReactElement } from 'react';
import { memo, useCallback, useRef } from 'react';
@@ -15,12 +15,11 @@ const sx: SystemStyleObject = {
};
export const TabButton = memo(({ tab, icon, label }: { tab: TabName; icon: ReactElement; label: string }) => {
const dispatch = useAppDispatch();
const ref = useRef<HTMLDivElement>(null);
const activeTabName = useAppSelector(selectActiveTab);
const selectTab = useCallback(() => {
dispatch(setActiveTab(tab));
}, [dispatch, tab]);
navigationApi.switchToTab(tab);
}, [tab]);
useCallbackOnDragEnter(selectTab, ref, 300);
return (

View File

@@ -325,7 +325,7 @@ export const initializeRootPanelLayout = (api: GridviewApi) => {
return { main, left, right } satisfies Record<string, IGridviewPanel>;
};
export const CanvasTabAutoLayout = memo(({ setIsLoading }: { setIsLoading: (isLoading: boolean) => void }) => {
export const CanvasTabAutoLayout = memo(() => {
const rootRef = useRef<HTMLDivElement>(null);
const [rootApi, setRootApi] = useState<GridviewApi | null>(null);
const onReady = useCallback<IGridviewReactProps['onReady']>(({ api }) => {
@@ -333,22 +333,18 @@ export const CanvasTabAutoLayout = memo(({ setIsLoading }: { setIsLoading: (isLo
}, []);
useEffect(() => {
setIsLoading(true);
if (!rootApi) {
return;
}
initializeRootPanelLayout(rootApi);
setTimeout(() => {
setIsLoading(false);
}, 300);
navigationApi.onSwitchedTab();
return () => {
navigationApi.unregisterTab('canvas');
};
}, [rootApi, setIsLoading]);
}, [rootApi]);
return (
<AutoLayoutProvider tab="canvas" rootRef={rootRef}>

View File

@@ -287,7 +287,7 @@ export const initializeRootPanelLayout = (layoutApi: GridviewApi) => {
return { main, left, right } satisfies Record<string, IGridviewPanel>;
};
export const GenerateTabAutoLayout = memo(({ setIsLoading }: { setIsLoading: (isLoading: boolean) => void }) => {
export const GenerateTabAutoLayout = memo(() => {
const rootRef = useRef<HTMLDivElement>(null);
const [rootApi, setRootApi] = useState<GridviewApi | null>(null);
const onReady = useCallback<IGridviewReactProps['onReady']>(({ api }) => {
@@ -295,22 +295,18 @@ export const GenerateTabAutoLayout = memo(({ setIsLoading }: { setIsLoading: (is
}, []);
useEffect(() => {
setIsLoading(true);
if (!rootApi) {
return;
}
initializeRootPanelLayout(rootApi);
setTimeout(() => {
setIsLoading(false);
}, 300);
navigationApi.onSwitchedTab();
return () => {
navigationApi.unregisterTab('generate');
};
}, [rootApi, setIsLoading]);
}, [rootApi]);
return (
<AutoLayoutProvider tab="generate" rootRef={rootRef}>

View File

@@ -24,7 +24,7 @@ export const initializeRootPanelLayout = (layoutApi: GridviewApi) => {
return { models } satisfies Record<string, IGridviewPanel>;
};
export const ModelsTabAutoLayout = memo(({ setIsLoading }: { setIsLoading: (isLoading: boolean) => void }) => {
export const ModelsTabAutoLayout = memo(() => {
const rootRef = useRef<HTMLDivElement>(null);
const [rootApi, setRootApi] = useState<GridviewApi | null>(null);
const onReady = useCallback<IGridviewReactProps['onReady']>(({ api }) => {
@@ -32,22 +32,18 @@ export const ModelsTabAutoLayout = memo(({ setIsLoading }: { setIsLoading: (isLo
}, []);
useEffect(() => {
setIsLoading(true);
if (!rootApi) {
return;
}
initializeRootPanelLayout(rootApi);
setTimeout(() => {
setIsLoading(false);
}, 300);
navigationApi.onSwitchedTab();
return () => {
navigationApi.unregisterTab('models');
};
}, [rootApi, setIsLoading]);
}, [rootApi]);
return (
<AutoLayoutProvider tab="models" rootRef={rootRef}>

View File

@@ -83,16 +83,16 @@ describe('AppNavigationApi', () => {
it('should connect to app', () => {
navigationApi.connectToApp({ setAppTab: mockSetAppTab, getAppTab: mockGetAppTab });
expect(navigationApi.setAppTab).toBe(mockSetAppTab);
expect(navigationApi.getAppTab).toBe(mockGetAppTab);
expect(navigationApi._setAppTab).toBe(mockSetAppTab);
expect(navigationApi._getAppTab).toBe(mockGetAppTab);
});
it('should disconnect from app', () => {
navigationApi.connectToApp({ setAppTab: mockSetAppTab, getAppTab: mockGetAppTab });
navigationApi.disconnectFromApp();
expect(navigationApi.setAppTab).toBeNull();
expect(navigationApi.getAppTab).toBeNull();
expect(navigationApi._setAppTab).toBeNull();
expect(navigationApi._getAppTab).toBeNull();
});
});
@@ -348,8 +348,8 @@ describe('AppNavigationApi', () => {
unregister();
navigationApi.disconnectFromApp();
expect(navigationApi.setAppTab).toBeNull();
expect(navigationApi.getAppTab).toBeNull();
expect(navigationApi._setAppTab).toBeNull();
expect(navigationApi._getAppTab).toBeNull();
expect(navigationApi.isPanelRegistered('generate', SETTINGS_PANEL_ID)).toBe(false);
});

View File

@@ -2,8 +2,15 @@ import { logger } from 'app/logging/logger';
import { createDeferredPromise, type Deferred } from 'common/util/createDeferredPromise';
import { GridviewPanel, type IDockviewPanel, type IGridviewPanel } from 'dockview';
import type { TabName } from 'features/ui/store/uiTypes';
import { atom } from 'nanostores';
import { LEFT_PANEL_ID, LEFT_PANEL_MIN_SIZE_PX, RIGHT_PANEL_ID, RIGHT_PANEL_MIN_SIZE_PX } from './shared';
import {
LEFT_PANEL_ID,
LEFT_PANEL_MIN_SIZE_PX,
RIGHT_PANEL_ID,
RIGHT_PANEL_MIN_SIZE_PX,
SWITCH_TABS_FAKE_DELAY_MS,
} from './shared';
const log = logger('system');
@@ -18,20 +25,49 @@ export class NavigationApi {
private panels: Map<string, PanelType> = new Map();
private waiters: Map<string, Waiter> = new Map();
$isSwitchingTabs = atom(false);
switchingTabsTimeout: ReturnType<typeof setTimeout> | null = null;
KEY_SEPARATOR = ':';
setAppTab: ((tab: TabName) => void) | null = null;
getAppTab: (() => TabName) | null = null;
_setAppTab: ((tab: TabName) => void) | null = null;
_getAppTab: (() => TabName) | null = null;
connectToApp = (arg: { setAppTab: (tab: TabName) => void; getAppTab: () => TabName }): void => {
const { setAppTab, getAppTab } = arg;
this.setAppTab = setAppTab;
this.getAppTab = getAppTab;
this._setAppTab = setAppTab;
this._getAppTab = getAppTab;
};
disconnectFromApp = (): void => {
this.setAppTab = null;
this.getAppTab = null;
this._setAppTab = null;
this._getAppTab = null;
};
switchToTab = (tab: TabName): boolean => {
if (this.switchingTabsTimeout !== null) {
clearTimeout(this.switchingTabsTimeout);
this.switchingTabsTimeout = null;
}
if (tab === this._getAppTab?.()) {
return true;
}
this.$isSwitchingTabs.set(true);
log.debug(`Switching to tab: ${tab}`);
if (this._setAppTab) {
this._setAppTab(tab);
return true;
} else {
log.error('No setAppTab function available to switch tabs');
return false;
}
};
onSwitchedTab = (): void => {
log.debug('Tab switch completed');
this.switchingTabsTimeout = setTimeout(() => {
this.$isSwitchingTabs.set(false);
}, SWITCH_TABS_FAKE_DELAY_MS);
};
/**
@@ -118,9 +154,7 @@ export class NavigationApi {
focusPanel = async (tab: TabName, panelId: string): Promise<boolean> => {
try {
// Switch to the target tab if needed
if (this.setAppTab && this.getAppTab && this.getAppTab() !== tab) {
this.setAppTab(tab);
}
this.switchToTab(tab);
// Wait for the panel to be ready
await this.waitForPanel(tab, panelId);
@@ -145,7 +179,7 @@ export class NavigationApi {
};
focusPanelInActiveTab = (panelId: string): Promise<boolean> => {
const activeTab = this.getAppTab ? this.getAppTab() : null;
const activeTab = this._getAppTab ? this._getAppTab() : null;
if (!activeTab) {
log.error('No active tab found');
return Promise.resolve(false);
@@ -169,7 +203,7 @@ export class NavigationApi {
};
toggleLeftPanel = (): boolean => {
const activeTab = this.getAppTab ? this.getAppTab() : null;
const activeTab = this._getAppTab ? this._getAppTab() : null;
if (!activeTab) {
log.warn('No active tab found to toggle left panel');
return false;
@@ -195,7 +229,7 @@ export class NavigationApi {
};
toggleRightPanel = (): boolean => {
const activeTab = this.getAppTab ? this.getAppTab() : null;
const activeTab = this._getAppTab ? this._getAppTab() : null;
if (!activeTab) {
log.warn('No active tab found to toggle right panel');
return false;
@@ -221,7 +255,7 @@ export class NavigationApi {
};
toggleLeftAndRightPanels = (): boolean => {
const activeTab = this.getAppTab ? this.getAppTab() : null;
const activeTab = this._getAppTab ? this._getAppTab() : null;
if (!activeTab) {
log.warn('No active tab found to toggle right panel');
return false;
@@ -256,7 +290,7 @@ export class NavigationApi {
* Reset panels in a specific tab (expand both left and right)
*/
resetLeftAndRightPanels = (): boolean => {
const activeTab = this.getAppTab ? this.getAppTab() : null;
const activeTab = this._getAppTab ? this._getAppTab() : null;
if (!activeTab) {
log.warn('No active tab found to toggle right panel');
return false;

View File

@@ -24,7 +24,7 @@ export const initializeRootPanelLayout = (layoutApi: GridviewApi) => {
return { queue } satisfies Record<string, IGridviewPanel>;
};
export const QueueTabAutoLayout = memo(({ setIsLoading }: { setIsLoading: (isLoading: boolean) => void }) => {
export const QueueTabAutoLayout = memo(() => {
const rootRef = useRef<HTMLDivElement>(null);
const [rootApi, setRootApi] = useState<GridviewApi | null>(null);
const onReady = useCallback<IGridviewReactProps['onReady']>(({ api }) => {
@@ -32,22 +32,18 @@ export const QueueTabAutoLayout = memo(({ setIsLoading }: { setIsLoading: (isLoa
}, []);
useEffect(() => {
setIsLoading(true);
if (!rootApi) {
return;
}
initializeRootPanelLayout(rootApi);
setTimeout(() => {
setIsLoading(false);
}, 300);
navigationApi.onSwitchedTab();
return () => {
navigationApi.unregisterTab('queue');
};
}, [rootApi, setIsLoading]);
}, [rootApi]);
return (
<AutoLayoutProvider tab="queue" rootRef={rootRef}>

View File

@@ -34,3 +34,5 @@ export const LAYERS_PANEL_DEFAULT_HEIGHT_PX = 232;
export const CANVAS_BOARD_PANEL_DEFAULT_HEIGHT_PX = 36; // Collapsed by default on Canvas
export const CANVAS_GALLERY_PANEL_DEFAULT_HEIGHT_PX = 200; // Smaller default size on Canvas
export const SWITCH_TABS_FAKE_DELAY_MS = 300;

View File

@@ -287,7 +287,7 @@ export const initializeRootPanelLayout = (layoutApi: GridviewApi) => {
return { main, left, right } satisfies Record<string, IGridviewPanel>;
};
export const UpscalingTabAutoLayout = memo(({ setIsLoading }: { setIsLoading: (isLoading: boolean) => void }) => {
export const UpscalingTabAutoLayout = memo(() => {
const rootRef = useRef<HTMLDivElement>(null);
const [rootApi, setRootApi] = useState<GridviewApi | null>(null);
const onReady = useCallback<IGridviewReactProps['onReady']>(({ api }) => {
@@ -295,21 +295,18 @@ export const UpscalingTabAutoLayout = memo(({ setIsLoading }: { setIsLoading: (i
}, []);
useEffect(() => {
setIsLoading(true);
if (!rootApi) {
return;
}
initializeRootPanelLayout(rootApi);
setTimeout(() => {
setIsLoading(false);
}, 300);
navigationApi.onSwitchedTab();
return () => {
navigationApi.unregisterTab('upscaling');
};
}, [rootApi, setIsLoading]);
}, [rootApi]);
return (
<AutoLayoutProvider tab="upscaling" rootRef={rootRef}>

View File

@@ -304,7 +304,7 @@ export const initializeRootPanelLayout = (api: GridviewApi) => {
return { main, left, right } satisfies Record<string, IGridviewPanel>;
};
export const WorkflowsTabAutoLayout = memo(({ setIsLoading }: { setIsLoading: (isLoading: boolean) => void }) => {
export const WorkflowsTabAutoLayout = memo(() => {
const rootRef = useRef<HTMLDivElement>(null);
const [rootApi, setRootApi] = useState<GridviewApi | null>(null);
const onReady = useCallback<IGridviewReactProps['onReady']>(({ api }) => {
@@ -312,22 +312,18 @@ export const WorkflowsTabAutoLayout = memo(({ setIsLoading }: { setIsLoading: (i
}, []);
useEffect(() => {
setIsLoading(true);
if (!rootApi) {
return;
}
initializeRootPanelLayout(rootApi);
setTimeout(() => {
setIsLoading(false);
}, 300);
navigationApi.onSwitchedTab();
return () => {
navigationApi.unregisterTab('workflows');
};
}, [rootApi, setIsLoading]);
}, [rootApi]);
return (
<AutoLayoutProvider tab="workflows" rootRef={rootRef}>

View File

@@ -1,9 +1,6 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSelector, createSlice } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store';
import { canvasReset } from 'features/controlLayers/store/actions';
import { canvasSessionReset, generateSessionReset } from 'features/controlLayers/store/canvasStagingAreaSlice';
import { workflowLoaded } from 'features/nodes/store/nodesSlice';
import { atom } from 'nanostores';
import type { TabName, UIState } from './uiTypes';
@@ -65,23 +62,6 @@ export const uiSlice = createSlice({
state.showCanvasTabSplashScreen = action.payload;
},
},
extraReducers(builder) {
builder.addCase(workflowLoaded, (state) => {
state.activeTab = 'workflows';
});
builder.addCase(canvasReset, (state) => {
state.activeTab = 'canvas';
});
builder.addCase(canvasSessionReset, (state) => {
state.activeTab = 'canvas';
});
builder.addCase(generateSessionReset, (state) => {
state.activeTab = 'generate';
});
// builder.addCase(canvasSessionTypeChanged, (state) => {
// state.activeTab = 'canvas';
// });
},
});
export const {

View File

@@ -7,6 +7,8 @@ import { WorkflowMigrationError, WorkflowVersionError } from 'features/nodes/typ
import type { WorkflowV3 } from 'features/nodes/types/workflow';
import { validateWorkflow } from 'features/nodes/util/workflow/validateWorkflow';
import { toast } from 'features/toast/toast';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { serializeError } from 'serialize-error';
@@ -47,6 +49,7 @@ export const useValidateAndLoadWorkflow = () => {
origin: 'file' | 'image' | 'object' | 'library'
): Promise<WorkflowV3 | null> => {
try {
await navigationApi.focusPanel('workflows', WORKSPACE_PANEL_ID);
const templates = $templates.get();
const { workflow, warnings } = await validateWorkflow({
workflow: unvalidatedWorkflow,

View File

@@ -2,11 +2,10 @@ import { Button, ExternalLink, Spinner, Text } from '@invoke-ai/ui-library';
import { skipToken } from '@reduxjs/toolkit/query';
import { logger } from 'app/logging/logger';
import type { AppDispatch, AppGetState } from 'app/store/store';
import { useAppDispatch } from 'app/store/storeHooks';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { toast, toastApi } from 'features/toast/toast';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { t } from 'i18next';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -150,12 +149,11 @@ const HFUnauthorizedToastDescription = () => {
const { data } = useGetHFTokenStatusQuery(isEnabled ? undefined : skipToken);
const { t } = useTranslation();
const dispatch = useAppDispatch();
const onClick = useCallback(() => {
dispatch(setActiveTab('models'));
navigationApi.switchToTab('models');
toastApi.close(UNAUTHORIZED_TOAST_ID);
}, [dispatch]);
}, []);
if (!data) {
return <Spinner />;