Compare commits

...

3 Commits

Author SHA1 Message Date
Jin Yi
dc731d50e5 fix: address review feedback for queue tab
- Disable clear-queue button based on pendingTasks count instead of activeJobsCount
- Use vi.hoisted() for test mock isolation in QueueAssetView tests
- Replace toReversed() with slice().reverse() for broader compatibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 12:56:48 +09:00
Jin Yi
a0a762898e fix: address review feedback for queue tab
- Replace queuedCount with activeJobsCount for clear button disabled state
- Convert clear queue label text to v-tooltip
- Simplify getJobIconClass logic
- Remove unnecessary comment and extra blank line
- Add QueueAssetView unit tests (FIFO ordering, state filtering)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 12:34:54 +09:00
Jin Yi
d0c58b79e3 feature: queue tab 2026-02-10 12:34:53 +09:00
8 changed files with 294 additions and 252 deletions

View File

@@ -1,8 +1,5 @@
<template> <template>
<div <div ref="container" class="h-full scrollbar-custom">
ref="container"
class="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
>
<div :style="topSpacerStyle" /> <div :style="topSpacerStyle" />
<div :style="mergedGridStyle"> <div :style="mergedGridStyle">
<div <div

View File

@@ -1,23 +1,7 @@
<template> <template>
<div class="flex h-full flex-col"> <div class="flex h-full flex-col">
<!-- Active Jobs Grid -->
<div
v-if="!isInFolderView && isQueuePanelV2Enabled && activeJobItems.length"
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
:style="gridStyle"
>
<ActiveMediaAssetCard
v-for="job in activeJobItems"
:key="job.id"
:job="job"
/>
</div>
<!-- Assets Header --> <!-- Assets Header -->
<div <div v-if="assets.length" class="px-2 2xl:px-4">
v-if="assets.length"
:class="cn('px-2 2xl:px-4', activeJobItems.length && 'mt-2')"
>
<div <div
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter" class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
> >
@@ -59,25 +43,18 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import VirtualGrid from '@/components/common/VirtualGrid.vue' import VirtualGrid from '@/components/common/VirtualGrid.vue'
import ActiveMediaAssetCard from '@/platform/assets/components/ActiveMediaAssetCard.vue'
import { useJobList } from '@/composables/queue/useJobList'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue' import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isActiveJobState } from '@/utils/queueUtil'
import { cn } from '@/utils/tailwindUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
const { const {
assets, assets,
isSelected, isSelected,
isInFolderView = false,
assetType = 'output', assetType = 'output',
showOutputCount, showOutputCount,
getOutputCount getOutputCount
} = defineProps<{ } = defineProps<{
assets: AssetItem[] assets: AssetItem[]
isSelected: (assetId: string) => boolean isSelected: (assetId: string) => boolean
isInFolderView?: boolean
assetType?: 'input' | 'output' assetType?: 'input' | 'output'
showOutputCount: (asset: AssetItem) => boolean showOutputCount: (asset: AssetItem) => boolean
getOutputCount: (asset: AssetItem) => number getOutputCount: (asset: AssetItem) => number
@@ -92,19 +69,9 @@ const emit = defineEmits<{
}>() }>()
const { t } = useI18n() const { t } = useI18n()
const { jobItems } = useJobList()
const settingStore = useSettingStore()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
type AssetGridItem = { key: string; asset: AssetItem } type AssetGridItem = { key: string; asset: AssetItem }
const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state)).toReversed()
)
const assetItems = computed<AssetGridItem[]>(() => const assetItems = computed<AssetGridItem[]>(() =>
assets.map((asset) => ({ assets.map((asset) => ({
key: `asset-${asset.id}`, key: `asset-${asset.id}`,

View File

@@ -1,6 +1,5 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import AssetsSidebarListView from './AssetsSidebarListView.vue' import AssetsSidebarListView from './AssetsSidebarListView.vue'
@@ -10,51 +9,12 @@ vi.mock('vue-i18n', () => ({
}) })
})) }))
vi.mock('@/composables/queue/useJobActions', () => ({
useJobActions: () => ({
cancelAction: { variant: 'ghost', label: 'Cancel', icon: 'pi pi-times' },
canCancelJob: ref(false),
runCancelJob: vi.fn()
})
}))
const mockJobItems = ref<
Array<{
id: string
title: string
meta: string
state: string
createTime?: number
}>
>([])
vi.mock('@/composables/queue/useJobList', () => ({
useJobList: () => ({
jobItems: mockJobItems
})
}))
vi.mock('@/stores/assetsStore', () => ({ vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({ useAssetsStore: () => ({
isAssetDeleting: () => false isAssetDeleting: () => false
}) })
})) }))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) => key === 'Comfy.Queue.QPOV2'
})
}))
vi.mock('@/utils/queueUtil', () => ({
isActiveJobState: (state: string) =>
state === 'pending' || state === 'running'
}))
vi.mock('@/utils/queueDisplay', () => ({
iconForJobState: () => 'pi pi-spinner'
}))
vi.mock('@/platform/assets/schemas/assetMetadataSchema', () => ({ vi.mock('@/platform/assets/schemas/assetMetadataSchema', () => ({
getOutputAssetMetadata: () => undefined getOutputAssetMetadata: () => undefined
})) }))
@@ -73,7 +33,6 @@ vi.mock('@/utils/formatUtil', () => ({
describe('AssetsSidebarListView', () => { describe('AssetsSidebarListView', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockJobItems.value = []
}) })
const defaultProps = { const defaultProps = {
@@ -84,67 +43,14 @@ describe('AssetsSidebarListView', () => {
toggleStack: async () => {} toggleStack: async () => {}
} }
it('displays active jobs in oldest-first order (FIFO)', () => { it('renders without errors with empty assets', () => {
mockJobItems.value = [
{
id: 'newest',
title: 'Newest Job',
meta: '',
state: 'pending',
createTime: 3000
},
{
id: 'middle',
title: 'Middle Job',
meta: '',
state: 'running',
createTime: 2000
},
{
id: 'oldest',
title: 'Oldest Job',
meta: '',
state: 'pending',
createTime: 1000
}
]
const wrapper = mount(AssetsSidebarListView, { const wrapper = mount(AssetsSidebarListView, {
props: defaultProps, props: defaultProps,
shallow: true shallow: true
}) })
const jobListItems = wrapper.findAllComponents({ name: 'AssetsListItem' }) expect(wrapper.exists()).toBe(true)
expect(jobListItems).toHaveLength(3) const listItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
expect(listItems).toHaveLength(0)
const displayedTitles = jobListItems.map((item) =>
item.props('primaryText')
)
expect(displayedTitles).toEqual(['Oldest Job', 'Middle Job', 'Newest Job'])
})
it('excludes completed and failed jobs from active jobs section', () => {
mockJobItems.value = [
{ id: 'pending', title: 'Pending', meta: '', state: 'pending' },
{ id: 'completed', title: 'Completed', meta: '', state: 'completed' },
{ id: 'failed', title: 'Failed', meta: '', state: 'failed' },
{ id: 'running', title: 'Running', meta: '', state: 'running' }
]
const wrapper = mount(AssetsSidebarListView, {
props: defaultProps,
shallow: true
})
const jobListItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
expect(jobListItems).toHaveLength(2)
const displayedTitles = jobListItems.map((item) =>
item.props('primaryText')
)
expect(displayedTitles).toContain('Running')
expect(displayedTitles).toContain('Pending')
expect(displayedTitles).not.toContain('Completed')
expect(displayedTitles).not.toContain('Failed')
}) })
}) })

