Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2b61add
feat(model manager): redesign queue
Feb 24, 2026
33e1a1e
feat(model manager queue): improve ui/ux
Feb 25, 2026
7dd18ad
fix(model manager queue): add missing imports
Feb 25, 2026
30e8cbd
fix(model manager queue): play/pause button condition
Feb 25, 2026
306fdf3
feat(model manager queue): remove backend status badge
Feb 25, 2026
1bbcfa1
Merge branch 'main' into feat/model-manager-queue-redesign
lstein Feb 27, 2026
04b064a
fix(model manager queue): remove unused useStore import
Mar 4, 2026
db1c17a
fix(model manager queue): prettier lint
Mar 4, 2026
2e014dc
feat(model meneger queue): backend disconnected visual feedback
Mar 4, 2026
eb54499
fix(model manager queue): qol list item ui tweaks
Mar 4, 2026
89bf302
feat(model manager queue): reorganize bulk actions
Mar 4, 2026
8d44474
feat(model manager queue): tweak column widths
Mar 5, 2026
afcdacb
Merge branch 'main' into feat/model-manager-queue-redesign
joshistoast Mar 10, 2026
faebc90
Merge branch 'main' into feat/model-manager-queue-redesign
joshistoast Mar 23, 2026
1c6870d
Merge branch 'main' into feat/model-manager-queue-redesign
lstein Mar 28, 2026
9d4bf2d
Merge branch 'main' into feat/model-manager-queue-redesign
joshistoast Mar 30, 2026
44efbc0
feat(model manager queue): disable actions dropdown if items disabled
Mar 30, 2026
106ae5a
feat(model manager queue): optimistic updated and code qulity
Mar 30, 2026
a96c4d8
style(model manager queue): fix prettier lint
Mar 30, 2026
9025b3c
Merge branch 'main' into feat/model-manager-queue-redesign
lstein Apr 5, 2026
1444b97
Merge branch 'main' into feat/model-manager-queue-redesign
joshistoast Apr 8, 2026
4b9fb0e
feat(model manager queue): keep prune action visible
Apr 8, 2026
6f12993
feat(model manager queue): prune button ui tweak
Apr 8, 2026
b1b73f0
Merge branch 'main' into feat/model-manager-queue-redesign
lstein Apr 10, 2026
aa536cf
Merge branch 'main' into feat/model-manager-queue-redesign
lstein Apr 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1240,6 +1240,7 @@
"triggerPhrases": "Trigger Phrases",
"loraTriggerPhrases": "LoRA Trigger Phrases",
"mainModelTriggerPhrases": "Main Model Trigger Phrases",
"queueEmpty": "The install queue is empty.",
"selectAll": "Select All",
"selectModelToView": "Select a model to view its details",
"typePhraseHere": "Type phrase here",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
import { Box, Button, Flex, Heading } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import {
Box,
Button,
ButtonGroup,
Flex,
Heading,
IconButton,
Menu,
MenuButton,
MenuItem,
MenuList,
Table,
Tbody,
Td,
Text,
Th,
Thead,
Tr,
} from '@invoke-ai/ui-library';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { getApiErrorDetail } from 'features/modelManagerV2/util/getApiErrorDetail';
import { toast } from 'features/toast/toast';
import { t } from 'i18next';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { PiPauseBold, PiPlayBold, PiXBold } from 'react-icons/pi';
import { useTranslation } from 'react-i18next';
import { PiBroomBold, PiCaretDownBold, PiPauseFill, PiPlayFill, PiXBold } from 'react-icons/pi';
import {
useCancelModelInstallMutation,
useListModelInstallsQuery,
Expand All @@ -14,20 +32,56 @@ import {
useResumeModelInstallMutation,
} from 'services/api/endpoints/models';
import type { ModelInstallJob } from 'services/api/types';
import { $isConnected } from 'services/events/stores';

import { ModelInstallQueueItem } from './ModelInstallQueueItem';

const hasRestartRequired = (job: ModelInstallJob) => {
return job.download_parts?.some((part) => part.resume_required || part.status === 'error') ?? false;
};

const ModelQueueTableSx: SystemStyleObject = {
'& tbody tr:nth-of-type(odd)': {
backgroundColor: 'rgba(255, 255, 255, 0.04)',
},
'& tbody tr:nth-of-type(even)': {
backgroundColor: 'transparent',
},
'td, th': {
borderColor: 'base.700',
},

th: {
position: 'sticky',
top: 0,
zIndex: 1,
backgroundColor: 'base.800',
py: 2,
},

'th:first-of-type': {
borderTopLeftRadius: 'base',
},
'th:last-of-type': {
borderTopRightRadius: 'base',
},
'tr:last-of-type td:first-of-type': {
borderBottomLeftRadius: 'base',
},
'tr:last-of-type td:last-of-type': {
borderBottomRightRadius: 'base',
},
};

export const ModelInstallQueue = memo(() => {
const isConnected = useStore($isConnected);
const { t } = useTranslation();
const { data } = useListModelInstallsQuery();
const [bulkActionInProgress, setBulkActionInProgress] = useState<'pause' | 'resume' | 'cancel' | null>(null);
const bulkActionLockRef = useRef(false);

const reversedData = useMemo(() => {
return data?.toReversed() ?? [];
}, [data]);

const [cancelModelInstall] = useCancelModelInstallMutation();
const [pauseModelInstall] = usePauseModelInstallMutation();
const [resumeModelInstall] = useResumeModelInstallMutation();
Expand All @@ -51,7 +105,7 @@ export const ModelInstallQueue = memo(() => {
continue;
}

if (model.status === 'running') {
if (model.status === 'running' || model.status === 'downloads_done') {
cancelable.push(model.id);
}
}
Expand Down Expand Up @@ -121,7 +175,7 @@ export const ModelInstallQueue = memo(() => {
setBulkActionInProgress(null);
}
},
[cancelModelInstall, isPruning, pauseModelInstall, resumeModelInstall]
[cancelModelInstall, isPruning, pauseModelInstall, resumeModelInstall, t]
);

const pruneCompletedModelInstalls = useCallback(async () => {
Expand All @@ -143,31 +197,25 @@ export const ModelInstallQueue = memo(() => {
status: 'error',
});
}
}, [_pruneCompletedModelInstalls]);
}, [_pruneCompletedModelInstalls, t]);

