Retain interrupted download progress cards and add in-place retry action

- Keep failed or interrupted downloads in active downloads queue instead of dismissing immediately
- Present interrupted state with error tooltip, distinct status chip, and progress percentage
- Add in-place Retry download play action that resumes downloading from existing partial files
- Support dismissing interrupted downloads via cancel button
- Update CSS styling and unit tests for download action handlers and progress states
This commit is contained in:
Dariusz L
2026-10-26 16:42:56 +01:00
parent c62edd755c
commit d9286b53d5
7 changed files with 186 additions and 23 deletions
+9 -1
View File
@@ -62,6 +62,7 @@ function createDownloadDialog(queueList, calls) {
cancelDownload: id => calls.push(['cancel', id]),
pauseDownload: id => calls.push(['pause', id]),
resumeDownload: id => calls.push(['resume', id]),
retryDownload: id => calls.push(['retry', id]),
openContainingFolder: context => calls.push(['folder', context.widget_name]),
switchToDownloadWorkflow: context => calls.push(['workflow', context.widget_name]),
setQueueDownloadsTab: () => {},
@@ -83,6 +84,7 @@ test('queue download controls trigger each action once and keep more as click-on
'mr-download-queue-cancel',
'mr-download-queue-pause',
'mr-download-queue-resume',
'mr-download-queue-retry',
'mr-download-queue-open-folder',
'mr-download-queue-switch-workflow',
'mr-download-queue-more',
@@ -103,12 +105,14 @@ test('queue download controls trigger each action once and keep more as click-on
dispatchPointerAndClick(window, buttons[2]);
dispatchPointerAndClick(window, buttons[3]);
dispatchPointerAndClick(window, buttons[4]);
buttons[5].dispatchEvent(new window.Event('click', { bubbles: true, cancelable: true }));
dispatchPointerAndClick(window, buttons[5]);
buttons[6].dispatchEvent(new window.Event('click', { bubbles: true, cancelable: true }));
assert.deepEqual(calls, [
['cancel', 'download-1'],
['pause', 'download-1'],
['resume', 'download-1'],
['retry', 'download-1'],
['folder', 'test-model'],
['workflow', 'test-model'],
]);
@@ -124,6 +128,7 @@ test('progress download controls support pending buttons and fallback download i
'cancel-download-btn-pending',
'pause-download-btn',
'resume-download-btn',
'retry-download-btn',
'mr-download-queue-open-folder',
'mr-download-queue-switch-workflow',
'mr-download-queue-more',
@@ -145,16 +150,19 @@ test('progress download controls support pending buttons and fallback download i
buttons[3].dataset.downloadId = '';
buttons[4].dataset.downloadId = '';
buttons[5].dataset.downloadId = '';
buttons[6].dataset.downloadId = '';
dispatchPointerAndClick(window, buttons[0]);
dispatchPointerAndClick(window, buttons[1]);
dispatchPointerAndClick(window, buttons[2]);
dispatchPointerAndClick(window, buttons[3]);
dispatchPointerAndClick(window, buttons[4]);
dispatchPointerAndClick(window, buttons[5]);
assert.deepEqual(calls, [
['cancel', 'fallback-download'],
['pause', 'fallback-download'],
['resume', 'fallback-download'],
['retry', 'fallback-download'],
['folder', 'test-model'],
['workflow', 'test-model'],
]);
+6
View File
@@ -13,6 +13,8 @@ test('download progress labels cover active, paused, checking, and terminal stat
assert.equal(context.getDownloadProgressStatusLabel('downloading', 42.5, true), 'Finalizing');
assert.equal(context.getDownloadProgressStatusLabel('starting'), 'Starting');
assert.equal(context.getDownloadProgressStatusLabel('paused'), 'Paused');
assert.equal(context.getDownloadProgressStatusLabel('error'), 'Interrupted');
assert.equal(context.getDownloadProgressStatusLabel('refresh_error'), 'Interrupted');
assert.equal(context.getDownloadProgressStatusLabel('completed_checking'), 'Checking');
assert.equal(context.getDownloadProgressStatusLabel('cancelled'), 'cancelled');
assert.equal(context.getDownloadProgressStatusLabel(''), 'Download');
@@ -26,6 +28,10 @@ test('download progress status distinguishes active and terminal states', () =>
assert.equal(isActive('paused'), true);
assert.equal(isActive('cancelling'), false);
assert.equal(isActive('completed'), false);
assert.equal(isActive('error', true), true);
assert.equal(isActive('error', false), false);
assert.equal(isActive('refresh_error', true), true);
assert.equal(isActive('refresh_error', false), false);
assert.equal(isActive('custom', true), true);
assert.equal(isActive('custom', false), false);
});
+23
View File
@@ -769,6 +769,29 @@
border-radius: var(--mr-missing-chip-radius);
}
.mr-download-queue-item.mr-download-queue-item-error {
border-color: rgba(239, 68, 68, 0.35);
}
.mr-download-queue-status.mr-download-queue-status-error {
color: var(--mr-color-danger-bright, #ff4d4f);
background: var(--mr-badge-error-bg, rgba(239, 68, 68, 0.16));
border-color: var(--mr-badge-error-border, rgba(239, 68, 68, 0.34));
}
.mr-download-queue-progress .mr-progress-fill.mr-progress-fill-error {
background: var(--mr-color-danger-bright, #ff4d4f);
box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
}
.mr-download-queue-stat.mr-download-queue-stat-error {
max-width: 220px;
overflow: hidden;
color: var(--mr-color-danger-bright, #ff4d4f);
text-overflow: ellipsis;
white-space: nowrap;
}
.mr-download-history-toolbar {
display: flex;
justify-content: flex-end;
@@ -18,12 +18,13 @@ export const downloadProgressMethods = {
}
if (status === 'starting') return 'Starting';
if (status === 'paused') return 'Paused';
if (status === 'error' || status === 'refresh_error') return 'Interrupted';
if (status === 'completed_checking') return 'Checking';
return String(status || '').replace(/_/g, ' ') || 'Download';
},
isDownloadProgressStatus(status = '', isActive = false) {
const terminalStatuses = new Set(['cancelling', 'cancelled', 'error', 'refresh_error', 'completed_checking', 'completed']);
const terminalStatuses = new Set(['cancelling', 'cancelled', 'completed_checking', 'completed']);
return status === 'starting'
|| status === 'downloading'
|| status === 'paused'
+26 -14
View File
@@ -1160,6 +1160,7 @@ export const queueMethods = {
cancel: '.mr-download-queue-cancel',
pause: '.mr-download-queue-pause',
resume: '.mr-download-queue-resume',
retry: '.mr-download-queue-retry',
openFolder: '.mr-download-queue-open-folder',
switchWorkflow: '.mr-download-queue-switch-workflow',
more: '.mr-download-queue-more',
@@ -1169,6 +1170,7 @@ export const queueMethods = {
onCancel: downloadId => this.cancelDownload(downloadId),
onPause: downloadId => this.pauseDownload(downloadId),
onResume: downloadId => this.resumeDownload(downloadId),
onRetry: downloadId => this.retryDownload?.(downloadId),
onOpenFolder: context => this.openContainingFolder?.(context),
onSwitchWorkflow: context => this.switchToDownloadWorkflow?.(context),
moreBinding: 'click',
@@ -1229,9 +1231,10 @@ export const queueMethods = {
const backend = String(progress.download_backend || info.downloadBackend || '').toLowerCase();
const isAria2 = backend === 'aria2';
const isPaused = status === 'paused';
const isError = status === 'error' || status === 'refresh_error';
const statusLabel = status === 'downloading'
? (presentation.isFinalizing ? 'Finalizing' : '')
: (status === 'starting' ? 'Starting' : (isPaused ? 'Paused' : status.replace(/_/g, ' ')));
: (status === 'starting' ? 'Starting' : (isPaused ? 'Paused' : (isError ? 'Interrupted' : status.replace(/_/g, ' '))));
const sizeTextBase = presentation.isFinalizing && logicalTotal
? `Finalizing file: ${logicalDownloaded} / ${logicalTotal}`
: (presentation.totalSize ? `${downloaded} / ${total}` : downloaded);
@@ -1260,21 +1263,27 @@ export const queueMethods = {
`<button type="button" class="mr-download-queue-action ${className}" data-download-id="${safeDownloadId}" data-tooltip="${label}" aria-label="${label}"${disabled ? ' disabled' : ''}>${renderIcon(iconName, 'mr-download-queue-action-icon')}</button>`
);
html += `<div class="mr-queue-item mr-download-queue-item" data-download-id="${this.escapeHtml(downloadId)}"${contextData}>`;
html += `<div class="mr-queue-item mr-download-queue-item${isError ? ' mr-download-queue-item-error' : ''}" data-download-id="${this.escapeHtml(downloadId)}"${contextData}>`;
html += `<div class="mr-download-queue-header">`;
html += `<div class="mr-download-queue-heading">`;
html += `<span class="mr-download-queue-filename" data-tooltip="${this.escapeHtml(filename)}">${this.escapeHtml(filename)}</span>`;
if (statusLabel) {
html += `<span class="mr-download-queue-status">${this.escapeHtml(statusLabel)}</span>`;
const errorMessage = progress.error || info.statusSnapshot?.message || '';
const errorTooltip = isError && errorMessage ? ` data-tooltip="${this.escapeHtml(errorMessage)}"` : '';
html += `<span class="mr-download-queue-status${isError ? ' mr-download-queue-status-error' : ''}"${errorTooltip}>${this.escapeHtml(statusLabel)}</span>`;
}
html += `</div>`;
html += `<div class="mr-download-queue-actions" role="group" aria-label="Download actions">`;
html += actionButton(
isPaused ? 'mr-download-queue-resume' : 'mr-download-queue-pause',
isPaused ? 'Resume download' : 'Pause download',
isPaused ? 'play' : 'pause',
!isAria2
);
if (isError) {
html += actionButton('mr-download-queue-retry', 'Retry download', 'play');
} else {
html += actionButton(
isPaused ? 'mr-download-queue-resume' : 'mr-download-queue-pause',
isPaused ? 'Resume download' : 'Pause download',
isPaused ? 'play' : 'pause',
!isAria2
);
}
html += actionButton('mr-download-queue-cancel', 'Cancel download', 'x');
html += actionButton('mr-download-queue-open-folder', 'Open download folder', 'folderOpen', !hasFolderAction);
if (showWorkflowAction) {
@@ -1284,20 +1293,23 @@ export const queueMethods = {
html += `</div>`;
html += `</div>`;
html += `<div class="mr-download-queue-progress">`;
html += `<div class="mr-progress-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${percent}" aria-label="Download progress"><div class="mr-progress-fill" style="width: ${percent}%;"></div></div>`;
html += `<div class="mr-progress-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${percent}" aria-label="Download progress"><div class="mr-progress-fill${isError ? ' mr-progress-fill-error' : ''}" style="width: ${percent}%;"></div></div>`;
html += `<div class="mr-download-queue-stats">`;
html += `<span class="mr-download-queue-size">${this.escapeHtml(sizeText)}</span>`;
html += `<span class="mr-download-queue-stat-group">`;
if (speedText) {
if (speedText && !isError) {
html += `<span class="mr-download-queue-stat">${renderIcon('download', 'mr-download-queue-stat-icon')}<span>${this.escapeHtml(speedText)}</span></span>`;
}
if (etaText) {
if (etaText && !isError) {
html += `<span class="mr-download-queue-stat"><span>${this.escapeHtml(etaText)}</span></span>`;
}
if (connections > 0) {
if (connections > 0 && !isError) {
html += `<span class="mr-download-queue-stat" data-tooltip="Active download connections">${renderIcon('network', 'mr-download-queue-stat-icon')}<span>${connections}</span></span>`;
}
if (!speedText && !etaText) {
if (isError) {
const errorMessage = progress.error || info.statusSnapshot?.message || 'Download interrupted';
html += `<span class="mr-download-queue-stat mr-download-queue-stat-error" data-tooltip="${this.escapeHtml(errorMessage)}"><span>${this.escapeHtml(errorMessage)}</span></span>`;
} else if (!speedText && !etaText) {
html += `<span class="mr-download-queue-stat mr-download-queue-stat-muted">${this.escapeHtml(statusLabel)}</span>`;
}
html += `</span>`;
@@ -1132,6 +1132,7 @@ export const resolveDownloadMethods = {
cancel: '.cancel-download-btn, .cancel-download-btn-pending, .mr-download-queue-cancel',
pause: '.pause-download-btn, .mr-download-queue-pause',
resume: '.resume-download-btn, .mr-download-queue-resume',
retry: '.retry-download-btn, .mr-download-queue-retry',
openFolder: '.mr-download-queue-open-folder',
switchWorkflow: '.mr-download-queue-switch-workflow',
more: '.mr-download-queue-more',
@@ -1140,6 +1141,7 @@ export const resolveDownloadMethods = {
onCancel: targetDownloadId => this.cancelDownload(targetDownloadId),
onPause: targetDownloadId => this.pauseDownload(targetDownloadId),
onResume: targetDownloadId => this.resumeDownload(targetDownloadId),
onRetry: targetDownloadId => this.retryDownload?.(targetDownloadId),
onOpenFolder: context => this.openContainingFolder?.(context),
onSwitchWorkflow: context => this.switchToDownloadWorkflow?.(context),
onMore: (event, button) => {
@@ -1943,6 +1945,8 @@ export const resolveDownloadMethods = {
downloadDirectory: '',
baseDirectory,
sourceUrl: url,
pathMetadata,
downloadMetadata,
workflowKey,
workflowRouteKey,
workflowLabel,
@@ -1995,6 +1999,17 @@ export const resolveDownloadMethods = {
}
const downloadId = data.download_id;
if (missing) {
for (const [existingId, existingInfo] of Object.entries(this.activeDownloads || {})) {
if (existingInfo?.missing === missing) {
const existingStatus = String(existingInfo.lastStatus || existingInfo.lastProgress?.status || '').toLowerCase();
if (existingStatus === 'error' || existingStatus === 'refresh_error') {
delete this.activeDownloads[existingId];
this.removeActiveDownloadRecovery?.(existingId);
}
}
}
}
this.activeDownloads[downloadId] = {
missing,
progressDiv,
@@ -2006,6 +2021,8 @@ export const resolveDownloadMethods = {
downloadDirectory: data.directory || '',
baseDirectory,
sourceUrl: url,
pathMetadata,
downloadMetadata,
workflowKey,
workflowRouteKey,
workflowLabel,
@@ -2049,6 +2066,8 @@ export const resolveDownloadMethods = {
downloadDirectory: '',
baseDirectory,
sourceUrl: url,
pathMetadata,
downloadMetadata,
workflowKey,
workflowRouteKey,
workflowLabel,
@@ -2253,11 +2272,13 @@ export const resolveDownloadMethods = {
status: 'error',
message: progress.error || 'Download failed',
type: 'error',
isActive: false
isActive: true
});
info.lastStatus = 'error';
info.lastProgress = progress;
info.statusSnapshot = errorSnapshot;
this.renderDownloadSnapshot(downloadId, errorSnapshot, { progressDiv, downloadBtn });
delete this.activeDownloads[downloadId];
this.removeActiveDownloadRecovery?.(downloadId);
this.persistActiveDownloadRecovery?.(downloadId, this.activeDownloads[downloadId]);
this.updateDownloadAllButtonState();
this.updateQueuePanel?.();
this.refreshLocalMatchesUiForMissing?.(missing);
@@ -2279,18 +2300,20 @@ export const resolveDownloadMethods = {
const snapshot = this.rememberDownloadUiState(
downloadId,
info,
{ ...(info.lastProgress || {}), status: 'error' },
{ ...(info.lastProgress || {}), status: 'error', error: error.message },
{
status: 'error',
message: 'Connection lost - download may have failed',
type: 'error',
isActive: false
isActive: true
}
);
info.lastStatus = 'error';
info.lastProgress = { ...(info.lastProgress || {}), status: 'error', error: error.message };
info.statusSnapshot = snapshot;
this.renderDownloadSnapshot(downloadId, snapshot);
this.persistActiveDownloadRecovery?.(downloadId, this.activeDownloads[downloadId]);
}
delete this.activeDownloads[downloadId];
this.removeActiveDownloadRecovery?.(downloadId);
this.updateDownloadAllButtonState();
this.updateQueuePanel?.();
}
@@ -2314,6 +2337,40 @@ export const resolveDownloadMethods = {
*/
cancelDownload(downloadId) {
const info = this.activeDownloads[downloadId];
const currentStatus = String(
info?.pendingDownloadStatus
|| info?.lastStatus
|| info?.lastProgress?.status
|| ''
).toLowerCase();
if (currentStatus === 'error' || currentStatus === 'refresh_error') {
delete this.activeDownloads[downloadId];
this.removeActiveDownloadRecovery?.(downloadId);
if (info) {
const elements = this.resolveDownloadUiElements?.(info) || {};
const cancelledSnapshot = this.rememberDownloadUiState(
downloadId,
info,
{ ...(info.lastProgress || {}), status: 'cancelled' },
{
status: 'cancelled',
message: 'Download dismissed',
type: 'info',
isActive: false
}
);
this.renderDownloadSnapshot?.(downloadId, cancelledSnapshot, elements);
this.refreshLocalMatchesUiForMissing?.(info.missing);
}
this.updateDownloadAllButtonState?.();
this.updateQueuePanel?.();
this.fetchJson(`/model_resolver/cancel/${downloadId}`, {
method: 'POST'
}, 'Cancel download').catch(() => {});
return;
}
if (info) {
this.setPendingDownloadStatus(info, 'cancelling', 30000);
const snapshot = this.rememberDownloadUiState(
@@ -2432,8 +2489,62 @@ export const resolveDownloadMethods = {
});
},
async retryDownload(downloadId) {
const info = this.activeDownloads?.[downloadId];
if (!info) return;
const missing = info.missing;
const sourceUrl = info.sourceUrl || missing?.download_source?.url || '';
const filename = info.filename || missing?.download_source?.filename || '';
const category = info.category || missing?.category || 'checkpoints';
const subfolder = info.subfolder || '';
const baseDirectory = info.baseDirectory || '';
const pathMetadata = info.pathMetadata || (missing && missing.download_source
? this.getDownloadPathMetadata?.(missing, missing.download_source)
: null);
const downloadMetadata = info.downloadMetadata || (missing && missing.download_source
? this.getDownloadMetadata?.(missing, missing.download_source, {
filename,
category,
url: sourceUrl,
pathMetadata
})
: null);
if (!sourceUrl) {
this.showNotification('Cannot retry download: missing download URL', 'error');
return;
}
delete this.activeDownloads[downloadId];
this.removeActiveDownloadRecovery?.(downloadId);
const elements = this.resolveDownloadUiElements?.(info) || {};
const btn = elements.downloadBtn || info.downloadBtn || null;
await this.executeDownloadRequest(missing, {
url: sourceUrl,
filename,
category,
subfolder,
baseDirectory,
pathMetadata,
downloadMetadata,
btn
});
},
resumeDownload(downloadId) {
const info = this.activeDownloads[downloadId];
const currentStatus = String(
info?.pendingDownloadStatus
|| info?.lastStatus
|| info?.lastProgress?.status
|| ''
).toLowerCase();
if (currentStatus === 'error' || currentStatus === 'refresh_error') {
return this.retryDownload(downloadId);
}
const previousProgress = info?.lastProgress ? { ...info.lastProgress } : null;
if (info) {
this.setPendingDownloadStatus(info, 'downloading');
@@ -27,6 +27,7 @@ export function bindDownloadActionHandlers(root, {
onCancel = null,
onPause = null,
onResume = null,
onRetry = null,
onOpenFolder = null,
onSwitchWorkflow = null,
onMore = null,
@@ -47,6 +48,7 @@ export function bindDownloadActionHandlers(root, {
bindIdAction(selectors.cancel, onCancel);
bindIdAction(selectors.pause, onPause);
bindIdAction(selectors.resume, onResume);
bindIdAction(selectors.retry, onRetry);
bindActionButtons(root, selectors.openFolder, (button) => {
const context = resolveContext(button);