View File

@@ -1,48 +1,6 @@
<template> <template>
<div class="flex h-full flex-col"> <div class="flex h-full flex-col">
<div <div v-if="assetItems.length" class="px-2">
v-if="isQueuePanelV2Enabled && activeJobItems.length"
class="flex max-h-[50%] scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
>
<AssetsListItem
v-for="job in activeJobItems"
:key="job.id"
:class="
cn(
'w-full shrink-0 text-text-primary transition-colors hover:bg-secondary-background-hover',
'cursor-default'
)
"
:preview-url="job.iconImageUrl"
:preview-alt="job.title"
:icon-name="job.iconName"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@mouseenter="onJobEnter(job.id)"
@mouseleave="onJobLeave(job.id)"
@click.stop
>
<template v-if="hoveredJobId === job.id" #actions>
<Button
v-if="canCancelJob"
:variant="cancelAction.variant"
size="icon"
:aria-label="cancelAction.label"
@click.stop="runCancelJob()"
>
<i :class="cancelAction.icon" class="size-4" />
</Button>
</template>
</AssetsListItem>
</div>
<div
v-if="assetItems.length"
:class="cn('px-2', activeJobItems.length && 'mt-2')"
>
<div <div
class="flex items-center p-2 text-sm font-normal leading-normal text-muted-foreground font-inter" class="flex items-center p-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
> >
@@ -119,31 +77,25 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue' import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue' import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Button from '@/components/ui/button/Button.vue' import Button from '@/components/ui/button/Button.vue'
import { useJobActions } from '@/composables/queue/useJobActions'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useJobList } from '@/composables/queue/useJobList'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue' import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks' import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema' import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil' import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
import { useAssetsStore } from '@/stores/assetsStore' import { useAssetsStore } from '@/stores/assetsStore'
import { isActiveJobState } from '@/utils/queueUtil'
import { import {
formatDuration, formatDuration,
formatSize, formatSize,
getMediaTypeFromFilename, getMediaTypeFromFilename,
truncateFilename truncateFilename
} from '@/utils/formatUtil' } from '@/utils/formatUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
const { const {
assetItems, assetItems,
@@ -170,24 +122,7 @@ const emit = defineEmits<{
}>() }>()
const { t } = useI18n() const { t } = useI18n()
const { jobItems } = useJobList()
const settingStore = useSettingStore()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const hoveredJobId = ref<string | null>(null)
const hoveredAssetId = ref<string | null>(null) const hoveredAssetId = ref<string | null>(null)
const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state)).toReversed()
)
const hoveredJob = computed(() =>
hoveredJobId.value
? (activeJobItems.value.find((job) => job.id === hoveredJobId.value) ??
null)
: null
)
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob)
const listGridStyle = { const listGridStyle = {
display: 'grid', display: 'grid',
@@ -240,16 +175,6 @@ function getAssetCardClass(selected: boolean): string {
) )
} }
function onJobEnter(jobId: string) {
hoveredJobId.value = jobId
}
function onJobLeave(jobId: string) {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
}
function onAssetEnter(assetId: string) { function onAssetEnter(assetId: string) {
hoveredAssetId.value = assetId hoveredAssetId.value = assetId
} }
@@ -259,13 +184,4 @@ function onAssetLeave(assetId: string) {
hoveredAssetId.value = null hoveredAssetId.value = null
} }
} }
function getJobIconClass(job: JobListItem): string | undefined {
const classes = []
const iconName = job.iconName ?? iconForJobState(job.state)
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
classes.push('animate-spin')
}
return classes.length ? classes.join(' ') : undefined
}
</script> </script>

