From b27eb5861ab6a7155cf4f941a569009fcac3fc20 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Fri, 20 Feb 2026 16:49:36 -0800 Subject: [PATCH] fix(queue): allow deleting failed jobs from queue progress UI (#8478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Failed jobs could not be removed from the Media Assets queue progress panel because `useJobActions` only supported cancel for pending/running jobs. ## Changes - Add `deleteAction`, `canDeleteJob`, `runDeleteJob` to `useJobActions` composable - Export `removeFailedJob` from `useJobMenu` with optional task parameter - Update `ActiveMediaAssetCard.vue` to show delete button on failed jobs ## Testing 1. Queue a workflow that will fail (e.g., missing model) 2. Open Media Assets panel 3. Hover over the failed job card → delete button (circle-minus icon) appears 4. Click delete → job is removed from queue ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8478-fix-queue-allow-deleting-failed-jobs-from-queue-progress-UI-2f86d73d3650810ba3aaf6cf38703bf5) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp --- src/composables/queue/useJobActions.ts | 33 ++++++- src/composables/queue/useJobMenu.ts | 12 ++- .../components/ActiveMediaAssetCard.test.ts | 97 ++++++++++++++++++- .../components/ActiveMediaAssetCard.vue | 33 +++++-- 4 files changed, 158 insertions(+), 17 deletions(-) diff --git a/src/composables/queue/useJobActions.ts b/src/composables/queue/useJobActions.ts index 65b91c88a..41b4aea03 100644 --- a/src/composables/queue/useJobActions.ts +++ b/src/composables/queue/useJobActions.ts @@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n' import { useErrorHandling } from '@/composables/useErrorHandling' import type { JobListItem } from '@/composables/queue/useJobList' import { useJobMenu } from '@/composables/queue/useJobMenu' +import type { TaskItemImpl } from '@/stores/queueStore' import { isActiveJobState } from '@/utils/queueUtil' export type JobAction = { @@ -18,7 +19,7 @@ export function useJobActions( ) { const { t } = useI18n() const { wrapWithErrorHandlingAsync } = useErrorHandling() - const { cancelJob } = useJobMenu() + const { cancelJob, removeFailedJob } = useJobMenu() const cancelAction: JobAction = { icon: 'icon-[lucide--x]', @@ -26,6 +27,12 @@ export function useJobActions( variant: 'destructive' } + const deleteAction: JobAction = { + icon: 'icon-[lucide--circle-minus]', + label: t('queue.jobMenu.removeJob'), + variant: 'destructive' + } + const jobRef = computed(() => toValue(job) ?? null) const canCancelJob = computed(() => { @@ -37,6 +44,15 @@ export function useJobActions( return currentJob.showClear !== false && isActiveJobState(currentJob.state) }) + const canDeleteJob = computed(() => { + const currentJob = jobRef.value + if (!currentJob) { + return false + } + + return currentJob.state === 'failed' + }) + const runCancelJob = wrapWithErrorHandlingAsync(async () => { const currentJob = jobRef.value if (!currentJob) { @@ -46,9 +62,22 @@ export function useJobActions( await cancelJob(currentJob) }) + const runDeleteJob = wrapWithErrorHandlingAsync(async () => { + const currentJob = jobRef.value + const task = currentJob?.taskRef as TaskItemImpl | undefined + if (!task) { + return + } + + await removeFailedJob(task) + }) + return { cancelAction, canCancelJob, - runCancelJob + runCancelJob, + deleteAction, + canDeleteJob, + runDeleteJob } } diff --git a/src/composables/queue/useJobMenu.ts b/src/composables/queue/useJobMenu.ts index a0466d5f5..1a81a8e58 100644 --- a/src/composables/queue/useJobMenu.ts +++ b/src/composables/queue/useJobMenu.ts @@ -217,10 +217,11 @@ export function useJobMenu( } } - const removeFailedJob = async () => { - const task = currentMenuItem()?.taskRef as TaskItemImpl | undefined - if (!task) return - await queueStore.delete(task) + const removeFailedJob = async (task?: TaskItemImpl | null) => { + const target = + task ?? (currentMenuItem()?.taskRef as TaskItemImpl | undefined) + if (!target) return + await queueStore.delete(target) } const jobMenuOpenWorkflowLabel = computed(() => @@ -369,6 +370,7 @@ export function useJobMenu( jobMenuEntries, openJobWorkflow, copyJobId, - cancelJob + cancelJob, + removeFailedJob } } diff --git a/src/platform/assets/components/ActiveMediaAssetCard.test.ts b/src/platform/assets/components/ActiveMediaAssetCard.test.ts index c00d319ed..1ad0849cd 100644 --- a/src/platform/assets/components/ActiveMediaAssetCard.test.ts +++ b/src/platform/assets/components/ActiveMediaAssetCard.test.ts @@ -1,12 +1,17 @@ import { mount } from '@vue/test-utils' -import { computed } from 'vue' -import { describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { createI18n } from 'vue-i18n' import ActiveJobCard from './ActiveMediaAssetCard.vue' import type { JobListItem } from '@/composables/queue/useJobList' +const mockRunCancelJob = vi.fn() +const mockRunDeleteJob = vi.fn() +const mockCanCancelJob = ref(false) +const mockCanDeleteJob = ref(false) + vi.mock('@/composables/queue/useJobActions', () => ({ useJobActions: () => ({ cancelAction: { @@ -14,8 +19,15 @@ vi.mock('@/composables/queue/useJobActions', () => ({ label: 'Cancel', variant: 'destructive' }, - canCancelJob: computed(() => false), - runCancelJob: vi.fn() + canCancelJob: mockCanCancelJob, + runCancelJob: mockRunCancelJob, + deleteAction: { + icon: 'icon-[lucide--circle-minus]', + label: 'Remove job', + variant: 'destructive' + }, + canDeleteJob: mockCanDeleteJob, + runDeleteJob: mockRunDeleteJob }) })) @@ -58,6 +70,13 @@ const mountComponent = (job: JobListItem) => }) describe('ActiveJobCard', () => { + beforeEach(() => { + mockCanCancelJob.value = false + mockCanDeleteJob.value = false + mockRunCancelJob.mockReset() + mockRunDeleteJob.mockReset() + }) + it('displays percentage and progress bar when job is running', () => { const wrapper = mountComponent( createJob({ state: 'running', progressTotalPercent: 65 }) @@ -121,4 +140,74 @@ describe('ActiveJobCard', () => { expect(container.exists()).toBe(true) expect(container.attributes('aria-label')).toBe('Active job: Generating...') }) + + it('shows delete button on hover for failed jobs', async () => { + mockCanDeleteJob.value = true + + const wrapper = mountComponent( + createJob({ state: 'failed', title: 'Failed' }) + ) + + expect(wrapper.findComponent({ name: 'Button' }).exists()).toBe(false) + + await wrapper.find('[role="status"]').trigger('mouseenter') + + const button = wrapper.findComponent({ name: 'Button' }) + expect(button.exists()).toBe(true) + expect(button.attributes('aria-label')).toBe('Remove job') + }) + + it('calls runDeleteJob when delete button is clicked on a failed job', async () => { + mockCanDeleteJob.value = true + + const wrapper = mountComponent( + createJob({ state: 'failed', title: 'Failed' }) + ) + + await wrapper.find('[role="status"]').trigger('mouseenter') + + const button = wrapper.findComponent({ name: 'Button' }) + await button.trigger('click') + + expect(mockRunDeleteJob).toHaveBeenCalledOnce() + }) + + it('does not show action button when job cannot be cancelled or deleted', async () => { + const wrapper = mountComponent( + createJob({ state: 'running', progressTotalPercent: 50 }) + ) + + await wrapper.find('[role="status"]').trigger('mouseenter') + + expect(wrapper.findComponent({ name: 'Button' }).exists()).toBe(false) + }) + + it('shows cancel button on hover for cancellable jobs', async () => { + mockCanCancelJob.value = true + + const wrapper = mountComponent( + createJob({ state: 'running', progressTotalPercent: 50 }) + ) + + await wrapper.find('[role="status"]').trigger('mouseenter') + + const button = wrapper.findComponent({ name: 'Button' }) + expect(button.exists()).toBe(true) + expect(button.attributes('aria-label')).toBe('Cancel') + }) + + it('calls runCancelJob when cancel button is clicked', async () => { + mockCanCancelJob.value = true + + const wrapper = mountComponent( + createJob({ state: 'running', progressTotalPercent: 50 }) + ) + + await wrapper.find('[role="status"]').trigger('mouseenter') + + const button = wrapper.findComponent({ name: 'Button' }) + await button.trigger('click') + + expect(mockRunCancelJob).toHaveBeenCalledOnce() + }) }) diff --git a/src/platform/assets/components/ActiveMediaAssetCard.vue b/src/platform/assets/components/ActiveMediaAssetCard.vue index 8d68b41ff..c0f462241 100644 --- a/src/platform/assets/components/ActiveMediaAssetCard.vue +++ b/src/platform/assets/components/ActiveMediaAssetCard.vue @@ -39,16 +39,16 @@ class="icon-[lucide--loader-circle] size-8 animate-spin text-muted-foreground" /> - + @@ -90,7 +90,28 @@ const { job } = defineProps<{ job: JobListItem }>() const { t } = useI18n() const hovered = ref(false) -const { cancelAction, canCancelJob, runCancelJob } = useJobActions(() => job) +const { + cancelAction, + canCancelJob, + runCancelJob, + deleteAction, + canDeleteJob, + runDeleteJob +} = useJobActions(() => job) + +const showActionButton = computed( + () => canCancelJob.value || canDeleteJob.value +) +const activeAction = computed(() => + canCancelJob.value ? cancelAction : deleteAction +) +const runActiveAction = () => { + if (canCancelJob.value) { + runCancelJob() + } else if (canDeleteJob.value) { + runDeleteJob() + } +} const { progressBarPrimaryClass, hasProgressPercent, progressPercentStyle } = useProgressBarBackground()