const hasPauseableInstalls = pauseableInstallIds.length > 0;
const hasResumableInstalls = resumableInstallIds.length > 0;
const hasCancelableInstalls = cancelableInstallIds.length > 0;
const showResumeAll = !hasPauseableInstalls && hasResumableInstalls;
const pauseResumeAvailable = hasPauseableInstalls || hasResumableInstalls;

const pruneAvailable = useMemo(() => {
return data?.some(
(model) => model.status === 'cancelled' || model.status === 'error' || model.status === 'completed'
);
}, [data]);

const pauseResumeLabel = showResumeAll ? t('modelManager.resumeAll') : t('modelManager.pauseAll');
const pauseResumeTooltip = showResumeAll ? t('modelManager.resumeAllTooltip') : t('modelManager.pauseAllTooltip');

const pauseOrResumeAll = useCallback(() => {
if (showResumeAll) {
void runBulkAction('resume', resumableInstallIds);
return;
}

const pauseAll = useCallback(() => {
void runBulkAction('pause', pauseableInstallIds);
}, [pauseableInstallIds, resumableInstallIds, runBulkAction, showResumeAll]);
}, [pauseableInstallIds, runBulkAction]);

const resumeAll = useCallback(() => {
void runBulkAction('resume', resumableInstallIds);
}, [resumableInstallIds, runBulkAction]);