View File

@@ -26,6 +26,15 @@
<template #tool-buttons> <template #tool-buttons>
<!-- Normal Tab View --> <!-- Normal Tab View -->
<TabList v-if="!isInFolderView" v-model="activeTab"> <TabList v-if="!isInFolderView" v-model="activeTab">
<Tab v-if="isQueuePanelV2Enabled" class="font-inter" value="queue">
{{ $t('sideToolbar.labels.queue') }}
<span
v-if="activeJobsCount > 0"
class="ml-1 inline-flex items-center justify-center rounded-full bg-primary px-1.5 text-xs text-base-foreground font-medium h-5"
>
{{ activeJobsCount }}
</span>
</Tab>
<Tab class="font-inter" value="output">{{ <Tab class="font-inter" value="output">{{
$t('sideToolbar.labels.generated') $t('sideToolbar.labels.generated')
}}</Tab> }}</Tab>
@@ -43,8 +52,9 @@
</Button> </Button>
</div> </div>
<!-- Filter Bar --> <!-- Filter Bar (hidden on queue tab) -->
<MediaAssetFilterBar <MediaAssetFilterBar
v-if="!isQueueTab"
v-model:search-query="searchQuery" v-model:search-query="searchQuery"
v-model:sort-by="sortBy" v-model:sort-by="sortBy"
v-model:view-mode="viewMode" v-model:view-mode="viewMode"
@@ -53,30 +63,29 @@
:show-generation-time-sort="activeTab === 'output'" :show-generation-time-sort="activeTab === 'output'"
/> />
<div <div
v-if="isQueuePanelV2Enabled && !isInFolderView" v-if="isQueueTab && !isInFolderView"
class="flex items-center justify-between px-2 py-2 2xl:px-4" class="flex items-center justify-between px-4 2xl:px-6"
> >
<span class="text-sm text-muted-foreground"> <span class="text-sm text-muted-foreground">
{{ activeJobsLabel }} {{ activeJobsLabel }}
</span> </span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-sm text-base-foreground"> <MediaAssetViewModeToggle v-model:view-mode="viewMode" />
{{ t('sideToolbar.queueProgressOverlay.clearQueueTooltip') }}
</span>
<Button <Button
v-tooltip.top="clearQueueTooltip"
variant="destructive" variant="destructive"
size="icon" size="icon"
:aria-label=" :aria-label="
t('sideToolbar.queueProgressOverlay.clearQueueTooltip') t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
" "
:disabled="queuedCount === 0" :disabled="queueStore.pendingTasks.length === 0"
@click="handleClearQueue" @click="handleClearQueue"
> >
<i class="icon-[lucide--list-x] size-4" /> <i class="icon-[lucide--list-x] size-4" />
</Button> </Button>
</div> </div>
</div> </div>
<Divider v-else type="dashed" class="my-2" /> <Divider v-else-if="!isQueueTab" type="dashed" class="my-2" />
</template> </template>
<template #body> <template #body>
<div v-if="showLoadingState"> <div v-if="showLoadingState">
@@ -87,23 +96,32 @@
icon="pi pi-info-circle" icon="pi pi-info-circle"
:title=" :title="
$t( $t(
activeTab === 'input' isQueueTab
? 'sideToolbar.noImportedFiles' ? 'sideToolbar.noQueueItems'
: 'sideToolbar.noGeneratedFiles' : activeTab === 'input'
? 'sideToolbar.noImportedFiles'
: 'sideToolbar.noGeneratedFiles'
)
"
:message="
$t(
isQueueTab
? 'sideToolbar.noQueueItemsMessage'
: 'sideToolbar.noFilesFoundMessage'
) )
" "
:message="$t('sideToolbar.noFilesFoundMessage')"
/> />
</div> </div>
<div v-else class="relative size-full" @click="handleEmptySpaceClick"> <div v-else class="relative size-full" @click="handleEmptySpaceClick">
<QueueAssetView v-if="isQueueTab" :view-mode="viewMode" />
<AssetsSidebarListView <AssetsSidebarListView
v-if="isListView" v-else-if="isListView"
:asset-items="listViewAssetItems" :asset-items="listViewAssetItems"
:is-selected="isSelected" :is-selected="isSelected"
:selectable-assets="listViewSelectableAssets" :selectable-assets="listViewSelectableAssets"
:is-stack-expanded="isListViewStackExpanded" :is-stack-expanded="isListViewStackExpanded"
:toggle-stack="toggleListViewStack" :toggle-stack="toggleListViewStack"
:asset-type="activeTab" :asset-type="assetTabType"
@select-asset="handleAssetSelect" @select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu" @context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd" @approach-end="handleApproachEnd"
@@ -112,8 +130,7 @@
v-else v-else
:assets="displayAssets" :assets="displayAssets"
:is-selected="isSelected" :is-selected="isSelected"
:is-in-folder-view="isInFolderView" :asset-type="assetTabType"
:asset-type="activeTab"
:show-output-count="shouldShowOutputCount" :show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount" :get-output-count="getOutputCount"
@select-asset="handleAssetSelect" @select-asset="handleAssetSelect"
@@ -224,13 +241,16 @@ const Load3dViewerContent = () =>
import('@/components/load3d/Load3dViewerContent.vue') import('@/components/load3d/Load3dViewerContent.vue')
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue' import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue' import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import QueueAssetView from '@/components/sidebar/tabs/QueueAssetView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue' import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue' import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue' import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue' import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue' import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue' import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue' import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import MediaAssetViewModeToggle from '@/platform/assets/components/MediaAssetViewModeToggle.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers' import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets' import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection' import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
@@ -257,7 +277,7 @@ const { activeJobsCount } = storeToRefs(queueStore)
const executionStore = useExecutionStore() const executionStore = useExecutionStore()
const settingStore = useSettingStore() const settingStore = useSettingStore()
const activeTab = ref<'input' | 'output'>('output') const activeTab = ref<'input' | 'output' | 'queue'>('output')
const folderPromptId = ref<string | null>(null) const folderPromptId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined) const folderExecutionTime = ref<number | undefined>(undefined)
const isInFolderView = computed(() => folderPromptId.value !== null) const isInFolderView = computed(() => folderPromptId.value !== null)
@@ -268,6 +288,10 @@ const viewMode = useStorage<'list' | 'grid'>(
const isQueuePanelV2Enabled = computed(() => const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2') settingStore.get('Comfy.Queue.QPOV2')
) )
const isQueueTab = computed(() => activeTab.value === 'queue')
const assetTabType = computed<'input' | 'output'>(() =>
activeTab.value === 'input' ? 'input' : 'output'
)
const isListView = computed( const isListView = computed(
() => isQueuePanelV2Enabled.value && viewMode.value === 'list' () => isQueuePanelV2Enabled.value && viewMode.value === 'list'
) )
@@ -302,7 +326,9 @@ const formattedExecutionTime = computed(() => {
return formatDuration(folderExecutionTime.value * 1000) return formatDuration(folderExecutionTime.value * 1000)
}) })
const queuedCount = computed(() => queueStore.pendingTasks.length) const clearQueueTooltip = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.clearQueueTooltip'))
)
const activeJobsLabel = computed(() => { const activeJobsLabel = computed(() => {
const count = activeJobsCount.value const count = activeJobsCount.value
return t( return t(
@@ -415,18 +441,15 @@ const isBulkMode = computed(
) )
const showLoadingState = computed( const showLoadingState = computed(
() => () => !isQueueTab.value && loading.value && displayAssets.value.length === 0
loading.value &&
displayAssets.value.length === 0 &&
activeJobsCount.value === 0
) )
const showEmptyState = computed( const showEmptyState = computed(() => {
() => if (isQueueTab.value) {
!loading.value && return activeJobsCount.value === 0
displayAssets.value.length === 0 && }
activeJobsCount.value === 0 return !loading.value && displayAssets.value.length === 0
) })
watch(visibleAssets, (newAssets) => { watch(visibleAssets, (newAssets) => {
// Alternative: keep hidden selections and surface them in UI; for now prune // Alternative: keep hidden selections and surface them in UI; for now prune
@@ -483,12 +506,21 @@ watch(
clearSelection() clearSelection()
// Clear search when switching tabs // Clear search when switching tabs
searchQuery.value = '' searchQuery.value = ''
// Reset pagination state when tab changes // Skip asset fetch for queue tab
void refreshAssets() if (activeTab.value !== 'queue') {
void refreshAssets()
}
}, },
{ immediate: true } { immediate: true }
) )
// Reset to output tab if QPOV2 is disabled while on queue tab
watch(isQueuePanelV2Enabled, (enabled) => {
if (!enabled && activeTab.value === 'queue') {
activeTab.value = 'output'
}
})
function handleAssetSelect(asset: AssetItem, assets?: AssetItem[]) { function handleAssetSelect(asset: AssetItem, assets?: AssetItem[]) {
const assetList = assets ?? visibleAssets.value const assetList = assets ?? visibleAssets.value
const index = assetList.findIndex((a) => a.id === asset.id) const index = assetList.findIndex((a) => a.id === asset.id)

View File

@@ -0,0 +1,98 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { ref } from 'vue'
import type { JobListItem } from '@/composables/queue/useJobList'
import QueueAssetView from './QueueAssetView.vue'
const { mockJobItems } = vi.hoisted<{
mockJobItems: Ref<JobListItem[]>
}>(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ref: hoistedRef } = require('vue')
return { mockJobItems: hoistedRef([]) }
})
vi.mock('@/composables/queue/useJobList', () => ({
useJobList: () => ({
jobItems: mockJobItems
})
}))
vi.mock('@/composables/queue/useJobActions', () => ({
useJobActions: () => ({
cancelAction: { variant: 'ghost', label: 'Cancel', icon: 'pi pi-times' },
canCancelJob: ref(false),
runCancelJob: vi.fn()
})
}))
vi.mock('@/utils/queueUtil', () => ({
isActiveJobState: (state: string) =>
state === 'pending' || state === 'running' || state === 'initialization'
}))
vi.mock('@/utils/queueDisplay', () => ({
iconForJobState: () => 'pi pi-spinner'
}))
function makeJob(overrides: Partial<JobListItem>): JobListItem {
return {
id: 'job-1',
title: 'Job 1',
meta: '',
state: 'pending',
...overrides
}
}
describe('QueueAssetView', () => {
beforeEach(() => {
vi.clearAllMocks()
mockJobItems.value = []
})
it('displays active jobs in oldest-first order (FIFO)', () => {
mockJobItems.value = [
makeJob({ id: 'newest', title: 'Newest Job', state: 'pending' }),
makeJob({ id: 'middle', title: 'Middle Job', state: 'running' }),
makeJob({ id: 'oldest', title: 'Oldest Job', state: 'pending' })
]
const wrapper = mount(QueueAssetView, {
props: { viewMode: 'list' },
shallow: true
})
const items = wrapper.findAllComponents({ name: 'AssetsListItem' })
expect(items).toHaveLength(3)
const titles = items.map((item) => item.props('primaryText'))
expect(titles).toEqual(['Oldest Job', 'Middle Job', 'Newest Job'])
})
it('excludes completed and failed jobs', () => {
mockJobItems.value = [
makeJob({ id: 'pending', title: 'Pending', state: 'pending' }),
makeJob({ id: 'completed', title: 'Completed', state: 'completed' }),
makeJob({ id: 'failed', title: 'Failed', state: 'failed' }),
makeJob({ id: 'running', title: 'Running', state: 'running' })
]
const wrapper = mount(QueueAssetView, {
props: { viewMode: 'list' },
shallow: true
})
const items = wrapper.findAllComponents({ name: 'AssetsListItem' })
expect(items).toHaveLength(2)
const titles = items.map((item) => item.props('primaryText'))
expect(titles).toContain('Running')
expect(titles).toContain('Pending')
expect(titles).not.toContain('Completed')
expect(titles).not.toContain('Failed')
})
})

View File

@@ -0,0 +1,124 @@
<template>
<div class="flex h-full flex-col">
<!-- Grid View -->
<VirtualGrid
v-if="viewMode === 'grid'"
class="flex-1"
:items="gridItems"
:grid-style="gridStyle"
>
<template #item="{ item }">
<ActiveMediaAssetCard :job="item.job" />
</template>
</VirtualGrid>
<!-- List View -->
<div
v-else
class="flex flex-1 scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
>
<AssetsListItem
v-for="job in activeJobItems"
:key="job.id"
:class="
cn(
'w-full shrink-0 text-text-primary transition-colors hover:bg-secondary-background-hover',
'cursor-default'
)
"
:preview-url="job.iconImageUrl"
:preview-alt="job.title"
:icon-name="job.iconName"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@mouseenter="onJobEnter(job.id)"
@mouseleave="onJobLeave(job.id)"
@click.stop
>
<template v-if="hoveredJobId === job.id" #actions>
<Button
v-if="canCancelJob"
:variant="cancelAction.variant"
size="icon"
:aria-label="cancelAction.label"
@click.stop="runCancelJob()"
>
<i :class="cancelAction.icon" class="size-4" />
</Button>
</template>
</AssetsListItem>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Button from '@/components/ui/button/Button.vue'
import { useJobActions } from '@/composables/queue/useJobActions'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useJobList } from '@/composables/queue/useJobList'
import ActiveMediaAssetCard from '@/platform/assets/components/ActiveMediaAssetCard.vue'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { isActiveJobState } from '@/utils/queueUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
const { viewMode = 'grid' } = defineProps<{
viewMode?: 'list' | 'grid'
}>()
const { jobItems } = useJobList()
const activeJobItems = computed(() =>
jobItems.value
.filter((item) => isActiveJobState(item.state))
.slice()
.reverse()
)
const gridItems = computed(() =>
activeJobItems.value.map((job) => ({
key: `queue-${job.id}`,
job
}))
)
const gridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
padding: '0 0.5rem',
gap: '0.5rem'
}
const hoveredJobId = ref<string | null>(null)
const hoveredJob = computed(() =>
hoveredJobId.value
? (activeJobItems.value.find((job) => job.id === hoveredJobId.value) ??
null)
: null
)
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob)
function onJobEnter(jobId: string) {
hoveredJobId.value = jobId
}
function onJobLeave(jobId: string) {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
}
function getJobIconClass(job: JobListItem): string | undefined {
const iconName = job.iconName ?? iconForJobState(job.state)
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
return 'animate-spin'
}
return undefined
}
</script>

View File

@@ -755,6 +755,8 @@
"noFilesFound": "No files found", "noFilesFound": "No files found",
"noImportedFiles": "No imported files found", "noImportedFiles": "No imported files found",
"noGeneratedFiles": "No generated files found", "noGeneratedFiles": "No generated files found",
"noQueueItems": "No active jobs",
"noQueueItemsMessage": "Queue a prompt to see active jobs here",
"generatedAssetsHeader": "Generated assets", "generatedAssetsHeader": "Generated assets",
"importedAssetsHeader": "Imported assets", "importedAssetsHeader": "Imported assets",
"activeJobStatus": "Active job: {status}", "activeJobStatus": "Active job: {status}",