mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-09 23:20:04 +00:00
feat: split job history into a dedicated sidebar tab (#8957)
## Summary Move queue job history into a dedicated sidebar tab (gated by `Comfy.Queue.QPOV2`) and remove mixed job-history UI from the Assets sidebar so assets and job controls are separated. ## Changes - **What**: - Added `JobHistorySidebarTab` with reusable job UI primitives: `JobFilterTabs`, `JobFilterActions`, `JobAssetsList`, and shared `JobHistoryActionsMenu`. - Added reactive `job-history` tab registration in `sidebarTabStore`; prepends above Assets when `Comfy.Queue.QPOV2` is enabled and unregisters cleanly when disabled. - Added debounced search to `useJobList` (filters by job title, metadata, and prompt id). - Extracted clear-history dialog logic to `useQueueClearHistoryDialog` and reused it from queue overlay and job history tab. - Removed active-job rendering and queue-clear controls from assets list/grid/tab views; assets sidebar now focuses on media assets only. - Removed the QPOV2 gate from `MediaAssetViewModeToggle` and updated queue/job localized copy. - Added and updated tests for queue overlay header actions, job filters, search filtering, sidebar tab registration, and assets sidebar behavior. ## Review Focus - Verify QPOV2 toggle behavior: - `Docked Job History` menu action toggles `Comfy.Queue.QPOV2`. - `job-history` tab insertion/removal order and active-tab reset on removal. - Verify behavior split between tabs: - Job controls (cancel/delete/view/filter/search/clear history/clear queue) live in Job History. - Assets sidebar loading/empty states and list/grid rendering remain correct after removing active jobs. ## Screenshots (if applicable) <img width="670" height="707" alt="image" src="https://github.com/user-attachments/assets/3a201fcb-d104-4e95-b5fe-49c4006a30a5" />
This commit is contained in:
@@ -1,23 +1,7 @@
|
||||
<template>
|
||||
<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 -->
|
||||
<div
|
||||
v-if="assets.length"
|
||||
:class="cn('px-2 2xl:px-4', activeJobItems.length && 'mt-2')"
|
||||
>
|
||||
<div v-if="assets.length" class="px-2 2xl:px-4">
|
||||
<div
|
||||
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 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 type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { isActiveJobState } from '@/utils/queueUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const {
|
||||
assets,
|
||||
isSelected,
|
||||
isInFolderView = false,
|
||||
assetType = 'output',
|
||||
showOutputCount,
|
||||
getOutputCount
|
||||
} = defineProps<{
|
||||
assets: AssetItem[]
|
||||
isSelected: (assetId: string) => boolean
|
||||
isInFolderView?: boolean
|
||||
assetType?: 'input' | 'output'
|
||||
showOutputCount: (asset: AssetItem) => boolean
|
||||
getOutputCount: (asset: AssetItem) => number
|
||||
@@ -92,19 +69,9 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { jobItems } = useJobList()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
|
||||
type AssetGridItem = { key: string; asset: AssetItem }
|
||||
|
||||
const activeJobItems = computed(() =>
|
||||
jobItems.value.filter((item) => isActiveJobState(item.state)).toReversed()
|
||||
)
|
||||
|
||||
const assetItems = computed<AssetGridItem[]>(() =>
|
||||
assets.map((asset) => ({
|
||||
key: `asset-${asset.id}`,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { defineComponent } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
|
||||
|
||||
import AssetsSidebarListView from './AssetsSidebarListView.vue'
|
||||
|
||||
@@ -10,141 +13,63 @@ 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', () => ({
|
||||
useAssetsStore: () => ({
|
||||
isAssetDeleting: () => false
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) => key === 'Comfy.Queue.QPOV2'
|
||||
const VirtualGridStub = defineComponent({
|
||||
name: 'VirtualGrid',
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
template:
|
||||
'<div><slot v-for="item in items" :key="item.key" name="item" :item="item" /></div>'
|
||||
})
|
||||
|
||||
const buildAsset = (id: string, name: string): AssetItem =>
|
||||
({
|
||||
id,
|
||||
name,
|
||||
tags: []
|
||||
}) satisfies AssetItem
|
||||
|
||||
const buildOutputItem = (asset: AssetItem): OutputStackListItem => ({
|
||||
key: `asset-${asset.id}`,
|
||||
asset
|
||||
})
|
||||
|
||||
const mountListView = (assetItems: OutputStackListItem[] = []) =>
|
||||
mount(AssetsSidebarListView, {
|
||||
props: {
|
||||
assetItems,
|
||||
selectableAssets: [],
|
||||
isSelected: () => false,
|
||||
isStackExpanded: () => false,
|
||||
toggleStack: async () => {},
|
||||
assetType: 'output'
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
VirtualGrid: VirtualGridStub
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
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', () => ({
|
||||
getOutputAssetMetadata: () => undefined
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/mediaIconUtil', () => ({
|
||||
iconForMediaType: () => 'pi pi-file'
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/formatUtil', () => ({
|
||||
formatDuration: (d: number) => `${d}s`,
|
||||
formatSize: (s: number) => `${s}B`,
|
||||
getMediaTypeFromFilename: () => 'image',
|
||||
truncateFilename: (name: string) => name
|
||||
}))
|
||||
|
||||
describe('AssetsSidebarListView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockJobItems.value = []
|
||||
it('shows generated assets header when there are assets', () => {
|
||||
const wrapper = mountListView([buildOutputItem(buildAsset('a1', 'x.png'))])
|
||||
|
||||
expect(wrapper.text()).toContain('sideToolbar.generatedAssetsHeader')
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
assetItems: [],
|
||||
selectableAssets: [],
|
||||
isSelected: () => false,
|
||||
isStackExpanded: () => false,
|
||||
toggleStack: async () => {}
|
||||
}
|
||||
it('does not show assets header when there are no assets', () => {
|
||||
const wrapper = mountListView([])
|
||||
|
||||
it('displays active jobs in oldest-first order (FIFO)', () => {
|
||||
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, {
|
||||
props: defaultProps,
|
||||
shallow: true
|
||||
})
|
||||
|
||||
const jobListItems = wrapper.findAllComponents({ name: 'AssetsListItem' })
|
||||
expect(jobListItems).toHaveLength(3)
|
||||
|
||||
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')
|
||||
expect(wrapper.text()).not.toContain('sideToolbar.generatedAssetsHeader')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,48 +1,6 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<div
|
||||
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 v-if="assetItems.length" class="px-2">
|
||||
<div
|
||||
class="flex items-center p-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
|
||||
>
|
||||
@@ -119,31 +77,25 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import LoadingOverlay from '@/components/common/LoadingOverlay.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 AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
|
||||
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { isActiveJobState } from '@/utils/queueUtil'
|
||||
import {
|
||||
formatDuration,
|
||||
formatSize,
|
||||
getMediaTypeFromFilename,
|
||||
truncateFilename
|
||||
} from '@/utils/formatUtil'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const {
|
||||
assetItems,
|
||||
@@ -170,24 +122,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
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 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 = {
|
||||
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) {
|
||||
hoveredAssetId.value = assetId
|
||||
}
|
||||
@@ -259,13 +184,4 @@ function onAssetLeave(assetId: string) {
|
||||
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>
|
||||
|
||||
@@ -53,31 +53,7 @@
|
||||
class="pb-1 px-2 2xl:px-4"
|
||||
:show-generation-time-sort="activeTab === 'output'"
|
||||
/>
|
||||
<div
|
||||
v-if="isQueuePanelV2Enabled && !isInFolderView"
|
||||
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" />
|
||||
<Divider type="dashed" class="my-2" />
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
@@ -126,7 +102,6 @@
|
||||
v-else
|
||||
:assets="displayAssets"
|
||||
:is-selected="isSelected"
|
||||
:is-in-folder-view="isInFolderView"
|
||||
:asset-type="activeTab"
|
||||
:show-output-count="shouldShowOutputCount"
|
||||
:get-output-count="getOutputCount"
|
||||
@@ -226,7 +201,6 @@ import {
|
||||
useResizeObserver,
|
||||
useStorage
|
||||
} from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Divider from 'primevue/divider'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
@@ -258,20 +232,12 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t, n } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
const { activeJobsCount } = storeToRefs(queueStore)
|
||||
const executionStore = useExecutionStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{ assetSelected: [asset: AssetItem] }>()
|
||||
|
||||
@@ -284,12 +250,7 @@ const viewMode = useStorage<'list' | 'grid'>(
|
||||
'Comfy.Assets.Sidebar.ViewMode',
|
||||
'grid'
|
||||
)
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
const isListView = computed(
|
||||
() => isQueuePanelV2Enabled.value && viewMode.value === 'list'
|
||||
)
|
||||
const isListView = computed(() => viewMode.value === 'list')
|
||||
|
||||
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
|
||||
const contextMenuAsset = ref<AssetItem | null>(null)
|
||||
@@ -321,16 +282,6 @@ const formattedExecutionTime = computed(() => {
|
||||
return formatDuration(folderExecutionTime.value * 1000)
|
||||
})
|
||||
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const activeJobsLabel = computed(() => {
|
||||
const count = activeJobsCount.value
|
||||
return t(
|
||||
'sideToolbar.queueProgressOverlay.activeJobs',
|
||||
{ count: n(count) },
|
||||
count
|
||||
)
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const inputAssets = useMediaAssets('input')
|
||||
@@ -456,17 +407,12 @@ const isFolderLoading = computed(
|
||||
|
||||
const showLoadingState = computed(
|
||||
() =>
|
||||
(loading.value || isFolderLoading.value) &&
|
||||
displayAssets.value.length === 0 &&
|
||||
activeJobsCount.value === 0
|
||||
(loading.value || isFolderLoading.value) && displayAssets.value.length === 0
|
||||
)
|
||||
|
||||
const showEmptyState = computed(
|
||||
() =>
|
||||
!loading.value &&
|
||||
!isFolderLoading.value &&
|
||||
displayAssets.value.length === 0 &&
|
||||
activeJobsCount.value === 0
|
||||
!loading.value && !isFolderLoading.value && displayAssets.value.length === 0
|
||||
)
|
||||
|
||||
watch(visibleAssets, (newAssets) => {
|
||||
@@ -562,16 +508,6 @@ const handleBulkDelete = async (assets: AssetItem[]) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearQueue = async () => {
|
||||
const pendingJobIds = queueStore.pendingTasks
|
||||
.map((task) => task.jobId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
|
||||
executionStore.clearInitializationByJobIds(pendingJobIds)
|
||||
}
|
||||
|
||||
const handleBulkAddToWorkflow = async (assets: AssetItem[]) => {
|
||||
await addMultipleToWorkflow(assets)
|
||||
clearSelection()
|
||||
|
||||
188
src/components/sidebar/tabs/JobHistorySidebarTab.vue
Normal file
188
src/components/sidebar/tabs/JobHistorySidebarTab.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<SidebarTabTemplate :title="$t('queue.jobHistory')">
|
||||
<template #alt-title>
|
||||
<div class="ml-auto flex shrink-0 items-center">
|
||||
<JobHistoryActionsMenu @clear-history="showQueueClearHistoryDialog" />
|
||||
</div>
|
||||
</template>
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-2 pb-1">
|
||||
<div class="px-3 py-2">
|
||||
<JobFilterTabs
|
||||
v-model:selected-job-tab="selectedJobTab"
|
||||
:has-failed-jobs="hasFailedJobs"
|
||||
/>
|
||||
</div>
|
||||
<JobFilterActions
|
||||
v-model:selected-workflow-filter="selectedWorkflowFilter"
|
||||
v-model:selected-sort-mode="selectedSortMode"
|
||||
v-model:search-query="searchQuery"
|
||||
class="px-3"
|
||||
:hide-show-assets-action="true"
|
||||
:show-search="true"
|
||||
:search-placeholder="t('sideToolbar.queueProgressOverlay.searchJobs')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between px-3 pb-1 text-xs leading-none text-text-primary"
|
||||
>
|
||||
<span class="text-text-secondary">{{ activeQueueSummary }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-base-foreground">
|
||||
{{ t('sideToolbar.queueProgressOverlay.clearQueueTooltip') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
|
||||
"
|
||||
:disabled="queuedCount === 0"
|
||||
@click="clearQueuedWorkflows"
|
||||
>
|
||||
<i class="icon-[lucide--list-x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<JobAssetsList
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
@cancel-item="onCancelItem"
|
||||
@delete-item="onDeleteItem"
|
||||
@view-item="onViewItem"
|
||||
@menu="onMenuItem"
|
||||
/>
|
||||
<JobContextMenu
|
||||
ref="jobContextMenuRef"
|
||||
:entries="jobMenuEntries"
|
||||
@action="onJobMenuAction"
|
||||
/>
|
||||
<ResultGallery
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
/>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import JobFilterActions from '@/components/queue/job/JobFilterActions.vue'
|
||||
import JobFilterTabs from '@/components/queue/job/JobFilterTabs.vue'
|
||||
import JobAssetsList from '@/components/queue/job/JobAssetsList.vue'
|
||||
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
|
||||
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
import { useJobMenu } from '@/composables/queue/useJobMenu'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHistoryDialog'
|
||||
import { useResultGallery } from '@/composables/queue/useResultGallery'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
|
||||
const { t, n } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const queueStore = useQueueStore()
|
||||
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
const {
|
||||
selectedJobTab,
|
||||
selectedWorkflowFilter,
|
||||
selectedSortMode,
|
||||
searchQuery,
|
||||
hasFailedJobs,
|
||||
filteredTasks,
|
||||
groupedJobItems
|
||||
} = useJobList()
|
||||
|
||||
const displayedJobGroups = computed(() => groupedJobItems.value)
|
||||
const runningCount = computed(() => queueStore.runningTasks.length)
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
|
||||
const runningJobsLabel = computed(() =>
|
||||
t('sideToolbar.queueProgressOverlay.runningJobsLabel', {
|
||||
count: n(runningCount.value)
|
||||
})
|
||||
)
|
||||
const queuedJobsLabel = computed(() =>
|
||||
t('sideToolbar.queueProgressOverlay.queuedJobsLabel', {
|
||||
count: n(queuedCount.value)
|
||||
})
|
||||
)
|
||||
const activeQueueSummary = computed(() => {
|
||||
if (runningCount.value === 0 && queuedCount.value === 0) {
|
||||
return t('sideToolbar.queueProgressOverlay.noActiveJobs')
|
||||
}
|
||||
if (queuedCount.value === 0) {
|
||||
return runningJobsLabel.value
|
||||
}
|
||||
if (runningCount.value === 0) {
|
||||
return queuedJobsLabel.value
|
||||
}
|
||||
return t('sideToolbar.queueProgressOverlay.runningQueuedSummary', {
|
||||
running: runningJobsLabel.value,
|
||||
queued: queuedJobsLabel.value
|
||||
})
|
||||
})
|
||||
|
||||
const clearQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
|
||||
const pendingJobIds = queueStore.pendingTasks
|
||||
.map((task) => task.jobId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
executionStore.clearInitializationByJobIds(pendingJobIds)
|
||||
})
|
||||
|
||||
const {
|
||||
galleryActiveIndex,
|
||||
galleryItems,
|
||||
onViewItem: openResultGallery
|
||||
} = useResultGallery(() => filteredTasks.value)
|
||||
|
||||
const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
await openResultGallery(item)
|
||||
})
|
||||
|
||||
const onInspectAsset = (item: JobListItem) => {
|
||||
void onViewItem(item)
|
||||
}
|
||||
|
||||
const currentMenuItem = ref<JobListItem | null>(null)
|
||||
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
|
||||
|
||||
const { jobMenuEntries, cancelJob } = useJobMenu(
|
||||
() => currentMenuItem.value,
|
||||
onInspectAsset
|
||||
)
|
||||
|
||||
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
await cancelJob(item)
|
||||
})
|
||||
|
||||
const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
if (!item.taskRef) return
|
||||
await queueStore.delete(item.taskRef)
|
||||
})
|
||||
|
||||
const onMenuItem = (item: JobListItem, event: Event) => {
|
||||
currentMenuItem.value = item
|
||||
jobContextMenuRef.value?.open(event)
|
||||
}
|
||||
|
||||
const onJobMenuAction = wrapWithErrorHandlingAsync(async (entry: MenuEntry) => {
|
||||
if (entry.kind === 'divider') return
|
||||
if (entry.onClick) await entry.onClick()
|
||||
jobContextMenuRef.value?.hide()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user