Compare commits

...

37 Commits

Author SHA1 Message Date
Benjamin Lu
e4521a4474 fix: support 3d inspect and loader nodes in job history 2026-02-18 14:36:35 -08:00
Benjamin Lu
0c8b9650a3 fix: support 3d asset inspect and workflow insertion 2026-02-18 14:21:30 -08:00
Benjamin Lu
4e45334b85 fix: truncate AssetsListItem fallback text 2026-02-18 14:07:31 -08:00
Benjamin Lu
15a4c72993 Merge branch 'feat/job-history-sidebar-tab-scaffold' of https://github.com/Comfy-Org/ComfyUI_frontend into feat/job-history-sidebar-tab-scaffold 2026-02-18 13:51:45 -08:00
Benjamin Lu
dd42fcc1e2 test: explicitly set disabled QPO V2 in sidebar tab test 2026-02-18 13:51:19 -08:00
GitHub Action
15b5b685ea [automated] Apply ESLint and Oxfmt fixes 2026-02-18 21:48:32 +00:00
Benjamin Lu
ee4e1939e8 Merge branch 'feat/job-history-sidebar-tab-scaffold' of https://github.com/Comfy-Org/ComfyUI_frontend into feat/job-history-sidebar-tab-scaffold 2026-02-18 13:46:09 -08:00
Benjamin Lu
47f72f8b33 Update src/stores/workspace/sidebarTabStore.test.ts
Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2026-02-18 13:43:53 -08:00
Benjamin Lu
85390dbeb1 nit 2026-02-18 13:36:59 -08:00
Benjamin Lu
04210e172c Update src/composables/queue/useQueueClearHistoryDialog.ts
Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2026-02-18 13:36:01 -08:00
Benjamin Lu
7631afdd8d Styling tweaks 2026-02-18 13:29:55 -08:00
Benjamin Lu
5301a30cca Use defineModel 2026-02-18 13:19:05 -08:00
Benjamin Lu
edcf750443 Merge branch 'main' into feat/job-history-sidebar-tab-scaffold 2026-02-18 07:30:07 -08:00
Benjamin Lu
8aba946232 Readd isCloud gate 2026-02-18 07:29:31 -08:00
Benjamin Lu
5f4b3e89e2 fix: toggle media assets button in queue overlay 2026-02-18 07:22:24 -08:00
Benjamin Lu
0958718ec6 Migrate once again to popover component 2026-02-18 07:19:47 -08:00
Benjamin Lu
843fed5816 Styling tweaks 2026-02-18 07:15:17 -08:00
Benjamin Lu
02738b3de1 test: use stable selectors for queue header menu actions 2026-02-18 06:59:00 -08:00
Benjamin Lu
c6106d0c7d fix: resolve JobFilterActions click handler parse error 2026-02-18 06:38:11 -08:00
Benjamin Lu
64de72f46a fix: reuse shared popover in job filter actions 2026-02-18 06:33:40 -08:00
Benjamin Lu
5c4662a67e refactor: wrap job menu actions with shared error handling 2026-02-18 06:25:28 -08:00
Benjamin Lu
044cadfc0b test: use satisfies for assets sidebar test asset helper 2026-02-18 06:21:26 -08:00
Benjamin Lu
45553d0abb refactor: avoid important padding override in clear history dialog 2026-02-18 06:17:46 -08:00
Benjamin Lu
3c1ea522ac refactor: use v-model bindings for job history filters 2026-02-18 06:13:45 -08:00
Benjamin Lu
d1d785c74a fix: remove redundant menu aria labels 2026-02-18 06:11:49 -08:00
Benjamin Lu
d9e30cb793 fix: align storybook useJobList mock exports 2026-02-18 06:05:41 -08:00
Benjamin Lu
ac7415a657 feat: add job history search and split job filter bar 2026-02-18 05:57:51 -08:00
Benjamin Lu
a8ca16a2cd fix: keep job history tab first when toggling qpov2 2026-02-18 05:46:26 -08:00
Benjamin Lu
db10c7554c feat: expand job history actions menu 2026-02-18 05:27:40 -08:00
Benjamin Lu
3df6b11acc Make Clear job history menu button match design 2026-02-18 05:20:47 -08:00
Benjamin Lu
cd0f49366b feat: move active jobs UI out of media assets panel 2026-02-18 05:10:24 -08:00
Benjamin Lu
b2cfeaae35 refactor: always show workflow filter controls 2026-02-18 03:26:33 -08:00
Benjamin Lu
4d1fbf709d feat: add job history actions menu and filters bar 2026-02-18 03:21:57 -08:00
Benjamin Lu
3ef02e7b53 Adjust sidebar tab positioning 2026-02-18 00:59:54 -08:00
Benjamin Lu
554fb0669e Gate on APOV2 flag 2026-02-18 00:59:54 -08:00
Benjamin Lu
df4489dd3c Add the sidebar placeholder 2026-02-18 00:59:54 -08:00
Benjamin Lu
2a1cb3ae1f fix: remove cloud guard for clear history menu 2026-02-18 00:59:20 -08:00
35 changed files with 1415 additions and 701 deletions

View File

@@ -0,0 +1,104 @@
<template>
<div class="flex items-center gap-1">
<Popover :show-arrow="false">
<template #button>
<Button
v-tooltip.top="moreTooltipConfig"
variant="textonly"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.moreOptions')"
>
<i
class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary"
/>
</Button>
</template>
<template #default="{ close }">
<div class="flex min-w-[14rem] flex-col items-stretch font-inter">
<Button
data-testid="docked-job-history-action"
class="w-full justify-between text-sm font-light"
variant="textonly"
size="sm"
@click="onToggleDockedJobHistory"
>
<span class="flex items-center gap-2">
<i
class="icon-[lucide--panel-left-close] size-4 text-text-secondary"
/>
<span>{{
t('sideToolbar.queueProgressOverlay.dockedJobHistory')
}}</span>
</span>
<i
v-if="isQueuePanelV2Enabled"
class="icon-[lucide--check] size-4"
/>
</Button>
<!-- TODO: Bug in assets sidebar panel derives assets from history, so despite this not deleting the assets, it still effectively shows to the user as deleted -->
<template v-if="showClearHistoryAction">
<div class="my-1 border-t border-interface-stroke" />
<Button
data-testid="clear-history-action"
class="h-auto min-h-0 w-full items-start justify-start whitespace-normal"
variant="textonly"
size="sm"
@click="onClearHistoryFromMenu(close)"
>
<i
class="icon-[lucide--trash-2] size-4 shrink-0 self-center text-destructive-background"
/>
<span
class="flex flex-col items-start break-words text-left leading-tight"
>
<span class="text-sm font-light">
{{ t('sideToolbar.queueProgressOverlay.clearHistory') }}
</span>
<span class="text-xs text-text-secondary font-light">
{{
t(
'sideToolbar.queueProgressOverlay.clearHistoryMenuAssetsNote'
)
}}
</span>
</span>
</Button>
</template>
</div>
</template>
</Popover>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
const emit = defineEmits<{
(e: 'clearHistory'): void
}>()
const { t } = useI18n()
const settingStore = useSettingStore()
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const showClearHistoryAction = computed(() => !isCloud)
const onClearHistoryFromMenu = (close: () => void) => {
close()
emit('clearHistory')
}
const onToggleDockedJobHistory = async () => {
await settingStore.set('Comfy.Queue.QPOV2', !isQueuePanelV2Enabled.value)
}
</script>

