Compare commits

...

6 Commits

Author SHA1 Message Date
Glary-Bot
83789067e9 test: add JobDetailsPopover unit tests for terminal state extra rows
Address review feedback on patch coverage: previously the new cancelled
branch in JobDetailsPopover.extraRows was only exercised via the e2e
test, leaving the file at 1.58% unit-test coverage. Add a focused
component test that mounts the popover with cancelled, failed, and
completed history tasks and asserts the correct extra rows and error
message section visibility. Line coverage on the file goes from 1.58%
to 67.46%.
2026-05-13 19:52:57 +00:00
Glary-Bot
6662bb2a53 fix: localize compute-hours unit string for completed jobs
Apply queue.jobDetails.computeHoursValue i18n key to the completed
branch as well, so all three terminal states (completed, failed,
cancelled) use the same localized formatter.
2026-05-12 02:24:59 +00:00
Glary-Bot
09f73ec443 fix: localize compute-hours unit string in job details popover
Address CodeRabbit feedback: the ' hours' literal in the compute hours
value was not localized. Introduce a queue.jobDetails.computeHoursValue
i18n key and apply it to both the failed and cancelled detail rows so
the unit follows the active locale.
2026-05-12 02:18:07 +00:00
Glary-Bot
9558d26e8f fix: use lucide circle-x icon for cancelled state
Per design request, the cancelled state icon should mirror the failed
state icon's properties but use circle-x instead of circle-alert. Also
normalize the failed icon name from the alert-circle alias to the
canonical circle-alert used throughout the rest of the codebase.
2026-05-12 02:06:37 +00:00
Glary-Bot
0615de1f76 feat: distinguish cancelled state in job context menu
Address review feedback: cancelled jobs were falling through to the
default non-terminal context menu which showed a no-op Cancel job
action. Add a dedicated cancelled menu branch that mirrors the failed
menu but omits the error-message actions, since cancelled jobs have no
execution_error to report.
2026-05-12 00:13:59 +00:00
Glary-Bot
859b898220 feat: distinguish Cancelled state from Failed in job queue UI
The /jobs API exposes cancelled and failed as distinct statuses, but the
frontend was collapsing them into a single failed state. As a result,
cancelled jobs displayed a Failed label and rendered an empty error
message container in the details popover.

- Add cancelled to JobState and a dedicated mapping in jobStateFromTask
- Render cancelled jobs with their own icon, labels, and detail rows
- Add a Cancelled filter tab that only appears when cancelled jobs exist
- Skip the error message section for cancelled jobs in the popover
- Allow cancelled jobs to be deleted from the list like failed jobs

Fixes empty error container for cancelled jobs.
2026-05-12 00:04:15 +00:00
22 changed files with 642 additions and 11 deletions

View File