const cancelAll = useCallback(() => {
void runBulkAction('cancel', cancelableInstallIds);
Expand All @@ -176,57 +224,101 @@ export const ModelInstallQueue = memo(() => {
const isBulkActionRunning = bulkActionInProgress !== null;

return (
<Flex flexDir="column" p={3} h="full" gap={3}>
<Flex flexDir="column" h="full" gap={4}>
{/* Model Queue Header */}
<Flex justifyContent="space-between" alignItems="center">
<Flex alignItems="center" gap={2}>
<Heading size="sm">{t('modelManager.installQueue')}</Heading>
{!isConnected && (
<Box layerStyle="first" px={2} py={0.5} borderRadius="base">
<Heading size="sm" color="error.300">
{t('modelManager.backendDisconnected')}
</Heading>
</Box>
)}
<Heading size="md">{t('modelManager.installQueue')}</Heading>
</Flex>
<Flex gap={2} alignItems="center">
<Button
size="sm"
leftIcon={showResumeAll ? <PiPlayBold /> : <PiPauseBold />}
isDisabled={!pauseResumeAvailable || isBulkActionRunning || isPruning}
isLoading={bulkActionInProgress === 'pause' || bulkActionInProgress === 'resume'}
onClick={pauseOrResumeAll}
tooltip={pauseResumeTooltip}
>
{pauseResumeLabel}
</Button>
<Button
size="sm"
leftIcon={<PiXBold />}
isDisabled={!hasCancelableInstalls || isBulkActionRunning || isPruning}
isLoading={bulkActionInProgress === 'cancel'}
onClick={cancelAll}
tooltip={t('modelManager.cancelAllTooltip')}
>
{t('modelManager.cancelAll')}
</Button>
<Button
size="sm"
isDisabled={!pruneAvailable || isBulkActionRunning}
isLoading={isPruning}
onClick={pruneCompletedModelInstalls}
tooltip={t('modelManager.pruneTooltip')}
>
{t('modelManager.prune')}
</Button>

{/* Bulk Actions */}
{/* Non-destructive, easily-ccessible actions */}
<Flex gap={2}>
{hasPauseableInstalls && (
<Button
size="sm"
leftIcon={<PiPauseFill />}
isDisabled={isBulkActionRunning || isPruning}
onClick={pauseAll}
variant="outline"
>
{t('modelManager.pauseAll')}
</Button>
)}

{hasResumableInstalls && (
<Button
size="sm"
leftIcon={<PiPlayFill />}
isDisabled={isBulkActionRunning || isPruning}
onClick={resumeAll}
variant="outline"
>
{t('modelManager.resumeAll')}
</Button>
)}

{/* Destructive Actions go to the button group/menu */}
<ButtonGroup>
<Button
leftIcon={<PiBroomBold />}
size="sm"
isDisabled={!pruneAvailable || isBulkActionRunning || isPruning}
onClick={pruneCompletedModelInstalls}
variant="outline"
>
{t('modelManager.prune')}
</Button>
<Menu>
<MenuButton
as={IconButton}
size="sm"
aria-label={t('accessibility.menu')}
icon={<PiCaretDownBold />}
disabled={!pruneAvailable && !hasCancelableInstalls}
/>
<MenuList>
<MenuItem
color="error.300"
icon={<PiXBold />}
isDisabled={!hasCancelableInstalls || isBulkActionRunning || isPruning}
onClick={cancelAll}
isDestructive
>
{t('modelManager.cancelAll')}
</MenuItem>
</MenuList>
</Menu>
</ButtonGroup>
</Flex>
</Flex>
<Box layerStyle="first" p={3} borderRadius="base" w="full" h="full">

{/* Model Queue List */}
<Box layerStyle="second" borderRadius="base" w="full" h="full">
<ScrollableContent>
<Flex flexDir="column-reverse" gap="2" w="full">
{data?.map((model) => (
<ModelInstallQueueItem key={model.id} installJob={model} />
))}
</Flex>
<Table size="sm" sx={ModelQueueTableSx}>
<Thead>
<Tr>
<Th minWidth="50px"></Th>
<Th width="80%">Name</Th>
<Th minWidth="130px">Status</Th>
<Th minWidth="160px" textAlign="right">
Actions
</Th>
</Tr>
</Thead>
<Tbody>
{data?.length === 0 ? (
<Tr>
<Td colSpan={4} textAlign="center" py={8}>
<Text variant="subtext">{t('modelManager.queueEmpty')}</Text>
</Td>
</Tr>
) : (
reversedData?.map((model) => <ModelInstallQueueItem key={model.id} installJob={model} />)
)}
</Tbody>
</Table>
</ScrollableContent>
</Box>
</Flex>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { ModelInstallStatus } from 'services/api/types';

const STATUSES = {
waiting: { colorScheme: 'cyan', translationKey: 'queue.pending' },
downloading: { colorScheme: 'yellow', translationKey: 'queue.in_progress' },
downloading: { colorScheme: 'blue', translationKey: 'queue.in_progress' },
downloads_done: { colorScheme: 'yellow', translationKey: 'queue.in_progress' },
running: { colorScheme: 'yellow', translationKey: 'queue.in_progress' },
paused: { colorScheme: 'orange', translationKey: 'queue.paused' },
Expand All @@ -14,18 +14,21 @@ const STATUSES = {
cancelled: { colorScheme: 'orange', translationKey: 'queue.canceled' },
} as const satisfies Partial<Record<ModelInstallStatus, { colorScheme: string; translationKey: string }>>;

const ModelInstallQueueBadge = ({ status }: { status?: ModelInstallStatus }) => {
const { t } = useTranslation();
const statusConfig = status ? STATUSES[status] : undefined;
export const ModelInstallQueueBadge = memo(
({ status, label }: { status?: ModelInstallStatus; label?: string | null }) => {
const { t } = useTranslation();
const statusConfig = status ? STATUSES[status] : undefined;

if (!statusConfig) {
return null;
if (!statusConfig) {
return null;
}

return (
<Badge variant="outline" colorScheme={statusConfig.colorScheme}>
{label ?? t(statusConfig.translationKey)}
</Badge>
);
}
);

return (
<Badge textAlign="center" w="134px" colorScheme={statusConfig.colorScheme}>
{t(statusConfig.translationKey)}
</Badge>
);
};
export default memo(ModelInstallQueueBadge);
ModelInstallQueueBadge.displayName = 'ModelInstallQueueBadge';
Loading
Loading