Compare commits

...

20 Commits

Author SHA1 Message Date
Benjamin Lu
003e72406d refactor: move queue files under domain 2026-01-08 20:00:49 -08:00
Benjamin Lu
5561efcfd3 Add ... context menu to list view 2025-12-31 18:23:22 -08:00
Benjamin Lu
79af71530d fix: avoid unused export in job actions 2025-12-31 18:17:59 -08:00
Benjamin Lu
f78b6eec47 Add and use knipIgnoreUnusedButUsedByStorybook 2025-12-31 18:16:07 -08:00
Benjamin Lu
d8e57c60bf Add stories for list view and general job card 2025-12-31 18:16:07 -08:00
Benjamin Lu
db3edd522d Add list view 2025-12-31 18:14:48 -08:00
Benjamin Lu
f128c61c53 Remove special failed job styling 2025-12-31 18:10:17 -08:00
GitHub Action
afa4664ad5 [automated] Apply ESLint and Prettier fixes 2025-12-31 17:57:26 -08:00
Benjamin Lu
627db6784e knip 2025-12-31 17:56:08 -08:00
Benjamin Lu
85c6825a79 Add list view 2025-12-31 17:56:08 -08:00
Benjamin Lu
f614914fdf Remove view action from AssetsListCard story 2025-12-31 17:52:10 -08:00
Benjamin Lu
da4889900e Add AssetsListCard stories 2025-12-31 17:52:10 -08:00
Benjamin Lu
5b1456896b Add AssetsListCard base template 2025-12-31 17:52:10 -08:00
Benjamin Lu
0c6ea56360 fix: read QPOV2 setting in assets sidebar 2025-12-31 16:54:17 -08:00
Benjamin Lu
2fd9a73b68 Readd divider as v-else 2025-12-31 15:05:30 -08:00
Benjamin Lu
dc53cbe3c9 Add N active jobs and clear queue button 2025-12-31 15:05:30 -08:00
Benjamin Lu
3d0c0d16ca Move feature flag to setting 2025-12-31 14:59:15 -08:00
Benjamin Lu
a0dad31e2f Add feature flag 2025-12-31 14:59:15 -08:00
Benjamin Lu
5ddea4e7b6 Extract to component 2025-12-31 14:59:15 -08:00
Benjamin Lu
94884d7a7c feat: add queue view toggle stub 2025-12-31 14:59:15 -08:00
80 changed files with 1126 additions and 165 deletions

View File

@@ -69,9 +69,32 @@ const config: StorybookConfig = {
allowedHosts: true allowedHosts: true
}, },
resolve: { resolve: {
alias: { alias: [
'@': process.cwd() + '/src' {
} find: '@/composables/queue/useJobList',
replacement: process.cwd() + '/src/storybook/mocks/useJobList.ts'
},
{
find: '@/composables/queue/useJobActions',
replacement: process.cwd() + '/src/storybook/mocks/useJobActions.ts'
},
{
find: '@/utils/formatUtil',
replacement:
process.cwd() +
'/packages/shared-frontend-utils/src/formatUtil.ts'
},
{
find: '@/utils/networkUtil',
replacement:
process.cwd() +
'/packages/shared-frontend-utils/src/networkUtil.ts'
},
{
find: '@',
replacement: process.cwd() + '/src'
}
]
}, },
esbuild: { esbuild: {
// Prevent minification of identifiers to preserve _sfc_main // Prevent minification of identifiers to preserve _sfc_main

View File

@@ -1,6 +1,6 @@
import type { Locator, Page } from '@playwright/test' import type { Locator, Page } from '@playwright/test'
import type { AutoQueueMode } from '../../src/stores/queueStore' import type { AutoQueueMode } from '../../src/queue/stores/queueStore'
export class ComfyActionbar { export class ComfyActionbar {
public readonly root: Locator public readonly root: Locator

View File

@@ -66,7 +66,8 @@ const config: KnipConfig = {
}, },
tags: [ tags: [
'-knipIgnoreUnusedButUsedByCustomNodes', '-knipIgnoreUnusedButUsedByCustomNodes',
'-knipIgnoreUnusedButUsedByVueNodesBranch' '-knipIgnoreUnusedButUsedByVueNodesBranch',
'-knipIgnoreUnusedButUsedByStorybook'
] ]
} }

View File

@@ -83,7 +83,7 @@ import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue' import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue' import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue' import QueueProgressOverlay from '@/queue/components/QueueProgressOverlay.vue'
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue' import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue' import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue' import LoginButton from '@/components/topbar/LoginButton.vue'
@@ -92,7 +92,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling' import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig' import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { useQueueStore } from '@/stores/queueStore' import { useQueueStore } from '@/queue/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore' import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore' import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil' import { isElectron } from '@/utils/envUtil'

View File

@@ -38,7 +38,7 @@ import InputNumber from 'primevue/inputnumber'
import { computed } from 'vue' import { computed } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore' import { useSettingStore } from '@/platform/settings/settingStore'
import { useQueueSettingsStore } from '@/stores/queueStore' import { useQueueSettingsStore } from '@/queue/stores/queueStore'
const queueSettingsStore = useQueueSettingsStore() const queueSettingsStore = useQueueSettingsStore()
const { batchCount } = storeToRefs(queueSettingsStore) const { batchCount } = storeToRefs(queueSettingsStore)

View File

@@ -48,7 +48,7 @@ import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore' import { useCommandStore } from '@/stores/commandStore'
import { useNodeDefStore } from '@/stores/nodeDefStore' import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useQueueSettingsStore } from '@/stores/queueStore' import { useQueueSettingsStore } from '@/queue/stores/queueStore'
import { useWorkspaceStore } from '@/stores/workspaceStore' import { useWorkspaceStore } from '@/stores/workspaceStore'
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes' import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'

View File

@@ -0,0 +1,155 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { JobAction } from '@/queue/composables/useJobActions'
import type { JobListItem } from '@/queue/composables/useJobList'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { setMockJobActions } from '@/storybook/mocks/useJobActions'
import { setMockJobItems } from '@/storybook/mocks/useJobList'
import { iconForJobState } from '@/queue/utils/queueDisplay'
import AssetsSidebarListView from './AssetsSidebarListView.vue'
type StoryArgs = {
assets: AssetItem[]
jobs: JobListItem[]
selectedAssetIds?: string[]
actionsByJobId?: Record<string, JobAction[]>
}
function baseDecorator() {
return {
template: `
<div class="bg-base-background p-6">
<story />
</div>
`
}
}
const meta: Meta<StoryArgs> = {
title: 'Components/Sidebar/AssetsSidebarListView',
component: AssetsSidebarListView,
parameters: {
layout: 'centered'
},
decorators: [baseDecorator]
}
export default meta
type Story = StoryObj<typeof meta>
const baseTimestamp = '2024-01-15T10:00:00Z'
const sampleJobs: JobListItem[] = [
{
id: 'job-pending-1',
title: 'In queue',
meta: '8:59:30pm',
state: 'pending',
iconName: iconForJobState('pending'),
showClear: true
},
{
id: 'job-init-1',
title: 'Initializing...',
meta: '8:59:35pm',
state: 'initialization',
iconName: iconForJobState('initialization'),
showClear: true
},
{
id: 'job-running-1',
title: 'Total: 30%',
meta: 'KSampler: 70%',
state: 'running',
iconName: iconForJobState('running'),
showClear: true,
progressTotalPercent: 30,
progressCurrentPercent: 70
}
]
const sampleAssets: AssetItem[] = [
{
id: 'asset-image-1',
name: 'image-032.png',
created_at: baseTimestamp,
preview_url: '/assets/images/comfy-logo-single.svg',
size: 1887437,
tags: [],
user_metadata: {
promptId: 'job-running-1',
nodeId: 12,
executionTimeInSeconds: 1.84
}
},
{
id: 'asset-video-1',
name: 'clip-01.mp4',
created_at: baseTimestamp,
preview_url: '/assets/images/default-template.png',
size: 8394820,
tags: [],
user_metadata: {
duration: 132000
}
},
{
id: 'asset-audio-1',
name: 'soundtrack-01.mp3',
created_at: baseTimestamp,
size: 5242880,
tags: [],
user_metadata: {
duration: 200000
}
},
{
id: 'asset-3d-1',
name: 'scene-01.glb',
created_at: baseTimestamp,
size: 134217728,
tags: []
}
]
const cancelAction: JobAction = {
key: 'cancel',
icon: 'icon-[lucide--x]',
label: 'Cancel',
variant: 'destructive'
}
export const RunningAndGenerated: Story = {
args: {
assets: sampleAssets,
jobs: sampleJobs,
actionsByJobId: {
'job-pending-1': [cancelAction],
'job-init-1': [cancelAction],
'job-running-1': [cancelAction]
}
},
render: renderAssetsSidebarListView
}
function renderAssetsSidebarListView(args: StoryArgs) {
return {
components: { AssetsSidebarListView },
setup() {
setMockJobItems(args.jobs)
setMockJobActions(args.actionsByJobId ?? {})
const selectedIds = new Set(args.selectedAssetIds ?? [])
function isSelected(assetId: string) {
return selectedIds.has(assetId)
}
return { args, isSelected }
},
template: `
<div class="h-[520px] w-[320px] overflow-hidden rounded-lg border border-panel-border">
<AssetsSidebarListView :assets="args.assets" :is-selected="isSelected" />
</div>
`
}
}

View File