View File

@@ -51,6 +51,7 @@ import type {
} from '@/composables/queue/useJobList'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import JobContextMenu from './job/JobContextMenu.vue'
@@ -83,6 +84,7 @@ const emit = defineEmits<{
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { jobMenuEntries } = useJobMenu(
() => currentMenuItem.value,
@@ -102,9 +104,9 @@ const onMenuItem = (item: JobListItem, event: Event) => {
jobContextMenuRef.value?.open(event)
}
const onJobMenuAction = async (entry: MenuEntry) => {
const onJobMenuAction = wrapWithErrorHandlingAsync(async (entry: MenuEntry) => {
if (entry.kind === 'divider') return
if (entry.onClick) await entry.onClick()
jobContextMenuRef.value?.hide()
}
})
</script>

View File

@@ -1,28 +1,40 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { defineComponent } from 'vue'
import { defineComponent, h } from 'vue'
const popoverToggleSpy = vi.fn()
const popoverHideSpy = vi.fn()
const popoverCloseSpy = vi.fn()
vi.mock('primevue/popover', () => {
vi.mock('@/components/ui/Popover.vue', () => {
const PopoverStub = defineComponent({
name: 'Popover',
setup(_, { slots, expose }) {
const toggle = (event: Event) => {
popoverToggleSpy(event)
}
const hide = () => {
popoverHideSpy()
}
expose({ toggle, hide })
return () => slots.default?.()
setup(_, { slots }) {
return () =>
h('div', [
slots.button?.(),
slots.default?.({
close: () => {
popoverCloseSpy()
}
})
])
}
})
return { default: PopoverStub }
})
const mockGetSetting = vi.fn((key: string) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const mockSetSetting = vi.fn()
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: mockGetSetting,
set: mockSetSetting
})
}))
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import * as tooltipConfig from '@/composables/useTooltipConfig'
@@ -43,7 +55,8 @@ const i18n = createI18n({
queuedSuffix: 'queued',
clearQueued: 'Clear queued',
moreOptions: 'More options',
clearHistory: 'Clear history'
clearHistory: 'Clear history',
dockedJobHistory: 'Docked Job History'
}
}
}
@@ -66,6 +79,11 @@ const mountHeader = (props = {}) =>
})
describe('QueueOverlayHeader', () => {
beforeEach(() => {
popoverCloseSpy.mockClear()
mockSetSetting.mockClear()
})
it('renders header title and concurrent indicator when enabled', () => {
const wrapper = mountHeader({ concurrentWorkflowCount: 3 })
@@ -102,19 +120,33 @@ describe('QueueOverlayHeader', () => {
)
})
it('toggles popover and emits clear history', async () => {
it('emits clear history from the menu', async () => {
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
const wrapper = mountHeader()
const moreButton = wrapper.get('button[aria-label="More options"]')
await moreButton.trigger('click')
expect(popoverToggleSpy).toHaveBeenCalledTimes(1)
expect(wrapper.find('button[aria-label="More options"]').exists()).toBe(
true
)
expect(spy).toHaveBeenCalledWith('More')
const clearHistoryButton = wrapper.get('button[aria-label="Clear history"]')
const clearHistoryButton = wrapper.get(
'[data-testid="clear-history-action"]'
)
await clearHistoryButton.trigger('click')
expect(popoverHideSpy).toHaveBeenCalledTimes(1)
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
})
it('toggles docked job history setting from the menu', async () => {
const wrapper = mountHeader()
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
expect(mockSetSetting).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', false)
})
})

View File

@@ -36,62 +36,15 @@
<i class="icon-[lucide--list-x] size-4" />
</Button>
</div>
<div v-if="!isCloud" class="flex items-center gap-1">
<Button
v-tooltip.top="moreTooltipConfig"
variant="textonly"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.moreOptions')"
@click="onMoreClick"
>
<i
class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary"
/>
</Button>
<Popover
ref="morePopoverRef"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: { class: 'absolute z-50' },
content: {
class: [
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
]
}
}"
>
<div
class="flex flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
>
<Button
class="w-full justify-start"
variant="textonly"
size="sm"
:aria-label="t('sideToolbar.queueProgressOverlay.clearHistory')"
@click="onClearHistoryFromMenu"
>
<i class="icon-[lucide--file-x-2] size-4 text-muted" />
<span>{{
t('sideToolbar.queueProgressOverlay.clearHistory')
}}</span>
</Button>
</div>
</Popover>
</div>
<JobHistoryActionsMenu @clear-history="$emit('clearHistory')" />
</div>
</template>
<script setup lang="ts">
import Popover from 'primevue/popover'
import type { PopoverMethods } from 'primevue/popover'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'
defineProps<{
headerTitle: string
@@ -100,21 +53,10 @@ defineProps<{
queuedCount: number
}>()
const emit = defineEmits<{
defineEmits<{
(e: 'clearHistory'): void
(e: 'clearQueued'): void
}>()
const { t } = useI18n()
const morePopoverRef = ref<PopoverMethods | null>(null)
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const onMoreClick = (event: MouseEvent) => {
morePopoverRef.value?.toggle(event)
}
const onClearHistoryFromMenu = () => {
morePopoverRef.value?.hide()
emit('clearHistory')
}
</script>

View File

@@ -7,6 +7,7 @@ import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
import { i18n } from '@/i18n'
import type { JobStatus } from '@/platform/remote/comfyui/jobs/jobTypes'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
@@ -20,7 +21,12 @@ const QueueOverlayExpandedStub = defineComponent({
required: true
}
},
template: '<div data-testid="expanded-title">{{ headerTitle }}</div>'
template: `
<div>
<div data-testid="expanded-title">{{ headerTitle }}</div>
<button data-testid="show-assets-button" @click="$emit('show-assets')" />
</div>
`
})
function createTask(id: string, status: JobStatus): TaskItemImpl {
@@ -41,10 +47,11 @@ const mountComponent = (
stubActions: false
})
const queueStore = useQueueStore(pinia)
const sidebarTabStore = useSidebarTabStore(pinia)
queueStore.runningTasks = runningTasks
queueStore.pendingTasks = pendingTasks
return mount(QueueProgressOverlay, {
const wrapper = mount(QueueProgressOverlay, {
props: {
expanded: true
},
@@ -60,6 +67,8 @@ const mountComponent = (
}
}
})
return { wrapper, sidebarTabStore }
}
describe('QueueProgressOverlay', () => {
@@ -68,7 +77,7 @@ describe('QueueProgressOverlay', () => {
})
it('shows expanded header with running and queued labels', () => {
const wrapper = mountComponent(
const { wrapper } = mountComponent(
[
createTask('running-1', 'in_progress'),
createTask('running-2', 'in_progress')
@@ -82,7 +91,10 @@ describe('QueueProgressOverlay', () => {
})
it('shows only running label when queued count is zero', () => {
const wrapper = mountComponent([createTask('running-1', 'in_progress')], [])
const { wrapper } = mountComponent(
[createTask('running-1', 'in_progress')],
[]
)
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
'1 running'
@@ -90,10 +102,22 @@ describe('QueueProgressOverlay', () => {
})
it('shows job queue title when there are no active jobs', () => {
const wrapper = mountComponent([], [])
const { wrapper } = mountComponent([], [])
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
'Job Queue'
)
})
it('toggles the assets sidebar tab when show-assets is clicked', async () => {
const { wrapper, sidebarTabStore } = mountComponent([], [])
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
await wrapper.get('[data-testid="show-assets-button"]').trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
await wrapper.get('[data-testid="show-assets-button"]').trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
})
})

View File

@@ -22,7 +22,7 @@
:queued-count="queuedCount"
:displayed-job-groups="displayedJobGroups"
:has-failed-jobs="hasFailedJobs"
@show-assets="openAssetsSidebar"
@show-assets="toggleAssetsSidebar"
@clear-history="onClearHistoryFromMenu"
@clear-queued="cancelQueuedWorkflows"
@cancel-item="onCancelItem"
@@ -59,10 +59,10 @@ import { useI18n } from 'vue-i18n'
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue'
import QueueClearHistoryDialog from '@/components/queue/dialogs/QueueClearHistoryDialog.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHistoryDialog'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
@@ -71,7 +71,6 @@ import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
@@ -97,9 +96,9 @@ const queueStore = useQueueStore()
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
const sidebarTabStore = useSidebarTabStore()
const dialogStore = useDialogStore()
const assetsStore = useAssetsStore()
const assetSelectionStore = useAssetSelectionStore()
const { showQueueClearHistoryDialog } = useQueueClearHistoryDialog()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const {
@@ -243,6 +242,10 @@ const viewAllJobs = () => {
setExpanded(true)
}
const toggleAssetsSidebar = () => {
sidebarTabStore.toggleSidebarTab('assets')
}
const openAssetsSidebar = () => {
sidebarTabStore.activeSidebarTabId = 'assets'
}
@@ -309,28 +312,7 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => {
await queueStore.update()
})
const showClearHistoryDialog = () => {
dialogStore.showDialog({
key: 'queue-clear-history',
component: QueueClearHistoryDialog,
dialogComponentProps: {
headless: true,
closable: false,
closeOnEscape: true,
dismissableMask: true,
pt: {
root: {
class: 'max-w-[360px] w-auto bg-transparent border-none shadow-none'
},
content: {
class: '!p-0 bg-transparent'
}
}
}
})
}
const onClearHistoryFromMenu = () => {
showClearHistoryDialog()
showQueueClearHistoryDialog()
}
</script>

View File

@@ -0,0 +1,110 @@
<template>
<div class="flex flex-col gap-4 px-3 pb-4">
<div
v-for="group in displayedJobGroups"
:key="group.key"
class="flex flex-col gap-2"
>
<div class="text-xs leading-none text-text-secondary">
{{ group.label }}
</div>
<AssetsListItem
v-for="job in group.items"
:key="job.id"
class="w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover"
:preview-url="job.iconImageUrl"
:preview-alt="job.title"
:icon-name="job.iconName ?? iconForJobState(job.state)"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@mouseenter="hoveredJobId = job.id"
@mouseleave="onJobLeave(job.id)"
@contextmenu.prevent.stop="$emit('menu', job, $event)"
@click.stop
>
<template v-if="hoveredJobId === job.id" #actions>
<Button
v-if="isCancelable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="$emit('cancelItem', job)"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-else-if="isFailedDeletable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="$emit('deleteItem', job)"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
v-else-if="job.state === 'completed'"
variant="textonly"
size="sm"
@click.stop="$emit('viewItem', job)"
>
{{ t('menuLabels.View') }}
</Button>
<Button
variant="secondary"
size="icon"
:aria-label="t('g.more')"
@click.stop="$emit('menu', job, $event)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</AssetsListItem>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { iconForJobState } from '@/utils/queueDisplay'
import { isActiveJobState } from '@/utils/queueUtil'
defineProps<{ displayedJobGroups: JobGroup[] }>()
defineEmits<{
(e: 'cancelItem', item: JobListItem): void
(e: 'deleteItem', item: JobListItem): void
(e: 'menu', item: JobListItem, ev: MouseEvent): void
(e: 'viewItem', item: JobListItem): void
}>()
const { t } = useI18n()
const hoveredJobId = ref<string | null>(null)
const onJobLeave = (jobId: string) => {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
}
const isCancelable = (job: JobListItem) =>
job.showClear !== false && isActiveJobState(job.state)
const isFailedDeletable = (job: JobListItem) =>
job.showClear !== false && job.state === 'failed'
const getJobIconClass = (job: JobListItem): string | undefined => {
const iconName = job.iconName ?? iconForJobState(job.state)
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
return 'animate-spin'
}
return undefined
}
</script>

View File

@@ -0,0 +1,199 @@
<template>
<div class="flex min-w-0 items-center gap-2">
<SearchBox
v-if="showSearch"
:model-value="searchQuery"
class="min-w-0 flex-1"
:placeholder="searchPlaceholderText"
@update:model-value="onSearchQueryUpdate"
/>
<div
class="flex shrink-0 items-center gap-2"
:class="{ 'ml-2': !showSearch }"
>
<Popover :show-arrow="false">
<template #button>
<Button
v-tooltip.top="filterTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
>
<i class="icon-[lucide--list-filter] size-4" />
<span
v-if="selectedWorkflowFilter !== 'all'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</Button>
</template>
<template #default="{ close }">
<div class="flex min-w-[12rem] flex-col items-stretch">
<Button
class="w-full justify-between"
variant="textonly"
size="md"
@click="onSelectWorkflowFilter('all', close)"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
}}</span>
<i
v-if="selectedWorkflowFilter === 'all'"
class="icon-[lucide--check] size-4"
/>
</Button>
<div class="mx-2 mt-1 h-px" />
<Button
class="w-full justify-between"
variant="textonly"
size="md"
@click="onSelectWorkflowFilter('current', close)"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
}}</span>
<i
v-if="selectedWorkflowFilter === 'current'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</Button>
</div>
</template>
</Popover>
<Popover :show-arrow="false">
<template #button>
<Button
v-tooltip.top="sortTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
>
<i class="icon-[lucide--arrow-up-down] size-4" />
<span
v-if="selectedSortMode !== 'mostRecent'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</Button>
</template>
<template #default="{ close }">
<div class="flex min-w-[12rem] flex-col items-stretch">
<template v-for="(mode, index) in jobSortModes" :key="mode">
<Button
class="w-full justify-between"
variant="textonly"
size="md"
@click="onSelectSortMode(mode, close)"
>
<span>{{ sortLabel(mode) }}</span>
<i
v-if="selectedSortMode === mode"
class="icon-[lucide--check] size-4 text-text-secondary"
/>
</Button>
<div
v-if="index < jobSortModes.length - 1"
class="mx-2 mt-1 h-px"
/>
</template>
</div>
</template>
</Popover>
<Button
v-if="showAssetsAction"
v-tooltip.top="showAssetsTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.showAssetsPanel')"
@click="emit('showAssets')"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { jobSortModes } from '@/composables/queue/useJobList'
import type { JobSortMode } from '@/composables/queue/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
const {
hideShowAssetsAction = false,
showSearch = false,
searchPlaceholder
} = defineProps<{
hideShowAssetsAction?: boolean
showSearch?: boolean
searchPlaceholder?: string
}>()
const selectedWorkflowFilter = defineModel<'all' | 'current'>(
'selectedWorkflowFilter',
{ required: true }
)
const selectedSortMode = defineModel<JobSortMode>('selectedSortMode', {
required: true
})
const searchQuery = defineModel<string>('searchQuery', { default: '' })
const emit = defineEmits<{
(e: 'showAssets'): void
}>()
const { t } = useI18n()
const filterTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.filterBy'))
)
const sortTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.sortBy'))
)
const showAssetsTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.showAssets'))
)
const showAssetsAction = computed(() => !hideShowAssetsAction)
const searchPlaceholderText = computed(
() => searchPlaceholder ?? t('sideToolbar.queueProgressOverlay.searchJobs')
)
const selectWorkflowFilter = (value: 'all' | 'current') => {
selectedWorkflowFilter.value = value
}
const onSelectWorkflowFilter = (
value: 'all' | 'current',
close: () => void
) => {
selectWorkflowFilter(value)
close()
}
const selectSortMode = (value: JobSortMode) => {
selectedSortMode.value = value
}
const onSelectSortMode = (value: JobSortMode, close: () => void) => {
selectSortMode(value)
close()
}
const onSearchQueryUpdate = (value: string | undefined) => {
searchQuery.value = value ?? ''
}
const sortLabel = (mode: JobSortMode) => {
if (mode === 'mostRecent') {
return t('queue.jobList.sortMostRecent')
}
if (mode === 'totalGenerationTime') {
return t('queue.jobList.sortTotalGenerationTime')
}
return ''
}
</script>

