Skip to content

Commit 3c9b282

Browse files
joshistoastjoshistoastlstein
authored
Redesign Model Manager Installation Queue (#8910)
* feat(model manager): redesign queue * feat(model manager queue): improve ui/ux - standardized table row widths - sticky table header - reverse table data direction (new items on top) - queue empty state - ui and icon tweaks - add progress tooltip - add code comments for sanity * fix(model manager queue): add missing imports dammit zed editor * fix(model manager queue): play/pause button condition * feat(model manager queue): remove backend status badge * fix(model manager queue): remove unused useStore import * fix(model manager queue): prettier lint * feat(model meneger queue): backend disconnected visual feedback * fix(model manager queue): qol list item ui tweaks * feat(model manager queue): reorganize bulk actions * feat(model manager queue): tweak column widths * feat(model manager queue): disable actions dropdown if items disabled * feat(model manager queue): optimistic updated and code qulity - Treated downloads_done as an active install phase for row UI and bulk cancel. - Stopped stale error text from overriding the badge after resume/restart by only showing the error label when the displayed status is actually error. - Added row-level action locking to block duplicate pause/resume/cancel/restart submissions. - Added optimistic row status handling so the UI does not briefly fall back to stale error/restart state before RTK Query/socket updates arrive. - Fixed local-path basename parsing for both the main row title and restart-required file rows. - Added an accessible aria-label to the overflow menu button. * style(model manager queue): fix prettier lint * feat(model manager queue): keep prune action visible * feat(model manager queue): prune button ui tweak --------- Co-authored-by: joshistoast <me@joshcorbett.com> Co-authored-by: Lincoln Stein <lincoln.stein@gmail.com>
1 parent a2e4fbb commit 3c9b282

5 files changed

Lines changed: 526 additions & 262 deletions

File tree

invokeai/frontend/web/public/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,6 +1240,7 @@
12401240
"triggerPhrases": "Trigger Phrases",
12411241
"loraTriggerPhrases": "LoRA Trigger Phrases",
12421242
"mainModelTriggerPhrases": "Main Model Trigger Phrases",
1243+
"queueEmpty": "The install queue is empty.",
12431244
"selectAll": "Select All",
12441245
"selectModelToView": "Select a model to view its details",
12451246
"typePhraseHere": "Type phrase here",

invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ModelInstallQueue/ModelInstallQueue.tsx

Lines changed: 158 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,29 @@
1-
import { Box, Button, Flex, Heading } from '@invoke-ai/ui-library';
2-
import { useStore } from '@nanostores/react';
1+
import type { SystemStyleObject } from '@invoke-ai/ui-library';
2+
import {
3+
Box,
4+
Button,
5+
ButtonGroup,
6+
Flex,
7+
Heading,
8+
IconButton,
9+
Menu,
10+
MenuButton,
11+
MenuItem,
12+
MenuList,
13+
Table,
14+
Tbody,
15+
Td,
16+
Text,
17+
Th,
18+
Thead,
19+
Tr,
20+
} from '@invoke-ai/ui-library';
321
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
422
import { getApiErrorDetail } from 'features/modelManagerV2/util/getApiErrorDetail';
523
import { toast } from 'features/toast/toast';
6-
import { t } from 'i18next';
724
import { memo, useCallback, useMemo, useRef, useState } from 'react';
8-
import { PiPauseBold, PiPlayBold, PiXBold } from 'react-icons/pi';
25+
import { useTranslation } from 'react-i18next';
26+
import { PiBroomBold, PiCaretDownBold, PiPauseFill, PiPlayFill, PiXBold } from 'react-icons/pi';
927
import {
1028
useCancelModelInstallMutation,
1129
useListModelInstallsQuery,
@@ -14,20 +32,56 @@ import {
1432
useResumeModelInstallMutation,
1533
} from 'services/api/endpoints/models';
1634
import type { ModelInstallJob } from 'services/api/types';
17-
import { $isConnected } from 'services/events/stores';
1835

1936
import { ModelInstallQueueItem } from './ModelInstallQueueItem';
2037

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

42+
const ModelQueueTableSx: SystemStyleObject = {
43+
'& tbody tr:nth-of-type(odd)': {
44+
backgroundColor: 'rgba(255, 255, 255, 0.04)',
45+
},
46+
'& tbody tr:nth-of-type(even)': {
47+
backgroundColor: 'transparent',
48+
},
49+
'td, th': {
50+
borderColor: 'base.700',
51+
},
52+
53+
th: {
54+
position: 'sticky',
55+
top: 0,
56+
zIndex: 1,
57+
backgroundColor: 'base.800',
58+
py: 2,
59+
},
60+
61+
'th:first-of-type': {
62+
borderTopLeftRadius: 'base',
63+
},
64+
'th:last-of-type': {
65+
borderTopRightRadius: 'base',
66+
},
67+
'tr:last-of-type td:first-of-type': {
68+
borderBottomLeftRadius: 'base',
69+
},
70+
'tr:last-of-type td:last-of-type': {
71+
borderBottomRightRadius: 'base',
72+
},
73+
};
74+
2575
export const ModelInstallQueue = memo(() => {
26-
const isConnected = useStore($isConnected);
76+
const { t } = useTranslation();
2777
const { data } = useListModelInstallsQuery();
2878
const [bulkActionInProgress, setBulkActionInProgress] = useState<'pause' | 'resume' | 'cancel' | null>(null);
2979
const bulkActionLockRef = useRef(false);
3080

81+
const reversedData = useMemo(() => {
82+
return data?.toReversed() ?? [];
83+
}, [data]);
84+
3185
const [cancelModelInstall] = useCancelModelInstallMutation();
3286
const [pauseModelInstall] = usePauseModelInstallMutation();
3387
const [resumeModelInstall] = useResumeModelInstallMutation();
@@ -51,7 +105,7 @@ export const ModelInstallQueue = memo(() => {
51105
continue;
52106
}
53107

54-
if (model.status === 'running') {
108+
if (model.status === 'running' || model.status === 'downloads_done') {
55109
cancelable.push(model.id);
56110
}
57111
}
@@ -121,7 +175,7 @@ export const ModelInstallQueue = memo(() => {
121175
setBulkActionInProgress(null);
122176
}
123177
},
124-
[cancelModelInstall, isPruning, pauseModelInstall, resumeModelInstall]
178+
[cancelModelInstall, isPruning, pauseModelInstall, resumeModelInstall, t]
125179
);
126180

127181
const pruneCompletedModelInstalls = useCallback(async () => {
@@ -143,31 +197,25 @@ export const ModelInstallQueue = memo(() => {
143197
status: 'error',
144198
});
145199
}
146-
}, [_pruneCompletedModelInstalls]);
200+
}, [_pruneCompletedModelInstalls, t]);
147201

148202
const hasPauseableInstalls = pauseableInstallIds.length > 0;
149203
const hasResumableInstalls = resumableInstallIds.length > 0;
150204
const hasCancelableInstalls = cancelableInstallIds.length > 0;
151-
const showResumeAll = !hasPauseableInstalls && hasResumableInstalls;
152-
const pauseResumeAvailable = hasPauseableInstalls || hasResumableInstalls;
153205

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

160-
const pauseResumeLabel = showResumeAll ? t('modelManager.resumeAll') : t('modelManager.pauseAll');
161-
const pauseResumeTooltip = showResumeAll ? t('modelManager.resumeAllTooltip') : t('modelManager.pauseAllTooltip');
162-
163-
const pauseOrResumeAll = useCallback(() => {
164-
if (showResumeAll) {
165-
void runBulkAction('resume', resumableInstallIds);
166-
return;
167-
}
168-
212+
const pauseAll = useCallback(() => {
169213
void runBulkAction('pause', pauseableInstallIds);
170-
}, [pauseableInstallIds, resumableInstallIds, runBulkAction, showResumeAll]);
214+
}, [pauseableInstallIds, runBulkAction]);
215+
216+
const resumeAll = useCallback(() => {
217+
void runBulkAction('resume', resumableInstallIds);
218+
}, [resumableInstallIds, runBulkAction]);
171219

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

178226
return (
179-
<Flex flexDir="column" p={3} h="full" gap={3}>
227+
<Flex flexDir="column" h="full" gap={4}>
228+
{/* Model Queue Header */}
180229
<Flex justifyContent="space-between" alignItems="center">
181230
<Flex alignItems="center" gap={2}>
182-
<Heading size="sm">{t('modelManager.installQueue')}</Heading>
183-
{!isConnected && (
184-
<Box layerStyle="first" px={2} py={0.5} borderRadius="base">
185-
<Heading size="sm" color="error.300">
186-
{t('modelManager.backendDisconnected')}
187-
</Heading>
188-
</Box>
189-
)}
231+
<Heading size="md">{t('modelManager.installQueue')}</Heading>
190232
</Flex>
191-
<Flex gap={2} alignItems="center">
192-
<Button
193-
size="sm"
194-
leftIcon={showResumeAll ? <PiPlayBold /> : <PiPauseBold />}
195-
isDisabled={!pauseResumeAvailable || isBulkActionRunning || isPruning}
196-
isLoading={bulkActionInProgress === 'pause' || bulkActionInProgress === 'resume'}
197-
onClick={pauseOrResumeAll}
198-
tooltip={pauseResumeTooltip}
199-
>
200-
{pauseResumeLabel}
201-
</Button>
202-
<Button
203-
size="sm"
204-
leftIcon={<PiXBold />}
205-
isDisabled={!hasCancelableInstalls || isBulkActionRunning || isPruning}
206-
isLoading={bulkActionInProgress === 'cancel'}
207-
onClick={cancelAll}
208-
tooltip={t('modelManager.cancelAllTooltip')}
209-
>
210-
{t('modelManager.cancelAll')}
211-
</Button>
212-
<Button
213-
size="sm"
214-
isDisabled={!pruneAvailable || isBulkActionRunning}
215-
isLoading={isPruning}
216-
onClick={pruneCompletedModelInstalls}
217-
tooltip={t('modelManager.pruneTooltip')}
218-
>
219-
{t('modelManager.prune')}
220-
</Button>
233+
234+
{/* Bulk Actions */}
235+
{/* Non-destructive, easily-ccessible actions */}
236+
<Flex gap={2}>
237+
{hasPauseableInstalls && (
238+
<Button
239+
size="sm"
240+
leftIcon={<PiPauseFill />}
241+
isDisabled={isBulkActionRunning || isPruning}
242+
onClick={pauseAll}
243+
variant="outline"
244+
>
245+
{t('modelManager.pauseAll')}
246+
</Button>
247+
)}
248+
249+
{hasResumableInstalls && (
250+
<Button
251+
size="sm"
252+
leftIcon={<PiPlayFill />}
253+
isDisabled={isBulkActionRunning || isPruning}
254+
onClick={resumeAll}
255+
variant="outline"
256+
>
257+
{t('modelManager.resumeAll')}
258+
</Button>
259+
)}
260+
261+
{/* Destructive Actions go to the button group/menu */}
262+
<ButtonGroup>
263+
<Button
264+
leftIcon={<PiBroomBold />}
265+
size="sm"
266+
isDisabled={!pruneAvailable || isBulkActionRunning || isPruning}
267+
onClick={pruneCompletedModelInstalls}
268+
variant="outline"
269+
>
270+
{t('modelManager.prune')}
271+
</Button>
272+
<Menu>
273+
<MenuButton
274+
as={IconButton}
275+
size="sm"
276+
aria-label={t('accessibility.menu')}
277+
icon={<PiCaretDownBold />}
278+
disabled={!pruneAvailable && !hasCancelableInstalls}
279+
/>
280+
<MenuList>
281+
<MenuItem
282+
color="error.300"
283+
icon={<PiXBold />}
284+
isDisabled={!hasCancelableInstalls || isBulkActionRunning || isPruning}
285+
onClick={cancelAll}
286+
isDestructive
287+
>
288+
{t('modelManager.cancelAll')}
289+
</MenuItem>
290+
</MenuList>
291+
</Menu>
292+
</ButtonGroup>
221293
</Flex>
222294
</Flex>
223-
<Box layerStyle="first" p={3} borderRadius="base" w="full" h="full">
295+
296+
{/* Model Queue List */}
297+
<Box layerStyle="second" borderRadius="base" w="full" h="full">
224298
<ScrollableContent>
225-
<Flex flexDir="column-reverse" gap="2" w="full">
226-
{data?.map((model) => (
227-
<ModelInstallQueueItem key={model.id} installJob={model} />
228-
))}
229-
</Flex>
299+
<Table size="sm" sx={ModelQueueTableSx}>
300+
<Thead>
301+
<Tr>
302+
<Th minWidth="50px"></Th>
303+
<Th width="80%">Name</Th>
304+
<Th minWidth="130px">Status</Th>
305+
<Th minWidth="160px" textAlign="right">
306+
Actions
307+
</Th>
308+
</Tr>
309+
</Thead>
310+
<Tbody>
311+
{data?.length === 0 ? (
312+
<Tr>
313+
<Td colSpan={4} textAlign="center" py={8}>
314+
<Text variant="subtext">{t('modelManager.queueEmpty')}</Text>
315+
</Td>
316+
</Tr>
317+
) : (
318+
reversedData?.map((model) => <ModelInstallQueueItem key={model.id} installJob={model} />)
319+
)}
320+
</Tbody>
321+
</Table>
230322
</ScrollableContent>
231323
</Box>
232324
</Flex>

invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ModelInstallQueue/ModelInstallQueueBadge.tsx

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { ModelInstallStatus } from 'services/api/types';
55

66
const STATUSES = {
77
waiting: { colorScheme: 'cyan', translationKey: 'queue.pending' },
8-
downloading: { colorScheme: 'yellow', translationKey: 'queue.in_progress' },
8+
downloading: { colorScheme: 'blue', translationKey: 'queue.in_progress' },
99
downloads_done: { colorScheme: 'yellow', translationKey: 'queue.in_progress' },
1010
running: { colorScheme: 'yellow', translationKey: 'queue.in_progress' },
1111
paused: { colorScheme: 'orange', translationKey: 'queue.paused' },
@@ -14,18 +14,21 @@ const STATUSES = {
1414
cancelled: { colorScheme: 'orange', translationKey: 'queue.canceled' },
1515
} as const satisfies Partial<Record<ModelInstallStatus, { colorScheme: string; translationKey: string }>>;
1616

17-
const ModelInstallQueueBadge = ({ status }: { status?: ModelInstallStatus }) => {
18-
const { t } = useTranslation();
19-
const statusConfig = status ? STATUSES[status] : undefined;
17+
export const ModelInstallQueueBadge = memo(
18+
({ status, label }: { status?: ModelInstallStatus; label?: string | null }) => {
19+
const { t } = useTranslation();
20+
const statusConfig = status ? STATUSES[status] : undefined;
2021

21-
if (!statusConfig) {
22-
return null;
22+
if (!statusConfig) {
23+
return null;
24+
}
25+
26+
return (
27+
<Badge variant="outline" colorScheme={statusConfig.colorScheme}>
28+
{label ?? t(statusConfig.translationKey)}
29+
</Badge>
30+
);
2331
}
32+
);
2433

25-
return (
26-
<Badge textAlign="center" w="134px" colorScheme={statusConfig.colorScheme}>
27-
{t(statusConfig.translationKey)}
28-
</Badge>
29-
);
30-
};
31-
export default memo(ModelInstallQueueBadge);
34+
ModelInstallQueueBadge.displayName = 'ModelInstallQueueBadge';

0 commit comments

Comments
 (0)