diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 699477e03d6..da87132fb08 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -864,6 +864,7 @@
},
"modelManager": {
"active": "active",
+ "actions": "Bulk Actions",
"addModel": "Add Model",
"addModels": "Add Models",
"advanced": "Advanced",
@@ -899,6 +900,7 @@
"delete": "Delete",
"deleteConfig": "Delete Config",
"deleteModel": "Delete Model",
+ "deleteModels": "Delete Models",
"deleteModelImage": "Delete Model Image",
"deleteMsg1": "Are you sure you want to delete this model from InvokeAI?",
"deleteMsg2": "This WILL delete the model from disk if it is in the InvokeAI root folder. If you are using a custom location, then the model WILL NOT be deleted from disk.",
@@ -1030,6 +1032,7 @@
"triggerPhrases": "Trigger Phrases",
"loraTriggerPhrases": "LoRA Trigger Phrases",
"mainModelTriggerPhrases": "Main Model Trigger Phrases",
+ "selectAll": "Select All",
"typePhraseHere": "Type phrase here",
"t5Encoder": "T5 Encoder",
"qwen3Encoder": "Qwen3 Encoder",
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx
index 0bf496b583b..2159d538bee 100644
--- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx
+++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx
@@ -1,7 +1,8 @@
-import { Flex, Text, useDisclosure, useToast } from '@invoke-ai/ui-library';
+import { Flex, Text, useToast } from '@invoke-ai/ui-library';
import { logger } from 'app/logging/logger';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
+import { buildUseDisclosure } from 'common/hooks/useBoolean';
import { MODEL_CATEGORIES_AS_LIST } from 'features/modelManagerV2/models';
import {
clearModelSelection,
@@ -23,11 +24,12 @@ import type { AnyModelConfig } from 'services/api/types';
import { BulkDeleteModelsModal } from './BulkDeleteModelsModal';
import { FetchingModelsLoader } from './FetchingModelsLoader';
-import { ModelListHeader } from './ModelListHeader';
import { ModelListWrapper } from './ModelListWrapper';
const log = logger('models');
+export const [useBulkDeleteModal] = buildUseDisclosure(false);
+
const ModelList = () => {
const dispatch = useAppDispatch();
const filteredModelType = useAppSelector(selectFilteredModelType);
@@ -35,7 +37,7 @@ const ModelList = () => {
const selectedModelKeys = useAppSelector(selectSelectedModelKeys);
const { t } = useTranslation();
const toast = useToast();
- const { isOpen, onOpen, onClose } = useDisclosure();
+ const { isOpen, close } = useBulkDeleteModal();
const [isDeleting, setIsDeleting] = useState(false);
const { data, isLoading } = useGetModelConfigsQuery();
@@ -62,10 +64,6 @@ const ModelList = () => {
return { total, byCategory };
}, [data, filteredModelType, searchTerm]);
- const handleBulkDelete = useCallback(() => {
- onOpen();
- }, [onOpen]);
-
const handleConfirmBulkDelete = useCallback(async () => {
setIsDeleting(true);
try {
@@ -74,7 +72,7 @@ const ModelList = () => {
// Clear selection and close modal
dispatch(clearModelSelection());
dispatch(setSelectedModelKey(null));
- onClose();
+ close();
// Show success/failure toast
if (result.failed.length === 0) {
@@ -127,12 +125,11 @@ const ModelList = () => {
} finally {
setIsDeleting(false);
}
- }, [bulkDeleteModels, selectedModelKeys, dispatch, onClose, toast, t]);
+ }, [bulkDeleteModels, selectedModelKeys, dispatch, close, toast, t]);
return (
<>
-
{isLoading && }
@@ -147,9 +144,10 @@ const ModelList = () => {
+
{
+ const dispatch = useAppDispatch();
+ const filteredModelType = useAppSelector(selectFilteredModelType);
+ const selectedModelKeys = useAppSelector(selectSelectedModelKeys);
+ const searchTerm = useAppSelector(selectSearchTerm);
+ const { data } = useGetModelConfigsQuery();
+ const bulkDeleteModal = useBulkDeleteModal();
+
+ const handleBulkDelete = useCallback(() => {
+ bulkDeleteModal.open();
+ }, [bulkDeleteModal]);
+
+ // Calculate displayed (filtered) model keys
+ const displayedModelKeys = useMemo(() => {
+ const modelConfigs = modelConfigsAdapterSelectors.selectAll(data ?? { ids: [], entities: {} });
+ const filteredModels = modelsFilter(modelConfigs, searchTerm, filteredModelType);
+ return filteredModels.map((m) => m.key);
+ }, [data, searchTerm, filteredModelType]);
+
+ const { allSelected, someSelected } = useMemo(() => {
+ if (displayedModelKeys.length === 0) {
+ return { allSelected: false, someSelected: false };
+ }
+ const selectedSet = new Set(selectedModelKeys);
+ const displayedSelectedCount = displayedModelKeys.filter((key) => selectedSet.has(key)).length;
+ return {
+ allSelected: displayedSelectedCount === displayedModelKeys.length,
+ someSelected: displayedSelectedCount > 0 && displayedSelectedCount < displayedModelKeys.length,
+ };
+ }, [displayedModelKeys, selectedModelKeys]);
+
+ const handleToggleAll = useCallback(() => {
+ if (allSelected) {
+ // Deselect all displayed models
+ const displayedSet = new Set(displayedModelKeys);
+ const newSelection = selectedModelKeys.filter((key) => !displayedSet.has(key));
+ dispatch(modelSelectionChanged(newSelection));
+ } else {
+ // Select all displayed models (merge with existing selection)
+ const selectedSet = new Set(selectedModelKeys);
+ displayedModelKeys.forEach((key) => selectedSet.add(key));
+ dispatch(modelSelectionChanged(Array.from(selectedSet)));
+ }
+ }, [allSelected, displayedModelKeys, selectedModelKeys, dispatch]);
+
+ const selectionCount = selectedModelKeys.length;
+
+ return (
+
+
+
+ {t('modelManager.selectAll')}
+
+
+
+
+
+ {selectionCount} {t('common.selected')}
+
+
+
+
+ );
+});
+
+ModelListBulkActions.displayName = 'ModelListBulkActions';
+
+const modelsFilter = (
+ data: T[],
+ nameFilter: string,
+ filteredModelType: FilterableModelType | null
+): T[] => {
+ return data.filter((model) => {
+ const matchesFilter =
+ model.name.toLowerCase().includes(nameFilter.toLowerCase()) ||
+ model.base.toLowerCase().includes(nameFilter.toLowerCase()) ||
+ model.type.toLowerCase().includes(nameFilter.toLowerCase()) ||
+ model.description?.toLowerCase().includes(nameFilter.toLowerCase()) ||
+ model.format.toLowerCase().includes(nameFilter.toLowerCase());
+
+ const matchesType = getMatchesType(model, filteredModelType);
+
+ return matchesFilter && matchesType;
+ });
+};
+
+const getMatchesType = (modelConfig: AnyModelConfig, filteredModelType: FilterableModelType | null): boolean => {
+ if (filteredModelType === 'refiner') {
+ return modelConfig.base === 'sdxl-refiner';
+ }
+
+ if (filteredModelType === 'main' && modelConfig.base === 'sdxl-refiner') {
+ return false;
+ }
+
+ return filteredModelType ? modelConfig.type === filteredModelType : true;
+};
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListHeader.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListHeader.tsx
deleted file mode 100644
index 3d0d71029b8..00000000000
--- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListHeader.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import {
- Button,
- Flex,
- Menu,
- MenuButton,
- MenuItem,
- MenuList,
- Tag,
- TagCloseButton,
- TagLabel,
-} from '@invoke-ai/ui-library';
-import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import { clearModelSelection, selectSelectedModelKeys } from 'features/modelManagerV2/store/modelManagerV2Slice';
-import { memo, useCallback } from 'react';
-import { useTranslation } from 'react-i18next';
-import { PiCaretDownBold, PiTrashSimpleBold } from 'react-icons/pi';
-
-type ModelListHeaderProps = {
- onBulkDelete: () => void;
-};
-
-export const ModelListHeader = memo(({ onBulkDelete }: ModelListHeaderProps) => {
- const { t } = useTranslation();
- const dispatch = useAppDispatch();
- const selectedModelKeys = useAppSelector(selectSelectedModelKeys);
- const selectionCount = selectedModelKeys.length;
-
- const handleClearSelection = useCallback(() => {
- dispatch(clearModelSelection());
- }, [dispatch]);
-
- if (selectionCount === 0) {
- return null;
- }
-
- return (
-
-
-
- {selectionCount} {t('common.selected')}
-
-
-
-
-
- );
-});
-
-ModelListHeader.displayName = 'ModelListHeader';
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx
index d6dda98e80e..5719752ff01 100644
--- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx
+++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListItem.tsx
@@ -1,5 +1,5 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
-import { Checkbox, Flex, Spacer, Text } from '@invoke-ai/ui-library';
+import { chakra, Checkbox, Flex, Spacer, Text } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import {
@@ -12,18 +12,22 @@ import ModelBaseBadge from 'features/modelManagerV2/subpanels/ModelManagerPanel/
import ModelFormatBadge from 'features/modelManagerV2/subpanels/ModelManagerPanel/ModelFormatBadge';
import { ModelDeleteButton } from 'features/modelManagerV2/subpanels/ModelPanel/ModelDeleteButton';
import { filesize } from 'filesize';
-import type { MouseEvent } from 'react';
+import type { ChangeEvent, MouseEvent } from 'react';
import { memo, useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
import type { AnyModelConfig } from 'services/api/types';
import ModelImage from './ModelImage';
+const StyledLabel = chakra('label');
+
type ModelListItemProps = {
model: AnyModelConfig;
};
const sx: SystemStyleObject = {
- paddingInline: 3,
+ paddingInlineStart: 10,
+ paddingInlineEnd: 3,
paddingBlock: 2,
position: 'relative',
rounded: 'base',
@@ -38,12 +42,6 @@ const sx: SystemStyleObject = {
insetInline: 3,
bg: 'base.850',
},
- '&:before': {
- left: 1,
- w: 1,
- insetBlock: 2,
- rounded: 'base',
- },
_hover: {
bg: 'base.850',
'& .delete-button': { opacity: 1 },
@@ -58,6 +56,7 @@ const sx: SystemStyleObject = {
};
const ModelListItem = ({ model }: ModelListItemProps) => {
+ const { t } = useTranslation();
const dispatch = useAppDispatch();
const selectIsSelected = useMemo(
() =>
@@ -71,16 +70,9 @@ const ModelListItem = ({ model }: ModelListItemProps) => {
const selectedModelKeys = useAppSelector(selectSelectedModelKeys);
const isChecked = selectedModelKeys.includes(model.key);
- const handleSelectModel = useCallback(
+ const handleRowClick = useCallback(
(e: MouseEvent) => {
- // Check if clicked on checkbox or delete button - if so, don't handle selection
- const target = e.target as HTMLElement;
- if (target.closest('input[type="checkbox"]') || target.closest('button')) {
- return;
- }
-
- // Clicking the row opens detail view (single select)
- // Ctrl/Cmd+Click still works as a power user feature for multi-select
+ // Ctrl/Cmd+Click toggles multi-selection
if (e.ctrlKey || e.metaKey) {
dispatch(toggleModelSelection(model.key));
} else {
@@ -90,55 +82,69 @@ const ModelListItem = ({ model }: ModelListItemProps) => {
[model.key, dispatch]
);
- const handleCheckboxChange = useCallback(() => {
- dispatch(toggleModelSelection(model.key));
- }, [model.key, dispatch]);
+ const handleCheckboxChange = useCallback(
+ (e: ChangeEvent) => {
+ e.stopPropagation();
+ dispatch(toggleModelSelection(model.key));
+ },
+ [model.key, dispatch]
+ );
- const handleCheckboxClick = useCallback((e: MouseEvent) => {
+ const stopPropagation = useCallback((e: MouseEvent) => {
e.stopPropagation();
}, []);
return (
-
-
-
-
-
-
-
- {model.name}
-
-
- {filesize(model.file_size)}
+
+
+
+
+
+
+
+
+
+
+ {model.name}
+
+
+ {filesize(model.file_size)}
+
+
+
+
+ {model.description || 'No Description'}
-
-
-
- {model.description || 'No Description'}
-
-
-
-
+
+
+
+
-
-
-
+
+
+
);
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListNavigation.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListNavigation.tsx
index 23081f68cf3..78bed8ab830 100644
--- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListNavigation.tsx
+++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListNavigation.tsx
@@ -1,48 +1,17 @@
-import { Checkbox, Flex, IconButton, Input, InputGroup, InputRightElement, Text } from '@invoke-ai/ui-library';
+import { Flex, IconButton, Input, InputGroup, InputRightElement } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
-import {
- type FilterableModelType,
- modelSelectionChanged,
- selectFilteredModelType,
- selectSearchTerm,
- selectSelectedModelKeys,
- setSearchTerm,
-} from 'features/modelManagerV2/store/modelManagerV2Slice';
+import { selectSearchTerm, setSearchTerm } from 'features/modelManagerV2/store/modelManagerV2Slice';
import { t } from 'i18next';
import type { ChangeEventHandler } from 'react';
-import { memo, useCallback, useMemo } from 'react';
+import { memo, useCallback } from 'react';
import { PiXBold } from 'react-icons/pi';
-import { modelConfigsAdapterSelectors, useGetModelConfigsQuery } from 'services/api/endpoints/models';
-import type { AnyModelConfig } from 'services/api/types';
+import { ModelListBulkActions } from './ModelListBulkActions';
import { ModelTypeFilter } from './ModelTypeFilter';
export const ModelListNavigation = memo(() => {
const dispatch = useAppDispatch();
const searchTerm = useAppSelector(selectSearchTerm);
- const filteredModelType = useAppSelector(selectFilteredModelType);
- const selectedModelKeys = useAppSelector(selectSelectedModelKeys);
- const { data } = useGetModelConfigsQuery();
-
- // Calculate displayed (filtered) model keys
- const displayedModelKeys = useMemo(() => {
- const modelConfigs = modelConfigsAdapterSelectors.selectAll(data ?? { ids: [], entities: {} });
- const filteredModels = modelsFilter(modelConfigs, searchTerm, filteredModelType);
- return filteredModels.map((m) => m.key);
- }, [data, searchTerm, filteredModelType]);
-
- // Calculate checkbox state
- const { allSelected, someSelected } = useMemo(() => {
- if (displayedModelKeys.length === 0) {
- return { allSelected: false, someSelected: false };
- }
- const selectedSet = new Set(selectedModelKeys);
- const displayedSelectedCount = displayedModelKeys.filter((key) => selectedSet.has(key)).length;
- return {
- allSelected: displayedSelectedCount === displayedModelKeys.length,
- someSelected: displayedSelectedCount > 0 && displayedSelectedCount < displayedModelKeys.length,
- };
- }, [displayedModelKeys, selectedModelKeys]);
const handleSearch: ChangeEventHandler = useCallback(
(event) => {
@@ -55,92 +24,38 @@ export const ModelListNavigation = memo(() => {
dispatch(setSearchTerm(''));
}, [dispatch]);
- const handleToggleAll = useCallback(() => {
- if (allSelected) {
- // Deselect all displayed models
- const displayedSet = new Set(displayedModelKeys);
- const newSelection = selectedModelKeys.filter((key) => !displayedSet.has(key));
- dispatch(modelSelectionChanged(newSelection));
- } else {
- // Select all displayed models (merge with existing selection)
- const selectedSet = new Set(selectedModelKeys);
- displayedModelKeys.forEach((key) => selectedSet.add(key));
- dispatch(modelSelectionChanged(Array.from(selectedSet)));
- }
- }, [allSelected, displayedModelKeys, selectedModelKeys, dispatch]);
-
return (
-
+
-
-
-
- {t('modelManager.selectAll')}
-
+
+
+
+
+ {!!searchTerm?.length && (
+
+ }
+ onClick={clearSearch}
+ />
+
+ )}
+
+
+
+
-
-
-
- {!!searchTerm?.length && (
-
- }
- onClick={clearSearch}
- />
-
- )}
-
-
-
-
+
);
});
ModelListNavigation.displayName = 'ModelListNavigation';
-
-const modelsFilter = (
- data: T[],
- nameFilter: string,
- filteredModelType: FilterableModelType | null
-): T[] => {
- return data.filter((model) => {
- const matchesFilter =
- model.name.toLowerCase().includes(nameFilter.toLowerCase()) ||
- model.base.toLowerCase().includes(nameFilter.toLowerCase()) ||
- model.type.toLowerCase().includes(nameFilter.toLowerCase()) ||
- model.description?.toLowerCase().includes(nameFilter.toLowerCase()) ||
- model.format.toLowerCase().includes(nameFilter.toLowerCase());
-
- const matchesType = getMatchesType(model, filteredModelType);
-
- return matchesFilter && matchesType;
- });
-};
-
-const getMatchesType = (modelConfig: AnyModelConfig, filteredModelType: FilterableModelType | null): boolean => {
- if (filteredModelType === 'refiner') {
- return modelConfig.base === 'sdxl-refiner';
- }
-
- if (filteredModelType === 'main' && modelConfig.base === 'sdxl-refiner') {
- return false;
- }
-
- return filteredModelType ? modelConfig.type === filteredModelType : true;
-};
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListWrapper.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListWrapper.tsx
index 9783a88b062..74b26dce386 100644
--- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListWrapper.tsx
+++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListWrapper.tsx
@@ -21,6 +21,7 @@ const contentSx = {
p: 0,
bg: 'base.900',
borderRadius: '0',
+ zIndex: 0,
} satisfies SystemStyleObject;
export const ModelListWrapper = memo((props: ModelListWrapperProps) => {
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelTypeFilter.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelTypeFilter.tsx
index 0ee479e86b2..dcb22071482 100644
--- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelTypeFilter.tsx
+++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelTypeFilter.tsx
@@ -17,8 +17,8 @@ export const ModelTypeFilter = memo(() => {
}, [dispatch]);
return (
-