View File

@@ -0,0 +1,45 @@
<template>
<div class="min-w-0 flex-1 overflow-x-auto">
<div class="inline-flex items-center gap-1 whitespace-nowrap">
<Button
v-for="tab in visibleJobTabs"
:key="tab"
:variant="selectedJobTab === tab ? 'secondary' : 'muted-textonly'"
size="sm"
@click="$emit('update:selectedJobTab', tab)"
>
{{ tabLabel(tab) }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { jobTabs } from '@/composables/queue/useJobList'
import type { JobTab } from '@/composables/queue/useJobList'
const { selectedJobTab, hasFailedJobs } = defineProps<{
selectedJobTab: JobTab
hasFailedJobs: boolean
}>()
defineEmits<{
(e: 'update:selectedJobTab', value: JobTab): void
}>()
const { t } = useI18n()
const visibleJobTabs = computed(() =>
hasFailedJobs ? jobTabs : jobTabs.filter((tab) => tab !== 'Failed')
)
const tabLabel = (tab: JobTab) => {
if (tab === 'All') return t('g.all')
if (tab === 'Completed') return t('g.completed')
return t('g.failed')
}
</script>

View File

@@ -76,4 +76,24 @@ describe('JobFiltersBar', () => {
expect(wrapper.emitted('showAssets')).toHaveLength(1)
})
it('hides the assets icon button when hideShowAssetsAction is true', () => {
const wrapper = mount(JobFiltersBar, {
props: {
selectedJobTab: 'All',
selectedWorkflowFilter: 'all',
selectedSortMode: 'mostRecent',
hasFailedJobs: false,
hideShowAssetsAction: true
},
global: {
plugins: [i18n],
directives: { tooltip: () => undefined }
}
})
expect(
wrapper.find('button[aria-label="Show assets panel"]').exists()
).toBe(false)
})
})