@@ -0,0 +1,241 @@
<template>
<div class="flex h-full flex-col">
<div v-if="activeJobItems.length" class="flex flex-col gap-2 px-2">
<AssetsListCard
v-for="job in activeJobItems"
:key="job.id"
:class="getJobCardClass()"
: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 && getJobActions(job).length"
#actions
>
<Button
v-for="action in getJobActions(job)"
:key="action.key"
:variant="action.variant"
size="icon"
:aria-label="action.label"
@click.stop="handleJobAction(action, job)"
>
<i :class="action.icon" class="size-4" />
</Button>
</template>
</AssetsListCard>
</div>
<div
v-if="assets.length"
:class="cn('px-2', activeJobItems.length && 'mt-2')"
>
<div
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
>
{{ t('sideToolbar.generatedAssetsHeader') }}
</div>
</div>
<VirtualGrid
class="flex-1"
:items="assetItems"
:grid-style="listGridStyle"
@approach-end="emit('approach-end')"
>
<template #item="{ item }">
<AssetsListCard
role="button"
tabindex="0"
:aria-label="
t('assetBrowser.ariaLabel.assetCard', {
name: item.asset.name,
type: getMediaTypeFromFilename(item.asset.name)
})
"
:class="getAssetCardClass(isSelected(item.asset.id))"
:preview-url="item.asset.preview_url"
:preview-alt="item.asset.name"
:icon-name="getAssetIconName(item.asset)"
:primary-text="getAssetPrimaryText(item.asset)"
:secondary-text="getAssetSecondaryText(item.asset)"
@mouseenter="onAssetEnter(item.asset.id)"
@mouseleave="onAssetLeave(item.asset.id)"
@contextmenu.prevent="handleAssetMenuClick($event, item.asset)"
@click.stop="emit('select-asset', item.asset)"
>
<template v-if="hoveredAssetId === item.asset.id" #actions>
<Button
variant="secondary"
size="icon"
:aria-label="t('g.moreOptions')"
@click.stop="handleAssetMenuClick($event, item.asset)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</AssetsListCard>
</template>
</VirtualGrid>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Button from '@/components/ui/button/Button.vue'
import type { JobAction } from '@/queue/composables/useJobActions'
import { useJobActions } from '@/queue/composables/useJobActions'
import type { JobListItem } from '@/queue/composables/useJobList'
import { useJobList } from '@/queue/composables/useJobList'
import AssetsListCard from '@/platform/assets/components/AssetsListCard.vue'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { JobState } from '@/queue/types/queue'
import {
formatDuration,
formatSize,
getMediaTypeFromFilename,
truncateFilename
} from '@/utils/formatUtil'
import { iconForJobState } from '@/queue/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
const { assets, isSelected } = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
}>()
const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem): void
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
(e: 'approach-end'): void
}>()
const { t } = useI18n()
const { jobItems } = useJobList()
const { getJobActions, runJobAction } = useJobActions()
const hoveredJobId = ref<string | null>(null)
const hoveredAssetId = ref<string | null>(null)
type AssetListItem = { key: string; asset: AssetItem }
const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state))
)
const assetItems = computed<AssetListItem[]>(() =>
assets.map((asset) => ({
key: `asset-${asset.id}`,
asset
}))
)
const listGridStyle = {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr)',
padding: '0 0.5rem',
gap: '0.5rem'
}
const listCardBaseClass =
'w-full text-text-primary transition-colors hover:bg-secondary-background-hover'
function isActiveJobState(state: JobState): boolean {
return (
state === 'pending' || state === 'initialization' || state === 'running'
)
}
function getAssetPrimaryText(asset: AssetItem): string {
return truncateFilename(asset.name)
}
function getAssetSecondaryText(asset: AssetItem): string {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (typeof metadata?.executionTimeInSeconds === 'number') {
return `${metadata.executionTimeInSeconds.toFixed(2)}s`
}
const duration = asset.user_metadata?.duration
if (typeof duration === 'number') {
return formatDuration(duration)
}
if (typeof asset.size === 'number') {
return formatSize(asset.size)
}
return ''
}
function getAssetIconName(asset: AssetItem): string {
const mediaType = getMediaTypeFromFilename(asset.name)
if (mediaType === 'video') return 'icon-[lucide--video]'
if (mediaType === 'audio') return 'icon-[lucide--music]'
if (mediaType === '3D') return 'icon-[lucide--box]'
return 'icon-[lucide--image]'
}
function getAssetCardClass(selected: boolean): string {
return cn(
listCardBaseClass,
'cursor-pointer',
selected &&
'bg-secondary-background-hover ring-1 ring-inset ring-modal-card-border-highlighted'
)
}
function getJobCardClass(): string {
return cn(listCardBaseClass, 'cursor-default')
}
function onJobEnter(jobId: string) {
hoveredJobId.value = jobId
}
function onJobLeave(jobId: string) {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
}
function onAssetEnter(assetId: string) {
hoveredAssetId.value = assetId
}
function onAssetLeave(assetId: string) {
if (hoveredAssetId.value === assetId) {
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
}
function handleJobAction(action: JobAction, job: JobListItem) {
void runJobAction(action, job)
}
function handleAssetMenuClick(event: MouseEvent, asset: AssetItem) {
event.stopPropagation()
emit('context-menu', event, asset)
}
</script>

View File

@@ -47,17 +47,42 @@
<MediaAssetFilterBar <MediaAssetFilterBar
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:media-type-filters="mediaTypeFilters" v-model:media-type-filters="mediaTypeFilters"
class="pb-1 px-2 2xl:px-4" class="pb-1 px-2 2xl:px-4"
:show-generation-time-sort="activeTab === 'output'" :show-generation-time-sort="activeTab === 'output'"
/> />
<Divider type="dashed" class="my-2" /> <div
v-if="isQueuePanelV2Enabled"
class="flex items-center justify-between px-2 py-2 2xl:px-4"
>
<span class="text-sm text-muted-foreground">
{{ activeJobsLabel }}
</span>
<div class="flex items-center gap-2">
<span class="text-sm text-base-foreground">
{{ t('sideToolbar.queueProgressOverlay.clearQueueTooltip') }}
</span>
<Button
variant="destructive"
size="icon"
:aria-label="
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
"
:disabled="queuedCount === 0"
@click="handleClearQueue"
>
<i class="icon-[lucide--list-x] size-4" />
</Button>
</div>
</div>
<Divider v-else type="dashed" class="my-2" />
</template> </template>
<template #body> <template #body>
<div v-if="loading && !displayAssets.length"> <div v-if="showLoadingState">
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" /> <ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
</div> </div>
<div v-else-if="!loading && !displayAssets.length"> <div v-else-if="showEmptyState">
<NoResultsPlaceholder <NoResultsPlaceholder
icon="pi pi-info-circle" icon="pi pi-info-circle"
:title=" :title="
@@ -71,7 +96,16 @@
/> />
</div> </div>
<div v-else class="relative size-full" @click="handleEmptySpaceClick"> <div v-else class="relative size-full" @click="handleEmptySpaceClick">
<AssetsSidebarListView
v-if="isListView"
:assets="displayAssets"
:is-selected="isSelected"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
/>
<VirtualGrid <VirtualGrid
v-else
:items="mediaAssetsWithKey" :items="mediaAssetsWithKey"
:grid-style="{ :grid-style="{
display: 'grid', display: 'grid',
@@ -87,13 +121,10 @@
:selected="isSelected(item.id)" :selected="isSelected(item.id)"
:show-output-count="shouldShowOutputCount(item)" :show-output-count="shouldShowOutputCount(item)"
:output-count="getOutputCount(item)" :output-count="getOutputCount(item)"
:show-delete-button="shouldShowDeleteButton"
:open-context-menu-id="openContextMenuId"
@click="handleAssetSelect(item)" @click="handleAssetSelect(item)"
@zoom="handleZoomClick(item)" @zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)" @output-count-click="enterFolderView(item)"
@asset-deleted="refreshAssets" @context-menu="handleAssetContextMenu($event, item)"
@context-menu-opened="openContextMenuId = item.id"
/> />
</template> </template>
</VirtualGrid> </VirtualGrid>
@@ -160,47 +191,71 @@
v-model:active-index="galleryActiveIndex" v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems" :all-gallery-items="galleryItems"
/> />
<MediaAssetContextMenu
v-if="contextMenuAsset"
ref="contextMenuRef"
:asset="contextMenuAsset"
:asset-type="contextMenuAssetType"
:file-kind="contextMenuFileKind"
:show-delete-button="shouldShowDeleteButton"
@zoom="handleContextMenuZoom"
@asset-deleted="refreshAssets"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core' import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
import { Divider } from 'primevue'
import ProgressSpinner from 'primevue/progressspinner' import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue' import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue' import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue' import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.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 MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue' import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.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 { 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'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions' import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering' import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
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 type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
import { isCloud } from '@/platform/distribution/types' import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore' import { useDialogStore } from '@/stores/dialogStore'
import { ResultItemImpl } from '@/stores/queueStore' import { ResultItemImpl, useQueueStore } from '@/queue/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil' import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n() const { t } = useI18n()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const settingStore = useSettingStore()
const activeTab = ref<'input' | 'output'>('output') const activeTab = ref<'input' | 'output'>('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)
const viewMode = ref<'list' | 'grid'>('grid')
// Track which asset's context menu is open (for single-instance context menu management) const isQueuePanelV2Enabled = computed(() =>
const openContextMenuId = ref<string | null>(null) settingStore.get('Comfy.Queue.QPOV2')
)
const isListView = computed(
() => isQueuePanelV2Enabled.value && viewMode.value === 'list'
)
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
const contextMenuAsset = ref<AssetItem | null>(null)
// Determine if delete button should be shown // Determine if delete button should be shown
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders) // Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
@@ -209,6 +264,14 @@ const shouldShowDeleteButton = computed(() => {
return true return true
}) })
const contextMenuAssetType = computed(() =>
contextMenuAsset.value ? getAssetType(contextMenuAsset.value.tags) : 'input'
)
const contextMenuFileKind = computed<MediaKind>(() =>
getMediaTypeFromFilename(contextMenuAsset.value?.name ?? '')
)
const getOutputCount = (item: AssetItem): number => { const getOutputCount = (item: AssetItem): number => {
const count = item.user_metadata?.outputCount const count = item.user_metadata?.outputCount
return typeof count === 'number' && count > 0 ? count : 1 return typeof count === 'number' && count > 0 ? count : 1
@@ -226,6 +289,15 @@ const formattedExecutionTime = computed(() => {
return formatDuration(folderExecutionTime.value * 1000) return formatDuration(folderExecutionTime.value * 1000)
}) })
const queuedCount = computed(() => queueStore.pendingTasks.length)
const activeJobsCount = computed(
() => queueStore.pendingTasks.length + queueStore.runningTasks.length
)
const activeJobsLabel = computed(
() =>
`${activeJobsCount.value} ${t('sideToolbar.queueProgressOverlay.activeJobsSuffix')}`
)
const toast = useToast() const toast = useToast()
const inputAssets = useMediaAssets('input') const inputAssets = useMediaAssets('input')
@@ -300,6 +372,20 @@ const displayAssets = computed(() => {
return filteredAssets.value return filteredAssets.value
}) })
const showLoadingState = computed(
() =>
loading.value &&
displayAssets.value.length === 0 &&
(!isListView.value || activeJobsCount.value === 0)
)
const showEmptyState = computed(
() =>
!loading.value &&
displayAssets.value.length === 0 &&
(!isListView.value || activeJobsCount.value === 0)
)
watch(displayAssets, (newAssets) => { watch(displayAssets, (newAssets) => {
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) { if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
const newIndex = newAssets.findIndex( const newIndex = newAssets.findIndex(
@@ -371,6 +457,18 @@ const handleAssetSelect = (asset: AssetItem) => {
handleAssetClick(asset, index, displayAssets.value) handleAssetClick(asset, index, displayAssets.value)
} }
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
contextMenuAsset.value = asset
void nextTick(() => {
contextMenuRef.value?.show(event)
})
}
function handleContextMenuZoom() {
if (!contextMenuAsset.value) return
handleZoomClick(contextMenuAsset.value)
}
const handleZoomClick = (asset: AssetItem) => { const handleZoomClick = (asset: AssetItem) => {
const mediaType = getMediaTypeFromFilename(asset.name) const mediaType = getMediaTypeFromFilename(asset.name)
@@ -490,6 +588,11 @@ const handleDeleteSelected = async () => {
clearSelection() clearSelection()
} }
const handleClearQueue = async () => {
if (queuedCount.value === 0) return
await commandStore.execute('Comfy.ClearPendingTasks')
}
const handleApproachEnd = useDebounceFn(async () => { const handleApproachEnd = useDebounceFn(async () => {
if ( if (
activeTab.value === 'output' && activeTab.value === 'output' &&

View File

@@ -8,7 +8,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import type { ResultItemImpl } from '@/stores/queueStore' import type { ResultItemImpl } from '@/queue/stores/queueStore'
const { result } = defineProps<{ const { result } = defineProps<{
result: ResultItemImpl result: ResultItemImpl

View File

@@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick } from 'vue' import { createApp, nextTick } from 'vue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema' import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ResultItemImpl } from '@/stores/queueStore' import type { ResultItemImpl } from '@/queue/stores/queueStore'
import ResultGallery from './ResultGallery.vue' import ResultGallery from './ResultGallery.vue'

View File

@@ -45,7 +45,7 @@ import Galleria from 'primevue/galleria'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import ComfyImage from '@/components/common/ComfyImage.vue' import ComfyImage from '@/components/common/ComfyImage.vue'
import type { ResultItemImpl } from '@/stores/queueStore' import type { ResultItemImpl } from '@/queue/stores/queueStore'
import ResultAudio from './ResultAudio.vue' import ResultAudio from './ResultAudio.vue'
import ResultVideo from './ResultVideo.vue' import ResultVideo from './ResultVideo.vue'

View File

@@ -10,7 +10,7 @@ import { computed } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore' import { useSettingStore } from '@/platform/settings/settingStore'
import { useExtensionStore } from '@/stores/extensionStore' import { useExtensionStore } from '@/stores/extensionStore'
import type { ResultItemImpl } from '@/stores/queueStore' import type { ResultItemImpl } from '@/queue/stores/queueStore'
const props = defineProps<{ const props = defineProps<{
result: ResultItemImpl result: ResultItemImpl

View File

@@ -39,7 +39,7 @@ import { useLitegraphService } from '@/services/litegraphService'
import type { ComfyCommand } from '@/stores/commandStore' import type { ComfyCommand } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore' import { useExecutionStore } from '@/stores/executionStore'
import { useHelpCenterStore } from '@/stores/helpCenterStore' import { useHelpCenterStore } from '@/stores/helpCenterStore'
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore' import { useQueueSettingsStore, useQueueStore } from '@/queue/stores/queueStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore' import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useSubgraphStore } from '@/stores/subgraphStore' import { useSubgraphStore } from '@/stores/subgraphStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore' import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
@@ -63,7 +63,6 @@ import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelector
const { isActiveSubscription, showSubscriptionDialog } = useSubscription() const { isActiveSubscription, showSubscriptionDialog } = useSubscription()
const moveSelectedNodesVersionAdded = '1.22.2' const moveSelectedNodesVersionAdded = '1.22.2'
export function useCoreCommands(): ComfyCommand[] { export function useCoreCommands(): ComfyCommand[] {
const workflowService = useWorkflowService() const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore() const workflowStore = useWorkflowStore()
@@ -75,6 +74,7 @@ export function useCoreCommands(): ComfyCommand[] {
const executionStore = useExecutionStore() const executionStore = useExecutionStore()
const telemetry = useTelemetry() const telemetry = useTelemetry()
const { staticUrls, buildDocsUrl } = useExternalLink() const { staticUrls, buildDocsUrl } = useExternalLink()
const settingStore = useSettingStore()
const bottomPanelStore = useBottomPanelStore() const bottomPanelStore = useBottomPanelStore()
@@ -82,6 +82,14 @@ export function useCoreCommands(): ComfyCommand[] {
useSelectedLiteGraphItems() useSelectedLiteGraphItems()
const getTracker = () => workflowStore.activeWorkflow?.changeTracker const getTracker = () => workflowStore.activeWorkflow?.changeTracker
function isQueuePanelV2Enabled() {
return settingStore.get('Comfy.Queue.QPOV2')
}
async function toggleQueuePanelV2() {
await settingStore.set('Comfy.Queue.QPOV2', !isQueuePanelV2Enabled())
}
const moveSelectedNodes = ( const moveSelectedNodes = (
positionUpdater: (pos: Point, gridSize: number) => Point positionUpdater: (pos: Point, gridSize: number) => Point
) => { ) => {
@@ -1175,6 +1183,12 @@ export function useCoreCommands(): ComfyCommand[] {
await useWorkflowService().reloadCurrentWorkflow() // ensure changes take effect immediately await useWorkflowService().reloadCurrentWorkflow() // ensure changes take effect immediately
} }
}, },
{
id: 'Comfy.ToggleQPOV2',
icon: 'pi pi-list',
label: 'Toggle Queue Panel V2',
function: toggleQueuePanelV2
},
{ {
id: 'Comfy.ToggleLinear', id: 'Comfy.ToggleLinear',
icon: 'pi pi-database', icon: 'pi pi-database',

View File

@@ -260,6 +260,9 @@
"Comfy_ToggleLinear": { "Comfy_ToggleLinear": {
"label": "toggle linear mode" "label": "toggle linear mode"
}, },
"Comfy_ToggleQPOV2": {
"label": "Toggle Queue Panel V2"
},
"Comfy_ToggleTheme": { "Comfy_ToggleTheme": {
"label": "Toggle Theme (Dark/Light)" "label": "Toggle Theme (Dark/Light)"
}, },

View File

@@ -687,6 +687,7 @@
"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",
"generatedAssetsHeader": "Generated assets",
"noFilesFoundMessage": "Upload files or generate content to see them here", "noFilesFoundMessage": "Upload files or generate content to see them here",
"browseTemplates": "Browse example templates", "browseTemplates": "Browse example templates",
"openWorkflow": "Open workflow in local file system", "openWorkflow": "Open workflow in local file system",
@@ -718,6 +719,8 @@
"colonPercent": ": {percent}", "colonPercent": ": {percent}",
"currentNode": "Current node:", "currentNode": "Current node:",
"viewAllJobs": "View all jobs", "viewAllJobs": "View all jobs",
"viewList": "List view",
"viewGrid": "Grid view",
"running": "running", "running": "running",
"preview": "Preview", "preview": "Preview",
"interruptAll": "Interrupt all running jobs", "interruptAll": "Interrupt all running jobs",

View File

@@ -0,0 +1,133 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Button from '@/components/ui/button/Button.vue'
import { iconForJobState } from '@/queue/utils/queueDisplay'
import AssetsListCard from './AssetsListCard.vue'
function centeredDecorator() {
return {
template: `
<div style="width: 380px;">
<story />
</div>
`
}
}
const meta: Meta<typeof AssetsListCard> = {
title: 'Platform/Assets/AssetsListCard',
component: AssetsListCard,
parameters: {
layout: 'centered'
},
decorators: [centeredDecorator]
}
export default meta
type Story = StoryObj<typeof meta>
const IMAGE_PREVIEW = '/assets/images/comfy-logo-single.svg'
const VIDEO_PREVIEW = '/assets/images/default-template.png'
export const PendingJob: Story = {
args: {
iconName: iconForJobState('pending'),
iconClass: 'animate-spin',
primaryText: 'In queue',
secondaryText: '8:59:30pm'
}
}
export const InitializationJob: Story = {
args: {
iconName: iconForJobState('initialization'),
primaryText: 'Initializing...',
secondaryText: '8:59:35pm'
}
}
export const RunningJob: Story = {
args: {
iconName: iconForJobState('running'),
primaryText: 'Total: 30%',
secondaryText: 'CLIP Text Encode: 70%',
progressTotalPercent: 30,
progressCurrentPercent: 70
}
}
export const RunningJobWithActions: Story = {
args: {
iconName: iconForJobState('running'),
primaryText: 'Total: 30%',
secondaryText: 'KSampler: 70%',
progressTotalPercent: 30,
progressCurrentPercent: 70
},
render: renderRunningJobWithActions
}
export const FailedJob: Story = {
args: {
iconName: iconForJobState('failed'),
iconClass: 'text-destructive-background',
iconWrapperClass: 'bg-modal-card-placeholder-background',
primaryText: 'Failed',
secondaryText: '8:59:30pm'
}
}
export const GeneratedImage: Story = {
args: {
previewUrl: IMAGE_PREVIEW,
previewAlt: 'image-032.png',
primaryText: 'image-032.png',
secondaryText: '1.84s'
}
}
export const GeneratedVideo: Story = {
args: {
previewUrl: VIDEO_PREVIEW,
previewAlt: 'clip-01.mp4',
primaryText: 'clip-01.mp4',
secondaryText: '2m 12s'
}
}
export const GeneratedAudio: Story = {
args: {
iconName: 'icon-[lucide--music]',
primaryText: 'soundtrack-01.mp3',
secondaryText: '3m 20s'
}
}
export const Generated3D: Story = {
args: {
iconName: 'icon-[lucide--box]',
primaryText: 'scene-01.glb',
secondaryText: '128 MB'
}
}
type AssetsListCardProps = InstanceType<typeof AssetsListCard>['$props']
function renderRunningJobWithActions(args: AssetsListCardProps) {
return {
components: { AssetsListCard, Button },
setup() {
return { args }
},
template: `
<AssetsListCard v-bind="args">
<template #actions>
<Button variant="destructive" size="icon" aria-label="Cancel">
<i class="icon-[lucide--x] size-4" />
</Button>
</template>
</AssetsListCard>
`
}
}

View File

@@ -0,0 +1,102 @@
<template>
<div class="relative flex items-center gap-2 overflow-hidden rounded-lg p-2">
<div
v-if="
progressTotalPercent !== undefined ||
progressCurrentPercent !== undefined
"
class="absolute inset-0"
>
<div
v-if="progressTotalPercent !== undefined"
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
:style="{ width: `${clampPercent(progressTotalPercent)}%` }"
/>
<div
v-if="progressCurrentPercent !== undefined"
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
:style="{ width: `${clampPercent(progressCurrentPercent)}%` }"
/>
</div>
<div
:class="
cn(
'relative z-1 flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-sm bg-secondary-background',
iconWrapperClass
)
"
:aria-label="iconAriaLabel || undefined"
>
<img
v-if="previewUrl"
:src="previewUrl"
:alt="previewAlt"
class="size-full object-cover"
/>
<div v-else class="flex size-full items-center justify-center">
<i
aria-hidden="true"
:class="
cn(
iconName ?? 'icon-[lucide--image]',
'size-4 text-text-secondary',
iconClass
)
"
/>
</div>
</div>
<div class="relative z-1 flex min-w-0 flex-1 flex-col gap-1">
<div
v-if="$slots.primary || primaryText"
class="text-xs leading-none text-text-primary"
>
<slot name="primary">{{ primaryText }}</slot>
</div>
<div
v-if="$slots.secondary || secondaryText"
class="text-xs leading-none text-text-secondary"
>
<slot name="secondary">{{ secondaryText }}</slot>
</div>
</div>
<div v-if="$slots.actions" class="relative z-1 flex items-center gap-2">
<slot name="actions" />
</div>
</div>
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
const {
previewUrl,
previewAlt = '',
iconName,
iconAriaLabel,
iconClass,
iconWrapperClass,
primaryText,
secondaryText,
progressTotalPercent,
progressCurrentPercent
} = defineProps<{
previewUrl?: string
previewAlt?: string
iconName?: string
iconAriaLabel?: string
iconClass?: string
iconWrapperClass?: string
primaryText?: string
secondaryText?: string
progressTotalPercent?: number
progressCurrentPercent?: number
}>()
function clampPercent(value: number) {
return Math.min(100, Math.max(0, value))
}
</script>

View File

@@ -110,21 +110,10 @@
</CardBottom> </CardBottom>
</template> </template>
</CardContainer> </CardContainer>
<MediaAssetContextMenu
v-if="asset"
ref="contextMenu"
:asset="asset"
:asset-type="assetType"
:file-kind="fileKind"
:show-delete-button="showDeleteButton"
@zoom="handleZoomClick"
@asset-deleted="emit('asset-deleted')"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useElementHover, whenever } from '@vueuse/core' import { useElementHover } from '@vueuse/core'
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue' import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
import IconGroup from '@/components/button/IconGroup.vue' import IconGroup from '@/components/button/IconGroup.vue'
@@ -141,7 +130,6 @@ import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema' import type { AssetItem } from '../schemas/assetSchema'
import type { MediaKind } from '../schemas/mediaAssetSchema' import type { MediaKind } from '../schemas/mediaAssetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema' import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetContextMenu from './MediaAssetContextMenu.vue'
const mediaComponents = { const mediaComponents = {
top: { top: {
@@ -166,34 +154,22 @@ function getBottomComponent(kind: MediaKind) {
return mediaComponents.bottom[kind] || mediaComponents.bottom.image return mediaComponents.bottom[kind] || mediaComponents.bottom.image
} }
const { const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
asset,
loading,
selected,
showOutputCount,
outputCount,
showDeleteButton,
openContextMenuId
} = defineProps<{
asset?: AssetItem asset?: AssetItem
loading?: boolean loading?: boolean
selected?: boolean selected?: boolean
showOutputCount?: boolean showOutputCount?: boolean
outputCount?: number outputCount?: number
showDeleteButton?: boolean
openContextMenuId?: string | null
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
click: [] click: []
zoom: [asset: AssetItem] zoom: [asset: AssetItem]
'output-count-click': [] 'output-count-click': []
'asset-deleted': [] 'context-menu': [event: MouseEvent]
'context-menu-opened': []
}>() }>()
const cardContainerRef = ref<HTMLElement>() const cardContainerRef = ref<HTMLElement>()
const contextMenu = ref<InstanceType<typeof MediaAssetContextMenu>>()
const isVideoPlaying = ref(false) const isVideoPlaying = ref(false)
const showVideoControls = ref(false) const showVideoControls = ref(false)
@@ -302,15 +278,6 @@ const handleOutputCountClick = () => {
} }
const handleContextMenu = (event: MouseEvent) => { const handleContextMenu = (event: MouseEvent) => {
emit('context-menu-opened') emit('context-menu', event)
contextMenu.value?.show(event)
} }
// Close this context menu when another opens
whenever(
() => openContextMenuId && openContextMenuId !== asset?.id,
() => {
contextMenu.value?.hide()
}
)
</script> </script>

View File

@@ -31,18 +31,26 @@
/> />
</template> </template>
</AssetSortButton> </AssetSortButton>
<MediaAssetViewModeToggle
v-if="isQueuePanelV2Enabled"
v-model:view-mode="viewMode"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue' import SearchBox from '@/components/common/SearchBox.vue'
import { isCloud } from '@/platform/distribution/types' import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import MediaAssetFilterButton from './MediaAssetFilterButton.vue' import MediaAssetFilterButton from './MediaAssetFilterButton.vue'
import MediaAssetFilterMenu from './MediaAssetFilterMenu.vue' import MediaAssetFilterMenu from './MediaAssetFilterMenu.vue'
import AssetSortButton from './MediaAssetSortButton.vue' import AssetSortButton from './MediaAssetSortButton.vue'
import MediaAssetSortMenu from './MediaAssetSortMenu.vue' import MediaAssetSortMenu from './MediaAssetSortMenu.vue'
import type { SortBy } from './MediaAssetSortMenu.vue' import type { SortBy } from './MediaAssetSortMenu.vue'
import MediaAssetViewModeToggle from './MediaAssetViewModeToggle.vue'
const { showGenerationTimeSort = false } = defineProps<{ const { showGenerationTimeSort = false } = defineProps<{
searchQuery: string searchQuery: string
@@ -56,6 +64,12 @@ const emit = defineEmits<{
}>() }>()
const sortBy = defineModel<SortBy>('sortBy', { required: true }) const sortBy = defineModel<SortBy>('sortBy', { required: true })
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
const settingStore = useSettingStore()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const handleSearchChange = (value: string | undefined) => { const handleSearchChange = (value: string | undefined) => {
emit('update:searchQuery', value ?? '') emit('update:searchQuery', value ?? '')

View File

@@ -0,0 +1,54 @@
<template>
<div
class="inline-flex items-center gap-1 rounded-lg bg-secondary-background p-1"
role="group"
>
<Button
type="button"
variant="muted-textonly"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.viewList')"
:aria-pressed="viewMode === 'list'"
:class="
cn(
'rounded-lg',
viewMode === 'list'
? 'bg-secondary-background-selected text-text-primary hover:bg-secondary-background-selected'
: 'text-text-secondary hover:bg-secondary-background-hover'
)
"
@click="viewMode = 'list'"
>
<i class="icon-[lucide--table-of-contents] size-4" />
</Button>
<Button
type="button"
variant="muted-textonly"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.viewGrid')"
:aria-pressed="viewMode === 'grid'"
:class="
cn(
'rounded-lg',
viewMode === 'grid'
? 'bg-secondary-background-selected text-text-primary hover:bg-secondary-background-selected'
: 'text-text-secondary hover:bg-secondary-background-hover'
)
"
@click="viewMode = 'grid'"
>
<i class="icon-[lucide--layout-grid] size-4" />
</Button>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
const { t } = useI18n()
</script>

View File

@@ -2,7 +2,7 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema' import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetContext } from '@/platform/assets/schemas/mediaAssetSchema' import type { AssetContext } from '@/platform/assets/schemas/mediaAssetSchema'
import { api } from '@/scripts/api' import { api } from '@/scripts/api'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore' import type { ResultItemImpl, TaskItemImpl } from '@/queue/stores/queueStore'
/** /**
* Extract asset type from tags array * Extract asset type from tags array

View File

@@ -1,12 +1,12 @@
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ResultItemImpl } from '@/stores/queueStore' import { ResultItemImpl } from '@/queue/stores/queueStore'
import type { AssetMeta } from '../schemas/mediaAssetSchema' import type { AssetMeta } from '../schemas/mediaAssetSchema'
import { useMediaAssetGalleryStore } from './useMediaAssetGalleryStore' import { useMediaAssetGalleryStore } from './useMediaAssetGalleryStore'
vi.mock('@/stores/queueStore', () => ({ vi.mock('@/queue/stores/queueStore', () => ({
ResultItemImpl: vi ResultItemImpl: vi
.fn<typeof ResultItemImpl>() .fn<typeof ResultItemImpl>()
.mockImplementation(function (data) { .mockImplementation(function (data) {

View File

@@ -1,7 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, shallowRef } from 'vue' import { ref, shallowRef } from 'vue'
import { ResultItemImpl } from '@/stores/queueStore' import { ResultItemImpl } from '@/queue/stores/queueStore'
import type { AssetMeta } from '../schemas/mediaAssetSchema' import type { AssetMeta } from '../schemas/mediaAssetSchema'

View File

@@ -1,5 +1,5 @@
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ResultItemImpl } from '@/stores/queueStore' import type { ResultItemImpl } from '@/queue/stores/queueStore'
/** /**
* Metadata for output assets from queue store * Metadata for output assets from queue store

View File

@@ -1139,5 +1139,13 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'hidden', type: 'hidden',
defaultValue: false, defaultValue: false,
versionAdded: '1.34.1' versionAdded: '1.34.1'
},
{
id: 'Comfy.Queue.QPOV2',
name: 'Queue Panel V2',
type: 'hidden',
tooltip: 'Enable the new Assets Panel design with list/grid view toggle',
defaultValue: false,
experimental: true
} }
] ]

View File

@@ -89,7 +89,7 @@ import Button from '@/components/ui/button/Button.vue'
import type { import type {
CompletionSummary, CompletionSummary,
CompletionSummaryMode CompletionSummaryMode
} from '@/composables/queue/useCompletionSummary' } from '@/queue/composables/useCompletionSummary'
type Props = { type Props = {
mode: CompletionSummaryMode mode: CompletionSummaryMode

View File

@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
import QueueOverlayEmpty from './QueueOverlayEmpty.vue' import QueueOverlayEmpty from './QueueOverlayEmpty.vue'
import type { CompletionSummary } from '@/composables/queue/useCompletionSummary' import type { CompletionSummary } from '@/queue/composables/useCompletionSummary'
const i18n = createI18n({ const i18n = createI18n({
legacy: false, legacy: false,

View File

@@ -14,8 +14,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import CompletionSummaryBanner from '@/components/queue/CompletionSummaryBanner.vue' import CompletionSummaryBanner from '@/queue/components/CompletionSummaryBanner.vue'
import type { CompletionSummary } from '@/composables/queue/useCompletionSummary' import type { CompletionSummary } from '@/queue/composables/useCompletionSummary'
defineProps<{ summary: CompletionSummary }>() defineProps<{ summary: CompletionSummary }>()

View File

@@ -79,9 +79,9 @@ import type {
JobListItem, JobListItem,
JobSortMode, JobSortMode,
JobTab JobTab
} from '@/composables/queue/useJobList' } from '@/queue/composables/useJobList'
import type { MenuEntry } from '@/composables/queue/useJobMenu' import type { MenuEntry } from '@/queue/composables/useJobMenu'
import { useJobMenu } from '@/composables/queue/useJobMenu' import { useJobMenu } from '@/queue/composables/useJobMenu'
import QueueOverlayHeader from './QueueOverlayHeader.vue' import QueueOverlayHeader from './QueueOverlayHeader.vue'
import JobContextMenu from './job/JobContextMenu.vue' import JobContextMenu from './job/JobContextMenu.vue'

View File

@@ -63,16 +63,16 @@
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue' import QueueOverlayActive from '@/queue/components/QueueOverlayActive.vue'
import QueueOverlayEmpty from '@/components/queue/QueueOverlayEmpty.vue' import QueueOverlayEmpty from '@/queue/components/QueueOverlayEmpty.vue'
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue' import QueueOverlayExpanded from '@/queue/components/QueueOverlayExpanded.vue'
import QueueClearHistoryDialog from '@/components/queue/dialogs/QueueClearHistoryDialog.vue' import QueueClearHistoryDialog from '@/queue/components/dialogs/QueueClearHistoryDialog.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue' import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import { useCompletionSummary } from '@/composables/queue/useCompletionSummary' import { useCompletionSummary } from '@/queue/composables/useCompletionSummary'
import { useJobList } from '@/composables/queue/useJobList' import { useJobList } from '@/queue/composables/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList' import type { JobListItem } from '@/queue/composables/useJobList'
import { useQueueProgress } from '@/composables/queue/useQueueProgress' import { useQueueProgress } from '@/queue/composables/useQueueProgress'
import { useResultGallery } from '@/composables/queue/useResultGallery' import { useResultGallery } from '@/queue/composables/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling' import { useErrorHandling } from '@/composables/useErrorHandling'
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore' import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
import { isCloud } from '@/platform/distribution/types' import { isCloud } from '@/platform/distribution/types'
@@ -81,7 +81,7 @@ import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore' import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore' import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore' import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore' import { useQueueStore } from '@/queue/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore' import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
type OverlayState = 'hidden' | 'empty' | 'active' | 'expanded' type OverlayState = 'hidden' | 'empty' | 'active' | 'expanded'

View File

@@ -53,7 +53,7 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue' import Button from '@/components/ui/button/Button.vue'
import { useErrorHandling } from '@/composables/useErrorHandling' import { useErrorHandling } from '@/composables/useErrorHandling'
import { useDialogStore } from '@/stores/dialogStore' import { useDialogStore } from '@/stores/dialogStore'
import { useQueueStore } from '@/stores/queueStore' import { useQueueStore } from '@/queue/stores/queueStore'
const dialogStore = useDialogStore() const dialogStore = useDialogStore()
const queueStore = useQueueStore() const queueStore = useQueueStore()

View File

@@ -47,7 +47,7 @@ import Popover from 'primevue/popover'
import { ref } from 'vue' import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue' import Button from '@/components/ui/button/Button.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu' import type { MenuEntry } from '@/queue/composables/useJobMenu'
defineProps<{ entries: MenuEntry[] }>() defineProps<{ entries: MenuEntry[] }>()

View File

@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { TaskStatus } from '@/schemas/apiSchema' import type { TaskStatus } from '@/schemas/apiSchema'
import { useExecutionStore } from '@/stores/executionStore' import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore' import { TaskItemImpl, useQueueStore } from '@/queue/stores/queueStore'
import JobDetailsPopover from './JobDetailsPopover.vue' import JobDetailsPopover from './JobDetailsPopover.vue'

View File

@@ -101,10 +101,10 @@ import { isCloud } from '@/platform/distribution/types'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService' import { useDialogService } from '@/services/dialogService'
import { useExecutionStore } from '@/stores/executionStore' import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore' import { useQueueStore } from '@/queue/stores/queueStore'
import type { TaskItemImpl } from '@/stores/queueStore' import type { TaskItemImpl } from '@/queue/stores/queueStore'
import { formatClockTime } from '@/utils/dateTimeUtil' import { formatClockTime } from '@/utils/dateTimeUtil'
import { jobStateFromTask } from '@/utils/queueUtil' import { jobStateFromTask } from '@/queue/utils/queueUtil'
import { useJobErrorReporting } from './useJobErrorReporting' import { useJobErrorReporting } from './useJobErrorReporting'
import { formatElapsedTime, useQueueEstimates } from './useQueueEstimates' import { formatElapsedTime, useQueueEstimates } from './useQueueEstimates'

View File

@@ -137,8 +137,8 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue' import Button from '@/components/ui/button/Button.vue'
import { jobSortModes, jobTabs } from '@/composables/queue/useJobList' import { jobSortModes, jobTabs } from '@/queue/composables/useJobList'
import type { JobSortMode, JobTab } from '@/composables/queue/useJobList' import type { JobSortMode, JobTab } from '@/queue/composables/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig' import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types' import { isCloud } from '@/platform/distribution/types'

View File

@@ -38,8 +38,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue' import { onBeforeUnmount, ref, watch } from 'vue'
import QueueJobItem from '@/components/queue/job/QueueJobItem.vue' import QueueJobItem from '@/queue/components/job/QueueJobItem.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList' import type { JobGroup, JobListItem } from '@/queue/composables/useJobList'
const props = defineProps<{ displayedJobGroups: JobGroup[] }>() const props = defineProps<{ displayedJobGroups: JobGroup[] }>()

View File

@@ -198,12 +198,12 @@
import { computed, nextTick, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue' import JobDetailsPopover from '@/queue/components/job/JobDetailsPopover.vue'
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue' import QueueAssetPreview from '@/queue/components/job/QueueAssetPreview.vue'
import Button from '@/components/ui/button/Button.vue' import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig' import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import type { JobState } from '@/types/queue' import type { JobState } from '@/queue/types/queue'
import { iconForJobState } from '@/utils/queueDisplay' import { iconForJobState } from '@/queue/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
const props = withDefaults( const props = withDefaults(

View File

@@ -2,7 +2,7 @@ import { computed } from 'vue'
import type { ComputedRef } from 'vue' import type { ComputedRef } from 'vue'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema' import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import type { TaskItemImpl } from '@/stores/queueStore' import type { TaskItemImpl } from '@/queue/stores/queueStore'
type CopyHandler = (value: string) => void | Promise<void> type CopyHandler = (value: string) => void | Promise<void>

View File

@@ -1,8 +1,8 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { TaskItemImpl } from '@/stores/queueStore' import type { TaskItemImpl } from '@/queue/stores/queueStore'
import type { JobState } from '@/types/queue' import type { JobState } from '@/queue/types/queue'
import { formatElapsedTime, useQueueEstimates } from './useQueueEstimates' import { formatElapsedTime, useQueueEstimates } from './useQueueEstimates'
import type { UseQueueEstimatesOptions } from './useQueueEstimates' import type { UseQueueEstimatesOptions } from './useQueueEstimates'

View File

@@ -2,8 +2,8 @@ import { computed } from 'vue'
import type { ComputedRef, Ref } from 'vue' import type { ComputedRef, Ref } from 'vue'
import type { useExecutionStore } from '@/stores/executionStore' import type { useExecutionStore } from '@/stores/executionStore'
import type { TaskItemImpl, useQueueStore } from '@/stores/queueStore' import type { TaskItemImpl, useQueueStore } from '@/queue/stores/queueStore'
import type { JobState } from '@/types/queue' import type { JobState } from '@/queue/types/queue'
type QueueStore = ReturnType<typeof useQueueStore> type QueueStore = ReturnType<typeof useQueueStore>
type ExecutionStore = ReturnType<typeof useExecutionStore> type ExecutionStore = ReturnType<typeof useExecutionStore>

View File

@@ -1,8 +1,8 @@
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useExecutionStore } from '@/stores/executionStore' import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore' import { useQueueStore } from '@/queue/stores/queueStore'
import { jobStateFromTask } from '@/utils/queueUtil' import { jobStateFromTask } from '@/queue/utils/queueUtil'
export type CompletionSummaryMode = 'allSuccess' | 'mixed' | 'allFailed' export type CompletionSummaryMode = 'allSuccess' | 'mixed' | 'allFailed'

View File

@@ -0,0 +1,56 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { JobListItem } from '@/queue/composables/useJobList'
import { useJobMenu } from '@/queue/composables/useJobMenu'
import type { JobState } from '@/queue/types/queue'
type JobActionKey = 'cancel'
export type JobAction = {
key: JobActionKey
icon: string
label: string
variant: 'destructive' | 'secondary' | 'textonly'
}
export function useJobActions() {
const { t } = useI18n()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const currentJob = ref<JobListItem | null>(null)
const { cancelJob } = useJobMenu(() => currentJob.value)
const jobActionSets = computed<Partial<Record<JobState, JobAction[]>>>(() => {
const cancelAction: JobAction = {
key: 'cancel',
icon: 'icon-[lucide--x]',
label: t('sideToolbar.queueProgressOverlay.cancelJobTooltip'),
variant: 'destructive'
}
return {
pending: [cancelAction],
initialization: [cancelAction],
running: [cancelAction]
}
})
const getJobActions = (job: JobListItem): JobAction[] =>
job.showClear === false ? [] : (jobActionSets.value[job.state] ?? [])
const runJobAction = wrapWithErrorHandlingAsync(
async (action: JobAction, job: JobListItem) => {
currentJob.value = job
if (action.key === 'cancel') {
await cancelJob()
}
}
)
return {
getJobActions,
runJobAction
}
}

View File

@@ -1,14 +1,14 @@
import { computed, onUnmounted, ref, watch } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useQueueProgress } from '@/composables/queue/useQueueProgress' import { useQueueProgress } from '@/queue/composables/useQueueProgress'
import { st } from '@/i18n' import { st } from '@/i18n'
import { isCloud } from '@/platform/distribution/types' import { isCloud } from '@/platform/distribution/types'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useExecutionStore } from '@/stores/executionStore' import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore' import { useQueueStore } from '@/queue/stores/queueStore'
import type { TaskItemImpl } from '@/stores/queueStore' import type { TaskItemImpl } from '@/queue/stores/queueStore'
import type { JobState } from '@/types/queue' import type { JobState } from '@/queue/types/queue'
import { import {
dateKey, dateKey,
formatClockTime, formatClockTime,
@@ -17,8 +17,8 @@ import {
isYesterday isYesterday
} from '@/utils/dateTimeUtil' } from '@/utils/dateTimeUtil'
import { normalizeI18nKey } from '@/utils/formatUtil' import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildJobDisplay } from '@/utils/queueDisplay' import { buildJobDisplay } from '@/queue/utils/queueDisplay'
import { jobStateFromTask } from '@/utils/queueUtil' import { jobStateFromTask } from '@/queue/utils/queueUtil'
/** Tabs for job list filtering */ /** Tabs for job list filtering */
export const jobTabs = ['All', 'Completed', 'Failed'] as const export const jobTabs = ['All', 'Completed', 'Failed'] as const

View File

@@ -1,7 +1,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import { downloadFile } from '@/base/common/downloadUtil' import { downloadFile } from '@/base/common/downloadUtil'
import type { JobListItem } from '@/composables/queue/useJobList' import type { JobListItem } from '@/queue/composables/useJobList'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard' import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { st, t } from '@/i18n' import { st, t } from '@/i18n'
import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/assetMappers' import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/assetMappers'
@@ -19,8 +19,8 @@ import { downloadBlob } from '@/scripts/utils'
import { useDialogService } from '@/services/dialogService' import { useDialogService } from '@/services/dialogService'
import { useLitegraphService } from '@/services/litegraphService' import { useLitegraphService } from '@/services/litegraphService'
import { useNodeDefStore } from '@/stores/nodeDefStore' import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useQueueStore } from '@/stores/queueStore' import { useQueueStore } from '@/queue/stores/queueStore'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore' import type { ResultItemImpl, TaskItemImpl } from '@/queue/stores/queueStore'
import { createAnnotatedPath } from '@/utils/createAnnotatedPath' import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
import { appendJsonExt } from '@/utils/formatUtil' import { appendJsonExt } from '@/utils/formatUtil'

View File

@@ -1,7 +1,7 @@
import { ref, shallowRef } from 'vue' import { ref, shallowRef } from 'vue'
import type { JobListItem } from '@/composables/queue/useJobList' import type { JobListItem } from '@/queue/composables/useJobList'
import type { ResultItemImpl } from '@/stores/queueStore' import type { ResultItemImpl } from '@/queue/stores/queueStore'
/** /**
* Manages result gallery state and activation for queue items. * Manages result gallery state and activation for queue items.

View File

@@ -3,7 +3,7 @@ import { app } from '@/scripts/app'
import { import {
useQueuePendingTaskCountStore, useQueuePendingTaskCountStore,
useQueueSettingsStore useQueueSettingsStore
} from '@/stores/queueStore' } from '@/queue/stores/queueStore'
export function setupAutoQueueHandler() { export function setupAutoQueueHandler() {
const queueCountStore = useQueuePendingTaskCountStore() const queueCountStore = useQueuePendingTaskCountStore()

View File

@@ -1,5 +1,5 @@
import type { TaskItemImpl } from '@/stores/queueStore' import type { TaskItemImpl } from '@/queue/stores/queueStore'
import type { JobState } from '@/types/queue' import type { JobState } from '@/queue/types/queue'
import { formatDuration } from '@/utils/formatUtil' import { formatDuration } from '@/utils/formatUtil'
import { clampPercentInt, formatPercent0 } from '@/utils/numberUtil' import { clampPercentInt, formatPercent0 } from '@/utils/numberUtil'

View File

@@ -1,5 +1,5 @@
import type { TaskItemImpl } from '@/stores/queueStore' import type { TaskItemImpl } from '@/queue/stores/queueStore'
import type { JobState } from '@/types/queue' import type { JobState } from '@/queue/types/queue'
/** /**
* Map a task to a UI job state, including initialization override. * Map a task to a UI job state, including initialization override.

View File

@@ -18,7 +18,7 @@ import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/compo
import type { ResultItemType } from '@/schemas/apiSchema' import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api' import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore' import { useAssetsStore } from '@/stores/assetsStore'
import { useQueueStore } from '@/stores/queueStore' import { useQueueStore } from '@/queue/stores/queueStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget' import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { AssetKind } from '@/types/widgetTypes' import type { AssetKind } from '@/types/widgetTypes'
import { import {

View File

@@ -491,6 +491,7 @@ const zSettings = z.object({
'Comfy.VueNodes.Enabled': z.boolean(), 'Comfy.VueNodes.Enabled': z.boolean(),
'Comfy.VueNodes.AutoScaleLayout': z.boolean(), 'Comfy.VueNodes.AutoScaleLayout': z.boolean(),
'Comfy.Assets.UseAssetAPI': z.boolean(), 'Comfy.Assets.UseAssetAPI': z.boolean(),
'Comfy.Queue.QPOV2': z.boolean(),
'Comfy-Desktop.AutoUpdate': z.boolean(), 'Comfy-Desktop.AutoUpdate': z.boolean(),
'Comfy-Desktop.SendStatistics': z.boolean(), 'Comfy-Desktop.SendStatistics': z.boolean(),
'Comfy-Desktop.WindowStyle': z.string(), 'Comfy-Desktop.WindowStyle': z.string(),

View File

@@ -130,9 +130,6 @@ The following table lists ALL 46 store instances in the system as of 2025-09-01:
| nodeBookmarkStore.ts | useNodeBookmarkStore | Manages node bookmarks and favorites | Nodes | | nodeBookmarkStore.ts | useNodeBookmarkStore | Manages node bookmarks and favorites | Nodes |
| nodeDefStore.ts | useNodeDefStore | Manages node definitions and schemas | Nodes | | nodeDefStore.ts | useNodeDefStore | Manages node definitions and schemas | Nodes |
| nodeDefStore.ts | useNodeFrequencyStore | Tracks node usage frequency | Nodes | | nodeDefStore.ts | useNodeFrequencyStore | Tracks node usage frequency | Nodes |
| queueStore.ts | useQueueStore | Manages execution queue and task history | Execution |
| queueStore.ts | useQueuePendingTaskCountStore | Tracks pending task counts | Execution |
| queueStore.ts | useQueueSettingsStore | Manages queue execution settings | Execution |
| releaseStore.ts | useReleaseStore | Manages application release information | System | | releaseStore.ts | useReleaseStore | Manages application release information | System |
| serverConfigStore.ts | useServerConfigStore | Handles server configuration | Config | | serverConfigStore.ts | useServerConfigStore | Handles server configuration | Config |
| settingStore.ts | useSettingStore | Manages application settings | Config | | settingStore.ts | useSettingStore | Manages application settings | Config |
@@ -148,6 +145,8 @@ The following table lists ALL 46 store instances in the system as of 2025-09-01:
| workflowTemplatesStore.ts | useWorkflowTemplatesStore | Manages workflow templates | Workflows | | workflowTemplatesStore.ts | useWorkflowTemplatesStore | Manages workflow templates | Workflows |
| workspaceStore.ts | useWorkspaceStore | Manages overall workspace state | Workspace | | workspaceStore.ts | useWorkspaceStore | Manages overall workspace state | Workspace |
Note: queue stores live in `src/queue/stores/queueStore.ts`.
### Workspace Stores ### Workspace Stores
Located in `stores/workspace/`: Located in `stores/workspace/`:

View File

@@ -11,7 +11,7 @@ import { isCloud } from '@/platform/distribution/types'
import type { TaskItem } from '@/schemas/apiSchema' import type { TaskItem } from '@/schemas/apiSchema'
import { api } from '@/scripts/api' import { api } from '@/scripts/api'
import { TaskItemImpl } from './queueStore' import { TaskItemImpl } from '@/queue/stores/queueStore'
const INPUT_LIMIT = 100 const INPUT_LIMIT = 100

View File

@@ -12,7 +12,7 @@ import type { SidebarTabExtension, ToastManager } from '@/types/extensionTypes'
import { useApiKeyAuthStore } from './apiKeyAuthStore' import { useApiKeyAuthStore } from './apiKeyAuthStore'
import { useCommandStore } from './commandStore' import { useCommandStore } from './commandStore'
import { useFirebaseAuthStore } from './firebaseAuthStore' import { useFirebaseAuthStore } from './firebaseAuthStore'
import { useQueueSettingsStore } from './queueStore' import { useQueueSettingsStore } from '@/queue/stores/queueStore'
import { useBottomPanelStore } from './workspace/bottomPanelStore' import { useBottomPanelStore } from './workspace/bottomPanelStore'
import { useSidebarTabStore } from './workspace/sidebarTabStore' import { useSidebarTabStore } from './workspace/sidebarTabStore'

View File

@@ -0,0 +1,26 @@
import { ref } from 'vue'
import type { JobAction } from '@/queue/composables/useJobActions'
import type { JobListItem } from '@/queue/composables/useJobList'
const actionsByJobId = ref<Record<string, JobAction[]>>({})
export function setMockJobActions(actions: Record<string, JobAction[]>) {
actionsByJobId.value = actions
}
/** @knipIgnoreUnusedButUsedByStorybook */
export function useJobActions() {
function getJobActions(job: JobListItem) {
return actionsByJobId.value[job.id] ?? []
}
async function runJobAction() {
return undefined
}
return {
getJobActions,
runJobAction
}
}

View File

@@ -0,0 +1,59 @@
import { computed, ref } from 'vue'
import type { TaskItemImpl } from '@/queue/stores/queueStore'
import type {
JobGroup,
JobListItem,
JobSortMode,
JobTab
} from '@/queue/composables/useJobList'
const jobItems = ref<JobListItem[]>([])
function buildGroupedJobItems(): JobGroup[] {
return [
{
key: 'storybook',
label: 'Storybook',
items: jobItems.value
}
]
}
const groupedJobItems = computed<JobGroup[]>(buildGroupedJobItems)
const selectedJobTab = ref<JobTab>('All')
const selectedWorkflowFilter = ref<'all' | 'current'>('all')
const selectedSortMode = ref<JobSortMode>('mostRecent')
const currentNodeName = ref('KSampler')
function buildEmptyTasks(): TaskItemImpl[] {
return []
}
const allTasksSorted = computed<TaskItemImpl[]>(buildEmptyTasks)
const filteredTasks = computed<TaskItemImpl[]>(buildEmptyTasks)
function buildHasFailedJobs() {
return jobItems.value.some((item) => item.state === 'failed')
}
const hasFailedJobs = computed(buildHasFailedJobs)
export function setMockJobItems(items: JobListItem[]) {
jobItems.value = items
}
/** @knipIgnoreUnusedButUsedByStorybook */
export function useJobList() {
return {
selectedJobTab,
selectedWorkflowFilter,
selectedSortMode,
hasFailedJobs,
allTasksSorted,
filteredTasks,
jobItems,
groupedJobItems,
currentNodeName
}
}

View File

@@ -58,7 +58,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { StatusWsMessageStatus } from '@/schemas/apiSchema' import type { StatusWsMessageStatus } from '@/schemas/apiSchema'
import { api } from '@/scripts/api' import { api } from '@/scripts/api'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { setupAutoQueueHandler } from '@/services/autoQueueService' import { setupAutoQueueHandler } from '@/queue/services/autoQueueService'
import { useKeybindingService } from '@/services/keybindingService' import { useKeybindingService } from '@/services/keybindingService'
import { useAssetsStore } from '@/stores/assetsStore' import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore' import { useCommandStore } from '@/stores/commandStore'
@@ -70,7 +70,7 @@ import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
import { import {
useQueuePendingTaskCountStore, useQueuePendingTaskCountStore,
useQueueStore useQueueStore
} from '@/stores/queueStore' } from '@/queue/stores/queueStore'
import { useServerConfigStore } from '@/stores/serverConfigStore' import { useServerConfigStore } from '@/stores/serverConfigStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore' import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore' import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'

View File

@@ -26,7 +26,7 @@ import WidgetInputNumberInput from '@/renderer/extensions/vueNodes/widgets/compo
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore' import { useCommandStore } from '@/stores/commandStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore' import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useQueueSettingsStore } from '@/stores/queueStore' import { useQueueSettingsStore } from '@/queue/stores/queueStore'
import { isElectron } from '@/utils/envUtil' import { isElectron } from '@/utils/envUtil'
const nodeOutputStore = useNodeOutputStore() const nodeOutputStore = useNodeOutputStore()

View File

@@ -2,8 +2,8 @@ import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue' import { defineComponent, nextTick } from 'vue'
import JobGroupsList from '@/components/queue/job/JobGroupsList.vue' import JobGroupsList from '@/queue/components/job/JobGroupsList.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList' import type { JobGroup, JobListItem } from '@/queue/composables/useJobList'
const QueueJobItemStub = defineComponent({ const QueueJobItemStub = defineComponent({
name: 'QueueJobItemStub', name: 'QueueJobItemStub',
@@ -22,7 +22,6 @@ const QueueJobItemStub = defineComponent({
runningNodeName: { type: String, default: undefined }, runningNodeName: { type: String, default: undefined },
activeDetailsId: { type: String, default: null } activeDetailsId: { type: String, default: null }
}, },
emits: ['cancel', 'delete', 'menu', 'view', 'details-enter', 'details-leave'],
template: '<div class="queue-job-item-stub"></div>' template: '<div class="queue-job-item-stub"></div>'
}) })

View File

@@ -3,12 +3,12 @@ import { computed, ref } from 'vue'
import type { ComputedRef } from 'vue' import type { ComputedRef } from 'vue'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema' import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import type { TaskItemImpl } from '@/stores/queueStore' import type { TaskItemImpl } from '@/queue/stores/queueStore'
import type { import type {
JobErrorDialogService, JobErrorDialogService,
UseJobErrorReportingOptions UseJobErrorReportingOptions
} from '@/components/queue/job/useJobErrorReporting' } from '@/queue/components/job/useJobErrorReporting'
import * as jobErrorReporting from '@/components/queue/job/useJobErrorReporting' import * as jobErrorReporting from '@/queue/components/job/useJobErrorReporting'
const createExecutionErrorMessage = ( const createExecutionErrorMessage = (
overrides: Partial<ExecutionErrorWsMessage> = {} overrides: Partial<ExecutionErrorWsMessage> = {}

View File

@@ -1,9 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive } from 'vue' import { nextTick, reactive } from 'vue'
import { useCompletionSummary } from '@/composables/queue/useCompletionSummary' import { useCompletionSummary } from '@/queue/composables/useCompletionSummary'
import { useExecutionStore } from '@/stores/executionStore' import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore' import { useQueueStore } from '@/queue/stores/queueStore'
type MockTask = { type MockTask = {
displayStatus: 'Completed' | 'Failed' | 'Cancelled' | 'Running' | 'Pending' displayStatus: 'Completed' | 'Failed' | 'Cancelled' | 'Running' | 'Pending'
@@ -14,7 +14,7 @@ type MockTask = {
} }
} }
vi.mock('@/stores/queueStore', () => { vi.mock('@/queue/stores/queueStore', () => {
const state = reactive({ const state = reactive({
runningTasks: [] as MockTask[], runningTasks: [] as MockTask[],
historyTasks: [] as MockTask[] historyTasks: [] as MockTask[]

View File

@@ -3,11 +3,11 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { nextTick, reactive, ref } from 'vue' import { nextTick, reactive, ref } from 'vue'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { useJobList } from '@/composables/queue/useJobList' import { useJobList } from '@/queue/composables/useJobList'
import type { JobState } from '@/types/queue' import type { JobState } from '@/queue/types/queue'
import { buildJobDisplay } from '@/utils/queueDisplay' import { buildJobDisplay } from '@/queue/utils/queueDisplay'
import type { BuildJobDisplayCtx } from '@/utils/queueDisplay' import type { BuildJobDisplayCtx } from '@/queue/utils/queueDisplay'
import type { TaskItemImpl } from '@/stores/queueStore' import type { TaskItemImpl } from '@/queue/stores/queueStore'
type TestTask = { type TestTask = {
promptId: string promptId: string
@@ -57,7 +57,7 @@ const ensureProgressRefs = () => {
if (!currentNodePercent) currentNodePercent = ref(0) as Ref<number> if (!currentNodePercent) currentNodePercent = ref(0) as Ref<number>
return { totalPercent, currentNodePercent } return { totalPercent, currentNodePercent }
} }
vi.mock('@/composables/queue/useQueueProgress', () => ({ vi.mock('@/queue/composables/useQueueProgress', () => ({
useQueueProgress: () => { useQueueProgress: () => {
ensureProgressRefs() ensureProgressRefs()
return { return {
@@ -67,7 +67,7 @@ vi.mock('@/composables/queue/useQueueProgress', () => ({
} }
})) }))
vi.mock('@/utils/queueDisplay', () => ({ vi.mock('@/queue/utils/queueDisplay', () => ({
buildJobDisplay: vi.fn( buildJobDisplay: vi.fn(
(task: TaskItemImpl, state: JobState, options: BuildJobDisplayCtx) => ({ (task: TaskItemImpl, state: JobState, options: BuildJobDisplayCtx) => ({
primary: `Job ${task.promptId}`, primary: `Job ${task.promptId}`,
@@ -80,7 +80,7 @@ vi.mock('@/utils/queueDisplay', () => ({
) )
})) }))
vi.mock('@/utils/queueUtil', () => ({ vi.mock('@/queue/utils/queueUtil', () => ({
jobStateFromTask: vi.fn( jobStateFromTask: vi.fn(
(task: TestTask, isInitializing?: boolean): JobState => (task: TestTask, isInitializing?: boolean): JobState =>
task.mockState ?? (isInitializing ? 'running' : 'completed') task.mockState ?? (isInitializing ? 'running' : 'completed')
@@ -102,7 +102,7 @@ const ensureQueueStore = () => {
} }
return queueStoreMock return queueStoreMock
} }
vi.mock('@/stores/queueStore', () => ({ vi.mock('@/queue/stores/queueStore', () => ({
useQueueStore: () => { useQueueStore: () => {
return ensureQueueStore() return ensureQueueStore()
} }

View File

@@ -2,8 +2,8 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
import { nextTick, ref } from 'vue' import { nextTick, ref } from 'vue'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import type { JobListItem } from '@/composables/queue/useJobList' import type { JobListItem } from '@/queue/composables/useJobList'
import type { MenuEntry } from '@/composables/queue/useJobMenu' import type { MenuEntry } from '@/queue/composables/useJobMenu'
const downloadFileMock = vi.fn() const downloadFileMock = vi.fn()
vi.mock('@/base/common/downloadUtil', () => ({ vi.mock('@/base/common/downloadUtil', () => ({
@@ -99,7 +99,7 @@ const queueStoreMock = {
update: vi.fn(), update: vi.fn(),
delete: vi.fn() delete: vi.fn()
} }
vi.mock('@/stores/queueStore', () => ({ vi.mock('@/queue/stores/queueStore', () => ({
useQueueStore: () => queueStoreMock useQueueStore: () => queueStoreMock
})) }))
@@ -116,7 +116,7 @@ vi.mock('@/utils/formatUtil', () => ({
appendJsonExtMock(...args) appendJsonExtMock(...args)
})) }))
import { useJobMenu } from '@/composables/queue/useJobMenu' import { useJobMenu } from '@/queue/composables/useJobMenu'
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => ({ const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => ({
id: overrides.id ?? 'job-1', id: overrides.id ?? 'job-1',

View File

@@ -4,7 +4,7 @@ import { nextTick, ref } from 'vue'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { useQueueProgress } from '@/composables/queue/useQueueProgress' import { useQueueProgress } from '@/queue/composables/useQueueProgress'
import { formatPercent0 } from '@/utils/numberUtil' import { formatPercent0 } from '@/utils/numberUtil'
type ProgressValue = number | null type ProgressValue = number | null

View File

@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { useResultGallery } from '@/composables/queue/useResultGallery' import { useResultGallery } from '@/queue/composables/useResultGallery'
import type { JobListItem } from '@/composables/queue/useJobList' import type { JobListItem } from '@/queue/composables/useJobList'
type PreviewLike = { url: string; supportsPreview: boolean } type PreviewLike = { url: string; supportsPreview: boolean }

View File

@@ -22,7 +22,7 @@ const {
WidgetToggleSwitch WidgetToggleSwitch
} = FOR_TESTING } = FOR_TESTING
vi.mock('@/stores/queueStore', () => ({ vi.mock('@/queue/stores/queueStore', () => ({
useQueueStore: vi.fn(() => ({ useQueueStore: vi.fn(() => ({
historyTasks: [] historyTasks: []
})) }))

View File

@@ -32,7 +32,7 @@ vi.mock('@/platform/distribution/types', () => ({
})) }))
// Mock TaskItemImpl // Mock TaskItemImpl
vi.mock('@/stores/queueStore', () => ({ vi.mock('@/queue/stores/queueStore', () => ({
TaskItemImpl: class { TaskItemImpl: class {
public flatOutputs: Array<{ public flatOutputs: Array<{
supportsPreview: boolean supportsPreview: boolean

View File

@@ -10,7 +10,7 @@ import type {
TaskStatus TaskStatus
} from '@/schemas/apiSchema' } from '@/schemas/apiSchema'
import { api } from '@/scripts/api' import { api } from '@/scripts/api'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore' import { TaskItemImpl, useQueueStore } from '@/queue/stores/queueStore'
// Fixture factories // Fixture factories
const createTaskPrompt = ( const createTaskPrompt = (

View File

@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyApp } from '@/scripts/app' import type { ComfyApp } from '@/scripts/app'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { TaskItemImpl } from '@/stores/queueStore' import { TaskItemImpl } from '@/queue/stores/queueStore'
import * as getWorkflowModule from '@/platform/workflow/cloud' import * as getWorkflowModule from '@/platform/workflow/cloud'
vi.mock('@/platform/distribution/types', () => ({ vi.mock('@/platform/distribution/types', () => ({