From 106ae5ada84bf8edf6faff07670468a0fbe214d1 Mon Sep 17 00:00:00 2001 From: joshistoast Date: Mon, 30 Mar 2026 14:45:19 -0600 Subject: [PATCH] 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. --- .../ModelInstallQueue/ModelInstallQueue.tsx | 3 +- .../ModelInstallQueueItem.tsx | 189 ++++++++++++------ 2 files changed, 132 insertions(+), 60 deletions(-) diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ModelInstallQueue/ModelInstallQueue.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ModelInstallQueue/ModelInstallQueue.tsx index c0ab751c9c..95666a6435 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ModelInstallQueue/ModelInstallQueue.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ModelInstallQueue/ModelInstallQueue.tsx @@ -104,7 +104,7 @@ export const ModelInstallQueue = memo(() => { continue; } - if (model.status === 'running') { + if (model.status === 'running' || model.status === 'downloads_done') { cancelable.push(model.id); } } @@ -287,6 +287,7 @@ export const ModelInstallQueue = memo(() => { } disabled={!pruneAvailable && !hasCancelableInstalls} /> diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ModelInstallQueue/ModelInstallQueueItem.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ModelInstallQueue/ModelInstallQueueItem.tsx index bbd7db7a09..0e76d06d56 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ModelInstallQueue/ModelInstallQueueItem.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ModelInstallQueue/ModelInstallQueueItem.tsx @@ -16,7 +16,7 @@ import { useStore } from '@nanostores/react'; import { isNil } from 'es-toolkit/compat'; import { getApiErrorDetail } from 'features/modelManagerV2/util/getApiErrorDetail'; import { toast } from 'features/toast/toast'; -import { memo, useCallback, useEffect, useMemo, useRef } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowClockwiseBold, @@ -36,7 +36,7 @@ import { useRestartModelInstallFileMutation, useResumeModelInstallMutation, } from 'services/api/endpoints/models'; -import type { ModelInstallJob } from 'services/api/types'; +import type { ModelInstallJob, ModelInstallStatus } from 'services/api/types'; import { $isConnected } from 'services/events/stores'; import { ModelInstallQueueBadge } from './ModelInstallQueueBadge'; @@ -45,6 +45,22 @@ type ModelListItemProps = { installJob: ModelInstallJob; }; +type QueueItemAction = 'cancel' | 'pause' | 'resume' | 'restartFailed' | 'restartFile'; +type OptimisticStatusState = { + status: ModelInstallStatus; + previousStatus: ModelInstallStatus | undefined; +}; + +const OPTIMISTIC_STATUS_BY_ACTION: Record = { + cancel: 'cancelled', + pause: 'paused', + resume: 'waiting', + restartFailed: 'waiting', + restartFile: 'waiting', +}; + +const isRestartableStatus = (status?: ModelInstallStatus) => status === 'paused' || status === 'error'; + const formatBytes = (bytes: number) => { const units = ['B', 'KB', 'MB', 'GB', 'TB']; @@ -98,46 +114,81 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { const [resumeModelInstall] = useResumeModelInstallMutation(); const [restartFailedModelInstall] = useRestartFailedModelInstallMutation(); const [restartModelInstallFile] = useRestartModelInstallFileMutation(); + const [actionInFlight, setActionInFlight] = useState(null); + const [optimisticStatus, setOptimisticStatus] = useState(null); + const actionInFlightRef = useRef(null); const resumeFromScratchShown = useRef(false); const isConnected = useStore($isConnected); + useEffect(() => { + if (!optimisticStatus) { + return; + } + + if (installJob.status !== optimisticStatus.previousStatus) { + setOptimisticStatus(null); + } + }, [installJob.status, optimisticStatus]); + + const withRowActionLock = useCallback( + async (action: QueueItemAction, previousStatus: ModelInstallStatus | undefined, fn: () => Promise) => { + if (actionInFlightRef.current) { + return; + } + + actionInFlightRef.current = action; + setActionInFlight(action); + setOptimisticStatus({ status: OPTIMISTIC_STATUS_BY_ACTION[action], previousStatus }); + + try { + await fn(); + } finally { + actionInFlightRef.current = null; + setActionInFlight(null); + } + }, + [] + ); + const handleDeleteModelImport = useCallback(() => { - deleteImportModel(installJob.id) - .unwrap() - .then((_) => { + void withRowActionLock('cancel', installJob.status, async () => { + try { + await deleteImportModel(installJob.id).unwrap(); toast({ id: 'MODEL_INSTALL_CANCELED', title: t('toast.modelImportCanceled'), status: 'success', }); - }) - .catch((error) => { + } catch (error) { + setOptimisticStatus(null); toast({ id: 'MODEL_INSTALL_CANCEL_FAILED', title: getApiErrorDetail(error), status: 'error', }); - }); - }, [deleteImportModel, installJob, t]); + } + }); + }, [deleteImportModel, installJob.id, installJob.status, t, withRowActionLock]); const handlePauseModelInstall = useCallback(() => { - pauseModelInstall(installJob.id) - .unwrap() - .then(() => { + void withRowActionLock('pause', installJob.status, async () => { + try { + await pauseModelInstall(installJob.id).unwrap(); toast({ id: 'MODEL_INSTALL_PAUSED', title: t('toast.modelDownloadPaused'), status: 'success', }); - }) - .catch((error) => { + } catch (error) { + setOptimisticStatus(null); toast({ id: 'MODEL_INSTALL_PAUSE_FAILED', title: getApiErrorDetail(error), status: 'error', }); - }); - }, [installJob, pauseModelInstall, t]); + } + }); + }, [installJob.id, installJob.status, pauseModelInstall, t, withRowActionLock]); const hasRestartedFromScratch = useCallback((job: ModelInstallJob) => { return ( @@ -149,9 +200,9 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { }, []); const handleResumeModelInstall = useCallback(() => { - resumeModelInstall(installJob.id) - .unwrap() - .then((job) => { + void withRowActionLock('resume', installJob.status, async () => { + try { + const job = await resumeModelInstall(installJob.id).unwrap(); const restartedFromScratch = hasRestartedFromScratch(job); if (restartedFromScratch && !resumeFromScratchShown.current) { resumeFromScratchShown.current = true; @@ -167,55 +218,58 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { title: t('toast.modelDownloadResumed'), status: 'success', }); - }) - .catch((error) => { + } catch (error) { + setOptimisticStatus(null); toast({ id: 'MODEL_INSTALL_RESUME_FAILED', title: getApiErrorDetail(error), status: 'error', }); - }); - }, [hasRestartedFromScratch, installJob, resumeModelInstall, t]); + } + }); + }, [hasRestartedFromScratch, installJob.id, installJob.status, resumeModelInstall, t, withRowActionLock]); const handleRestartFailed = useCallback(() => { - restartFailedModelInstall(installJob.id) - .unwrap() - .then(() => { + void withRowActionLock('restartFailed', installJob.status, async () => { + try { + await restartFailedModelInstall(installJob.id).unwrap(); toast({ id: 'MODEL_INSTALL_RESTART_FAILED', title: t('toast.modelDownloadRestartFailed'), status: 'success', }); - }) - .catch((error) => { + } catch (error) { + setOptimisticStatus(null); toast({ id: 'MODEL_INSTALL_RESTART_FAILED_ERROR', title: getApiErrorDetail(error), status: 'error', }); - }); - }, [installJob.id, restartFailedModelInstall, t]); + } + }); + }, [installJob.id, installJob.status, restartFailedModelInstall, t, withRowActionLock]); const handleRestartFile = useCallback( (fileSource: string) => { - restartModelInstallFile({ id: installJob.id, file_source: fileSource }) - .unwrap() - .then(() => { + void withRowActionLock('restartFile', installJob.status, async () => { + try { + await restartModelInstallFile({ id: installJob.id, file_source: fileSource }).unwrap(); toast({ id: 'MODEL_INSTALL_RESTART_FILE', title: t('toast.modelDownloadRestartFile'), status: 'success', }); - }) - .catch((error) => { + } catch (error) { + setOptimisticStatus(null); toast({ id: 'MODEL_INSTALL_RESTART_FILE_ERROR', title: getApiErrorDetail(error), status: 'error', }); - }); + } + }); }, - [installJob.id, restartModelInstallFile, t] + [installJob.id, installJob.status, restartModelInstallFile, t, withRowActionLock] ); const getRestartFileHandler = useCallback( @@ -236,6 +290,8 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { } }, [installJob.source, t]); + const displayStatus = optimisticStatus?.status ?? installJob.status; + const modelName = useMemo(() => { switch (installJob.source.type) { case 'hf': { @@ -248,14 +304,14 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { case 'url': return installJob.source.url.split('/').slice(-1)[0] ?? t('common.unknown'); case 'local': - return installJob.source.path.split('\\').slice(-1)[0] ?? t('common.unknown'); + return installJob.source.path.split(/[/\\]/).slice(-1)[0] ?? t('common.unknown'); default: return t('common.unknown'); } }, [installJob.source, t]); const progressValue = useMemo(() => { - if (installJob.status === 'completed' || installJob.status === 'error' || installJob.status === 'cancelled') { + if (displayStatus === 'completed' || displayStatus === 'error' || displayStatus === 'cancelled') { return 100; } @@ -276,10 +332,10 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { } return null; - }, [installJob.bytes, installJob.download_parts, installJob.status, installJob.total_bytes]); + }, [displayStatus, installJob.bytes, installJob.download_parts, installJob.total_bytes]); const progressTooltip = useMemo(() => { - if (installJob.status !== 'downloading') { + if (displayStatus !== 'downloading' && displayStatus !== 'downloads_done') { return ''; } const parts = installJob.download_parts; @@ -297,7 +353,7 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { return `${formatBytes(installJob.bytes)} / ${formatBytes(installJob.total_bytes)}`; } return ''; - }, [installJob.bytes, installJob.download_parts, installJob.total_bytes, installJob.status]); + }, [displayStatus, installJob.bytes, installJob.download_parts, installJob.total_bytes]); const restartRequiredParts = useMemo(() => { return installJob.download_parts?.filter((part) => part.resume_required || part.status === 'error') ?? []; @@ -318,26 +374,36 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { } }, [hasRestartedFromScratch, installJob, t]); - const hasRestartRequired = restartRequiredParts.length > 0; + const hasRestartRequired = isRestartableStatus(displayStatus) && restartRequiredParts.length > 0; - const canPause = installJob.status === 'downloading' || installJob.status === 'waiting'; - const canResume = installJob.status === 'paused' && !hasRestartRequired; + const canPause = displayStatus === 'downloading' || displayStatus === 'waiting'; + const canResume = displayStatus === 'paused' && !hasRestartRequired; const canCancel = - installJob.status === 'downloading' || - installJob.status === 'waiting' || - installJob.status === 'running' || - installJob.status === 'paused'; + displayStatus === 'downloading' || + displayStatus === 'waiting' || + displayStatus === 'downloads_done' || + displayStatus === 'running' || + displayStatus === 'paused'; + + const isActiveInstall = + displayStatus === 'downloading' || + displayStatus === 'waiting' || + displayStatus === 'downloads_done' || + displayStatus === 'running'; + + const hasVisibleError = displayStatus === 'error' ? installJob.error : null; + const isActionInFlight = actionInFlight !== null; const showDisconnectedIndicator = !isConnected && - (installJob.status === 'downloading' || installJob.status === 'waiting' || installJob.status === 'running'); + isActiveInstall; return ( {/* Progress */} - {installJob.status === 'downloading' || installJob.status === 'waiting' || installJob.status === 'running' ? ( + {isActiveInstall ? ( { isIndeterminate={ !isConnected || progressValue === null || - installJob.status === 'waiting' || - installJob.status === 'running' + displayStatus === 'waiting' || + displayStatus === 'downloads_done' || + displayStatus === 'running' } aria-label={t('accessibility.invokeProgressBar')} sx={CircularProgressSx} thickness={12} /> - ) : installJob.status === 'paused' ? ( + ) : displayStatus === 'paused' ? ( - ) : installJob.status === 'cancelled' ? ( + ) : displayStatus === 'cancelled' ? ( - ) : installJob.status === 'error' ? ( + ) : displayStatus === 'error' ? ( @@ -377,13 +444,13 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { {modelName} - + {sourceLocation} {hasRestartRequired && ( {restartRequiredParts.map((part) => { - const fileName = part.source.split('/').slice(-1)[0] ?? t('common.unknown'); + const fileName = part.source.split(/[/\\]/).slice(-1)[0] ?? t('common.unknown'); const isResumeRequired = part.resume_required; return ( @@ -408,6 +475,7 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { onClick={getRestartFileHandler(part.source)} variant="ghost" ml="auto" + isDisabled={isActionInFlight} /> ); @@ -427,7 +495,7 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { )} - + {hasRestartRequired && ( @@ -449,6 +517,7 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { leftIcon={canResume ? : } onClick={canResume ? handleResumeModelInstall : handlePauseModelInstall} variant={canResume ? 'solid' : 'outline'} + isDisabled={isActionInFlight} > {canResume ? t('modelManager.resume') : t('modelManager.pause')} @@ -463,6 +532,7 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { onClick={handleRestartFailed} colorScheme="error" variant="ghost" + isDisabled={isActionInFlight} > {t('modelManager.restartFailed')} @@ -477,6 +547,7 @@ export const ModelInstallQueueItem = memo((props: ModelListItemProps) => { onClick={handleDeleteModelImport} size="sm" colorScheme="error" + isDisabled={isActionInFlight} /> )}