View File

@@ -1,225 +1,46 @@
<template>
<div class="flex items-center justify-between gap-2 px-3">
<div class="min-w-0 flex-1 overflow-x-auto">
<div class="inline-flex items-center gap-1 whitespace-nowrap">
<Button
v-for="tab in visibleJobTabs"
:key="tab"
:variant="selectedJobTab === tab ? 'secondary' : 'muted-textonly'"
size="sm"
class="px-3"
@click="$emit('update:selectedJobTab', tab)"
>
{{ tabLabel(tab) }}
</Button>
</div>
</div>
<div class="ml-2 flex shrink-0 items-center gap-2">
<Button
v-if="showWorkflowFilter"
v-tooltip.top="filterTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
@click="onFilterClick"
>
<i class="icon-[lucide--list-filter] size-4" />
<span
v-if="selectedWorkflowFilter !== 'all'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</Button>
<Popover
v-if="showWorkflowFilter"
ref="filterPopoverRef"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: { class: 'absolute z-50' },
content: {
class: [
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
]
}
}"
>
<div
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
>
<Button
class="w-full justify-between"
variant="textonly"
size="sm"
@click="selectWorkflowFilter('all')"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
}}</span>
<i
v-if="selectedWorkflowFilter === 'all'"
class="icon-[lucide--check] size-4"
/>
</Button>
<div class="mx-2 mt-1 h-px" />
<Button
class="w-full justify-between"
variant="textonly"
@click="selectWorkflowFilter('current')"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
}}</span>
<i
v-if="selectedWorkflowFilter === 'current'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</Button>
</div>
</Popover>
<Button
v-tooltip.top="sortTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
@click="onSortClick"
>
<i class="icon-[lucide--arrow-up-down] size-4" />
<span
v-if="selectedSortMode !== 'mostRecent'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</Button>
<Popover
ref="sortPopoverRef"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: { class: 'absolute z-50' },
content: {
class: [
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
]
}
}"
>
<div
class="flex min-w-[12rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
>
<template v-for="(mode, index) in jobSortModes" :key="mode">
<Button
class="w-full justify-between"
variant="textonly"
size="sm"
@click="selectSortMode(mode)"
>
<span>{{ sortLabel(mode) }}</span>
<i
v-if="selectedSortMode === mode"
class="icon-[lucide--check] size-4 text-text-secondary"
/>
</Button>
<div
v-if="index < jobSortModes.length - 1"
class="mx-2 mt-1 h-px"
/>
</template>
</div>
</Popover>
<Button
v-tooltip.top="showAssetsTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.showAssetsPanel')"
@click="$emit('showAssets')"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
</Button>
</div>
<JobFilterTabs
:selected-job-tab="selectedJobTab"
:has-failed-jobs="hasFailedJobs"
@update:selected-job-tab="$emit('update:selectedJobTab', $event)"
/>
<JobFilterActions
:selected-workflow-filter="selectedWorkflowFilter"
:selected-sort-mode="selectedSortMode"
:hide-show-assets-action="hideShowAssetsAction"
@show-assets="$emit('showAssets')"
@update:selected-workflow-filter="
$emit('update:selectedWorkflowFilter', $event)
"
@update:selected-sort-mode="$emit('update:selectedSortMode', $event)"
/>
</div>
</template>
<script setup lang="ts">
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { jobSortModes, jobTabs } from '@/composables/queue/useJobList'
import JobFilterActions from '@/components/queue/job/JobFilterActions.vue'
import JobFilterTabs from '@/components/queue/job/JobFilterTabs.vue'
import type { JobSortMode, JobTab } from '@/composables/queue/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'
const props = defineProps<{
const {
selectedJobTab,
selectedWorkflowFilter,
selectedSortMode,
hasFailedJobs,
hideShowAssetsAction
} = defineProps<{
selectedJobTab: JobTab
selectedWorkflowFilter: 'all' | 'current'
selectedSortMode: JobSortMode
hasFailedJobs: boolean
hideShowAssetsAction?: boolean
}>()
const emit = defineEmits<{
defineEmits<{
(e: 'showAssets'): void
(e: 'update:selectedJobTab', value: JobTab): void
(e: 'update:selectedWorkflowFilter', value: 'all' | 'current'): void
(e: 'update:selectedSortMode', value: JobSortMode): void
}>()
const { t } = useI18n()
const filterPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
const sortPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
const filterTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.filterBy'))
)
const sortTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.sortBy'))
)
const showAssetsTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.showAssets'))
)
// This can be removed when cloud implements /jobs and we switch to it.
const showWorkflowFilter = !isCloud
const visibleJobTabs = computed(() =>
props.hasFailedJobs ? jobTabs : jobTabs.filter((tab) => tab !== 'Failed')
)
const onFilterClick = (event: Event) => {
if (filterPopoverRef.value) {
filterPopoverRef.value.toggle(event)
}
}
const selectWorkflowFilter = (value: 'all' | 'current') => {
filterPopoverRef.value?.hide()
emit('update:selectedWorkflowFilter', value)
}
const onSortClick = (event: Event) => {
if (sortPopoverRef.value) {
sortPopoverRef.value.toggle(event)
}
}
const selectSortMode = (value: JobSortMode) => {
sortPopoverRef.value?.hide()
emit('update:selectedSortMode', value)
}
const tabLabel = (tab: JobTab) => {
if (tab === 'All') return t('g.all')
if (tab === 'Completed') return t('g.completed')
return t('g.failed')
}
const sortLabel = (mode: JobSortMode) => {
if (mode === 'mostRecent') {
return t('queue.jobList.sortMostRecent')
}
if (mode === 'totalGenerationTime') {
return t('queue.jobList.sortTotalGenerationTime')
}
return ''
}
</script>

