@@ -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
(() =>
assets.map((asset) => ({
key: `asset-${asset.id}`,
diff --git a/src/components/sidebar/tabs/AssetsSidebarListView.test.ts b/src/components/sidebar/tabs/AssetsSidebarListView.test.ts
index 57746e5fc..be6ead7e1 100644
--- a/src/components/sidebar/tabs/AssetsSidebarListView.test.ts
+++ b/src/components/sidebar/tabs/AssetsSidebarListView.test.ts
@@ -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:
+ '
'
+})
+
+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')
})
})
diff --git a/src/components/sidebar/tabs/AssetsSidebarListView.vue b/src/components/sidebar/tabs/AssetsSidebarListView.vue
index 0c0fb4944..b1fc52ac5 100644
--- a/src/components/sidebar/tabs/AssetsSidebarListView.vue
+++ b/src/components/sidebar/tabs/AssetsSidebarListView.vue
@@ -1,48 +1,6 @@
-
-
-
+
@@ -119,31 +77,25 @@
diff --git a/src/components/sidebar/tabs/AssetsSidebarTab.vue b/src/components/sidebar/tabs/AssetsSidebarTab.vue
index 74a4c88a4..564958c23 100644
--- a/src/components/sidebar/tabs/AssetsSidebarTab.vue
+++ b/src/components/sidebar/tabs/AssetsSidebarTab.vue
@@ -53,31 +53,7 @@
class="pb-1 px-2 2xl:px-4"
:show-generation-time-sort="activeTab === 'output'"
/>
-
-
- {{ activeJobsLabel }}
-
-
-
- {{ t('sideToolbar.queueProgressOverlay.clearQueueTooltip') }}
-
-
-
-
-
+
()
@@ -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
>()
const contextMenuAsset = ref(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()
diff --git a/src/components/sidebar/tabs/JobHistorySidebarTab.vue b/src/components/sidebar/tabs/JobHistorySidebarTab.vue
new file mode 100644
index 000000000..579afa624
--- /dev/null
+++ b/src/components/sidebar/tabs/JobHistorySidebarTab.vue
@@ -0,0 +1,188 @@
+
+
+
+
+
+
+
+
+
+
+
{{ activeQueueSummary }}
+
+
+ {{ t('sideToolbar.queueProgressOverlay.clearQueueTooltip') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/ui/Popover.vue b/src/components/ui/Popover.vue
index 5482cb775..f459503c9 100644
--- a/src/components/ui/Popover.vue
+++ b/src/components/ui/Popover.vue
@@ -15,10 +15,16 @@ defineOptions({
inheritAttrs: false
})
-defineProps<{
+const {
+ entries,
+ icon,
+ to,
+ showArrow = true
+} = defineProps<{
entries?: MenuItem[]
icon?: string
to?: string | HTMLElement
+ showArrow?: boolean
}>()
@@ -39,7 +45,7 @@ defineProps<{
v-bind="$attrs"
class="z-1700 rounded-lg p-2 bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
>
-
+
-
+
diff --git a/src/composables/queue/useJobList.test.ts b/src/composables/queue/useJobList.test.ts
index d48c57b83..905c4f376 100644
--- a/src/composables/queue/useJobList.test.ts
+++ b/src/composables/queue/useJobList.test.ts
@@ -421,6 +421,52 @@ describe('useJobList', () => {
expect(instance.filteredTasks.value.map((t) => t.jobId)).toEqual(['wf-1'])
})
+ it('filters jobs by search query', async () => {
+ vi.useFakeTimers()
+ queueStoreMock.historyTasks = [
+ createTask({
+ jobId: 'alpha',
+ queueIndex: 2,
+ mockState: 'completed',
+ createTime: 2000,
+ executionEndTimestamp: 2000
+ }),
+ createTask({
+ jobId: 'beta',
+ queueIndex: 1,
+ mockState: 'failed',
+ createTime: 1000,
+ executionEndTimestamp: 1000
+ })
+ ]
+
+ const instance = initComposable()
+ await flush()
+ expect(instance.filteredTasks.value.map((task) => task.jobId)).toEqual([
+ 'alpha',
+ 'beta'
+ ])
+
+ instance.searchQuery.value = 'beta'
+ await vi.advanceTimersByTimeAsync(200)
+ await flush()
+ expect(instance.filteredTasks.value.map((task) => task.jobId)).toEqual([
+ 'beta'
+ ])
+
+ instance.searchQuery.value = 'failed meta'
+ await vi.advanceTimersByTimeAsync(200)
+ await flush()
+ expect(instance.filteredTasks.value.map((task) => task.jobId)).toEqual([
+ 'beta'
+ ])
+
+ instance.searchQuery.value = 'does-not-exist'
+ await vi.advanceTimersByTimeAsync(200)
+ await flush()
+ expect(instance.filteredTasks.value).toEqual([])
+ })
+
it('hydrates job items with active progress and compute hours', async () => {
queueStoreMock.runningTasks = [
createTask({
diff --git a/src/composables/queue/useJobList.ts b/src/composables/queue/useJobList.ts
index c0f723819..f251c69e3 100644
--- a/src/composables/queue/useJobList.ts
+++ b/src/composables/queue/useJobList.ts
@@ -1,3 +1,4 @@
+import { refDebounced } from '@vueuse/core'
import { orderBy } from 'es-toolkit/array'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -197,6 +198,8 @@ export function useJobList() {
const selectedJobTab = ref('All')
const selectedWorkflowFilter = ref<'all' | 'current'>('all')
const selectedSortMode = ref('mostRecent')
+ const searchQuery = ref('')
+ const debouncedSearchQuery = refDebounced(searchQuery, 150)
const mostRecentTimestamp = (task: TaskItemImpl) => task.createTime ?? 0
@@ -248,8 +251,12 @@ export function useJobList() {
return entries
})
+ const normalizedSearchQuery = computed(() =>
+ debouncedSearchQuery.value.trim().toLocaleLowerCase()
+ )
+
const filteredTasks = computed(() =>
- filteredTaskEntries.value.map(({ task }) => task)
+ searchableTaskEntries.value.map(({ task }) => task)
)
const jobItems = computed(() => {
@@ -307,11 +314,31 @@ export function useJobList() {
return m
})
+ const searchableTaskEntries = computed(() => {
+ if (!normalizedSearchQuery.value) return filteredTaskEntries.value
+
+ return filteredTaskEntries.value.filter(({ task }) => {
+ const taskId = String(task.jobId ?? '').toLocaleLowerCase()
+ const item = jobItemById.value.get(String(task.jobId))
+ if (!item) {
+ return taskId.includes(normalizedSearchQuery.value)
+ }
+
+ const title = item.title.toLocaleLowerCase()
+ const meta = item.meta.toLocaleLowerCase()
+ return (
+ title.includes(normalizedSearchQuery.value) ||
+ meta.includes(normalizedSearchQuery.value) ||
+ taskId.includes(normalizedSearchQuery.value)
+ )
+ })
+ })
+
const groupedJobItems = computed(() => {
const groups: JobGroup[] = []
const index = new Map()
const localeValue = locale.value
- for (const { task, state } of filteredTaskEntries.value) {
+ for (const { task, state } of searchableTaskEntries.value) {
let ts: number | undefined
if (state === 'completed' || state === 'failed') {
ts = task.executionEndTimestamp
@@ -356,6 +383,7 @@ export function useJobList() {
selectedJobTab,
selectedWorkflowFilter,
selectedSortMode,
+ searchQuery,
hasFailedJobs,
// data sources
allTasksSorted,
diff --git a/src/composables/queue/useQueueClearHistoryDialog.ts b/src/composables/queue/useQueueClearHistoryDialog.ts
new file mode 100644
index 000000000..bb50abd11
--- /dev/null
+++ b/src/composables/queue/useQueueClearHistoryDialog.ts
@@ -0,0 +1,32 @@
+import QueueClearHistoryDialog from '@/components/queue/dialogs/QueueClearHistoryDialog.vue'
+import { useDialogStore } from '@/stores/dialogStore'
+
+export const useQueueClearHistoryDialog = () => {
+ const dialogStore = useDialogStore()
+
+ const showQueueClearHistoryDialog = () => {
+ dialogStore.showDialog({
+ key: 'queue-clear-history',
+ component: QueueClearHistoryDialog,
+ dialogComponentProps: {
+ headless: true,
+ closable: false,
+ closeOnEscape: true,
+ dismissableMask: true,
+ pt: {
+ root: {
+ class: 'max-w-90 w-auto bg-transparent border-none shadow-none'
+ },
+ content: {
+ class: 'bg-transparent',
+ style: 'padding: 0'
+ }
+ }
+ }
+ })
+ }
+
+ return {
+ showQueueClearHistoryDialog
+ }
+}
diff --git a/src/composables/sidebarTabs/useJobHistorySidebarTab.ts b/src/composables/sidebarTabs/useJobHistorySidebarTab.ts
new file mode 100644
index 000000000..3cee68fb3
--- /dev/null
+++ b/src/composables/sidebarTabs/useJobHistorySidebarTab.ts
@@ -0,0 +1,16 @@
+import { markRaw } from 'vue'
+
+import JobHistorySidebarTab from '@/components/sidebar/tabs/JobHistorySidebarTab.vue'
+import type { SidebarTabExtension } from '@/types/extensionTypes'
+
+export const useJobHistorySidebarTab = (): SidebarTabExtension => {
+ return {
+ id: 'job-history',
+ icon: 'icon-[lucide--history]',
+ title: 'queue.jobHistory',
+ tooltip: 'queue.jobHistory',
+ label: 'queue.jobHistory',
+ component: markRaw(JobHistorySidebarTab),
+ type: 'vue'
+ }
+}
diff --git a/src/locales/en/main.json b/src/locales/en/main.json
index e0776926e..29b0454d4 100644
--- a/src/locales/en/main.json
+++ b/src/locales/en/main.json
@@ -825,11 +825,14 @@
"showAssetsPanel": "Show assets panel",
"queuedSuffix": "queued",
"clearQueued": "Clear queued",
- "clearHistory": "Clear job queue history",
+ "clearHistory": "Clear job history",
+ "dockedJobHistory": "Docked Job History",
+ "clearHistoryMenuAssetsNote": "Media assets won't be deleted.",
"filterJobs": "Filter jobs",
"filterBy": "Filter by",
"filterAllWorkflows": "All workflows",
"filterCurrentWorkflow": "Current workflow",
+ "searchJobs": "Search...",
"sortJobs": "Sort jobs",
"sortBy": "Sort by",
"activeJobs": "{count} active job | {count} active jobs",
diff --git a/src/platform/assets/components/AssetsListItem.vue b/src/platform/assets/components/AssetsListItem.vue
index 9acbec09a..d20317147 100644
--- a/src/platform/assets/components/AssetsListItem.vue
+++ b/src/platform/assets/components/AssetsListItem.vue
@@ -59,19 +59,28 @@
- {{ primaryText }}
+
+
+ {{ primaryText }}
+
- {{ secondaryText }}
+
+
+ {{ secondaryText }}
+
-
+
diff --git a/src/platform/assets/components/MediaAssetFilterBar.vue b/src/platform/assets/components/MediaAssetFilterBar.vue
index 42fa2e6cb..15e3846a5 100644
--- a/src/platform/assets/components/MediaAssetFilterBar.vue
+++ b/src/platform/assets/components/MediaAssetFilterBar.vue
@@ -34,20 +34,14 @@
/>
-
+