@@ -0,0 +1,188 @@
import type { JobEntry } from '@comfyorg/ingest-types'
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
import { TestIds } from '@e2e/fixtures/selectors'
import {
createMockJob,
createMockJobRecords
} from '@e2e/fixtures/utils/jobFixtures'
const test = mergeTests(comfyPageFixture, jobsApiMockFixture)
const now = Date.now()
const MOCK_JOBS: JobEntry[] = [
createMockJob({
id: 'job-completed-1',
status: 'completed',
create_time: now - 60_000,
execution_start_time: now - 60_000,
execution_end_time: now - 50_000,
outputs_count: 1
}),
createMockJob({
id: 'job-failed-1',
status: 'failed',
create_time: now - 30_000,
execution_start_time: now - 30_000,
execution_end_time: now - 28_000,
outputs_count: 0
}),
createMockJob({
id: 'job-cancelled-1',
status: 'cancelled',
create_time: now - 20_000,
execution_start_time: now - 20_000,
execution_end_time: now - 19_000,
outputs_count: 0
})
]
test.describe('Queue cancelled state', () => {
test.beforeEach(async ({ comfyPage, jobsApi }) => {
await jobsApi.mockJobs(createMockJobRecords(MOCK_JOBS))
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
await comfyPage.setup()
})
test('Cancelled tab is shown when cancelled jobs exist', async ({
comfyPage
}) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible()
await expect(
comfyPage.page.getByRole('button', { name: 'Cancelled', exact: true })
).toBeVisible()
})
test('Cancelled tab is distinct from Failed tab', async ({ comfyPage }) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible()
const failedTab = comfyPage.page.getByRole('button', {
name: 'Failed',
exact: true
})
const cancelledTab = comfyPage.page.getByRole('button', {
name: 'Cancelled',
exact: true
})
await expect(failedTab).toBeVisible()
await expect(cancelledTab).toBeVisible()
})
test('Failed filter shows only failed jobs (excludes cancelled)', async ({
comfyPage
}) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible()
await comfyPage.page
.getByRole('button', { name: 'Failed', exact: true })
.click()
await expect(
comfyPage.page.locator('[data-job-id="job-failed-1"]')
).toBeVisible()
await expect(
comfyPage.page.locator('[data-job-id="job-cancelled-1"]')
).toBeHidden()
await expect(
comfyPage.page.locator('[data-job-id="job-completed-1"]')
).toBeHidden()
})
test('Cancelled filter shows only cancelled jobs (excludes failed)', async ({
comfyPage
}) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible()
await comfyPage.page
.getByRole('button', { name: 'Cancelled', exact: true })
.click()
await expect(
comfyPage.page.locator('[data-job-id="job-cancelled-1"]')
).toBeVisible()
await expect(
comfyPage.page.locator('[data-job-id="job-failed-1"]')
).toBeHidden()
await expect(
comfyPage.page.locator('[data-job-id="job-completed-1"]')
).toBeHidden()
})
test('Cancelled job details popover does not show an empty error container', async ({
comfyPage
}) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
const cancelledRow = comfyPage.page.locator(
'[data-job-id="job-cancelled-1"]'
)
await expect(cancelledRow).toBeVisible()
await cancelledRow.scrollIntoViewIfNeeded()
const rowBox = await cancelledRow.boundingBox()
if (!rowBox) throw new Error('Cancelled job row should be measurable')
await comfyPage.page.mouse.move(0, 0)
await comfyPage.page.mouse.move(
rowBox.x + rowBox.width / 2,
rowBox.y + rowBox.height / 2,
{ steps: 5 }
)
const popover = comfyPage.page.getByTestId(TestIds.queue.jobDetailsPopover)
await expect(popover).toBeVisible()
await expect(popover.getByText('Cancelled after')).toBeVisible()
await expect(popover.getByText('Failed after')).toBeHidden()
await expect(popover.getByText('Error message')).toBeHidden()
})
test('Hides Cancelled tab when no cancelled jobs are present', async ({
comfyPage,
jobsApi
}) => {
const completedOnly: JobEntry[] = [
createMockJob({
id: 'job-only-completed',
status: 'completed',
create_time: now,
execution_start_time: now,
execution_end_time: now + 1_000,
outputs_count: 1
})
]
await jobsApi.mockJobs(createMockJobRecords(completedOnly))
await comfyPage.page.reload()
await comfyPage.setup()
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
await expect(
comfyPage.page.locator('[data-job-id="job-only-completed"]')
).toBeVisible()
await expect(
comfyPage.page.getByRole('button', { name: 'Cancelled', exact: true })
).toBeHidden()
})
})

View File