View File

@@ -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}`,

View File

@@ -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')
})
})

View File

@@ -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>

View File

@@ -52,31 +52,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 v-if="showLoadingState">
@@ -112,7 +88,6 @@
v-else
:assets="displayAssets"
:is-selected="isSelected"
:is-in-folder-view="isInFolderView"
:asset-type="activeTab"
:show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount"
@@ -189,7 +164,6 @@
ref="contextMenuRef"
:asset="contextMenuAsset"
:asset-type="contextMenuAssetType"
:file-kind="contextMenuFileKind"
:show-delete-button="shouldShowDeleteButton"
:selected-assets="selectedAssets"
:is-bulk-mode="isBulkMode"
@@ -211,7 +185,6 @@ import {
useResizeObserver,
useStorage
} from '@vueuse/core'
import { storeToRefs } from 'pinia'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
@@ -239,23 +212,14 @@ import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAs
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
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 activeTab = ref<'input' | 'output'>('output')
const folderPromptId = ref<string | null>(null)
@@ -265,12 +229,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)
@@ -286,10 +245,6 @@ const contextMenuAssetType = computed(() =>
contextMenuAsset.value ? getAssetType(contextMenuAsset.value.tags) : 'input'
)
const contextMenuFileKind = computed<MediaKind>(() =>
getMediaTypeFromFilename(contextMenuAsset.value?.name ?? '')
)
const shouldShowOutputCount = (item: AssetItem): boolean => {
if (activeTab.value !== 'output' || isInFolderView.value) {
return false
@@ -302,16 +257,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')
@@ -415,17 +360,11 @@ const isBulkMode = computed(
)
const showLoadingState = computed(
() =>
loading.value &&
displayAssets.value.length === 0 &&
activeJobsCount.value === 0
() => loading.value && displayAssets.value.length === 0
)
const showEmptyState = computed(
() =>
!loading.value &&
displayAssets.value.length === 0 &&
activeJobsCount.value === 0
() => !loading.value && displayAssets.value.length === 0
)
watch(visibleAssets, (newAssets) => {
@@ -520,16 +459,6 @@ const handleBulkDelete = async (assets: AssetItem[]) => {
}
}
const handleClearQueue = async () => {
const pendingPromptIds = queueStore.pendingTasks
.map((task) => task.promptId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByPromptIds(pendingPromptIds)
}
const handleBulkAddToWorkflow = async (assets: AssetItem[]) => {
await addMultipleToWorkflow(assets)
clearSelection()

View 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 pendingPromptIds = queueStore.pendingTasks
.map((task) => task.promptId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByPromptIds(pendingPromptIds)
})
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>

View File

@@ -14,10 +14,11 @@ defineOptions({
inheritAttrs: false
})
defineProps<{
const { showArrow = true } = defineProps<{
entries?: { label: string; action?: () => void; icon?: string }[][]
icon?: string
to?: string | HTMLElement
showArrow?: boolean
}>()
</script>
@@ -38,7 +39,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"
>
<slot>
<slot :close>
<div class="flex flex-col p-1">
<section
v-for="(entryGroup, index) in entries ?? []"
@@ -69,7 +70,10 @@ defineProps<{
</section>
</div>
</slot>
<PopoverArrow class="fill-base-background stroke-border-subtle" />
<PopoverArrow
v-if="showArrow"
class="fill-base-background stroke-border-subtle"
/>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>

View File

@@ -351,6 +351,52 @@ describe('useJobList', () => {
])
})
it('filters jobs by search query', async () => {
vi.useFakeTimers()
queueStoreMock.historyTasks = [
createTask({
promptId: 'alpha',
queueIndex: 2,
mockState: 'completed',
createTime: 2000,
executionEndTimestamp: 2000
}),
createTask({
promptId: 'beta',
queueIndex: 1,
mockState: 'failed',
createTime: 1000,
executionEndTimestamp: 1000
})
]
const instance = initComposable()
await flush()
expect(instance.filteredTasks.value.map((task) => task.promptId)).toEqual([
'alpha',
'beta'
])
instance.searchQuery.value = 'beta'
await vi.advanceTimersByTimeAsync(200)
await flush()
expect(instance.filteredTasks.value.map((task) => task.promptId)).toEqual([
'beta'
])
instance.searchQuery.value = 'failed meta'
await vi.advanceTimersByTimeAsync(200)
await flush()
expect(instance.filteredTasks.value.map((task) => task.promptId)).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({

View File

@@ -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<JobTab>('All')
const selectedWorkflowFilter = ref<'all' | 'current'>('all')
const selectedSortMode = ref<JobSortMode>('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<TaskItemImpl[]>(() =>
filteredTaskEntries.value.map(({ task }) => task)
searchableTaskEntries.value.map(({ task }) => task)
)
const jobItems = computed<JobListItem[]>(() => {
@@ -308,11 +315,31 @@ export function useJobList() {
return m
})
const searchableTaskEntries = computed<TaskWithState[]>(() => {
if (!normalizedSearchQuery.value) return filteredTaskEntries.value
return filteredTaskEntries.value.filter(({ task }) => {
const taskId = String(task.promptId ?? '').toLocaleLowerCase()
const item = jobItemById.value.get(String(task.promptId))
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<JobGroup[]>(() => {
const groups: JobGroup[] = []
const index = new Map<string, number>()
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
@@ -357,6 +384,7 @@ export function useJobList() {
selectedJobTab,
selectedWorkflowFilter,
selectedSortMode,
searchQuery,
hasFailedJobs,
// data sources
allTasksSorted,

View File

@@ -207,7 +207,8 @@ describe('useJobMenu', () => {
nodeDefStoreMock.nodeDefsByName = {
LoadImage: { name: 'LoadImage' },
LoadVideo: { name: 'LoadVideo' },
LoadAudio: { name: 'LoadAudio' }
LoadAudio: { name: 'LoadAudio' },
Load3D: { name: 'Load3D' }
}
// Default: no workflow available via lazy loading
getJobWorkflowMock.mockResolvedValue(undefined)
@@ -415,6 +416,12 @@ describe('useJobMenu', () => {
flags: { isAudio: true },
expectedNode: 'LoadAudio',
widget: 'audio'
},
{
label: '3D',
flags: { is3D: true },
expectedNode: 'Load3D',
widget: 'model_file'
}
] as const

View File

@@ -122,8 +122,9 @@ export function useJobMenu(
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
if (!result) return
let nodeType: 'LoadImage' | 'LoadVideo' | 'LoadAudio' | null = null
let widgetName: 'image' | 'file' | 'audio' | null = null
let nodeType: 'LoadImage' | 'LoadVideo' | 'LoadAudio' | 'Load3D' | null =
null
let widgetName: 'image' | 'file' | 'audio' | 'model_file' | null = null
if (result.isImage) {
nodeType = 'LoadImage'
widgetName = 'image'
@@ -133,6 +134,9 @@ export function useJobMenu(
} else if (result.isAudio) {
nodeType = 'LoadAudio'
widgetName = 'audio'
} else if (result.is3D) {
nodeType = 'Load3D'
widgetName = 'model_file'
}
if (!nodeType || !widgetName) return

View File

@@ -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
}
}

View File

@@ -1,11 +1,18 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import type { JobListItem as JobListViewItem } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
const showDialogMock = vi.fn()
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
showDialog: showDialogMock
})
}))
const createResultItem = (
url: string,
supportsPreview = true
@@ -61,6 +68,7 @@ const createJobViewItem = (
describe('useResultGallery', () => {
beforeEach(() => {
setActivePinia(createPinia())
showDialogMock.mockReset()
})
it('collects only previewable outputs and preserves their order', async () => {
@@ -168,4 +176,27 @@ describe('useResultGallery', () => {
expect(galleryItems.value).toEqual(fullOutputs)
expect(galleryActiveIndex.value).toBe(0)
})
it('opens standalone 3D viewer for 3D preview outputs', async () => {
const previewOutput = createResultItem('model.glb')
Object.defineProperty(previewOutput, 'is3D', { get: () => true })
const task = createTask(previewOutput)
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
() => [task]
)
await onViewItem(createJobViewItem('job-3d', task))
expect(showDialogMock).toHaveBeenCalledWith(
expect.objectContaining({
key: 'queue-asset-3d-viewer',
title: 'model.glb',
props: expect.objectContaining({ modelUrl: 'model.glb' }),
dialogComponentProps: expect.objectContaining({ maximizable: true })
})
)
expect(galleryItems.value).toEqual([])
expect(galleryActiveIndex.value).toBe(-1)
})
})

View File

@@ -2,14 +2,19 @@ import { ref, shallowRef } from 'vue'
import type { JobListItem } from '@/composables/queue/useJobList'
import { findActiveIndex, getOutputsForTask } from '@/services/jobOutputCache'
import { useDialogStore } from '@/stores/dialogStore'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
const Load3dViewerContent = () =>
import('@/components/load3d/Load3dViewerContent.vue')
/**
* Manages result gallery state and activation for queue items.
*/
export function useResultGallery(getFilteredTasks: () => TaskItemImpl[]) {
const galleryActiveIndex = ref(-1)
const galleryItems = shallowRef<ResultItemImpl[]>([])
const dialogStore = useDialogStore()
async function onViewItem(item: JobListItem) {
const tasks = getFilteredTasks()
@@ -23,6 +28,23 @@ export function useResultGallery(getFilteredTasks: () => TaskItemImpl[]) {
// Request was superseded by a newer one
if (targetOutputs === null && targetTask) return
const previewOutput = targetTask?.previewOutput
if (previewOutput?.is3D) {
dialogStore.showDialog({
key: 'queue-asset-3d-viewer',
title: previewOutput.filename,
component: Load3dViewerContent,
props: {
modelUrl: previewOutput.url
},
dialogComponentProps: {
style: 'width: 80vw; height: 80vh;',
maximizable: true
}
})
return
}
// Use target's outputs if available, otherwise fall back to all previews
const items = targetOutputs?.length
? targetOutputs

View File

@@ -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'
}
}

View File

@@ -804,11 +804,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",

View File

@@ -59,19 +59,28 @@
<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"
class="min-w-0 text-xs leading-none text-text-primary"
>
<slot name="primary">{{ primaryText }}</slot>
<slot v-if="$slots.primary" name="primary" />
<span v-else class="block truncate" :title="primaryText">
{{ primaryText }}
</span>
</div>
<div
v-if="$slots.secondary || secondaryText"
class="text-xs leading-none text-text-secondary"
class="min-w-0 text-xs leading-none text-text-secondary"
>
<slot name="secondary">{{ secondaryText }}</slot>
<slot v-if="$slots.secondary" name="secondary" />
<span v-else class="block truncate" :title="secondaryText">
{{ secondaryText }}
</span>
</div>
</div>
<div v-if="$slots.actions" class="relative z-1 flex items-center gap-2">
<div
v-if="$slots.actions"
class="relative z-1 flex shrink-0 items-center gap-2"
>
<slot name="actions" />
</div>

View File

@@ -0,0 +1,73 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
const actionMocks = vi.hoisted(() => ({
addWorkflow: vi.fn(),
copyJobId: vi.fn(),
deleteAssets: vi.fn(),
downloadAsset: vi.fn(),
exportWorkflow: vi.fn(),
openWorkflow: vi.fn()
}))
vi.mock('primevue/contextmenu', () => ({
default: {
name: 'ContextMenu',
props: ['model', 'pt'],
emits: ['hide'],
template: '<div data-testid="context-menu" />'
}
}))
vi.mock('vue-i18n', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
vi.mock('@/platform/assets/composables/useMediaAssetActions', () => ({
useMediaAssetActions: () => actionMocks
}))
type MenuItemLike = {
label?: string
command?: () => void
}
function createAsset(name: string): AssetItem {
return {
id: 'asset-1',
name,
tags: []
}
}
describe('MediaAssetContextMenu', () => {
it('includes inspect action for 3D assets and emits zoom when triggered', () => {
const wrapper = mount(MediaAssetContextMenu, {
props: {
asset: createAsset('model.glb'),
assetType: 'output'
}
})
const model = wrapper
.findComponent({ name: 'ContextMenu' })
.props('model') as MenuItemLike[]
const inspectItem = model.find(
(item) => item.label === 'mediaAsset.actions.inspect'
)
expect(inspectItem).toBeDefined()
inspectItem?.command?.()
expect(wrapper.emitted('zoom')).toHaveLength(1)
})
})

View File

@@ -44,23 +44,16 @@ import { cn } from '@/utils/tailwindUtil'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema'
import type { AssetContext, MediaKind } from '../schemas/mediaAssetSchema'
import type { AssetContext } from '../schemas/mediaAssetSchema'
const {
asset,
assetType,
fileKind,
showDeleteButton,
selectedAssets,
isBulkMode
} = defineProps<{
asset: AssetItem
assetType: AssetContext['type']
fileKind: MediaKind
showDeleteButton?: boolean
selectedAssets?: AssetItem[]
isBulkMode?: boolean
}>()
const { asset, assetType, showDeleteButton, selectedAssets, isBulkMode } =
defineProps<{
asset: AssetItem
assetType: AssetContext['type']
showDeleteButton?: boolean
selectedAssets?: AssetItem[]
isBulkMode?: boolean
}>()
const emit = defineEmits<{
zoom: []
@@ -193,14 +186,12 @@ const contextMenuItems = computed<MenuItem[]>(() => {
// Individual mode: Show all menu options
// Inspect (if not 3D)
if (fileKind !== '3D') {
items.push({
label: t('mediaAsset.actions.inspect'),
icon: 'icon-[lucide--zoom-in]',
command: () => emit('zoom')
})
}
// Inspect
items.push({
label: t('mediaAsset.actions.inspect'),
icon: 'icon-[lucide--zoom-in]',
command: () => emit('zoom')
})
// Add to workflow (conditional)
if (showAddToWorkflow.value) {

View File

@@ -34,20 +34,14 @@
/>
</template>
</AssetSortButton>
<MediaAssetViewModeToggle
v-if="isQueuePanelV2Enabled"
v-model:view-mode="viewMode"
/>
<MediaAssetViewModeToggle v-model:view-mode="viewMode" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import MediaAssetFilterButton from './MediaAssetFilterButton.vue'
import MediaAssetFilterMenu from './MediaAssetFilterMenu.vue'
@@ -70,11 +64,6 @@ const emit = defineEmits<{
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) => {
emit('update:searchQuery', value ?? '')
}

View File

@@ -0,0 +1,153 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const { mockGetSetting, mockRegisterCommand, mockRegisterCommands } =
vi.hoisted(() => ({
mockGetSetting: vi.fn(),
mockRegisterCommand: vi.fn(),
mockRegisterCommands: vi.fn()
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: mockGetSetting
})
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
registerCommand: mockRegisterCommand,
commands: []
})
}))
vi.mock('@/stores/menuItemStore', () => ({
useMenuItemStore: () => ({
registerCommands: mockRegisterCommands
})
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key,
te: () => false
}))
vi.mock('@/composables/sidebarTabs/useAssetsSidebarTab', () => ({
useAssetsSidebarTab: () => ({
id: 'assets',
title: 'assets',
type: 'vue',
component: {}
})
}))
vi.mock('@/composables/sidebarTabs/useJobHistorySidebarTab', () => ({
useJobHistorySidebarTab: () => ({
id: 'job-history',
title: 'job-history',
type: 'vue',
component: {}
})
}))
vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
useNodeLibrarySidebarTab: () => ({
id: 'node-library',
title: 'node-library',
type: 'vue',
component: {}
})
}))
vi.mock('@/composables/sidebarTabs/useModelLibrarySidebarTab', () => ({
useModelLibrarySidebarTab: () => ({
id: 'model-library',
title: 'model-library',
type: 'vue',
component: {}
})
}))
vi.mock(
'@/platform/workflow/management/composables/useWorkflowsSidebarTab',
() => ({
useWorkflowsSidebarTab: () => ({
id: 'workflows',
title: 'workflows',
type: 'vue',
component: {}
})
})
)
describe('useSidebarTabStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mockGetSetting.mockImplementation(
(key: string) => key === 'Comfy.Queue.QPOV2' && false
)
mockRegisterCommand.mockClear()
mockRegisterCommands.mockClear()
})
it('registers the job history tab when QPO V2 is enabled', () => {
mockGetSetting.mockImplementation(
(key: string) => key === 'Comfy.Queue.QPOV2'
)
const store = useSidebarTabStore()
store.registerCoreSidebarTabs()
expect(store.sidebarTabs.map((tab) => tab.id)).toEqual([
'job-history',
'assets',
'node-library',
'model-library',
'workflows'
])
expect(mockRegisterCommand).toHaveBeenCalledTimes(5)
})
it('does not register the job history tab when QPO V2 is disabled', () => {
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
const store = useSidebarTabStore()
store.registerCoreSidebarTabs()
expect(store.sidebarTabs.map((tab) => tab.id)).toEqual([
'assets',
'node-library',
'model-library',
'workflows'
])
expect(mockRegisterCommand).toHaveBeenCalledTimes(4)
})
it('prepends the job history tab when QPO V2 is toggled on', async () => {
const qpoV2Enabled = ref(false)
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.Queue.QPOV2' ? qpoV2Enabled.value : undefined
)
const store = useSidebarTabStore()
store.registerCoreSidebarTabs()
qpoV2Enabled.value = true
await nextTick()
expect(store.sidebarTabs.map((tab) => tab.id)).toEqual([
'job-history',
'assets',
'node-library',
'model-library',
'workflows'
])
expect(mockRegisterCommand).toHaveBeenCalledTimes(5)
})
})

View File

@@ -1,7 +1,8 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useAssetsSidebarTab } from '@/composables/sidebarTabs/useAssetsSidebarTab'
import { useJobHistorySidebarTab } from '@/composables/sidebarTabs/useJobHistorySidebarTab'
import { useModelLibrarySidebarTab } from '@/composables/sidebarTabs/useModelLibrarySidebarTab'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import { t, te } from '@/i18n'
@@ -26,8 +27,13 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
activeSidebarTabId.value = activeSidebarTabId.value === tabId ? null : tabId
}
const registerSidebarTab = (tab: SidebarTabExtension) => {
sidebarTabs.value = [...sidebarTabs.value, tab]
const registerSidebarTab = (
tab: SidebarTabExtension,
options?: { prepend?: boolean }
) => {
sidebarTabs.value = options?.prepend
? [tab, ...sidebarTabs.value]
: [...sidebarTabs.value, tab]
// Generate label in format "Toggle X Sidebar"
const labelFunction = () => {
@@ -45,7 +51,8 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
'node-library': 'sideToolbar.nodeLibrary',
'model-library': 'sideToolbar.modelLibrary',
workflows: 'sideToolbar.workflows',
assets: 'sideToolbar.assets'
assets: 'sideToolbar.assets',
'job-history': 'queue.jobHistory'
}
const key = menubarLabelKeys[tab.id]
@@ -95,6 +102,9 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
const newSidebarTabs = [...sidebarTabs.value]
newSidebarTabs.splice(index, 1)
sidebarTabs.value = newSidebarTabs
if (activeSidebarTabId.value === id) {
activeSidebarTabId.value = null
}
}
}
@@ -102,6 +112,25 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
* Register the core sidebar tabs.
*/
const registerCoreSidebarTabs = () => {
const settingStore = useSettingStore()
const jobHistoryTabId = 'job-history'
const syncJobHistoryTab = (enabled: boolean) => {
const hasJobHistoryTab = sidebarTabs.value.some(
(tab) => tab.id === jobHistoryTabId
)
if (enabled && !hasJobHistoryTab) {
registerSidebarTab(useJobHistorySidebarTab(), { prepend: true })
} else if (!enabled && hasJobHistoryTab) {
unregisterSidebarTab(jobHistoryTabId)
}
}
syncJobHistoryTab(settingStore.get('Comfy.Queue.QPOV2'))
watch(
() => settingStore.get('Comfy.Queue.QPOV2'),
(enabled) => syncJobHistoryTab(enabled)
)
registerSidebarTab(useAssetsSidebarTab())
registerSidebarTab(useNodeLibrarySidebarTab())
registerSidebarTab(useModelLibrarySidebarTab())

View File

@@ -22,9 +22,13 @@ function buildGroupedJobItems(): JobGroup[] {
const groupedJobItems = computed<JobGroup[]>(buildGroupedJobItems)
export const jobTabs = ['All', 'Completed', 'Failed'] as const
export const jobSortModes = ['mostRecent', 'totalGenerationTime'] as const
const selectedJobTab = ref<JobTab>('All')
const selectedWorkflowFilter = ref<'all' | 'current'>('all')
const selectedSortMode = ref<JobSortMode>('mostRecent')
const searchQuery = ref('')
const currentNodeName = ref('KSampler')
function buildEmptyTasks(): TaskItemImpl[] {
return []
@@ -48,6 +52,7 @@ export function useJobList() {
selectedJobTab,
selectedWorkflowFilter,
selectedSortMode,
searchQuery,
hasFailedJobs,
allTasksSorted,
filteredTasks,

View File

@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getMediaTypeFromFilename } from '@comfyorg/shared-frontend-utils/formatUtil'
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
vi.mock('@comfyorg/shared-frontend-utils/formatUtil', () => ({
getMediaTypeFromFilename: vi.fn()
}))
const mockGetMediaTypeFromFilename = vi.mocked(getMediaTypeFromFilename)
describe('loaderNodeUtil', () => {
beforeEach(() => {
mockGetMediaTypeFromFilename.mockReset()
})
describe('detectNodeTypeFromFilename', () => {
it('maps image files to LoadImage', () => {
mockGetMediaTypeFromFilename.mockReturnValue('image')
expect(detectNodeTypeFromFilename('image.png')).toEqual({
nodeType: 'LoadImage',
widgetName: 'image'
})
})
it('maps video files to LoadVideo', () => {
mockGetMediaTypeFromFilename.mockReturnValue('video')
expect(detectNodeTypeFromFilename('video.mp4')).toEqual({
nodeType: 'LoadVideo',
widgetName: 'file'
})
})
it('maps audio files to LoadAudio', () => {
mockGetMediaTypeFromFilename.mockReturnValue('audio')
expect(detectNodeTypeFromFilename('audio.mp3')).toEqual({
nodeType: 'LoadAudio',
widgetName: 'audio'
})
})
it('maps 3D files to Load3D', () => {
mockGetMediaTypeFromFilename.mockReturnValue('3D')
expect(detectNodeTypeFromFilename('model.glb')).toEqual({
nodeType: 'Load3D',
widgetName: 'model_file'
})
})
it('returns null node mapping for unsupported files', () => {
mockGetMediaTypeFromFilename.mockReturnValue('unknown' as never)
expect(detectNodeTypeFromFilename('document.txt')).toEqual({
nodeType: null,
widgetName: null
})
})
})
})

View File

@@ -17,10 +17,11 @@ import { getMediaTypeFromFilename } from '@comfyorg/shared-frontend-utils/format
* detectNodeTypeFromFilename('image.png') // { nodeType: 'LoadImage', widgetName: 'image' }
* detectNodeTypeFromFilename('video.mp4') // { nodeType: 'LoadVideo', widgetName: 'file' }
* detectNodeTypeFromFilename('audio.mp3') // { nodeType: 'LoadAudio', widgetName: 'audio' }
* detectNodeTypeFromFilename('model.glb') // { nodeType: 'Load3D', widgetName: 'model_file' }
*/
export function detectNodeTypeFromFilename(filename: string): {
nodeType: 'LoadImage' | 'LoadVideo' | 'LoadAudio' | null
widgetName: 'image' | 'file' | 'audio' | null
nodeType: 'LoadImage' | 'LoadVideo' | 'LoadAudio' | 'Load3D' | null
widgetName: 'image' | 'file' | 'audio' | 'model_file' | null
} {
const mediaType = getMediaTypeFromFilename(filename)
@@ -31,8 +32,10 @@ export function detectNodeTypeFromFilename(filename: string): {
return { nodeType: 'LoadVideo', widgetName: 'file' }
case 'audio':
return { nodeType: 'LoadAudio', widgetName: 'audio' }
case '3D':
return { nodeType: 'Load3D', widgetName: 'model_file' }
default:
// 3D and other types don't have loader nodes
// Other unsupported media types don't have loader nodes
return { nodeType: null, widgetName: null }
}
}