@@ -63,7 +63,8 @@ const defaultProps = {
selectedWorkflowFilter: 'all' as const,
selectedSortMode: 'mostRecent' as const,
displayedJobGroups: [],
hasFailedJobs: false
hasFailedJobs: false,
hasCancelledJobs: false
}
const stubs = {

View File

@@ -12,6 +12,7 @@
:selected-workflow-filter="selectedWorkflowFilter"
:selected-sort-mode="selectedSortMode"
:has-failed-jobs="hasFailedJobs"
:has-cancelled-jobs="hasCancelledJobs"
@show-assets="$emit('showAssets')"
@update:selected-job-tab="onUpdateSelectedJobTab"
@update:selected-workflow-filter="
@@ -65,6 +66,7 @@ defineProps<{
selectedSortMode: JobSortMode
displayedJobGroups: JobGroup[]
hasFailedJobs: boolean
hasCancelledJobs: boolean
}>()
const emit = defineEmits<{

View File

@@ -20,6 +20,7 @@
:queued-count="queuedCount"
:displayed-job-groups="displayedJobGroups"
:has-failed-jobs="hasFailedJobs"
:has-cancelled-jobs="hasCancelledJobs"
@show-assets="toggleAssetsSidebar"
@clear-history="onClearHistoryFromMenu"
@clear-queued="cancelQueuedWorkflows"
@@ -182,6 +183,7 @@ const {
selectedWorkflowFilter,
selectedSortMode,
hasFailedJobs,
hasCancelledJobs,
filteredTasks,
groupedJobItems,
currentNodeName

View File

@@ -327,7 +327,10 @@ function isCancelable(job: JobListItem) {
}
function isFailedDeletable(job: JobListItem) {
return job.showClear !== false && job.state === 'failed'
return (
job.showClear !== false &&
(job.state === 'failed' || job.state === 'cancelled')
)
}
function getPreviewOutput(job: JobListItem) {

View File

@@ -0,0 +1,118 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import { i18n } from '@/i18n'
import type { JobStatus } from '@/platform/remote/comfyui/jobs/jobTypes'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({ copyToClipboard: vi.fn() })
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showExecutionErrorDialog: vi.fn(),
showErrorDialog: vi.fn()
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: null
})
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => ({
isJobInitializing: () => false
})
}))
function createHistoryTask(
id: string,
status: JobStatus,
executionMs: number
): TaskItemImpl {
const now = Date.now() / 1000
return new TaskItemImpl({
id,
status,
create_time: now - 100,
execution_start_time: now - 100,
execution_end_time: now - 100 + executionMs / 1000,
priority: 1
})
}
function renderPopover(task: TaskItemImpl) {
const pinia = createTestingPinia({
createSpy: vi.fn,
stubActions: false
})
const queueStore = useQueueStore(pinia)
queueStore.historyTasks = [task]
return render(JobDetailsPopover, {
props: { jobId: task.jobId, workflowId: 'wf-1' },
global: {
plugins: [pinia, i18n],
stubs: { Button: { template: '<button><slot /></button>' } },
directives: { tooltip: () => {} }
}
})
}
describe('JobDetailsPopover extraRows', () => {
beforeEach(() => {
i18n.global.locale.value = 'en'
})
it('renders Cancelled after row for a cancelled job and omits the error message section', () => {
const task = createHistoryTask('job-cancelled', 'cancelled', 1500)
renderPopover(task)
expect(screen.getByText('Cancelled after')).toBeTruthy()
expect(screen.queryByText('Error message')).toBeNull()
expect(screen.queryByText('Failed after')).toBeNull()
})
it('renders Failed after row plus the error message section for a failed job', () => {
const task = createHistoryTask('job-failed', 'failed', 2500)
renderPopover(task)
expect(screen.getByText('Failed after')).toBeTruthy()
expect(screen.getByText('Error message')).toBeTruthy()
expect(screen.queryByText('Cancelled after')).toBeNull()
})
it('renders completed-state rows for a completed job and omits failed/cancelled rows', () => {
const task = createHistoryTask('job-completed', 'completed', 4200)
renderPopover(task)
expect(screen.getByText('Generated on')).toBeTruthy()
expect(screen.getByText('Total generation time')).toBeTruthy()
expect(screen.queryByText('Failed after')).toBeNull()
expect(screen.queryByText('Cancelled after')).toBeNull()
expect(screen.queryByText('Error message')).toBeNull()
})
it('localizes the compute hours value via queue.jobDetails.computeHoursValue for all terminal states', () => {
const cases: Array<{ id: string; status: JobStatus }> = [
{ id: 'c1', status: 'completed' },
{ id: 'f1', status: 'failed' },
{ id: 'x1', status: 'cancelled' }
]
for (const { id, status } of cases) {
const task = createHistoryTask(id, status, 1500)
const { unmount } = renderPopover(task)
expect(screen.getByText(/hours$/)).toBeTruthy()
unmount()
}
})
})

View File

@@ -297,7 +297,11 @@ const extraRows = computed<DetailRow[]>(() => {
const totalGenTimeValue =
execMs !== undefined ? formatElapsedTime(execMs) : ''
const computeHoursValue =
execMs !== undefined ? (execMs / 3600000).toFixed(3) + ' hours' : ''
execMs !== undefined
? t('queue.jobDetails.computeHoursValue', {
value: (execMs / 3600000).toFixed(3)
})
: ''
const rows: DetailRow[] = [
{ label: t('queue.jobDetails.generatedOn'), value: generatedOnValue },
@@ -320,7 +324,11 @@ const extraRows = computed<DetailRow[]>(() => {
const failedAfterValue =
execMs !== undefined ? formatElapsedTime(execMs) : ''
const computeHoursValue =
execMs !== undefined ? (execMs / 3600000).toFixed(3) + ' hours' : ''
execMs !== undefined
? t('queue.jobDetails.computeHoursValue', {
value: (execMs / 3600000).toFixed(3)
})
: ''
const rows: DetailRow[] = [
{ label: t('queue.jobDetails.queuedAt'), value: queuedAtValue.value },
{ label: t('queue.jobDetails.failedAfter'), value: failedAfterValue }
@@ -333,6 +341,32 @@ const extraRows = computed<DetailRow[]>(() => {
}
return rows
}
if (jobState.value === 'cancelled') {
const task = taskForJob.value
const execMs: number | undefined = task?.executionTime
const cancelledAfterValue =
execMs !== undefined ? formatElapsedTime(execMs) : ''
const computeHoursValue =
execMs !== undefined
? t('queue.jobDetails.computeHoursValue', {
value: (execMs / 3600000).toFixed(3)
})
: ''
const rows: DetailRow[] = [
{ label: t('queue.jobDetails.queuedAt'), value: queuedAtValue.value },
{
label: t('queue.jobDetails.cancelledAfter'),
value: cancelledAfterValue
}
]
if (isCloud) {
rows.push({
label: t('queue.jobDetails.computeHoursUsed'),
value: computeHoursValue
})
}
return rows
}
return []
})

View File

@@ -22,9 +22,10 @@ import Button from '@/components/ui/button/Button.vue'
import { jobTabs } from '@/composables/queue/useJobList'
import type { JobTab } from '@/composables/queue/useJobList'
const { selectedJobTab, hasFailedJobs } = defineProps<{
const { selectedJobTab, hasFailedJobs, hasCancelledJobs } = defineProps<{
selectedJobTab: JobTab
hasFailedJobs: boolean
hasCancelledJobs: boolean
}>()
defineEmits<{
@@ -34,12 +35,17 @@ defineEmits<{
const { t } = useI18n()
const visibleJobTabs = computed(() =>
hasFailedJobs ? jobTabs : jobTabs.filter((tab) => tab !== 'Failed')
jobTabs.filter((tab) => {
if (tab === 'Failed') return hasFailedJobs
if (tab === 'Cancelled') return hasCancelledJobs
return true
})
)
const tabLabel = (tab: JobTab) => {
if (tab === 'All') return t('g.all')
if (tab === 'Completed') return t('g.completed')
if (tab === 'Cancelled') return t('g.cancelled')
return t('g.failed')
}
</script>

View File

@@ -66,6 +66,7 @@ describe('JobFiltersBar', () => {
selectedWorkflowFilter: 'all',
selectedSortMode: 'mostRecent',
hasFailedJobs: false,
hasCancelledJobs: false,
onShowAssets: showAssetsSpy
},
global: {
@@ -86,6 +87,7 @@ describe('JobFiltersBar', () => {
selectedWorkflowFilter: 'all',
selectedSortMode: 'mostRecent',
hasFailedJobs: false,
hasCancelledJobs: false,
hideShowAssetsAction: true
},
global: {

View File

@@ -3,6 +3,7 @@
<JobFilterTabs
:selected-job-tab="selectedJobTab"
:has-failed-jobs="hasFailedJobs"
:has-cancelled-jobs="hasCancelledJobs"
@update:selected-job-tab="$emit('update:selectedJobTab', $event)"
/>
<JobFilterActions
@@ -28,12 +29,14 @@ const {
selectedWorkflowFilter,
selectedSortMode,
hasFailedJobs,
hasCancelledJobs,
hideShowAssetsAction
} = defineProps<{
selectedJobTab: JobTab
selectedWorkflowFilter: 'all' | 'current'
selectedSortMode: JobSortMode
hasFailedJobs: boolean
hasCancelledJobs: boolean
hideShowAssetsAction?: boolean
}>()

View File

@@ -11,6 +11,7 @@
<JobFilterTabs
:selected-job-tab="selectedJobTab"
:has-failed-jobs="hasFailedJobs"
:has-cancelled-jobs="hasCancelledJobs"
@update:selected-job-tab="onUpdateSelectedJobTab"
/>
</div>
@@ -123,6 +124,7 @@ const {
selectedSortMode,
searchQuery,
hasFailedJobs,
hasCancelledJobs,
filteredTasks,
groupedJobItems
} = useJobList()

View File

@@ -392,6 +392,37 @@ describe('useJobList', () => {
expect(instance.selectedJobTab.value).toBe('All')
})
it('exposes cancelled jobs separately from failed jobs and resets tab when they disappear', async () => {
queueStoreMock.historyTasks = [
createTask({ jobId: 'c', job: { priority: 3 }, mockState: 'completed' }),
createTask({ jobId: 'f', job: { priority: 2 }, mockState: 'failed' }),
createTask({ jobId: 'x', job: { priority: 1 }, mockState: 'cancelled' })
]
const instance = initComposable()
await flush()
expect(instance.hasFailedJobs.value).toBe(true)
expect(instance.hasCancelledJobs.value).toBe(true)
instance.selectedJobTab.value = 'Failed'
await flush()
expect(instance.filteredTasks.value.map((t) => t.jobId)).toEqual(['f'])
instance.selectedJobTab.value = 'Cancelled'
await flush()
expect(instance.filteredTasks.value.map((t) => t.jobId)).toEqual(['x'])
queueStoreMock.historyTasks = [
createTask({ jobId: 'c', job: { priority: 3 }, mockState: 'completed' }),
createTask({ jobId: 'f', job: { priority: 2 }, mockState: 'failed' })
]
await flush()
expect(instance.hasCancelledJobs.value).toBe(false)
expect(instance.selectedJobTab.value).toBe('All')
})
it('filters by active workflow when requested', async () => {
queueStoreMock.pendingTasks = [
createTask({

View File

@@ -24,7 +24,7 @@ import { buildJobDisplay } from '@/utils/queueDisplay'
import { jobStateFromTask } from '@/utils/queueUtil'
/** Tabs for job list filtering */
export const jobTabs = ['All', 'Completed', 'Failed'] as const
export const jobTabs = ['All', 'Completed', 'Failed', 'Cancelled'] as const
export type JobTab = (typeof jobTabs)[number]
export const jobSortModes = ['mostRecent', 'totalGenerationTime'] as const
@@ -223,6 +223,10 @@ export function useJobList() {
tasksWithJobState.value.some(({ state }) => state === 'failed')
)
const hasCancelledJobs = computed(() =>
tasksWithJobState.value.some(({ state }) => state === 'cancelled')
)
watch(
() => hasFailedJobs.value,
(hasFailed) => {
@@ -232,12 +236,23 @@ export function useJobList() {
}
)
watch(
() => hasCancelledJobs.value,
(hasCancelled) => {
if (!hasCancelled && selectedJobTab.value === 'Cancelled') {
selectedJobTab.value = 'All'
}
}
)
const filteredTaskEntries = computed<TaskWithState[]>(() => {
let entries = tasksWithJobState.value
if (selectedJobTab.value === 'Completed') {
entries = entries.filter(({ state }) => state === 'completed')
} else if (selectedJobTab.value === 'Failed') {
entries = entries.filter(({ state }) => state === 'failed')
} else if (selectedJobTab.value === 'Cancelled') {
entries = entries.filter(({ state }) => state === 'cancelled')
}
if (selectedWorkflowFilter.value === 'current') {
@@ -340,7 +355,11 @@ export function useJobList() {
const localeValue = locale.value
for (const { task, state } of searchableTaskEntries.value) {
let ts: number | undefined
if (state === 'completed' || state === 'failed') {
if (
state === 'completed' ||
state === 'failed' ||
state === 'cancelled'
) {
ts = task.executionEndTimestamp
} else {
ts = task.createTime
@@ -385,6 +404,7 @@ export function useJobList() {
selectedSortMode,
searchQuery,
hasFailedJobs,
hasCancelledJobs,
// data sources
allTasksSorted,
filteredTasks,

View File

@@ -846,6 +846,25 @@ describe('useJobMenu', () => {
expect(queueStoreMock.update).toHaveBeenCalled()
})
it('returns cancelled menu entries without error actions or cancel action', async () => {
const taskRef = { id: 'task-cancel' }
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'cancelled', taskRef }))
await nextTick()
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
'open-workflow',
'd1',
'copy-id',
'd2',
'delete'
])
const deleteEntry = findActionEntry(jobMenuEntries.value, 'delete')
await deleteEntry?.onClick?.()
expect(queueStoreMock.delete).toHaveBeenCalledWith(taskRef)
})
it('returns empty menu when no job selected', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(null)

View File

@@ -342,6 +342,30 @@ export function useJobMenu(
}
]
}
if (state === 'cancelled') {
return [
{
key: 'open-workflow',
label: jobMenuOpenWorkflowFailedLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: openJobWorkflow
},
{ kind: 'divider', key: 'd1' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: copyJobId
},
{ kind: 'divider', key: 'd2' },
{
key: 'delete',
label: st('queue.jobMenu.removeJob', 'Remove job'),
icon: 'icon-[lucide--circle-minus]',
onClick: removeFailedJob
}
]
}
return [
{
key: 'open-workflow',

View File

@@ -1306,7 +1306,9 @@
"generatedOn": "Generated on",
"totalGenerationTime": "Total generation time",
"computeHoursUsed": "Compute hours used",
"computeHoursValue": "{value} hours",
"failedAfter": "Failed after",
"cancelledAfter": "Cancelled after",
"errorMessage": "Error message",
"report": "Report",
"queuePositionValue": "~{count} job ahead of yours | ~{count} jobs ahead of yours",

View File

@@ -22,7 +22,7 @@ function buildGroupedJobItems(): JobGroup[] {
const groupedJobItems = computed<JobGroup[]>(buildGroupedJobItems)
export const jobTabs = ['All', 'Completed', 'Failed'] as const
export const jobTabs = ['All', 'Completed', 'Failed', 'Cancelled'] as const
export const jobSortModes = ['mostRecent', 'totalGenerationTime'] as const
const selectedJobTab = ref<JobTab>('All')
@@ -41,7 +41,12 @@ function buildHasFailedJobs() {
return jobItems.value.some((item) => item.state === 'failed')
}
function buildHasCancelledJobs() {
return jobItems.value.some((item) => item.state === 'cancelled')
}
const hasFailedJobs = computed(buildHasFailedJobs)
const hasCancelledJobs = computed(buildHasCancelledJobs)
export function setMockJobItems(items: JobListItem[]) {
jobItems.value = items
@@ -54,6 +59,7 @@ export function useJobList() {
selectedSortMode,
searchQuery,
hasFailedJobs,
hasCancelledJobs,
allTasksSorted,
filteredTasks,
jobItems,

View File

@@ -7,3 +7,4 @@ export type JobState =
| 'running'
| 'completed'
| 'failed'
| 'cancelled'

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest'
import type { TaskItemImpl } from '@/stores/queueStore'
import type { JobState } from '@/types/queue'
import type { BuildJobDisplayCtx } from './queueDisplay'
import { buildJobDisplay, iconForJobState } from './queueDisplay'
const noopFormatClockTime = (ts: number) => `t:${ts}`
const baseCtx = (
overrides: Partial<BuildJobDisplayCtx> = {}
): BuildJobDisplayCtx => ({
t: (key: string) => key,
locale: 'en-US',
formatClockTimeFn: noopFormatClockTime,
isActive: false,
...overrides
})
const taskFor = (overrides: Partial<TaskItemImpl> = {}): TaskItemImpl =>
({
jobId: 'job-1',
job: { priority: 1 },
createTime: 0,
...overrides
}) as unknown as TaskItemImpl
describe('iconForJobState', () => {
it('returns a distinct icon for cancelled vs failed', () => {
expect(iconForJobState('cancelled')).toBe('icon-[lucide--circle-x]')
expect(iconForJobState('failed')).toBe('icon-[lucide--circle-alert]')
expect(iconForJobState('cancelled')).not.toBe(iconForJobState('failed'))
})
it('returns expected icons for in-progress and completed states', () => {
expect(iconForJobState('running')).toBe('icon-[lucide--zap]')
expect(iconForJobState('completed')).toBe('icon-[lucide--check-check]')
expect(iconForJobState('pending')).toBe('icon-[lucide--loader-circle]')
})
})
describe('buildJobDisplay - cancelled state', () => {
it('uses g.cancelled labels (not g.failed) for cancelled jobs', () => {
const seen: string[] = []
const t = (key: string) => {
seen.push(key)
return key
}
const display = buildJobDisplay(taskFor(), 'cancelled', baseCtx({ t }))
expect(display.primary).toBe('g.cancelled')
expect(display.secondary).toBe('g.cancelled')
expect(seen).toContain('g.cancelled')
expect(seen).not.toContain('g.failed')
})
it('uses the cancelled icon, not the failed icon', () => {
const display = buildJobDisplay(taskFor(), 'cancelled', baseCtx())
expect(display.iconName).toBe(iconForJobState('cancelled'))
expect(display.iconName).not.toBe(iconForJobState('failed'))
})
it('keeps the failed branch using g.failed and the failed icon', () => {
const display = buildJobDisplay(taskFor(), 'failed', baseCtx())
expect(display.primary).toBe('g.failed')
expect(display.iconName).toBe(iconForJobState('failed'))
})
it('allows the row to be cleared (showClear true) for cancelled', () => {
const display = buildJobDisplay(taskFor(), 'cancelled', baseCtx())
expect(display.showClear).toBe(true)
})
})
describe('buildJobDisplay - exhaustive state handling', () => {
const states: JobState[] = [
'pending',
'initialization',
'running',
'completed',
'failed',
'cancelled'
]
it.each(states)('returns a non-empty display for %s state', (state) => {
const display = buildJobDisplay(taskFor(), state, baseCtx())
expect(display.iconName).toBeTruthy()
expect(display.primary).toBeTruthy()
})
})

View File

@@ -35,7 +35,9 @@ export const iconForJobState = (state: JobState): string => {
case 'completed':
return 'icon-[lucide--check-check]'
case 'failed':
return 'icon-[lucide--alert-circle]'
return 'icon-[lucide--circle-alert]'
case 'cancelled':
return 'icon-[lucide--circle-x]'
default:
return 'icon-[lucide--circle]'
}
@@ -151,6 +153,14 @@ export const buildJobDisplay = (
showClear: true
}
}
if (state === 'cancelled') {
return {
iconName: iconForJobState(state),
primary: ctx.t('g.cancelled'),
secondary: ctx.t('g.cancelled'),
showClear: true
}
}
return {
iconName: iconForJobState(state),
primary: buildTitle(task, ctx.t),

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import type { TaskItemImpl } from '@/stores/queueStore'
import { isActiveJobState, jobStateFromTask } from './queueUtil'
type TaskDisplayStatus =
| 'Running'
| 'Pending'
| 'Completed'
| 'Failed'
| 'Cancelled'
const taskWithStatus = (displayStatus: TaskDisplayStatus): TaskItemImpl =>
({ displayStatus }) as unknown as TaskItemImpl
describe('jobStateFromTask', () => {
it('returns initialization when isInitializing is true regardless of status', () => {
expect(jobStateFromTask(taskWithStatus('Running'), true)).toBe(
'initialization'
)
expect(jobStateFromTask(taskWithStatus('Pending'), true)).toBe(
'initialization'
)
})
it('maps Running to running', () => {
expect(jobStateFromTask(taskWithStatus('Running'), false)).toBe('running')
})
it('maps Pending to pending', () => {
expect(jobStateFromTask(taskWithStatus('Pending'), false)).toBe('pending')
})
it('maps Completed to completed', () => {
expect(jobStateFromTask(taskWithStatus('Completed'), false)).toBe(
'completed'
)
})
it('maps Failed to failed', () => {
expect(jobStateFromTask(taskWithStatus('Failed'), false)).toBe('failed')
})
it('maps Cancelled to cancelled (distinct from failed)', () => {
expect(jobStateFromTask(taskWithStatus('Cancelled'), false)).toBe(
'cancelled'
)
})
})
describe('isActiveJobState', () => {
it('identifies in-progress states as active', () => {
expect(isActiveJobState('pending')).toBe(true)
expect(isActiveJobState('initialization')).toBe(true)
expect(isActiveJobState('running')).toBe(true)
})
it('identifies terminal states as inactive', () => {
expect(isActiveJobState('completed')).toBe(false)
expect(isActiveJobState('failed')).toBe(false)
expect(isActiveJobState('cancelled')).toBe(false)
})
})

View File

@@ -31,8 +31,9 @@ export const jobStateFromTask = (
case 'Completed':
return 'completed'
case 'Failed':
case 'Cancelled':
return 'failed'
case 'Cancelled':
return 'cancelled'
}
return 'failed'
}