Implement workflow progress panel (#6092)

Adds a workflow progress panel component underneath the
`actionbar-container`.

I suggest starting a review at the extraneous changes that were needed.
Including but not limited to:

- `get createTime()` in queueStore
- `promptIdToWorkflowId`, `initializingPromptIds`, and
`nodeProgressStatesByPrompt` in executionStore
- `create_time` handling in v2ToV1Adapter
- `pointer-events-auto` on ComfyActionbar.vue

The rest of the changes should be contained under
`QueueProgressOverlay.vue`, and has less of a blast radius in case
something goes wrong.

---------

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
This commit is contained in:
Benjamin Lu
2025-11-18 22:43:49 -08:00
committed by GitHub
parent 92968f3f9b
commit e42715086e
76 changed files with 7117 additions and 39 deletions

View File

@@ -85,11 +85,10 @@
</template>
<script setup lang="ts">
import {
InstallStage,
type InstallStageInfo,
type InstallStageName,
ProgressStatus
import { InstallStage, ProgressStatus } from '@comfyorg/comfyui-electron-types'
import type {
InstallStageInfo,
InstallStageName
} from '@comfyorg/comfyui-electron-types'
import type { Terminal } from '@xterm/xterm'
import Button from 'primevue/button'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -2,6 +2,7 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
import { fitToViewInstant } from '../helpers/fitToView'
// TODO: there might be a better solution for this
// Helper function to pan canvas and select node
@@ -516,6 +517,7 @@ This is English documentation.
)
await comfyPage.loadWorkflow('default')
await fitToViewInstant(comfyPage)
// Select KSampler first
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -98,6 +98,9 @@
--color-comfy-menu-secondary: var(--comfy-menu-secondary-bg);
--color-interface-panel-job-progress-primary: var(--color-azure-300);
--color-interface-panel-job-progress-secondary: var(--color-alpha-azure-600-30);
--color-blue-selection: rgb(from var(--color-azure-600) r g b / 0.3);
--color-node-hover-100: rgb(from var(--color-charcoal-800) r g b/ 0.15);
--color-node-hover-200: rgb(from var(--color-charcoal-800) r g b/ 0.1);
@@ -330,6 +333,12 @@
--node-stroke-error: var(--color-error);
--node-stroke-executing: var(--color-azure-600);
/* Queue progress (dark theme) */
--color-interface-panel-job-progress-primary: var(--color-cobalt-800);
--color-interface-panel-job-progress-secondary: var(
--color-alpha-azure-600-30
);
--text-secondary: var(--color-slate-100);
--text-primary: var(--color-white);
@@ -1827,4 +1836,4 @@ audio.comfy-audio.empty-audio-widget {
.maskEditor_sidePanelLayerCheckbox {
margin-left: 15px;
}
/* ===================== End of Mask Editor Styles ===================== */
/* ===================== End of Mask Editor Styles ===================== */

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.33398 1.33337V4.00004C9.33398 4.35366 9.47446 4.6928 9.72451 4.94285C9.97456 5.1929 10.3137 5.33337 10.6673 5.33337H13.334M2.66732 4.66671V2.66671C2.66732 2.31309 2.80779 1.97395 3.05784 1.7239C3.30789 1.47385 3.64703 1.33337 4.00065 1.33337H10.0006L13.334 4.66671V13.3334C13.334 13.687 13.1935 14.0261 12.9435 14.2762C12.6934 14.5262 12.3543 14.6667 12.0006 14.6667L4.04264 14.666C3.77927 14.7004 3.51166 14.6552 3.2741 14.5365C3.03655 14.4177 2.83988 14.2307 2.70931 13.9994M3.33398 7.33337L1.33398 9.33337M1.33398 9.33337L3.33398 11.3334M1.33398 9.33337H8.00065" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 771 B

View File

@@ -4,38 +4,80 @@
<SubgraphBreadcrumb />
</div>
<div
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-[var(--interface-stroke)] px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div class="mx-1 flex flex-col items-end gap-1">
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-interface-stroke px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<IconButton
v-tooltip.bottom="queueHistoryTooltipConfig"
type="transparent"
size="sm"
class="queue-history-toggle relative mr-2 transition-colors duration-200 ease-in-out hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:class="queueHistoryButtonBackgroundClass"
:aria-pressed="isQueueOverlayExpanded"
:aria-label="
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
"
@click="toggleQueueOverlay"
>
<i
class="icon-[lucide--history] block size-4 text-muted-foreground"
/>
<span
v-if="queuedCount > 0"
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
>
{{ queuedCount }}
</span>
</IconButton>
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
</div>
<QueueProgressOverlay v-model:expanded="isQueueOverlayExpanded" />
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import IconButton from '@/components/button/IconButton.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { app } from '@/scripts/app'
import { useQueueStore } from '@/stores/queueStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
const workspaceStore = useWorkspaceStore()
const { isLoggedIn } = useCurrentUser()
const isDesktop = isElectron()
const { t } = useI18n()
const isQueueOverlayExpanded = ref(false)
const queueStore = useQueueStore()
const queuedCount = computed(() => queueStore.pendingTasks.length)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
const queueHistoryButtonBackgroundClass = computed(() =>
isQueueOverlayExpanded.value
? 'bg-secondary-background-selected'
: 'bg-secondary-background'
)
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()
@@ -45,6 +87,10 @@ onMounted(() => {
legacyCommandsContainerRef.value.appendChild(app.menu.element)
}
})
const toggleQueueOverlay = () => {
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
}
</script>
<style scoped>

View File

@@ -260,6 +260,7 @@ const actionbarClass = computed(() =>
'w-[265px] border-dashed border-blue-500 opacity-80',
'm-1.5 flex items-center justify-center self-stretch',
'rounded-md before:w-50 before:-ml-50 before:h-full',
'pointer-events-auto',
isMouseOverDropZone.value &&
'border-[3px] opacity-100 scale-105 shadow-[0_0_20px] shadow-blue-500'
)

View File

@@ -0,0 +1,73 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import CompletionSummaryBanner from './CompletionSummaryBanner.vue'
const meta: Meta<typeof CompletionSummaryBanner> = {
title: 'Queue/CompletionSummaryBanner',
component: CompletionSummaryBanner,
parameters: {
layout: 'padded'
}
}
export default meta
type Story = StoryObj<typeof meta>
const thumb = (hex: string) =>
`data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><rect width='24' height='24' fill='%23${hex}'/></svg>`
const thumbs = [thumb('ff6b6b'), thumb('4dabf7'), thumb('51cf66')]
export const AllSuccessSingle: Story = {
args: {
mode: 'allSuccess',
completedCount: 1,
failedCount: 0,
thumbnailUrls: [thumbs[0]]
}
}
export const AllSuccessPlural: Story = {
args: {
mode: 'allSuccess',
completedCount: 3,
failedCount: 0,
thumbnailUrls: thumbs
}
}
export const MixedSingleSingle: Story = {
args: {
mode: 'mixed',
completedCount: 1,
failedCount: 1,
thumbnailUrls: thumbs.slice(0, 2)
}
}
export const MixedPluralPlural: Story = {
args: {
mode: 'mixed',
completedCount: 2,
failedCount: 3,
thumbnailUrls: thumbs
}
}
export const AllFailedSingle: Story = {
args: {
mode: 'allFailed',
completedCount: 0,
failedCount: 1,
thumbnailUrls: []
}
}
export const AllFailedPlural: Story = {
args: {
mode: 'allFailed',
completedCount: 0,
failedCount: 4,
thumbnailUrls: []
}
}

View File

@@ -0,0 +1,91 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import CompletionSummaryBanner from './CompletionSummaryBanner.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
sideToolbar: {
queueProgressOverlay: {
jobsCompleted: '{count} job completed | {count} jobs completed',
jobsFailed: '{count} job failed | {count} jobs failed'
}
}
}
}
})
const mountComponent = (props: Record<string, unknown>) =>
mount(CompletionSummaryBanner, {
props: {
mode: 'allSuccess',
completedCount: 0,
failedCount: 0,
...props
},
global: {
plugins: [i18n]
}
})
describe('CompletionSummaryBanner', () => {
it('renders success mode text, thumbnails, and aria label', () => {
const wrapper = mountComponent({
mode: 'allSuccess',
completedCount: 3,
failedCount: 0,
thumbnailUrls: [
'https://example.com/thumb-a.png',
'https://example.com/thumb-b.png'
],
ariaLabel: 'Open queue summary'
})
const button = wrapper.get('button')
expect(button.attributes('aria-label')).toBe('Open queue summary')
expect(wrapper.text()).toContain('3 jobs completed')
const thumbnailImages = wrapper.findAll('img')
expect(thumbnailImages).toHaveLength(2)
expect(thumbnailImages[0].attributes('src')).toBe(
'https://example.com/thumb-a.png'
)
expect(thumbnailImages[1].attributes('src')).toBe(
'https://example.com/thumb-b.png'
)
const thumbnailContainers = wrapper.findAll('.inline-block.h-6.w-6')
expect(thumbnailContainers[1].attributes('style')).toContain(
'margin-left: -12px'
)
expect(wrapper.html()).not.toContain('icon-[lucide--circle-alert]')
})
it('renders mixed mode with success and failure counts', () => {
const wrapper = mountComponent({
mode: 'mixed',
completedCount: 2,
failedCount: 1
})
const summaryText = wrapper.text().replace(/\s+/g, ' ').trim()
expect(summaryText).toContain('2 jobs completed, 1 job failed')
})
it('renders failure mode icon without thumbnails', () => {
const wrapper = mountComponent({
mode: 'allFailed',
completedCount: 0,
failedCount: 4
})
expect(wrapper.text()).toContain('4 jobs failed')
expect(wrapper.html()).toContain('icon-[lucide--circle-alert]')
expect(wrapper.findAll('img')).toHaveLength(0)
})
})

View File

@@ -0,0 +1,99 @@
<template>
<button
type="button"
class="group flex w-full items-center justify-between gap-3 rounded-lg border-0 bg-secondary-background p-1 text-left transition-colors duration-200 ease-in-out hover:cursor-pointer hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="props.ariaLabel"
>
<span class="inline-flex items-center gap-2">
<span v-if="props.mode === 'allFailed'" class="inline-flex items-center">
<i
class="ml-1 icon-[lucide--circle-alert] block size-4 leading-none"
:class="'text-destructive-background'"
/>
</span>
<span class="inline-flex items-center gap-2">
<span
v-if="props.mode !== 'allFailed'"
class="relative inline-flex h-6 items-center"
>
<span
v-for="(url, idx) in props.thumbnailUrls"
:key="url + idx"
class="inline-block h-6 w-6 overflow-hidden rounded-[6px] border-0 bg-secondary-background"
:style="{ marginLeft: idx === 0 ? '0' : '-12px' }"
>
<img :src="url" alt="preview" class="h-full w-full object-cover" />
</span>
</span>
<span class="text-[14px] font-normal text-text-primary">
<template v-if="props.mode === 'allSuccess'">
<i18n-t
keypath="sideToolbar.queueProgressOverlay.jobsCompleted"
:plural="props.completedCount"
>
<template #count>
<span class="font-bold">{{ props.completedCount }}</span>
</template>
</i18n-t>
</template>
<template v-else-if="props.mode === 'mixed'">
<i18n-t
keypath="sideToolbar.queueProgressOverlay.jobsCompleted"
:plural="props.completedCount"
>
<template #count>
<span class="font-bold">{{ props.completedCount }}</span>
</template>
</i18n-t>
<span>, </span>
<i18n-t
keypath="sideToolbar.queueProgressOverlay.jobsFailed"
:plural="props.failedCount"
>
<template #count>
<span class="font-bold">{{ props.failedCount }}</span>
</template>
</i18n-t>
</template>
<template v-else>
<i18n-t
keypath="sideToolbar.queueProgressOverlay.jobsFailed"
:plural="props.failedCount"
>
<template #count>
<span class="font-bold">{{ props.failedCount }}</span>
</template>
</i18n-t>
</template>
</span>
</span>
</span>
<span
class="flex items-center justify-center rounded p-1 text-text-secondary transition-colors duration-200 ease-in-out"
>
<i class="icon-[lucide--chevron-down] block size-4 leading-none" />
</span>
</button>
</template>
<script setup lang="ts">
import type {
CompletionSummary,
CompletionSummaryMode
} from '@/composables/queue/useCompletionSummary'
type Props = {
mode: CompletionSummaryMode
completedCount: CompletionSummary['completedCount']
failedCount: CompletionSummary['failedCount']
thumbnailUrls?: CompletionSummary['thumbnailUrls']
ariaLabel?: string
}
const props = withDefaults(defineProps<Props>(), {
thumbnailUrls: () => []
})
</script>

View File

@@ -0,0 +1,125 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import QueueOverlayActive from './QueueOverlayActive.vue'
import * as tooltipConfig from '@/composables/useTooltipConfig'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
sideToolbar: {
queueProgressOverlay: {
total: 'Total: {percent}',
currentNode: 'Current node:',
running: 'running',
interruptAll: 'Interrupt all running jobs',
queuedSuffix: 'queued',
clearQueued: 'Clear queued',
viewAllJobs: 'View all jobs',
cancelJobTooltip: 'Cancel job',
clearQueueTooltip: 'Clear queue'
}
}
}
}
})
const tooltipDirectiveStub = {
mounted: vi.fn(),
updated: vi.fn()
}
const SELECTORS = {
interruptAllButton: 'button[aria-label="Interrupt all running jobs"]',
clearQueuedButton: 'button[aria-label="Clear queued"]',
summaryRow: '.flex.items-center.gap-2',
currentNodeRow: '.flex.items-center.gap-1.text-text-secondary'
}
const COPY = {
viewAllJobs: 'View all jobs'
}
const mountComponent = (props: Record<string, unknown> = {}) =>
mount(QueueOverlayActive, {
props: {
totalProgressStyle: { width: '65%' },
currentNodeProgressStyle: { width: '40%' },
totalPercentFormatted: '65%',
currentNodePercentFormatted: '40%',
currentNodeName: 'Sampler',
runningCount: 1,
queuedCount: 2,
bottomRowClass: 'flex custom-bottom-row',
...props
},
global: {
plugins: [i18n],
directives: {
tooltip: tooltipDirectiveStub
}
}
})
describe('QueueOverlayActive', () => {
it('renders progress metrics and emits actions when buttons clicked', async () => {
const wrapper = mountComponent({ runningCount: 2, queuedCount: 3 })
const progressBars = wrapper.findAll('.absolute.inset-0')
expect(progressBars[0].attributes('style')).toContain('width: 65%')
expect(progressBars[1].attributes('style')).toContain('width: 40%')
const content = wrapper.text().replace(/\s+/g, ' ')
expect(content).toContain('Total: 65%')
const [runningSection, queuedSection] = wrapper.findAll(
SELECTORS.summaryRow
)
expect(runningSection.text()).toContain('2')
expect(runningSection.text()).toContain('running')
expect(queuedSection.text()).toContain('3')
expect(queuedSection.text()).toContain('queued')
const currentNodeSection = wrapper.find(SELECTORS.currentNodeRow)
expect(currentNodeSection.text()).toContain('Current node:')
expect(currentNodeSection.text()).toContain('Sampler')
expect(currentNodeSection.text()).toContain('40%')
const interruptButton = wrapper.get(SELECTORS.interruptAllButton)
await interruptButton.trigger('click')
expect(wrapper.emitted('interruptAll')).toHaveLength(1)
const clearQueuedButton = wrapper.get(SELECTORS.clearQueuedButton)
await clearQueuedButton.trigger('click')
expect(wrapper.emitted('clearQueued')).toHaveLength(1)
const buttons = wrapper.findAll('button')
const viewAllButton = buttons.find((btn) =>
btn.text().includes(COPY.viewAllJobs)
)
expect(viewAllButton).toBeDefined()
await viewAllButton!.trigger('click')
expect(wrapper.emitted('viewAllJobs')).toHaveLength(1)
expect(wrapper.find('.custom-bottom-row').exists()).toBe(true)
})
it('hides action buttons when counts are zero', () => {
const wrapper = mountComponent({ runningCount: 0, queuedCount: 0 })
expect(wrapper.find(SELECTORS.interruptAllButton).exists()).toBe(false)
expect(wrapper.find(SELECTORS.clearQueuedButton).exists()).toBe(false)
})
it('builds tooltip configs with translated strings', () => {
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
mountComponent()
expect(spy).toHaveBeenCalledWith('Cancel job')
expect(spy).toHaveBeenCalledWith('Clear queue')
})
})

View File

@@ -0,0 +1,119 @@
<template>
<div class="flex flex-col gap-3 p-2">
<div class="flex flex-col gap-1">
<div
class="relative h-2 w-full overflow-hidden rounded-full border border-interface-stroke bg-interface-panel-surface"
>
<div
class="absolute inset-0 h-full rounded-full transition-[width]"
:style="totalProgressStyle"
/>
<div
class="absolute inset-0 h-full rounded-full transition-[width]"
:style="currentNodeProgressStyle"
/>
</div>
<div class="flex items-start justify-end gap-4 text-[12px] leading-none">
<div class="flex items-center gap-1 text-text-primary opacity-90">
<i18n-t keypath="sideToolbar.queueProgressOverlay.total">
<template #percent>
<span class="font-bold">{{ totalPercentFormatted }}</span>
</template>
</i18n-t>
</div>
<div class="flex items-center gap-1 text-text-secondary">
<span>{{ t('sideToolbar.queueProgressOverlay.currentNode') }}</span>
<span class="inline-block max-w-[10rem] truncate">{{
currentNodeName
}}</span>
<span class="flex items-center gap-1">
<span>{{ currentNodePercentFormatted }}</span>
</span>
</div>
</div>
</div>
<div :class="bottomRowClass">
<div class="flex items-center gap-4 text-[12px] text-text-primary">
<div class="flex items-center gap-2">
<span class="opacity-90">
<span class="font-bold">{{ runningCount }}</span>
<span class="ml-1">{{
t('sideToolbar.queueProgressOverlay.running')
}}</span>
</span>
<button
v-if="runningCount > 0"
v-tooltip.top="cancelJobTooltip"
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 transition-colors hover:bg-destructive-background"
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
@click="$emit('interruptAll')"
>
<i
class="icon-[lucide--x] block size-4 leading-none text-text-primary"
/>
</button>
</div>
<div class="flex items-center gap-2">
<span class="opacity-90">
<span class="font-bold">{{ queuedCount }}</span>
<span class="ml-1">{{
t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span>
</span>
<button
v-if="queuedCount > 0"
v-tooltip.top="clearQueueTooltip"
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 transition-colors hover:bg-destructive-background"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')"
>
<i
class="icon-[lucide--list-x] block size-4 leading-none text-text-primary"
/>
</button>
</div>
</div>
<button
class="inline-flex h-6 min-w-[120px] flex-1 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background px-2 py-0 text-[12px] text-text-primary hover:bg-secondary-background-hover hover:opacity-90"
@click="$emit('viewAllJobs')"
>
{{ t('sideToolbar.queueProgressOverlay.viewAllJobs') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
defineProps<{
totalProgressStyle: Record<string, string>
currentNodeProgressStyle: Record<string, string>
totalPercentFormatted: string
currentNodePercentFormatted: string
currentNodeName: string
runningCount: number
queuedCount: number
bottomRowClass: string
}>()
defineEmits<{
(e: 'interruptAll'): void
(e: 'clearQueued'): void
(e: 'viewAllJobs'): void
}>()
const { t } = useI18n()
const cancelJobTooltip = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.cancelJobTooltip'))
)
const clearQueueTooltip = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.clearQueueTooltip'))
)
</script>

View File

@@ -0,0 +1,69 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import QueueOverlayEmpty from './QueueOverlayEmpty.vue'
import type { CompletionSummary } from '@/composables/queue/useCompletionSummary'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
sideToolbar: {
queueProgressOverlay: {
expandCollapsedQueue: 'Expand job queue',
noActiveJobs: 'No active jobs'
}
}
}
}
})
const CompletionSummaryBannerStub = {
name: 'CompletionSummaryBanner',
props: [
'mode',
'completedCount',
'failedCount',
'thumbnailUrls',
'ariaLabel'
],
emits: ['click'],
template: '<button class="summary-banner" @click="$emit(\'click\')"></button>'
}
const mountComponent = (summary: CompletionSummary) =>
mount(QueueOverlayEmpty, {
props: { summary },
global: {
plugins: [i18n],
components: { CompletionSummaryBanner: CompletionSummaryBannerStub }
}
})
describe('QueueOverlayEmpty', () => {
it('renders completion summary banner and proxies click', async () => {
const summary: CompletionSummary = {
mode: 'mixed',
completedCount: 2,
failedCount: 1,
thumbnailUrls: ['thumb-a']
}
const wrapper = mountComponent(summary)
const summaryBanner = wrapper.findComponent(CompletionSummaryBannerStub)
expect(summaryBanner.exists()).toBe(true)
expect(summaryBanner.props()).toMatchObject({
mode: 'mixed',
completedCount: 2,
failedCount: 1,
thumbnailUrls: ['thumb-a'],
ariaLabel: 'Expand job queue'
})
await summaryBanner.trigger('click')
expect(wrapper.emitted('summaryClick')).toHaveLength(1)
})
})

View File

@@ -0,0 +1,27 @@
<template>
<div class="pointer-events-auto">
<CompletionSummaryBanner
:mode="summary.mode"
:completed-count="summary.completedCount"
:failed-count="summary.failedCount"
:thumbnail-urls="summary.thumbnailUrls"
:aria-label="t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')"
@click="$emit('summaryClick')"
/>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import CompletionSummaryBanner from '@/components/queue/CompletionSummaryBanner.vue'
import type { CompletionSummary } from '@/composables/queue/useCompletionSummary'
defineProps<{ summary: CompletionSummary }>()
defineEmits<{
(e: 'summaryClick'): void
}>()
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,144 @@
<template>
<div class="flex w-full flex-col gap-4">
<QueueOverlayHeader
:header-title="headerTitle"
:show-concurrent-indicator="showConcurrentIndicator"
:concurrent-workflow-count="concurrentWorkflowCount"
@clear-history="$emit('clearHistory')"
/>
<div class="flex items-center justify-between px-3">
<button
class="inline-flex grow cursor-pointer items-center justify-center gap-1 rounded border-0 bg-secondary-background p-2 text-center font-inter text-[12px] leading-none text-text-primary hover:bg-secondary-background-hover hover:opacity-90"
:aria-label="t('sideToolbar.queueProgressOverlay.showAssets')"
@click="$emit('showAssets')"
>
<div
class="pointer-events-none block size-4 shrink-0 leading-none icon-[comfy--image-ai-edit]"
aria-hidden="true"
/>
<span>{{ t('sideToolbar.queueProgressOverlay.showAssets') }}</span>
</button>
<div class="ml-4 inline-flex items-center">
<div
class="inline-flex h-6 items-center text-[12px] leading-none text-text-primary opacity-90"
>
<span class="font-bold">{{ queuedCount }}</span>
<span class="ml-1">{{
t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span>
</div>
<button
v-if="queuedCount > 0"
class="group ml-2 inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 transition-colors hover:bg-destructive-background"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')"
>
<i
class="pointer-events-none icon-[lucide--list-x] block size-4 leading-none text-text-primary transition-colors group-hover:text-base-background"
/>
</button>
</div>
</div>
<JobFiltersBar
:selected-job-tab="selectedJobTab"
:selected-workflow-filter="selectedWorkflowFilter"
:selected-sort-mode="selectedSortMode"
:has-failed-jobs="hasFailedJobs"
@update:selected-job-tab="$emit('update:selectedJobTab', $event)"
@update:selected-workflow-filter="
$emit('update:selectedWorkflowFilter', $event)
"
@update:selected-sort-mode="$emit('update:selectedSortMode', $event)"
/>
<div class="flex-1 min-h-0 overflow-y-auto">
<JobGroupsList
:displayed-job-groups="displayedJobGroups"
@cancel-item="onCancelItemEvent"
@delete-item="onDeleteItemEvent"
@view-item="$emit('viewItem', $event)"
@menu="onMenuItem"
/>
</div>
<JobContextMenu
ref="jobContextMenuRef"
:entries="jobMenuEntries"
@action="onJobMenuAction"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
JobGroup,
JobListItem,
JobSortMode,
JobTab
} from '@/composables/queue/useJobList'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import JobContextMenu from './job/JobContextMenu.vue'
import JobFiltersBar from './job/JobFiltersBar.vue'
import JobGroupsList from './job/JobGroupsList.vue'
defineProps<{
headerTitle: string
showConcurrentIndicator: boolean
concurrentWorkflowCount: number
queuedCount: number
selectedJobTab: JobTab
selectedWorkflowFilter: 'all' | 'current'
selectedSortMode: JobSortMode
displayedJobGroups: JobGroup[]
hasFailedJobs: boolean
}>()
const emit = defineEmits<{
(e: 'showAssets'): void
(e: 'clearHistory'): void
(e: 'clearQueued'): void
(e: 'update:selectedJobTab', value: JobTab): void
(e: 'update:selectedWorkflowFilter', value: 'all' | 'current'): void
(e: 'update:selectedSortMode', value: JobSortMode): void
(e: 'cancelItem', item: JobListItem): void
(e: 'deleteItem', item: JobListItem): void
(e: 'viewItem', item: JobListItem): void
}>()
const { t } = useI18n()
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
const { jobMenuEntries } = useJobMenu(
() => currentMenuItem.value,
(item) => emit('viewItem', item)
)
const onCancelItemEvent = (item: JobListItem) => {
emit('cancelItem', item)
}
const onDeleteItemEvent = (item: JobListItem) => {
emit('deleteItem', item)
}
const onMenuItem = (item: JobListItem, event: Event) => {
currentMenuItem.value = item
jobContextMenuRef.value?.open(event)
}
const onJobMenuAction = async (entry: MenuEntry) => {
if (entry.kind === 'divider') return
if (entry.onClick) await entry.onClick()
jobContextMenuRef.value?.hide()
}
</script>

View File

@@ -0,0 +1,98 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { defineComponent } from 'vue'
const popoverToggleSpy = vi.fn()
const popoverHideSpy = vi.fn()
vi.mock('primevue/popover', () => {
const PopoverStub = defineComponent({
name: 'Popover',
setup(_, { slots, expose }) {
const toggle = (event: Event) => {
popoverToggleSpy(event)
}
const hide = () => {
popoverHideSpy()
}
expose({ toggle, hide })
return () => slots.default?.()
}
})
return { default: PopoverStub }
})
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import * as tooltipConfig from '@/composables/useTooltipConfig'
const tooltipDirectiveStub = {
mounted: vi.fn(),
updated: vi.fn()
}
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { more: 'More' },
sideToolbar: {
queueProgressOverlay: {
running: 'running',
moreOptions: 'More options',
clearHistory: 'Clear history'
}
}
}
}
})
const mountHeader = (props = {}) =>
mount(QueueOverlayHeader, {
props: {
headerTitle: 'Job queue',
showConcurrentIndicator: true,
concurrentWorkflowCount: 2,
...props
},
global: {
plugins: [i18n],
directives: { tooltip: tooltipDirectiveStub }
}
})
describe('QueueOverlayHeader', () => {
it('renders header title and concurrent indicator when enabled', () => {
const wrapper = mountHeader({ concurrentWorkflowCount: 3 })
expect(wrapper.text()).toContain('Job queue')
const indicator = wrapper.find('.inline-flex.items-center.gap-1')
expect(indicator.exists()).toBe(true)
expect(indicator.text()).toContain('3')
expect(indicator.text()).toContain('running')
})
it('hides concurrent indicator when flag is false', () => {
const wrapper = mountHeader({ showConcurrentIndicator: false })
expect(wrapper.text()).toContain('Job queue')
expect(wrapper.find('.inline-flex.items-center.gap-1').exists()).toBe(false)
})
it('toggles popover and emits clear history', 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(spy).toHaveBeenCalledWith('More')
const clearHistoryButton = wrapper.get('button[aria-label="Clear history"]')
await clearHistoryButton.trigger('click')
expect(popoverHideSpy).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
})
})

View File

@@ -0,0 +1,96 @@
<template>
<div
class="flex h-12 items-center justify-between gap-2 border-b border-interface-stroke px-2"
>
<div class="px-2 text-[14px] font-normal text-text-primary">
<span>{{ headerTitle }}</span>
<span
v-if="showConcurrentIndicator"
class="ml-4 inline-flex items-center gap-1 text-blue-100"
>
<span class="inline-block size-2 rounded-full bg-blue-100" />
<span>
<span class="font-bold">{{ concurrentWorkflowCount }}</span>
<span class="ml-1">{{
t('sideToolbar.queueProgressOverlay.running')
}}</span>
</span>
</span>
</div>
<div class="flex items-center gap-1">
<button
v-tooltip.top="moreTooltipConfig"
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 hover:bg-secondary-background hover:opacity-100"
: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="inline-flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
:aria-label="t('sideToolbar.queueProgressOverlay.clearHistory')"
@click="onClearHistoryFromMenu"
>
<i
class="icon-[lucide--file-x-2] block size-4 leading-none text-text-secondary"
/>
<span>{{
t('sideToolbar.queueProgressOverlay.clearHistory')
}}</span>
</button>
</div>
</Popover>
</div>
</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 { buildTooltipConfig } from '@/composables/useTooltipConfig'
defineProps<{
headerTitle: string
showConcurrentIndicator: boolean
concurrentWorkflowCount: number
}>()
const emit = defineEmits<{
(e: 'clearHistory'): 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

@@ -0,0 +1,290 @@
<template>
<div
v-show="isVisible"
:class="['flex', 'justify-end', 'w-full', 'pointer-events-none']"
>
<div
class="pointer-events-auto flex w-[350px] min-w-[310px] max-h-[60vh] flex-col overflow-hidden rounded-lg border font-inter transition-colors duration-200 ease-in-out"
:class="containerClass"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
<!-- Expanded state -->
<QueueOverlayExpanded
v-if="isExpanded"
v-model:selected-job-tab="selectedJobTab"
v-model:selected-workflow-filter="selectedWorkflowFilter"
v-model:selected-sort-mode="selectedSortMode"
class="flex-1 min-h-0"
:header-title="headerTitle"
:show-concurrent-indicator="showConcurrentIndicator"
:concurrent-workflow-count="concurrentWorkflowCount"
:queued-count="queuedCount"
:displayed-job-groups="displayedJobGroups"
:has-failed-jobs="hasFailedJobs"
@show-assets="openAssetsSidebar"
@clear-history="onClearHistoryFromMenu"
@clear-queued="cancelQueuedWorkflows"
@cancel-item="onCancelItem"
@delete-item="onDeleteItem"
@view-item="inspectJobAsset"
/>
<QueueOverlayActive
v-else-if="hasActiveJob"
:total-progress-style="totalProgressStyle"
:current-node-progress-style="currentNodeProgressStyle"
:total-percent-formatted="totalPercentFormatted"
:current-node-percent-formatted="currentNodePercentFormatted"
:current-node-name="currentNodeName"
:running-count="runningCount"
:queued-count="queuedCount"
:bottom-row-class="bottomRowClass"
@interrupt-all="interruptAll"
@clear-queued="cancelQueuedWorkflows"
@view-all-jobs="viewAllJobs"
/>
<QueueOverlayEmpty
v-else-if="completionSummary"
:summary="completionSummary"
@summary-click="onSummaryClick"
/>
</div>
</div>
<ResultGallery
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
</template>
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
import QueueOverlayEmpty from '@/components/queue/QueueOverlayEmpty.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 { useCompletionSummary } from '@/composables/queue/useCompletionSummary'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
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'
type OverlayState = 'hidden' | 'empty' | 'active' | 'expanded'
const props = defineProps<{
expanded?: boolean
}>()
const emit = defineEmits<{
(e: 'update:expanded', value: boolean): void
}>()
const { t } = useI18n()
const queueStore = useQueueStore()
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
const sidebarTabStore = useSidebarTabStore()
const dialogStore = useDialogStore()
const assetsStore = useAssetsStore()
const assetSelectionStore = useAssetSelectionStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const {
totalPercentFormatted,
currentNodePercentFormatted,
totalProgressStyle,
currentNodeProgressStyle
} = useQueueProgress()
const isHovered = ref(false)
const internalExpanded = ref(false)
const isExpanded = computed({
get: () =>
props.expanded === undefined ? internalExpanded.value : props.expanded,
set: (value) => {
if (props.expanded === undefined) {
internalExpanded.value = value
}
emit('update:expanded', value)
}
})
const { summary: completionSummary, clearSummary } = useCompletionSummary()
const hasCompletionSummary = computed(() => completionSummary.value !== null)
const runningCount = computed(() => queueStore.runningTasks.length)
const queuedCount = computed(() => queueStore.pendingTasks.length)
const isExecuting = computed(() => !executionStore.isIdle)
const hasActiveJob = computed(() => runningCount.value > 0 || isExecuting.value)
const activeJobsCount = computed(() => runningCount.value + queuedCount.value)
const overlayState = computed<OverlayState>(() => {
if (isExpanded.value) return 'expanded'
if (hasActiveJob.value) return 'active'
if (hasCompletionSummary.value) return 'empty'
return 'hidden'
})
const showBackground = computed(
() =>
overlayState.value === 'expanded' ||
overlayState.value === 'empty' ||
(overlayState.value === 'active' && isHovered.value)
)
const isVisible = computed(() => overlayState.value !== 'hidden')
const containerClass = computed(() =>
showBackground.value
? 'border-interface-stroke bg-interface-panel-surface shadow-interface'
: 'border-transparent bg-transparent shadow-none'
)
const bottomRowClass = computed(
() =>
`flex items-center justify-end gap-4 transition-opacity duration-200 ease-in-out ${
overlayState.value === 'active' && isHovered.value
? 'opacity-100 pointer-events-auto'
: 'opacity-0 pointer-events-none'
}`
)
const headerTitle = computed(() =>
hasActiveJob.value
? `${activeJobsCount.value} ${t('sideToolbar.queueProgressOverlay.activeJobsSuffix')}`
: t('sideToolbar.queueProgressOverlay.jobQueue')
)
const concurrentWorkflowCount = computed(
() => executionStore.runningWorkflowCount
)
const showConcurrentIndicator = computed(
() => concurrentWorkflowCount.value > 1
)
const {
selectedJobTab,
selectedWorkflowFilter,
selectedSortMode,
hasFailedJobs,
filteredTasks,
groupedJobItems,
currentNodeName
} = useJobList()
const displayedJobGroups = computed(() => groupedJobItems.value)
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
const promptId = item.taskRef?.promptId
if (!promptId) return
await api.interrupt(promptId)
})
const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
if (!item.taskRef) return
await queueStore.delete(item.taskRef)
})
const {
galleryActiveIndex,
galleryItems,
onViewItem: openResultGallery
} = useResultGallery(() => filteredTasks.value)
const setExpanded = (expanded: boolean) => {
isExpanded.value = expanded
}
const openExpandedFromEmpty = () => {
setExpanded(true)
}
const viewAllJobs = () => {
setExpanded(true)
}
const onSummaryClick = () => {
openExpandedFromEmpty()
clearSummary()
}
const openAssetsSidebar = () => {
sidebarTabStore.activeSidebarTabId = 'assets'
}
const focusAssetInSidebar = async (item: JobListItem) => {
const task = item.taskRef
const promptId = task?.promptId
const preview = task?.previewOutput
if (!promptId || !preview) return
const assetId = String(promptId)
openAssetsSidebar()
await nextTick()
await assetsStore.updateHistory()
const asset = assetsStore.historyAssets.find(
(existingAsset) => existingAsset.id === assetId
)
if (!asset) {
throw new Error('Asset not found in media assets panel')
}
assetSelectionStore.setSelection([assetId])
}
const inspectJobAsset = wrapWithErrorHandlingAsync(
async (item: JobListItem) => {
openResultGallery(item)
await focusAssetInSidebar(item)
}
)
const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
await commandStore.execute('Comfy.ClearPendingTasks')
})
const interruptAll = wrapWithErrorHandlingAsync(async () => {
const tasks = queueStore.runningTasks
await Promise.all(
tasks
.filter((task) => task.promptId != null)
.map((task) => api.interrupt(task.promptId))
)
})
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()
}
</script>

View File

@@ -0,0 +1,88 @@
<template>
<section
class="w-[360px] rounded-2xl border border-interface-stroke bg-interface-panel-surface text-text-primary shadow-interface font-inter"
>
<header
class="flex items-center justify-between border-b border-interface-stroke px-4 py-4"
>
<p class="m-0 text-[14px] font-normal leading-none">
{{ t('sideToolbar.queueProgressOverlay.clearHistoryDialogTitle') }}
</p>
<button
class="inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 text-text-secondary transition hover:bg-secondary-background hover:opacity-100"
:aria-label="t('g.close')"
@click="onCancel"
>
<i class="icon-[lucide--x] block size-4 leading-none" />
</button>
</header>
<div class="flex flex-col gap-4 px-4 py-4 text-[14px] text-text-secondary">
<p class="m-0">
{{
t('sideToolbar.queueProgressOverlay.clearHistoryDialogDescription')
}}
</p>
<p class="m-0">
{{ t('sideToolbar.queueProgressOverlay.clearHistoryDialogAssetsNote') }}
</p>
</div>
<footer class="flex items-center justify-end px-4 py-4">
<div class="flex items-center gap-4 text-[14px] leading-none">
<button
class="inline-flex min-h-[24px] cursor-pointer items-center rounded-md border-0 bg-transparent px-1 py-1 text-[14px] leading-[1] text-text-secondary transition hover:text-text-primary"
:aria-label="t('g.cancel')"
@click="onCancel"
>
{{ t('g.cancel') }}
</button>
<button
class="inline-flex min-h-[32px] items-center rounded-lg border-0 bg-secondary-background px-4 py-2 text-[12px] font-normal leading-[1] text-text-primary transition hover:bg-secondary-background-hover hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-60"
:aria-label="t('g.clear')"
:disabled="isClearing"
@click="onConfirm"
>
{{ t('g.clear') }}
</button>
</div>
</footer>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useDialogStore } from '@/stores/dialogStore'
import { useQueueStore } from '@/stores/queueStore'
const dialogStore = useDialogStore()
const queueStore = useQueueStore()
const { t } = useI18n()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const isClearing = ref(false)
const clearHistory = wrapWithErrorHandlingAsync(
async () => {
await queueStore.clear(['history'])
dialogStore.closeDialog()
},
undefined,
() => {
isClearing.value = false
}
)
const onConfirm = async () => {
if (isClearing.value) return
isClearing.value = true
await clearHistory()
}
const onCancel = () => {
dialogStore.closeDialog()
}
</script>

View File

@@ -0,0 +1,72 @@
<template>
<Popover
ref="jobItemPopoverRef"
: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-[14rem] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
>
<template v-for="entry in entries" :key="entry.key">
<div v-if="entry.kind === 'divider'" class="px-2 py-1">
<div class="h-px bg-interface-stroke" />
</div>
<button
v-else
class="inline-flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary transition-colors duration-150 hover:bg-interface-panel-hover-surface"
:aria-label="entry.label"
@click="onEntry(entry)"
>
<i
v-if="entry.icon"
:class="[
entry.icon,
'block size-4 shrink-0 leading-none text-text-secondary'
]"
/>
<span>{{ entry.label }}</span>
</button>
</template>
</div>
</Popover>
</template>
<script setup lang="ts">
import Popover from 'primevue/popover'
import { ref } from 'vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
defineProps<{ entries: MenuEntry[] }>()
const emit = defineEmits<{
(e: 'action', entry: MenuEntry): void
}>()
const jobItemPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
function open(event: Event) {
if (jobItemPopoverRef.value) {
jobItemPopoverRef.value.toggle(event)
}
}
function hide() {
jobItemPopoverRef.value?.hide()
}
function onEntry(entry: MenuEntry) {
emit('action', entry)
}
defineExpose({ open, hide })
</script>

View File

@@ -0,0 +1,423 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { TaskStatus } from '@/schemas/apiSchema'
import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import JobDetailsPopover from './JobDetailsPopover.vue'
const meta: Meta<typeof JobDetailsPopover> = {
title: 'Queue/JobDetailsPopover',
component: JobDetailsPopover,
args: {
workflowId: 'WF-1234'
},
parameters: {
layout: 'padded',
backgrounds: {
default: 'dark'
}
},
globals: {
theme: 'dark'
}
}
export default meta
type Story = StoryObj<typeof meta>
function resetStores() {
const queue = useQueueStore()
const exec = useExecutionStore()
queue.pendingTasks = []
queue.runningTasks = []
queue.historyTasks = []
exec.nodeProgressStatesByPrompt = {}
}
function makePendingTask(
id: string,
index: number,
createTimeMs?: number
): TaskItemImpl {
const extraData = {
client_id: 'c1',
...(typeof createTimeMs === 'number' ? { create_time: createTimeMs } : {})
}
return new TaskItemImpl('Pending', [index, id, {}, extraData, []])
}
function makeRunningTask(
id: string,
index: number,
createTimeMs?: number
): TaskItemImpl {
const extraData = {
client_id: 'c1',
...(typeof createTimeMs === 'number' ? { create_time: createTimeMs } : {})
}
return new TaskItemImpl('Running', [index, id, {}, extraData, []])
}
function makeRunningTaskWithStart(
id: string,
index: number,
startedSecondsAgo: number
): TaskItemImpl {
const start = Date.now() - startedSecondsAgo * 1000
const status: TaskStatus = {
status_str: 'success',
completed: false,
messages: [['execution_start', { prompt_id: id, timestamp: start } as any]]
}
return new TaskItemImpl(
'Running',
[index, id, {}, { client_id: 'c1', create_time: start - 5000 }, []],
status
)
}
function makeHistoryTask(
id: string,
index: number,
durationSec: number,
ok: boolean,
errorMessage?: string
): TaskItemImpl {
const start = Date.now() - durationSec * 1000 - 1000
const end = start + durationSec * 1000
const messages: TaskStatus['messages'] = ok
? [
['execution_start', { prompt_id: id, timestamp: start } as any],
['execution_success', { prompt_id: id, timestamp: end } as any]
]
: [
['execution_start', { prompt_id: id, timestamp: start } as any],
[
'execution_error',
{
prompt_id: id,
timestamp: end,
node_id: '1',
node_type: 'Node',
executed: [],
exception_message:
errorMessage || 'Demo error: Node failed during execution',
exception_type: 'RuntimeError',
traceback: [],
current_inputs: {},
current_outputs: {}
} as any
]
]
const status: TaskStatus = {
status_str: ok ? 'success' : 'error',
completed: true,
messages
}
return new TaskItemImpl(
'History',
[index, id, {}, { client_id: 'c1', create_time: start }, []],
status
)
}
export const Queued: Story = {
render: (args) => ({
components: { JobDetailsPopover },
setup() {
resetStores()
const queue = useQueueStore()
const exec = useExecutionStore()
const jobId = 'job-queued-1'
const queueIndex = 104
// Current job in pending
queue.pendingTasks = [
makePendingTask(jobId, queueIndex, Date.now() - 90_000)
]
// Add some other pending jobs to give context
queue.pendingTasks.push(makePendingTask('job-older-1', 100))
queue.pendingTasks.push(makePendingTask('job-older-2', 101))
// Queued at (in metadata on prompt[4])
// One running workflow
exec.nodeProgressStatesByPrompt = {
p1: {
'1': {
value: 1,
max: 1,
state: 'running',
node_id: '1',
prompt_id: 'p1'
}
}
} as any
return { args: { ...args, jobId } }
},
template: `
<div style="padding: 12px; background: var(--color-charcoal-700); display:inline-block;">
<JobDetailsPopover v-bind="args" />
</div>
`
})
}
export const QueuedParallel: Story = {
render: (args) => ({
components: { JobDetailsPopover },
setup() {
resetStores()
const queue = useQueueStore()
const exec = useExecutionStore()
const jobId = 'job-queued-parallel'
const queueIndex = 210
// Current job in pending with some ahead
queue.pendingTasks = [
makePendingTask('job-ahead-1', 200, Date.now() - 180_000),
makePendingTask('job-ahead-2', 205, Date.now() - 150_000),
makePendingTask(jobId, queueIndex, Date.now() - 120_000)
]
// Seen 2 minutes ago - set via prompt metadata above
// History durations for ETA (in seconds)
queue.historyTasks = [
makeHistoryTask('hist-1', 150, 25, true),
makeHistoryTask('hist-2', 151, 40, true),
makeHistoryTask('hist-3', 152, 60, true)
]
// Two parallel workflows running
exec.nodeProgressStatesByPrompt = {
p1: {
'1': {
value: 1,
max: 2,
state: 'running',
node_id: '1',
prompt_id: 'p1'
}
},
p2: {
'2': {
value: 1,
max: 2,
state: 'running',
node_id: '2',
prompt_id: 'p2'
}
}
} as any
return { args: { ...args, jobId } }
},
template: `
<div style="padding: 12px; background: var(--color-charcoal-700); display:inline-block;">
<JobDetailsPopover v-bind="args" />
</div>
`
})
}
export const Running: Story = {
render: (args) => ({
components: { JobDetailsPopover },
setup() {
resetStores()
const queue = useQueueStore()
const exec = useExecutionStore()
const jobId = 'job-running-1'
const queueIndex = 300
queue.runningTasks = [
makeRunningTask(jobId, queueIndex, Date.now() - 65_000)
]
queue.historyTasks = [
makeHistoryTask('hist-r1', 250, 30, true),
makeHistoryTask('hist-r2', 251, 45, true),
makeHistoryTask('hist-r3', 252, 60, true)
]
exec.nodeProgressStatesByPrompt = {
p1: {
'1': {
value: 5,
max: 10,
state: 'running',
node_id: '1',
prompt_id: 'p1'
}
}
} as any
return { args: { ...args, jobId } }
},
template: `
<div style="padding: 12px; background: var(--color-charcoal-700); display:inline-block;">
<JobDetailsPopover v-bind="args" />
</div>
`
})
}
export const QueuedZeroAheadSingleRunning: Story = {
render: (args) => ({
components: { JobDetailsPopover },
setup() {
resetStores()
const queue = useQueueStore()
const exec = useExecutionStore()
const jobId = 'job-queued-zero-ahead-single'
const queueIndex = 510
queue.pendingTasks = [
makePendingTask(jobId, queueIndex, Date.now() - 45_000)
]
queue.historyTasks = [
makeHistoryTask('hist-s1', 480, 30, true),
makeHistoryTask('hist-s2', 481, 50, true),
makeHistoryTask('hist-s3', 482, 80, true)
]
queue.runningTasks = [makeRunningTaskWithStart('running-1', 505, 20)]
exec.nodeProgressStatesByPrompt = {
p1: {
'1': {
value: 1,
max: 3,
state: 'running',
node_id: '1',
prompt_id: 'p1'
}
}
} as any
return { args: { ...args, jobId } }
},
template: `
<div style="padding: 12px; background: var(--color-charcoal-700); display:inline-block;">
<JobDetailsPopover v-bind="args" />
</div>
`
})
}
export const QueuedZeroAheadMultiRunning: Story = {
render: (args) => ({
components: { JobDetailsPopover },
setup() {
resetStores()
const queue = useQueueStore()
const exec = useExecutionStore()
const jobId = 'job-queued-zero-ahead-multi'
const queueIndex = 520
queue.pendingTasks = [
makePendingTask(jobId, queueIndex, Date.now() - 20_000)
]
queue.historyTasks = [
makeHistoryTask('hist-m1', 490, 40, true),
makeHistoryTask('hist-m2', 491, 55, true),
makeHistoryTask('hist-m3', 492, 70, true)
]
queue.runningTasks = [
makeRunningTaskWithStart('running-a', 506, 35),
makeRunningTaskWithStart('running-b', 507, 10)
]
exec.nodeProgressStatesByPrompt = {
p1: {
'1': {
value: 2,
max: 5,
state: 'running',
node_id: '1',
prompt_id: 'p1'
}
},
p2: {
'2': {
value: 3,
max: 5,
state: 'running',
node_id: '2',
prompt_id: 'p2'
}
}
} as any
return { args: { ...args, jobId } }
},
template: `
<div style="padding: 12px; background: var(--color-charcoal-700); display:inline-block;">
<JobDetailsPopover v-bind="args" />
</div>
`
})
}
export const Completed: Story = {
render: (args) => ({
components: { JobDetailsPopover },
setup() {
resetStores()
const queue = useQueueStore()
const jobId = 'job-completed-1'
const queueIndex = 400
queue.historyTasks = [makeHistoryTask(jobId, queueIndex, 37, true)]
return { args: { ...args, jobId } }
},
template: `
<div style="padding: 12px; background: var(--color-charcoal-700); display:inline-block;">
<JobDetailsPopover v-bind="args" />
</div>
`
})
}
export const Failed: Story = {
render: (args) => ({
components: { JobDetailsPopover },
setup() {
resetStores()
const queue = useQueueStore()
const jobId = 'job-failed-1'
const queueIndex = 410
queue.historyTasks = [
makeHistoryTask(
jobId,
queueIndex,
12,
false,
'Example error: invalid inputs for node X'
)
]
// Show a queued-at time for the failed job via history extra_data (2 minutes ago)
// Already set by makeHistoryTask using its start timestamp
return { args: { ...args, jobId } }
},
template: `
<div style="padding: 12px; background: var(--color-charcoal-700); display:inline-block;">
<JobDetailsPopover v-bind="args" />
</div>
`
})
}

View File

@@ -0,0 +1,350 @@
<template>
<div
class="w-[300px] min-w-[260px] rounded-lg border border-interface-stroke bg-interface-panel-surface shadow-md"
>
<div class="flex items-center border-b border-interface-stroke p-4">
<span
class="text-[0.875rem] leading-normal font-normal text-text-primary"
>{{ t('queue.jobDetails.header') }}</span
>
</div>
<div class="flex flex-col gap-6 px-4 pt-4 pb-4">
<div class="grid grid-cols-2 items-center gap-x-2 gap-y-2">
<template v-for="row in baseRows" :key="row.label">
<div
class="flex items-center text-[0.75rem] leading-normal font-normal text-text-primary"
>
{{ row.label }}
</div>
<div
class="flex min-w-0 items-center text-[0.75rem] leading-normal font-normal text-text-secondary"
>
<span class="block min-w-0 truncate">{{ row.value }}</span>
<button
v-if="row.canCopy"
type="button"
class="ml-2 inline-flex size-6 items-center justify-center rounded border-0 bg-transparent p-0 hover:opacity-90"
:aria-label="copyAriaLabel"
@click.stop="copyJobId"
>
<i
class="icon-[lucide--copy] block size-4 leading-none text-text-secondary"
/>
</button>
</div>
</template>
</div>
<div
v-if="extraRows.length"
class="grid grid-cols-2 items-center gap-x-2 gap-y-2"
>
<template v-for="row in extraRows" :key="row.label">
<div
class="flex items-center text-[0.75rem] leading-normal font-normal text-text-primary"
>
{{ row.label }}
</div>
<div
class="flex min-w-0 items-center text-[0.75rem] leading-normal font-normal text-text-secondary"
>
<span class="block min-w-0 truncate">{{ row.value }}</span>
</div>
</template>
</div>
<div v-if="jobState === 'failed'" class="grid grid-cols-2 gap-x-2">
<div
class="flex items-center text-[0.75rem] leading-normal font-normal text-text-primary"
>
{{ t('queue.jobDetails.errorMessage') }}
</div>
<div class="flex items-center justify-between gap-4">
<button
type="button"
class="inline-flex h-6 items-center justify-center gap-2 rounded border-none bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
:aria-label="copyAriaLabel"
@click.stop="copyErrorMessage"
>
<span>{{ copyAriaLabel }}</span>
<i class="icon-[lucide--copy] block size-3.5 leading-none" />
</button>
<button
type="button"
class="inline-flex h-6 items-center justify-center gap-2 rounded border-none bg-transparent px-0 text-[0.75rem] leading-none text-text-secondary hover:opacity-90"
@click.stop="reportJobError"
>
<span>{{ t('queue.jobDetails.report') }}</span>
<i
class="icon-[lucide--message-circle-warning] block size-3.5 leading-none"
/>
</button>
</div>
<div
class="col-span-2 mt-2 rounded bg-interface-panel-hover-surface px-4 py-2 text-[0.75rem] leading-normal text-text-secondary"
>
{{ errorMessageValue }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import type { TaskItemImpl } from '@/stores/queueStore'
import { formatClockTime } from '@/utils/dateTimeUtil'
import { jobStateFromTask } from '@/utils/queueUtil'
import { useJobErrorReporting } from './useJobErrorReporting'
import { formatElapsedTime, useQueueEstimates } from './useQueueEstimates'
const props = defineProps<{
jobId: string
workflowId?: string
}>()
const copyAriaLabel = computed(() => t('g.copy'))
const workflowStore = useWorkflowStore()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const dialog = useDialogService()
const { locale } = useI18n()
const workflowValue = computed(() => {
const wid = props.workflowId
if (!wid) return ''
const activeId = workflowStore.activeWorkflow?.activeState?.id
if (activeId && activeId === wid) {
return workflowStore.activeWorkflow?.filename ?? wid
}
return wid
})
const jobIdValue = computed(() => props.jobId)
const { copyToClipboard } = useCopyToClipboard()
const copyJobId = () => void copyToClipboard(jobIdValue.value)
const taskForJob = computed(() => {
const pid = props.jobId
const findIn = (arr: TaskItemImpl[]) =>
arr.find((t) => String(t.promptId ?? '') === String(pid))
return (
findIn(queueStore.pendingTasks) ||
findIn(queueStore.runningTasks) ||
findIn(queueStore.historyTasks) ||
null
)
})
const jobState = computed(() => {
const task = taskForJob.value
if (!task) return null
const isInitializing = executionStore.isPromptInitializing(
String(task?.promptId)
)
return jobStateFromTask(task, isInitializing)
})
const firstSeenTs = computed<number | undefined>(() => {
const task = taskForJob.value
return task?.createTime
})
const queuedAtValue = computed(() =>
firstSeenTs.value !== undefined
? formatClockTime(firstSeenTs.value, locale.value)
: ''
)
const currentQueueIndex = computed<number | null>(() => {
const task = taskForJob.value
return task ? Number(task.queueIndex) : null
})
const jobsAhead = computed<number | null>(() => {
const idx = currentQueueIndex.value
if (idx == null) return null
const ahead = queueStore.pendingTasks.filter(
(t: TaskItemImpl) => Number(t.queueIndex) < idx
)
return ahead.length
})
const queuePositionValue = computed(() => {
if (jobsAhead.value == null) return ''
const n = jobsAhead.value
return t('queue.jobDetails.queuePositionValue', { count: n }, n)
})
const nowTs = ref<number>(Date.now())
let timer: number | null = null
onMounted(() => {
timer = window.setInterval(() => {
nowTs.value = Date.now()
}, 1000)
})
onUnmounted(() => {
if (timer != null) {
clearInterval(timer)
timer = null
}
})
const {
showParallelQueuedStats,
estimateRangeSeconds,
estimateRemainingRangeSeconds,
timeElapsedValue
} = useQueueEstimates({
queueStore,
executionStore,
taskForJob,
jobState,
firstSeenTs,
jobsAhead,
nowTs
})
const formatEta = (lo: number, hi: number): string => {
if (hi <= 60) {
const hiS = Math.max(1, Math.round(hi))
const loS = Math.max(1, Math.min(hiS, Math.round(lo)))
if (loS === hiS)
return t('queue.jobDetails.eta.seconds', { count: hiS }, hiS)
return t('queue.jobDetails.eta.secondsRange', { lo: loS, hi: hiS })
}
if (lo >= 60 && hi < 90) {
return t('queue.jobDetails.eta.minutes', { count: 1 }, 1)
}
const loM = Math.max(1, Math.floor(lo / 60))
const hiM = Math.max(loM, Math.ceil(hi / 60))
if (loM === hiM) {
return t('queue.jobDetails.eta.minutes', { count: loM }, loM)
}
return t('queue.jobDetails.eta.minutesRange', { lo: loM, hi: hiM })
}
const estimatedStartInValue = computed(() => {
const range = estimateRangeSeconds.value
if (!range) return ''
const [lo, hi] = range
return formatEta(lo, hi)
})
const estimatedFinishInValue = computed(() => {
const range = estimateRemainingRangeSeconds.value
if (!range) return ''
const [lo, hi] = range
return formatEta(lo, hi)
})
type DetailRow = { label: string; value: string; canCopy?: boolean }
const baseRows = computed<DetailRow[]>(() => [
{ label: t('queue.jobDetails.workflow'), value: workflowValue.value },
{ label: t('queue.jobDetails.jobId'), value: jobIdValue.value, canCopy: true }
])
const extraRows = computed<DetailRow[]>(() => {
if (jobState.value === 'pending') {
if (!firstSeenTs.value) return []
const rows: DetailRow[] = [
{ label: t('queue.jobDetails.queuedAt'), value: queuedAtValue.value }
]
if (showParallelQueuedStats.value) {
rows.push(
{
label: t('queue.jobDetails.queuePosition'),
value: queuePositionValue.value
},
{
label: t('queue.jobDetails.timeElapsed'),
value: timeElapsedValue.value
},
{
label: t('queue.jobDetails.estimatedStartIn'),
value: estimatedStartInValue.value
}
)
}
return rows
}
if (jobState.value === 'running') {
if (!firstSeenTs.value) return []
return [
{ label: t('queue.jobDetails.queuedAt'), value: queuedAtValue.value },
{
label: t('queue.jobDetails.timeElapsed'),
value: timeElapsedValue.value
},
{
label: t('queue.jobDetails.estimatedFinishIn'),
value: estimatedFinishInValue.value
}
]
}
if (jobState.value === 'completed') {
const task = taskForJob.value as any
const endTs: number | undefined = task?.executionEndTimestamp
const execMs: number | undefined = task?.executionTime
const generatedOnValue = endTs ? formatClockTime(endTs, locale.value) : ''
const totalGenTimeValue =
execMs !== undefined ? formatElapsedTime(execMs) : ''
const computeHoursValue =
execMs !== undefined ? (execMs / 3600000).toFixed(3) + ' hours' : ''
const rows: DetailRow[] = [
{ label: t('queue.jobDetails.generatedOn'), value: generatedOnValue },
{
label: t('queue.jobDetails.totalGenerationTime'),
value: totalGenTimeValue
}
]
if (isCloud) {
rows.push({
label: t('queue.jobDetails.computeHoursUsed'),
value: computeHoursValue
})
}
return rows
}
if (jobState.value === 'failed') {
const task = taskForJob.value as any
const execMs: number | undefined = task?.executionTime
const failedAfterValue =
execMs !== undefined ? formatElapsedTime(execMs) : ''
const computeHoursValue =
execMs !== undefined ? (execMs / 3600000).toFixed(3) + ' hours' : ''
const rows: DetailRow[] = [
{ label: t('queue.jobDetails.queuedAt'), value: queuedAtValue.value },
{ label: t('queue.jobDetails.failedAfter'), value: failedAfterValue }
]
if (isCloud) {
rows.push({
label: t('queue.jobDetails.computeHoursUsed'),
value: computeHoursValue
})
}
return rows
}
return []
})
const { errorMessageValue, copyErrorMessage, reportJobError } =
useJobErrorReporting({
taskForJob,
copyToClipboard,
dialog
})
</script>

View File

@@ -0,0 +1,224 @@
<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"
class="h-6 cursor-pointer rounded border-0 px-3 py-1 text-[12px] leading-none hover:opacity-90"
:class="[
selectedJobTab === tab
? 'bg-secondary-background text-text-primary'
: 'bg-transparent text-text-secondary'
]"
@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"
class="relative inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 hover:bg-secondary-background-hover hover:opacity-90"
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
@click="onFilterClick"
>
<i
class="icon-[lucide--list-filter] block size-4 leading-none text-text-primary"
/>
<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="inline-flex w-full cursor-pointer items-center justify-start gap-1 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
:aria-label="
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
"
@click="selectWorkflowFilter('all')"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterAllWorkflows')
}}</span>
<span class="ml-auto inline-flex items-center">
<i
v-if="selectedWorkflowFilter === 'all'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</span>
</button>
<div class="mx-2 mt-1 h-px" />
<button
class="inline-flex w-full cursor-pointer items-center justify-start gap-1 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
:aria-label="
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
"
@click="selectWorkflowFilter('current')"
>
<span>{{
t('sideToolbar.queueProgressOverlay.filterCurrentWorkflow')
}}</span>
<span class="ml-auto inline-flex items-center">
<i
v-if="selectedWorkflowFilter === 'current'"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</span>
</button>
</div>
</Popover>
<button
v-tooltip.top="sortTooltipConfig"
class="relative inline-flex size-6 cursor-pointer items-center justify-center rounded border-0 bg-secondary-background p-0 hover:bg-secondary-background-hover hover:opacity-90"
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
@click="onSortClick"
>
<i
class="icon-[lucide--arrow-up-down] block size-4 leading-none text-text-primary"
/>
<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="inline-flex w-full cursor-pointer items-center justify-start gap-1 rounded-lg border-0 bg-transparent p-2 font-inter text-[12px] leading-none text-text-primary hover:bg-transparent hover:opacity-90"
:aria-label="sortLabel(mode)"
@click="selectSortMode(mode)"
>
<span>{{ sortLabel(mode) }}</span>
<span class="ml-auto inline-flex items-center">
<i
v-if="selectedSortMode === mode"
class="icon-[lucide--check] block size-4 leading-none text-text-secondary"
/>
</span>
</button>
<div
v-if="index < jobSortModes.length - 1"
class="mx-2 mt-1 h-px"
/>
</template>
</div>
</Popover>
</div>
</div>
</template>
<script setup lang="ts">
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { jobSortModes, jobTabs } from '@/composables/queue/useJobList'
import type { JobSortMode, JobTab } from '@/composables/queue/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'
const props = defineProps<{
selectedJobTab: JobTab
selectedWorkflowFilter: 'all' | 'current'
selectedSortMode: JobSortMode
hasFailedJobs: boolean
}>()
const emit = defineEmits<{
(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'))
)
// 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 as any)?.hide?.()
emit('update:selectedWorkflowFilter', value)
}
const onSortClick = (event: Event) => {
if (sortPopoverRef.value) {
sortPopoverRef.value.toggle(event)
}
}
const selectSortMode = (value: JobSortMode) => {
;(sortPopoverRef.value as any)?.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

@@ -0,0 +1,92 @@
<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-[12px] leading-none text-text-secondary">
{{ group.label }}
</div>
<QueueJobItem
v-for="ji in group.items"
:key="ji.id"
:job-id="ji.id"
:workflow-id="ji.taskRef?.workflow?.id"
:state="ji.state"
:title="ji.title"
:right-text="ji.meta"
:icon-name="ji.iconName"
:icon-image-url="ji.iconImageUrl"
:show-clear="ji.showClear"
:show-menu="true"
:progress-total-percent="ji.progressTotalPercent"
:progress-current-percent="ji.progressCurrentPercent"
:running-node-name="ji.runningNodeName"
:active-details-id="activeDetailsId"
@cancel="emitCancelItem(ji)"
@delete="emitDeleteItem(ji)"
@menu="(ev) => $emit('menu', ji, ev)"
@view="$emit('viewItem', ji)"
@details-enter="onDetailsEnter"
@details-leave="onDetailsLeave"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import QueueJobItem from '@/components/queue/job/QueueJobItem.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
defineProps<{ displayedJobGroups: JobGroup[] }>()
const emit = defineEmits<{
(e: 'cancelItem', item: JobListItem): void
(e: 'deleteItem', item: JobListItem): void
(e: 'menu', item: JobListItem, ev: MouseEvent): void
(e: 'viewItem', item: JobListItem): void
}>()
const emitCancelItem = (item: JobListItem) => {
emit('cancelItem', item)
}
const emitDeleteItem = (item: JobListItem) => {
emit('deleteItem', item)
}
const activeDetailsId = ref<string | null>(null)
const hideTimer = ref<number | null>(null)
const showTimer = ref<number | null>(null)
const clearHideTimer = () => {
if (hideTimer.value !== null) {
clearTimeout(hideTimer.value)
hideTimer.value = null
}
}
const clearShowTimer = () => {
if (showTimer.value !== null) {
clearTimeout(showTimer.value)
showTimer.value = null
}
}
const onDetailsEnter = (jobId: string) => {
clearHideTimer()
clearShowTimer()
showTimer.value = window.setTimeout(() => {
activeDetailsId.value = jobId
showTimer.value = null
}, 200)
}
const onDetailsLeave = (jobId: string) => {
clearHideTimer()
clearShowTimer()
hideTimer.value = window.setTimeout(() => {
if (activeDetailsId.value === jobId) activeDetailsId.value = null
hideTimer.value = null
}, 150)
}
</script>

View File

@@ -0,0 +1,65 @@
<template>
<div class="w-[300px] min-w-[260px] rounded-lg shadow-md">
<div class="p-3">
<div class="relative aspect-square w-full overflow-hidden rounded-lg">
<img
ref="imgRef"
:src="imageUrl"
:alt="name"
class="h-full w-full cursor-pointer object-contain"
@click="$emit('image-click')"
@load="onImgLoad"
/>
<div
v-if="timeLabel"
class="absolute bottom-2 left-2 rounded px-2 py-0.5 text-xs text-text-primary"
:style="{
background: 'rgba(217, 217, 217, 0.40)',
backdropFilter: 'blur(2px)'
}"
>
{{ timeLabel }}
</div>
</div>
<div class="mt-2 text-center">
<div
class="truncate text-[0.875rem] leading-normal font-semibold text-text-primary"
:title="name"
>
{{ name }}
</div>
<div
v-if="width && height"
class="mt-1 text-[0.75rem] leading-normal text-text-secondary"
>
{{ width }}x{{ height }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
defineOptions({ inheritAttrs: false })
defineProps<{
imageUrl: string
name: string
timeLabel?: string
}>()
defineEmits(['image-click'])
const imgRef = ref<HTMLImageElement | null>(null)
const width = ref<number | null>(null)
const height = ref<number | null>(null)
const onImgLoad = () => {
const el = imgRef.value
if (!el) return
width.value = el.naturalWidth || null
height.value = el.naturalHeight || null
}
</script>

View File

@@ -0,0 +1,134 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import QueueJobItem from './QueueJobItem.vue'
const meta: Meta<typeof QueueJobItem> = {
title: 'Queue/QueueJobItem',
component: QueueJobItem,
parameters: {
layout: 'padded'
},
argTypes: {
onCancel: { action: 'cancel' },
onDelete: { action: 'delete' },
onMenu: { action: 'menu' },
onView: { action: 'view' }
}
}
export default meta
type Story = StoryObj<typeof meta>
const thumb = (hex: string) =>
`data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='256' height='256'><rect width='256' height='256' fill='%23${hex}'/></svg>`
export const PendingRecentlyAdded: Story = {
args: {
jobId: 'job-pending-added-1',
state: 'pending',
title: 'Job added to queue',
rightText: '12:30 PM',
iconName: 'icon-[lucide--check]'
}
}
export const Pending: Story = {
args: {
jobId: 'job-pending-1',
state: 'pending',
title: 'Pending job',
rightText: '12:31 PM'
}
}
export const Initialization: Story = {
args: {
jobId: 'job-init-1',
state: 'initialization',
title: 'Initializing...'
}
}
export const RunningTotalOnly: Story = {
args: {
jobId: 'job-running-1',
state: 'running',
title: 'Generating image',
progressTotalPercent: 42
}
}
export const RunningWithCurrent: Story = {
args: {
jobId: 'job-running-2',
state: 'running',
title: 'Generating image',
progressTotalPercent: 66,
progressCurrentPercent: 10,
runningNodeName: 'KSampler'
}
}
export const CompletedWithPreview: Story = {
args: {
jobId: 'job-completed-1',
state: 'completed',
title: 'Prompt #1234',
rightText: '12.79s',
iconImageUrl: thumb('4dabf7')
}
}
export const CompletedNoPreview: Story = {
args: {
jobId: 'job-completed-2',
state: 'completed',
title: 'Prompt #5678',
rightText: '8.12s'
}
}
export const Failed: Story = {
args: {
jobId: 'job-failed-1',
state: 'failed',
title: 'Failed job',
rightText: 'Failed'
}
}
export const Gallery: Story = {
render: (args) => ({
components: { QueueJobItem },
setup() {
return { args }
},
template: `
<div class="flex flex-col gap-2 w-[420px]">
<QueueJobItem job-id="job-pending-added-1" state="pending" title="Job added to queue" right-text="12:30 PM" icon-name="icon-[lucide--check]" v-bind="args" />
<QueueJobItem job-id="job-pending-1" state="pending" title="Pending job" right-text="12:31 PM" v-bind="args" />
<QueueJobItem job-id="job-init-1" state="initialization" title="Initializing..." v-bind="args" />
<QueueJobItem job-id="job-running-1" state="running" title="Generating image" :progress-total-percent="42" v-bind="args" />
<QueueJobItem
job-id="job-running-2"
state="running"
title="Generating image"
:progress-total-percent="66"
:progress-current-percent="10"
running-node-name="KSampler"
v-bind="args"
/>
<QueueJobItem
job-id="job-completed-1"
state="completed"
title="Prompt #1234"
right-text="12.79s"
icon-image-url="${thumb('4dabf7')}"
v-bind="args"
/>
<QueueJobItem job-id="job-completed-2" state="completed" title="Prompt #5678" right-text="8.12s" v-bind="args" />
<QueueJobItem job-id="job-failed-1" state="failed" title="Failed job" right-text="Failed" v-bind="args" />
</div>
`
})
}

View File

@@ -0,0 +1,310 @@
<template>
<div
ref="rowRef"
class="relative"
@mouseenter="onRowEnter"
@mouseleave="onRowLeave"
@contextmenu.stop.prevent="onContextMenu"
>
<Teleport to="body">
<div
v-if="!isPreviewVisible && showDetails && popoverPosition"
class="fixed z-50"
:style="{
top: `${popoverPosition.top}px`,
right: `${popoverPosition.right}px`
}"
@mouseenter="onPopoverEnter"
@mouseleave="onPopoverLeave"
>
<JobDetailsPopover
:job-id="props.jobId"
:workflow-id="props.workflowId"
/>
</div>
</Teleport>
<Teleport to="body">
<div
v-if="isPreviewVisible && canShowPreview && popoverPosition"
class="fixed z-50"
:style="{
top: `${popoverPosition.top}px`,
right: `${popoverPosition.right}px`
}"
@mouseenter="onPreviewEnter"
@mouseleave="onPreviewLeave"
>
<QueueAssetPreview
:image-url="iconImageUrl!"
:name="props.title"
:time-label="rightText || undefined"
@image-click="emit('view')"
/>
</div>
</Teleport>
<div
class="relative flex items-center justify-between gap-2 overflow-hidden rounded-lg border border-secondary-background bg-secondary-background p-1 text-[12px] text-text-primary transition-colors duration-150 ease-in-out hover:border-secondary-background-hover hover:bg-secondary-background-hover"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
<div
v-if="
props.state === 'running' &&
(props.progressTotalPercent !== undefined ||
props.progressCurrentPercent !== undefined)
"
class="absolute inset-0"
>
<div
v-if="props.progressTotalPercent !== undefined"
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
:style="{ width: `${props.progressTotalPercent}%` }"
/>
<div
v-if="props.progressCurrentPercent !== undefined"
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
:style="{ width: `${props.progressCurrentPercent}%` }"
/>
</div>
<div class="relative z-[1] flex items-center gap-1">
<div class="relative inline-flex items-center justify-center">
<div
class="absolute left-1/2 top-1/2 size-10 -translate-x-1/2 -translate-y-1/2"
@mouseenter.stop="onIconEnter"
@mouseleave.stop="onIconLeave"
/>
<div
class="inline-flex h-6 w-6 items-center justify-center overflow-hidden rounded-[6px]"
>
<img
v-if="iconImageUrl"
:src="iconImageUrl"
class="h-full w-full object-cover"
/>
<i v-else :class="[iconClass, 'size-4']" />
</div>
</div>
</div>
<div class="relative z-[1] min-w-0 flex-1">
<div class="truncate opacity-90" :title="props.title">
<slot name="primary">{{ props.title }}</slot>
</div>
</div>
<div class="relative z-[1] flex items-center gap-2 text-text-secondary">
<Transition
mode="out-in"
enter-active-class="transition-opacity transition-transform duration-150 ease-out"
leave-active-class="transition-opacity transition-transform duration-150 ease-in"
enter-from-class="opacity-0 translate-y-0.5"
enter-to-class="opacity-100 translate-y-0"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-0.5"
>
<div
v-if="isHovered"
key="actions"
class="inline-flex items-center gap-2 pr-1"
>
<button
v-if="props.state === 'failed' && computedShowClear"
v-tooltip.top="deleteTooltipConfig"
type="button"
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
:aria-label="t('g.delete')"
@click.stop="emit('delete')"
>
<i class="icon-[lucide--trash-2] size-4" />
</button>
<button
v-else-if="props.state !== 'completed' && computedShowClear"
v-tooltip.top="cancelTooltipConfig"
type="button"
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
:aria-label="t('g.cancel')"
@click.stop="emit('cancel')"
>
<i class="icon-[lucide--x] size-4" />
</button>
<button
v-else-if="props.state === 'completed'"
type="button"
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-2 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
:aria-label="t('menuLabels.View')"
@click.stop="emit('view')"
>
<span>{{ t('menuLabels.View') }}</span>
</button>
<button
v-if="props.showMenu !== undefined ? props.showMenu : true"
v-tooltip.top="moreTooltipConfig"
type="button"
class="inline-flex h-6 transform cursor-pointer items-center gap-1 rounded border-0 bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
:aria-label="t('g.more')"
@click.stop="emit('menu', $event)"
>
<i class="icon-[lucide--more-horizontal] size-4" />
</button>
</div>
<div v-else key="secondary" class="pr-2">
<slot name="secondary">{{ props.rightText }}</slot>
</div>
</Transition>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import type { JobState } from '@/types/queue'
import { iconForJobState } from '@/utils/queueDisplay'
const props = withDefaults(
defineProps<{
jobId: string
workflowId?: string
state: JobState
title: string
rightText?: string
iconName?: string
iconImageUrl?: string
showClear?: boolean
showMenu?: boolean
progressTotalPercent?: number
progressCurrentPercent?: number
runningNodeName?: string
activeDetailsId?: string | null
}>(),
{
workflowId: undefined,
rightText: '',
iconName: undefined,
iconImageUrl: undefined,
showClear: undefined,
showMenu: undefined,
progressTotalPercent: undefined,
progressCurrentPercent: undefined,
runningNodeName: undefined,
activeDetailsId: null
}
)
const emit = defineEmits<{
(e: 'cancel'): void
(e: 'delete'): void
(e: 'menu', event: MouseEvent): void
(e: 'view'): void
(e: 'details-enter', jobId: string): void
(e: 'details-leave', jobId: string): void
}>()
const { t } = useI18n()
const cancelTooltipConfig = computed(() => buildTooltipConfig(t('g.cancel')))
const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const rowRef = ref<HTMLDivElement | null>(null)
const showDetails = computed(() => props.activeDetailsId === props.jobId)
const onRowEnter = () => {
if (!isPreviewVisible.value) emit('details-enter', props.jobId)
}
const onRowLeave = () => emit('details-leave', props.jobId)
const onPopoverEnter = () => emit('details-enter', props.jobId)
const onPopoverLeave = () => emit('details-leave', props.jobId)
const isPreviewVisible = ref(false)
const previewHideTimer = ref<number | null>(null)
const previewShowTimer = ref<number | null>(null)
const clearPreviewHideTimer = () => {
if (previewHideTimer.value !== null) {
clearTimeout(previewHideTimer.value)
previewHideTimer.value = null
}
}
const clearPreviewShowTimer = () => {
if (previewShowTimer.value !== null) {
clearTimeout(previewShowTimer.value)
previewShowTimer.value = null
}
}
const canShowPreview = computed(
() => props.state === 'completed' && !!props.iconImageUrl
)
const scheduleShowPreview = () => {
if (!canShowPreview.value) return
clearPreviewHideTimer()
clearPreviewShowTimer()
previewShowTimer.value = window.setTimeout(() => {
isPreviewVisible.value = true
previewShowTimer.value = null
}, 200)
}
const scheduleHidePreview = () => {
clearPreviewHideTimer()
clearPreviewShowTimer()
previewHideTimer.value = window.setTimeout(() => {
isPreviewVisible.value = false
previewHideTimer.value = null
}, 150)
}
const onIconEnter = () => scheduleShowPreview()
const onIconLeave = () => scheduleHidePreview()
const onPreviewEnter = () => scheduleShowPreview()
const onPreviewLeave = () => scheduleHidePreview()
const popoverPosition = ref<{ top: number; right: number } | null>(null)
const updatePopoverPosition = () => {
const el = rowRef.value
if (!el) return
const rect = el.getBoundingClientRect()
const gap = 8
popoverPosition.value = {
top: rect.top,
right: window.innerWidth - rect.left + gap
}
}
const isAnyPopoverVisible = computed(
() => showDetails.value || (isPreviewVisible.value && canShowPreview.value)
)
watch(
isAnyPopoverVisible,
(visible) => {
if (visible) {
nextTick(updatePopoverPosition)
} else {
popoverPosition.value = null
}
},
{ immediate: false }
)
const isHovered = ref(false)
const iconClass = computed(() => {
if (props.iconName) return props.iconName
return iconForJobState(props.state)
})
const computedShowClear = computed(() => {
if (props.showClear !== undefined) return props.showClear
return props.state !== 'completed'
})
const onContextMenu = (event: MouseEvent) => {
const shouldShowMenu = props.showMenu !== undefined ? props.showMenu : true
if (shouldShowMenu) emit('menu', event)
}
</script>

View File

@@ -0,0 +1,83 @@
import { computed } from 'vue'
import type { ComputedRef } from 'vue'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import type { TaskItemImpl } from '@/stores/queueStore'
type CopyHandler = (value: string) => void | Promise<void>
export type JobErrorDialogService = {
showExecutionErrorDialog: (error: ExecutionErrorWsMessage) => void
showErrorDialog: (
error: Error,
options?: {
reportType?: string
[key: string]: unknown
}
) => void
}
type JobExecutionError = {
detail?: ExecutionErrorWsMessage
message: string
}
export const extractExecutionError = (
task: TaskItemImpl | null
): JobExecutionError | null => {
const status = (task as TaskItemImpl | null)?.status
const messages = (status as { messages?: unknown[] } | undefined)?.messages
if (!Array.isArray(messages) || !messages.length) return null
const record = messages.find((entry: unknown) => {
return Array.isArray(entry) && entry[0] === 'execution_error'
}) as [string, ExecutionErrorWsMessage?] | undefined
if (!record) return null
const detail = record[1]
const message = String(detail?.exception_message ?? '')
return {
detail,
message
}
}
type UseJobErrorReportingOptions = {
taskForJob: ComputedRef<TaskItemImpl | null>
copyToClipboard: CopyHandler
dialog: JobErrorDialogService
}
export const useJobErrorReporting = ({
taskForJob,
copyToClipboard,
dialog
}: UseJobErrorReportingOptions) => {
const errorMessageValue = computed(() => {
const error = extractExecutionError(taskForJob.value)
return error?.message ?? ''
})
const copyErrorMessage = () => {
if (errorMessageValue.value) {
void copyToClipboard(errorMessageValue.value)
}
}
const reportJobError = () => {
const error = extractExecutionError(taskForJob.value)
if (error?.detail) {
dialog.showExecutionErrorDialog(error.detail)
return
}
if (errorMessageValue.value) {
dialog.showErrorDialog(new Error(errorMessageValue.value), {
reportType: 'queueJobError'
})
}
}
return {
errorMessageValue,
copyErrorMessage,
reportJobError
}
}

View File

@@ -0,0 +1,230 @@
import { computed, ref } from 'vue'
import { describe, expect, it } from 'vitest'
import type { TaskItemImpl } from '@/stores/queueStore'
import type { JobState } from '@/types/queue'
import { formatElapsedTime, useQueueEstimates } from './useQueueEstimates'
import type { UseQueueEstimatesOptions } from './useQueueEstimates'
type QueueStore = UseQueueEstimatesOptions['queueStore']
type ExecutionStore = UseQueueEstimatesOptions['executionStore']
const makeHistoryTask = (
executionTimeInSeconds: number | string | undefined
): TaskItemImpl =>
({
executionTimeInSeconds
}) as TaskItemImpl
const makeRunningTask = (executionStartTimestamp?: number): TaskItemImpl =>
({
executionStartTimestamp
}) as TaskItemImpl
const createQueueStore = (data?: Partial<QueueStore>): QueueStore =>
({
historyTasks: [],
runningTasks: [],
...data
}) as QueueStore
const createExecutionStore = (data?: Partial<ExecutionStore>): ExecutionStore =>
({
runningWorkflowCount: 1,
...data
}) as ExecutionStore
type HarnessOptions = {
queueStore?: QueueStore
executionStore?: ExecutionStore
task?: TaskItemImpl | null
jobState?: JobState | null
firstSeenTs?: number
jobsAhead?: number | null
now?: number
}
const createHarness = (options?: HarnessOptions) => {
const queueStore = options?.queueStore ?? createQueueStore()
const executionStore = options?.executionStore ?? createExecutionStore()
const taskRef = ref<TaskItemImpl | null>(options?.task ?? null)
const jobStateRef = ref<JobState | null>(options?.jobState ?? null)
const firstSeenRef = ref<number | undefined>(options?.firstSeenTs)
const jobsAheadRef = ref<number | null>(options?.jobsAhead ?? null)
const nowRef = ref(options?.now ?? 0)
const result = useQueueEstimates({
queueStore,
executionStore,
taskForJob: computed(() => taskRef.value),
jobState: computed(() => jobStateRef.value),
firstSeenTs: computed(() => firstSeenRef.value),
jobsAhead: computed(() => jobsAheadRef.value),
nowTs: nowRef
})
return {
...result,
queueStore,
executionStore,
taskRef,
jobStateRef,
firstSeenRef,
jobsAheadRef,
nowRef
}
}
describe('formatElapsedTime', () => {
it('formats elapsed milliseconds and clamps negatives to zero', () => {
expect(formatElapsedTime(0)).toBe('0m 0s')
expect(formatElapsedTime(61000)).toBe('1m 1s')
expect(formatElapsedTime(90000)).toBe('1m 30s')
expect(formatElapsedTime(-5000)).toBe('0m 0s')
})
})
describe('useQueueEstimates', () => {
it('only shows parallel queued stats for pending jobs seen with multiple runners', () => {
const ready = createHarness({
executionStore: createExecutionStore({ runningWorkflowCount: 2 }),
jobState: 'pending',
firstSeenTs: 1000
})
expect(ready.showParallelQueuedStats.value).toBe(true)
const missingTimestamp = createHarness({
executionStore: createExecutionStore({ runningWorkflowCount: 2 }),
jobState: 'pending'
})
expect(missingTimestamp.showParallelQueuedStats.value).toBe(false)
const singleRunner = createHarness({
executionStore: createExecutionStore({ runningWorkflowCount: 1 }),
jobState: 'pending',
firstSeenTs: 1000
})
expect(singleRunner.showParallelQueuedStats.value).toBe(false)
const runningJob = createHarness({
executionStore: createExecutionStore({ runningWorkflowCount: 3 }),
jobState: 'running',
firstSeenTs: 1000
})
expect(runningJob.showParallelQueuedStats.value).toBe(false)
})
it('uses the last 20 valid durations to estimate queued batches', () => {
const durations = Array.from({ length: 25 }, (_, idx) => idx + 1)
const queueStore = createQueueStore({
historyTasks: [
...durations.slice(0, 5).map((value) => makeHistoryTask(value)),
makeHistoryTask('not-a-number'),
makeHistoryTask(undefined),
...durations.slice(5).map((value) => makeHistoryTask(value))
]
})
const { estimateRangeSeconds } = createHarness({
queueStore,
executionStore: createExecutionStore({ runningWorkflowCount: 2 }),
jobsAhead: 5
})
expect(estimateRangeSeconds.value).toEqual([47, 63])
})
it('returns null for estimateRangeSeconds when no history or jobsAhead is unknown', () => {
const emptyHistory = createHarness({
queueStore: createQueueStore(),
jobsAhead: 2
})
expect(emptyHistory.estimateRangeSeconds.value).toBeNull()
const missingAhead = createHarness({
queueStore: createQueueStore({
historyTasks: [makeHistoryTask(10)]
})
})
expect(missingAhead.estimateRangeSeconds.value).toBeNull()
})
it('falls back to the running remaining range when there are no jobs ahead', () => {
const now = 20000
const queueStore = createQueueStore({
historyTasks: [10, 20, 30].map((value) => makeHistoryTask(value)),
runningTasks: [
makeRunningTask(now - 5000),
makeRunningTask(now - 15000),
makeRunningTask(undefined)
]
})
const { estimateRangeSeconds } = createHarness({
queueStore,
jobsAhead: 0,
now
})
expect(estimateRangeSeconds.value).toEqual([5, 15])
})
it('subtracts elapsed time when estimating a running job', () => {
const now = 25000
const queueStore = createQueueStore({
historyTasks: [10, 20, 30].map((value) => makeHistoryTask(value))
})
const { estimateRemainingRangeSeconds } = createHarness({
queueStore,
task: makeRunningTask(5000),
jobState: 'running',
firstSeenTs: 2000,
now
})
expect(estimateRemainingRangeSeconds.value).toEqual([0, 10])
})
it('uses the first-seen timestamp for pending jobs and clamps negatives to zero', () => {
const queueStore = createQueueStore({
historyTasks: [10, 20, 30].map((value) => makeHistoryTask(value))
})
const harness = createHarness({
queueStore,
jobState: 'pending',
firstSeenTs: 10000,
now: 25000
})
expect(harness.estimateRemainingRangeSeconds.value).toEqual([5, 15])
harness.firstSeenRef.value = 1000
harness.nowRef.value = 70000
expect(harness.estimateRemainingRangeSeconds.value).toEqual([0, 0])
})
it('computes the elapsed label using execution start, then first-seen timestamp', () => {
const harness = createHarness()
harness.taskRef.value = makeRunningTask(1000)
harness.jobStateRef.value = 'running'
harness.nowRef.value = 4000
expect(harness.timeElapsedValue.value).toBe('0m 3s')
harness.jobStateRef.value = 'pending'
harness.firstSeenRef.value = 2000
harness.nowRef.value = 5000
expect(harness.timeElapsedValue.value).toBe('0m 3s')
harness.taskRef.value = null
harness.firstSeenRef.value = undefined
expect(harness.timeElapsedValue.value).toBe('')
})
})

View File

@@ -0,0 +1,149 @@
import { computed } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import type { useExecutionStore } from '@/stores/executionStore'
import type { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import type { JobState } from '@/types/queue'
type QueueStore = ReturnType<typeof useQueueStore>
type ExecutionStore = ReturnType<typeof useExecutionStore>
export type UseQueueEstimatesOptions = {
queueStore: QueueStore
executionStore: ExecutionStore
taskForJob: ComputedRef<TaskItemImpl | null>
jobState: ComputedRef<JobState | null>
firstSeenTs: ComputedRef<number | undefined>
jobsAhead: ComputedRef<number | null>
nowTs: Ref<number>
}
type EstimateRange = [number, number]
export const formatElapsedTime = (ms: number): string => {
const totalSec = Math.max(0, Math.floor(ms / 1000))
const minutes = Math.floor(totalSec / 60)
const seconds = totalSec % 60
return `${minutes}m ${seconds}s`
}
const pickRecentDurations = (queueStore: QueueStore) =>
queueStore.historyTasks
.map((task: TaskItemImpl) => Number(task.executionTimeInSeconds))
.filter(
(value: number | undefined) =>
typeof value === 'number' && !Number.isNaN(value)
) as number[]
export const useQueueEstimates = ({
queueStore,
executionStore,
taskForJob,
jobState,
firstSeenTs,
jobsAhead,
nowTs
}: UseQueueEstimatesOptions) => {
const runningWorkflowCount = computed(
() => executionStore.runningWorkflowCount
)
const showParallelQueuedStats = computed(
() =>
jobState.value === 'pending' &&
!!firstSeenTs.value &&
(runningWorkflowCount.value ?? 0) > 1
)
const recentDurations = computed<number[]>(() =>
pickRecentDurations(queueStore).slice(-20)
)
const runningRemainingRangeSeconds = computed<EstimateRange | null>(() => {
const durations = recentDurations.value
if (!durations.length) return null
const sorted = durations.slice().sort((a, b) => a - b)
const avg = sorted.reduce((sum, value) => sum + value, 0) / sorted.length
const p75 =
sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.75))]
const running = queueStore.runningTasks as TaskItemImpl[]
const now = nowTs.value
const remaining = running
.map((task) => task.executionStartTimestamp)
.filter((timestamp): timestamp is number => typeof timestamp === 'number')
.map((startTs) => {
const elapsed = Math.max(0, Math.floor((now - startTs) / 1000))
return {
lo: Math.max(0, Math.round(avg - elapsed)),
hi: Math.max(0, Math.round(p75 - elapsed))
}
})
if (!remaining.length) return null
const minLo = remaining.reduce(
(min, range) => Math.min(min, range.lo),
Infinity
)
const minHi = remaining.reduce(
(min, range) => Math.min(min, range.hi),
Infinity
)
return [minLo, minHi]
})
const estimateRangeSeconds = computed<EstimateRange | null>(() => {
const durations = recentDurations.value
if (!durations.length) return null
const ahead = jobsAhead.value
if (ahead == null) return null
const sorted = durations.slice().sort((a, b) => a - b)
const avg = sorted.reduce((sum, value) => sum + value, 0) / sorted.length
const p75 =
sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.75))]
if (ahead <= 0) {
return runningRemainingRangeSeconds.value ?? [0, 0]
}
const runningCount = Math.max(1, runningWorkflowCount.value || 1)
const batches = Math.ceil(ahead / runningCount)
return [Math.round(avg * batches), Math.round(p75 * batches)]
})
const estimateRemainingRangeSeconds = computed<EstimateRange | null>(() => {
const durations = recentDurations.value
if (!durations.length) return null
const sorted = durations.slice().sort((a, b) => a - b)
const avg = sorted.reduce((sum, value) => sum + value, 0) / sorted.length
const p75 =
sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.75))]
const task = taskForJob.value as TaskItemImpl & {
executionStartTimestamp?: number
}
const execStart =
jobState.value === 'running' ? task?.executionStartTimestamp : undefined
const baseTs = execStart ?? firstSeenTs.value
const elapsed = baseTs
? Math.max(0, Math.floor((nowTs.value - baseTs) / 1000))
: 0
const lo = Math.max(0, Math.round(avg - elapsed))
const hi = Math.max(0, Math.round(p75 - elapsed))
return [lo, hi]
})
const timeElapsedValue = computed(() => {
const task = taskForJob.value as TaskItemImpl & {
executionStartTimestamp?: number
}
const execStart =
jobState.value === 'running' ? task?.executionStartTimestamp : undefined
const baseTs = execStart ?? firstSeenTs.value
if (!baseTs) return ''
return formatElapsedTime(nowTs.value - baseTs)
})
return {
runningWorkflowCount,
showParallelQueuedStats,
estimateRangeSeconds,
estimateRemainingRangeSeconds,
timeElapsedValue
}
}

View File

@@ -5,9 +5,9 @@
:value="allGalleryItems"
:show-indicators="false"
change-item-on-indicator-hover
show-item-navigators
:show-item-navigators="hasMultiple"
full-screen
circular
:circular="hasMultiple"
:show-thumbnails="false"
:pt="{
mask: {
@@ -42,7 +42,7 @@
<script setup lang="ts">
import Galleria from 'primevue/galleria'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import ComfyImage from '@/components/common/ComfyImage.vue'
import type { ResultItemImpl } from '@/stores/queueStore'
@@ -61,6 +61,8 @@ const props = defineProps<{
activeIndex: number
}>()
const hasMultiple = computed(() => props.allGalleryItems.length > 1)
let maskMouseDownTarget: EventTarget | null = null
const onMaskMouseDown = (event: MouseEvent) => {

View File

@@ -0,0 +1,116 @@
import { computed, ref, watch } from 'vue'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import { jobStateFromTask } from '@/utils/queueUtil'
export type CompletionSummaryMode = 'allSuccess' | 'mixed' | 'allFailed'
export type CompletionSummary = {
mode: CompletionSummaryMode
completedCount: number
failedCount: number
thumbnailUrls: string[]
}
/**
* Tracks queue activity transitions and exposes a short-lived summary of the
* most recent generation batch.
*/
export const useCompletionSummary = () => {
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const isActive = computed(
() => queueStore.runningTasks.length > 0 || !executionStore.isIdle
)
const lastActiveStartTs = ref<number | null>(null)
const _summary = ref<CompletionSummary | null>(null)
const dismissTimer = ref<number | null>(null)
const clearDismissTimer = () => {
if (dismissTimer.value !== null) {
clearTimeout(dismissTimer.value)
dismissTimer.value = null
}
}
const startDismissTimer = () => {
clearDismissTimer()
dismissTimer.value = window.setTimeout(() => {
_summary.value = null
dismissTimer.value = null
}, 6000)
}
const clearSummary = () => {
_summary.value = null
clearDismissTimer()
}
watch(
isActive,
(active, prev) => {
if (!prev && active) {
lastActiveStartTs.value = Date.now()
}
if (prev && !active) {
const start = lastActiveStartTs.value ?? 0
const finished = queueStore.historyTasks.filter((t: any) => {
const ts: number | undefined = t.executionEndTimestamp
return typeof ts === 'number' && ts >= start
})
if (!finished.length) {
_summary.value = null
clearDismissTimer()
return
}
let completedCount = 0
let failedCount = 0
const imagePreviews: string[] = []
for (const task of finished) {
const state = jobStateFromTask(task, false)
if (state === 'completed') {
completedCount++
const preview = task.previewOutput
if (preview?.isImage) {
imagePreviews.push(preview.urlWithTimestamp)
}
} else if (state === 'failed') {
failedCount++
}
}
if (completedCount === 0 && failedCount === 0) {
_summary.value = null
clearDismissTimer()
return
}
let mode: CompletionSummaryMode = 'mixed'
if (failedCount === 0) mode = 'allSuccess'
else if (completedCount === 0) mode = 'allFailed'
_summary.value = {
mode,
completedCount,
failedCount,
thumbnailUrls: imagePreviews.slice(0, 3)
}
startDismissTimer()
}
},
{ immediate: true }
)
const summary = computed(() => _summary.value)
return {
summary,
clearSummary
}
}

View File

@@ -0,0 +1,352 @@
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { st } from '@/i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import type { TaskItemImpl } from '@/stores/queueStore'
import type { JobState } from '@/types/queue'
import {
dateKey,
formatClockTime,
formatShortMonthDay,
isToday,
isYesterday
} from '@/utils/dateTimeUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildJobDisplay } from '@/utils/queueDisplay'
import { jobStateFromTask } from '@/utils/queueUtil'
/** Tabs for job list filtering */
export const jobTabs = ['All', 'Completed', 'Failed'] as const
export type JobTab = (typeof jobTabs)[number]
export const jobSortModes = ['mostRecent', 'totalGenerationTime'] as const
export type JobSortMode = (typeof jobSortModes)[number]
/**
* UI item in the job list. Mirrors data previously prepared inline.
*/
export type JobListItem = {
id: string
title: string
meta: string
state: JobState
iconName?: string
iconImageUrl?: string
showClear?: boolean
taskRef?: any
progressTotalPercent?: number
progressCurrentPercent?: number
runningNodeName?: string
executionTimeMs?: number
computeHours?: number
}
export type JobGroup = {
key: string
label: string
items: JobListItem[]
}
const ADDED_HINT_DURATION_MS = 3000
const relativeTimeFormatterCache = new Map<string, Intl.RelativeTimeFormat>()
const taskIdToKey = (id: string | number | undefined) => {
if (id === null || id === undefined) return null
const key = String(id)
return key.length ? key : null
}
/**
* Returns localized Today/Yesterday (capitalized) or localized Mon DD.
*/
const dateLabelForTimestamp = (
ts: number,
locale: string,
relativeFormatter: Intl.RelativeTimeFormat
) => {
const formatRelativeDay = (value: number) => {
const formatted = relativeFormatter.format(value, 'day')
return formatted
? formatted[0].toLocaleUpperCase(locale) + formatted.slice(1)
: formatted
}
if (isToday(ts)) {
return formatRelativeDay(0)
}
if (isYesterday(ts)) {
return formatRelativeDay(-1)
}
return formatShortMonthDay(ts, locale)
}
type TaskWithState = {
task: TaskItemImpl
state: JobState
}
/**
* Builds the reactive job list, filters, and grouped view for the queue overlay.
*/
export function useJobList() {
const { t, locale } = useI18n()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const workflowStore = useWorkflowStore()
const recentlyAddedPendingIds = ref<Set<string>>(new Set())
const addedHintTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const clearAddedHintTimeout = (id: string) => {
const timeoutId = addedHintTimeouts.get(id)
if (timeoutId !== undefined) {
clearTimeout(timeoutId)
addedHintTimeouts.delete(id)
}
}
const scheduleAddedHintExpiry = (id: string) => {
clearAddedHintTimeout(id)
const timeoutId = setTimeout(() => {
addedHintTimeouts.delete(id)
const updated = new Set(recentlyAddedPendingIds.value)
if (updated.delete(id)) {
recentlyAddedPendingIds.value = updated
}
}, ADDED_HINT_DURATION_MS)
addedHintTimeouts.set(id, timeoutId)
}
watch(
() =>
queueStore.pendingTasks
.map((task) => taskIdToKey(task.promptId))
.filter((id): id is string => !!id),
(pendingIds) => {
const pendingSet = new Set(pendingIds)
const next = new Set(recentlyAddedPendingIds.value)
pendingIds.forEach((id) => {
if (!next.has(id)) {
next.add(id)
scheduleAddedHintExpiry(id)
}
})
for (const id of Array.from(next)) {
if (!pendingSet.has(id)) {
next.delete(id)
clearAddedHintTimeout(id)
}
}
recentlyAddedPendingIds.value = next
},
{ immediate: true }
)
const shouldShowAddedHint = (task: TaskItemImpl, state: JobState) => {
if (state !== 'pending') return false
const id = taskIdToKey(task.promptId)
if (!id) return false
return recentlyAddedPendingIds.value.has(id)
}
onUnmounted(() => {
addedHintTimeouts.forEach((timeoutId) => clearTimeout(timeoutId))
addedHintTimeouts.clear()
recentlyAddedPendingIds.value = new Set<string>()
})
const { totalPercent, currentNodePercent } = useQueueProgress()
const relativeTimeFormatter = computed(() => {
const localeValue = locale.value
let formatter = relativeTimeFormatterCache.get(localeValue)
if (!formatter) {
formatter = new Intl.RelativeTimeFormat(localeValue, { numeric: 'auto' })
relativeTimeFormatterCache.set(localeValue, formatter)
}
return formatter
})
const undatedLabel = computed(() => t('queue.jobList.undated') || 'Undated')
const isJobInitializing = (promptId: string | number | undefined) =>
executionStore.isPromptInitializing(promptId)
const currentNodeName = computed(() => {
const node = executionStore.executingNode
if (!node) return t('g.emDash')
const title = (node.title ?? '').toString().trim()
if (title) return title
const nodeType = (node.type ?? '').toString().trim() || t('g.untitled')
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
return st(key, nodeType)
})
const selectedJobTab = ref<JobTab>('All')
const selectedWorkflowFilter = ref<'all' | 'current'>('all')
const selectedSortMode = ref<JobSortMode>('mostRecent')
const allTasksSorted = computed<TaskItemImpl[]>(() => {
const all = [
...queueStore.pendingTasks,
...queueStore.runningTasks,
...queueStore.historyTasks
]
return all.sort((a, b) => b.queueIndex - a.queueIndex)
})
const tasksWithJobState = computed<TaskWithState[]>(() =>
allTasksSorted.value.map((task) => ({
task,
state: jobStateFromTask(task, isJobInitializing(task?.promptId))
}))
)
const hasFailedJobs = computed(() =>
tasksWithJobState.value.some(({ state }) => state === 'failed')
)
watch(
() => hasFailedJobs.value,
(hasFailed) => {
if (!hasFailed && selectedJobTab.value === 'Failed') {
selectedJobTab.value = 'All'
}
}
)
const filteredTaskEntries = computed<TaskWithState[]>(() => {
let entries = tasksWithJobState.value
if (selectedJobTab.value === 'Completed') {
entries = entries.filter(({ state }) => state === 'completed')
} else if (selectedJobTab.value === 'Failed') {
entries = entries.filter(({ state }) => state === 'failed')
}
if (selectedWorkflowFilter.value === 'current') {
const activeId = workflowStore.activeWorkflow?.activeState?.id
if (!activeId) return []
entries = entries.filter(({ task }) => {
const wid = task.workflow?.id
return !!wid && wid === activeId
})
}
return entries
})
const filteredTasks = computed<TaskItemImpl[]>(() =>
filteredTaskEntries.value.map(({ task }) => task)
)
const jobItems = computed<JobListItem[]>(() => {
return filteredTaskEntries.value.map(({ task, state }) => {
const isActive =
String(task.promptId ?? '') ===
String(executionStore.activePromptId ?? '')
const showAddedHint = shouldShowAddedHint(task, state)
const display = buildJobDisplay(task, state, {
t,
locale: locale.value,
formatClockTimeFn: formatClockTime,
isActive,
totalPercent: isActive ? totalPercent.value : undefined,
currentNodePercent: isActive ? currentNodePercent.value : undefined,
currentNodeName: isActive ? currentNodeName.value : undefined,
showAddedHint
})
return {
id: String(task.promptId),
title: display.primary,
meta: display.secondary,
state,
iconName: display.iconName,
iconImageUrl: display.iconImageUrl,
showClear: display.showClear,
taskRef: task,
progressTotalPercent:
state === 'running' && isActive ? totalPercent.value : undefined,
progressCurrentPercent:
state === 'running' && isActive
? currentNodePercent.value
: undefined,
runningNodeName:
state === 'running' && isActive ? currentNodeName.value : undefined,
executionTimeMs: task.executionTime,
computeHours:
task.executionTime !== undefined
? task.executionTime / 3_600_000
: undefined
} as JobListItem
})
})
const jobItemById = computed(() => {
const m = new Map<string, JobListItem>()
jobItems.value.forEach((ji) => m.set(ji.id, ji))
return m
})
const groupedJobItems = computed<JobGroup[]>(() => {
const groups: JobGroup[] = []
const index = new Map<string, number>()
const localeValue = locale.value
for (const { task, state } of filteredTaskEntries.value) {
let ts: number | undefined
if (state === 'completed' || state === 'failed') {
ts = task.executionEndTimestamp
} else {
ts = task.createTime
}
const key = ts === undefined ? 'undated' : dateKey(ts)
let groupIdx = index.get(key)
if (groupIdx === undefined) {
const label =
ts === undefined
? undatedLabel.value
: dateLabelForTimestamp(
ts,
localeValue,
relativeTimeFormatter.value
)
groups.push({ key, label, items: [] })
groupIdx = groups.length - 1
index.set(key, groupIdx)
}
const ji = jobItemById.value.get(String(task.promptId))
if (ji) groups[groupIdx].items.push(ji)
}
if (selectedSortMode.value === 'totalGenerationTime') {
const valueOrDefault = (value: JobListItem['executionTimeMs']) =>
typeof value === 'number' && !Number.isNaN(value) ? value : -1
const sortByExecutionTimeDesc = (a: JobListItem, b: JobListItem) =>
valueOrDefault(b.executionTimeMs) - valueOrDefault(a.executionTimeMs)
groups.forEach((group) => {
group.items.sort(sortByExecutionTimeDesc)
})
}
return groups
})
return {
// filters/state
selectedJobTab,
selectedWorkflowFilter,
selectedSortMode,
hasFailedJobs,
// data sources
allTasksSorted,
filteredTasks,
jobItems,
groupedJobItems,
currentNodeName
}
}

View File

@@ -0,0 +1,356 @@
import { computed } from 'vue'
import { downloadFile } from '@/base/common/downloadUtil'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { st, t } from '@/i18n'
import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/assetMappers'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type {
ExecutionErrorWsMessage,
ResultItem,
ResultItemType
} from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { downloadBlob } from '@/scripts/utils'
import { useDialogService } from '@/services/dialogService'
import { useLitegraphService } from '@/services/litegraphService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useQueueStore } from '@/stores/queueStore'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
import { appendJsonExt } from '@/utils/formatUtil'
export type MenuEntry =
| {
kind?: 'item'
key: string
label: string
icon?: string
onClick?: () => void | Promise<void>
}
| { kind: 'divider'; key: string }
/**
* Provides job context menu entries and actions.
*
* @param currentMenuItem Getter for the currently targeted job list item
* @param onInspectAsset Callback to trigger when inspecting a completed job's asset
*/
export function useJobMenu(
currentMenuItem: () => JobListItem | null,
onInspectAsset?: (item: JobListItem) => void
) {
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const queueStore = useQueueStore()
const { copyToClipboard } = useCopyToClipboard()
const litegraphService = useLitegraphService()
const nodeDefStore = useNodeDefStore()
const mediaAssetActions = useMediaAssetActions()
const openJobWorkflow = async () => {
const item = currentMenuItem()
if (!item) return
const data = item.taskRef?.workflow
if (!data) return
const filename = `Job ${item.id}.json`
const temp = workflowStore.createTemporary(filename, data)
await workflowService.openWorkflow(temp)
}
const copyJobId = async () => {
const item = currentMenuItem()
if (!item) return
await copyToClipboard(item.id)
}
const cancelJob = async () => {
const item = currentMenuItem()
if (!item) return
if (item.state === 'running' || item.state === 'initialization') {
await api.interrupt(item.id)
} else if (item.state === 'pending') {
await api.deleteItem('queue', item.id)
}
await queueStore.update()
}
const copyErrorMessage = async () => {
const item = currentMenuItem()
if (!item) return
const msgs = item.taskRef?.status?.messages as any[] | undefined
const err = msgs?.find((m: any) => m?.[0] === 'execution_error')?.[1] as
| ExecutionErrorWsMessage
| undefined
const message = err?.exception_message
if (message) await copyToClipboard(String(message))
}
const reportError = () => {
const item = currentMenuItem()
if (!item) return
const msgs = item.taskRef?.status?.messages as any[] | undefined
const err = msgs?.find((m: any) => m?.[0] === 'execution_error')?.[1] as
| ExecutionErrorWsMessage
| undefined
if (err) useDialogService().showExecutionErrorDialog(err)
}
// This is very magical only because it matches the respective backend implementation
// There is or will be a better way to do this
const addOutputLoaderNode = async () => {
const item = currentMenuItem()
if (!item) return
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
if (!result) return
let nodeType: 'LoadImage' | 'LoadVideo' | 'LoadAudio' | null = null
let widgetName: 'image' | 'file' | 'audio' | null = null
if (result.isImage) {
nodeType = 'LoadImage'
widgetName = 'image'
} else if (result.isVideo) {
nodeType = 'LoadVideo'
widgetName = 'file'
} else if (result.isAudio) {
nodeType = 'LoadAudio'
widgetName = 'audio'
}
if (!nodeType || !widgetName) return
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
if (!nodeDef) return
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: litegraphService.getCanvasCenter()
})
if (!node) return
const isResultItemType = (v: string | undefined): v is ResultItemType =>
v === 'input' || v === 'output' || v === 'temp'
const apiItem: ResultItem = {
filename: result.filename,
subfolder: result.subfolder,
type: isResultItemType(result.type) ? result.type : undefined
}
const annotated = createAnnotatedPath(apiItem, {
rootFolder: apiItem.type
})
const widget = node.widgets?.find((w) => w.name === widgetName)
if (widget) {
widget.value = annotated
widget.callback?.(annotated)
}
node.graph?.setDirtyCanvas(true, true)
}
/**
* Trigger a download of the job's previewable output asset.
*/
const downloadPreviewAsset = () => {
const item = currentMenuItem()
if (!item) return
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
if (!result) return
downloadFile(result.url)
}
/**
* Export the workflow JSON attached to the job.
*/
const exportJobWorkflow = async () => {
const item = currentMenuItem()
if (!item) return
const data = item.taskRef?.workflow
if (!data) return
const settingStore = useSettingStore()
let filename = `Job ${item.id}.json`
if (settingStore.get('Comfy.PromptFilename')) {
const input = await useDialogService().prompt({
title: t('workflowService.exportWorkflow'),
message: t('workflowService.enterFilename') + ':',
defaultValue: filename
})
if (!input) return
filename = appendJsonExt(input)
}
const json = JSON.stringify(data, null, 2)
const blob = new Blob([json], { type: 'application/json' })
downloadBlob(filename, blob)
}
const deleteJobAsset = async () => {
const item = currentMenuItem()
if (!item) return
const task = item.taskRef as TaskItemImpl | undefined
const preview = task?.previewOutput
if (!task || !preview) return
const asset = mapTaskOutputToAssetItem(task, preview)
const success = await mediaAssetActions.confirmDelete(asset)
if (success) {
await queueStore.update()
}
}
const removeFailedJob = async () => {
const task = currentMenuItem()?.taskRef as TaskItemImpl | undefined
if (!task) return
await queueStore.delete(task)
}
const jobMenuOpenWorkflowLabel = computed(() =>
st('queue.jobMenu.openAsWorkflowNewTab', 'Open as workflow in new tab')
)
const jobMenuOpenWorkflowFailedLabel = computed(() =>
st('queue.jobMenu.openWorkflowNewTab', 'Open workflow in new tab')
)
const jobMenuCopyJobIdLabel = computed(() =>
st('queue.jobMenu.copyJobId', 'Copy job ID')
)
const jobMenuCancelLabel = computed(() =>
st('queue.jobMenu.cancelJob', 'Cancel job')
)
const jobMenuEntries = computed<MenuEntry[]>(() => {
const item = currentMenuItem()
const state = item?.state
if (!state) return []
const hasDeletableAsset = !!item?.taskRef?.previewOutput
if (state === 'completed') {
return [
{
key: 'inspect-asset',
label: st('queue.jobMenu.inspectAsset', 'Inspect asset'),
icon: 'icon-[lucide--zoom-in]',
onClick: onInspectAsset
? () => {
const item = currentMenuItem()
if (item) onInspectAsset(item)
}
: undefined
},
{
key: 'add-to-current',
label: st(
'queue.jobMenu.addToCurrentWorkflow',
'Add to current workflow'
),
icon: 'icon-[comfy--node]',
onClick: addOutputLoaderNode
},
{
key: 'download',
label: st('queue.jobMenu.download', 'Download'),
icon: 'icon-[lucide--download]',
onClick: downloadPreviewAsset
},
{ kind: 'divider', key: 'd1' },
{
key: 'open-workflow',
label: jobMenuOpenWorkflowLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: openJobWorkflow
},
{
key: 'export-workflow',
label: st('queue.jobMenu.exportWorkflow', 'Export workflow'),
icon: 'icon-[comfy--file-output]',
onClick: exportJobWorkflow
},
{ kind: 'divider', key: 'd2' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: copyJobId
},
{ kind: 'divider', key: 'd3' },
...(hasDeletableAsset
? [
{
key: 'delete',
label: st('queue.jobMenu.deleteAsset', 'Delete asset'),
icon: 'icon-[lucide--trash-2]',
onClick: deleteJobAsset
}
]
: [])
]
}
if (state === 'failed') {
return [
{
key: 'open-workflow',
label: jobMenuOpenWorkflowFailedLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: openJobWorkflow
},
{ kind: 'divider', key: 'd1' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: copyJobId
},
{
key: 'copy-error',
label: st('queue.jobMenu.copyErrorMessage', 'Copy error message'),
icon: 'icon-[lucide--copy]',
onClick: copyErrorMessage
},
{
key: 'report-error',
label: st('queue.jobMenu.reportError', 'Report error'),
icon: 'icon-[lucide--message-circle-warning]',
onClick: reportError
},
{ kind: 'divider', key: 'd2' },
{
key: 'delete',
label: st('queue.jobMenu.removeJob', 'Remove job'),
icon: 'icon-[lucide--circle-minus]',
onClick: removeFailedJob
}
]
}
return [
{
key: 'open-workflow',
label: jobMenuOpenWorkflowLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: openJobWorkflow
},
{ kind: 'divider', key: 'd1' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: copyJobId
},
{ kind: 'divider', key: 'd2' },
{
key: 'cancel-job',
label: jobMenuCancelLabel.value,
icon: 'icon-[lucide--x]',
onClick: cancelJob
}
]
})
return {
jobMenuEntries,
openJobWorkflow,
copyJobId,
cancelJob
}
}

View File

@@ -0,0 +1,50 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useExecutionStore } from '@/stores/executionStore'
import { clampPercentInt, formatPercent0 } from '@/utils/numberUtil'
/**
* Queue progress composable exposing total/current node progress values and styles.
*/
export function useQueueProgress() {
const { locale } = useI18n()
const executionStore = useExecutionStore()
const totalPercent = computed(() =>
clampPercentInt(Math.round((executionStore.executionProgress ?? 0) * 100))
)
const totalPercentFormatted = computed(() =>
formatPercent0(locale.value, totalPercent.value)
)
const currentNodePercent = computed(() =>
clampPercentInt(
Math.round((executionStore.executingNodeProgress ?? 0) * 100)
)
)
const currentNodePercentFormatted = computed(() =>
formatPercent0(locale.value, currentNodePercent.value)
)
const totalProgressStyle = computed(() => ({
width: `${totalPercent.value}%`,
background: 'var(--color-interface-panel-job-progress-primary)'
}))
const currentNodeProgressStyle = computed(() => ({
width: `${currentNodePercent.value}%`,
background: 'var(--color-interface-panel-job-progress-secondary)'
}))
return {
totalPercent,
totalPercentFormatted,
currentNodePercent,
currentNodePercentFormatted,
totalProgressStyle,
currentNodeProgressStyle
}
}

View File

@@ -0,0 +1,32 @@
import { ref, shallowRef } from 'vue'
import type { JobListItem } from '@/composables/queue/useJobList'
import type { ResultItemImpl } from '@/stores/queueStore'
/**
* Manages result gallery state and activation for queue items.
*/
export function useResultGallery(getFilteredTasks: () => any[]) {
const galleryActiveIndex = ref(-1)
const galleryItems = shallowRef<ResultItemImpl[]>([])
const onViewItem = (item: JobListItem) => {
const items: ResultItemImpl[] = getFilteredTasks().flatMap((t: any) => {
const preview = t.previewOutput
return preview && preview.supportsPreview ? [preview] : []
})
if (!items.length) return
galleryItems.value = items
const activeUrl: string | undefined = item.taskRef?.previewOutput?.url
const idx = activeUrl ? items.findIndex((o) => o.url === activeUrl) : 0
galleryActiveIndex.value = idx >= 0 ? idx : 0
}
return {
galleryActiveIndex,
galleryItems,
onViewItem
}
}

View File

@@ -0,0 +1,18 @@
/**
* Build a tooltip configuration object compatible with v-tooltip.
* Consumers pass the translated text value.
*/
export const buildTooltipConfig = (value: string) => ({
value,
showDelay: 300,
hideDelay: 0,
pt: {
text: {
class:
'border-node-component-tooltip-border bg-node-component-tooltip-surface text-node-component-tooltip border rounded-md px-2 py-1 text-xs leading-none shadow-none'
},
arrow: {
class: 'border-t-node-component-tooltip-border'
}
}
})

View File

@@ -71,6 +71,7 @@
"info": "Node Info",
"bookmark": "Save to Library",
"moreOptions": "More Options",
"more": "More",
"loading": "Loading",
"loadingPanel": "Loading {panel} panel...",
"preview": "PREVIEW",
@@ -170,6 +171,13 @@
"inProgress": "In progress",
"completed": "Completed",
"interrupted": "Interrupted",
"queued": "Queued",
"running": "Running",
"failed": "Failed",
"cancelled": "Cancelled",
"job": "Job",
"untitled": "Untitled",
"emDash": "—",
"enabling": "Enabling {id}",
"disabling": "Disabling {id}",
"updating": "Updating {id}",
@@ -683,6 +691,40 @@
"hideCanceled": "Hide Canceled"
}
},
"queueProgressOverlay": {
"title": "Queue Progress",
"total": "Total: {percent}",
"colonPercent": ": {percent}",
"currentNode": "Current node:",
"viewAllJobs": "View all jobs",
"running": "running",
"interruptAll": "Interrupt all running jobs",
"moreOptions": "More options",
"showAssets": "Show assets",
"showAssetsPanel": "Show assets panel",
"queuedSuffix": "queued",
"clearQueued": "Clear queued",
"clearHistory": "Clear job queue history",
"filterJobs": "Filter jobs",
"filterBy": "Filter by",
"filterAllWorkflows": "All workflows",
"filterCurrentWorkflow": "Current workflow",
"sortJobs": "Sort jobs",
"sortBy": "Sort by",
"activeJobsSuffix": "active jobs",
"jobQueue": "Job Queue",
"expandCollapsedQueue": "Expand job queue",
"viewJobHistory": "View job history",
"noActiveJobs": "No active jobs",
"stubClipTextEncode": "CLIP Text Encode:",
"jobsCompleted": "{count} job completed | {count} jobs completed",
"jobsFailed": "{count} job failed | {count} jobs failed",
"cancelJobTooltip": "Cancel job",
"clearQueueTooltip": "Clear queue",
"clearHistoryDialogTitle": "Clear your job queue history?",
"clearHistoryDialogDescription": "All the finished or failed jobs below will be removed from this Job queue panel.",
"clearHistoryDialogAssetsNote": "Assets generated by these jobs wont be deleted and can always be viewed from the assets panel."
},
"workflowTab": {
"confirmDeleteTitle": "Delete workflow?",
"confirmDelete": "Are you sure you want to delete this workflow?",
@@ -940,6 +982,55 @@
"help": "Help",
"queue": "Queue Panel"
},
"queue": {
"initializingAlmostReady": "Initializing - Almost ready",
"inQueue": "In queue...",
"jobAddedToQueue": "Job added to queue",
"jobMenu": {
"openAsWorkflowNewTab": "Open as workflow in new tab",
"openWorkflowNewTab": "Open workflow in new tab",
"copyJobId": "Copy job ID",
"cancelJob": "Cancel job",
"inspectAsset": "Inspect asset",
"addToCurrentWorkflow": "Add to current workflow",
"download": "Download",
"exportWorkflow": "Export workflow",
"delete": "Delete",
"deleteAsset": "Delete asset",
"removeJob": "Remove job",
"copyErrorMessage": "Copy error message",
"reportError": "Report error"
},
"jobList": {
"undated": "Undated",
"sortMostRecent": "Most recent",
"sortTotalGenerationTime": "Total generation time (longest first)",
"sortComputeHoursUsed": "Compute hours used (most first)"
},
"jobDetails": {
"header": "Job Details",
"workflow": "Workflow",
"jobId": "Job ID",
"queuedAt": "Queued at",
"queuePosition": "Queue position",
"timeElapsed": "Time elapsed",
"estimatedStartIn": "Estimated to start in",
"estimatedFinishIn": "Estimated to finish in",
"generatedOn": "Generated on",
"totalGenerationTime": "Total generation time",
"computeHoursUsed": "Compute hours used",
"failedAfter": "Failed after",
"errorMessage": "Error message",
"report": "Report",
"queuePositionValue": "~{count} job ahead of yours | ~{count} jobs ahead of yours",
"eta": {
"seconds": "~{count} second | ~{count} seconds",
"secondsRange": "~{lo}-{hi} seconds",
"minutes": "~{count} minute | ~{count} minutes",
"minutesRange": "~{lo}-{hi} minutes"
}
}
},
"menuLabels": {
"File": "File",
"Edit": "Edit",

View File

@@ -109,6 +109,10 @@ const zProgressTextWsMessage = z.object({
text: z.string()
})
const zNotificationWsMessage = z.object({
value: z.string(),
id: z.string().optional()
})
const zTerminalSize = z.object({
cols: z.number(),
row: z.number()
@@ -149,6 +153,8 @@ export type ProgressStateWsMessage = z.infer<typeof zProgressStateWsMessage>
export type FeatureFlagsWsMessage = z.infer<typeof zFeatureFlagsWsMessage>
// End of ws messages
export type NotificationWsMessage = z.infer<typeof zNotificationWsMessage>
const zPromptInputItem = z.object({
inputs: z.record(z.string(), z.any()),
class_type: zNodeType

View File

@@ -29,6 +29,7 @@ import type {
HistoryTaskItem,
LogsRawResponse,
LogsWsMessage,
NotificationWsMessage,
PendingTaskItem,
ProgressStateWsMessage,
ProgressTextWsMessage,
@@ -119,6 +120,7 @@ interface BackendApiCalls {
executing: ExecutingWsMessage
executed: ExecutedWsMessage
status: StatusWsMessage
notification: NotificationWsMessage
execution_start: ExecutionStartWsMessage
execution_success: ExecutionSuccessWsMessage
execution_error: ExecutionErrorWsMessage
@@ -649,6 +651,7 @@ export class ComfyApi extends EventTarget {
case 'promptQueued':
case 'logs':
case 'b_preview':
case 'notification':
this.dispatchCustomEvent(msg.type, msg.data)
break
case 'feature_flags':
@@ -873,17 +876,43 @@ export class ComfyApi extends EventTarget {
try {
const res = await this.fetchApi('/queue')
const data = await res.json()
// Normalize queue tuple shape across backends:
// - Backend (V1): [idx, prompt_id, inputs, extra_data(object), outputs_to_execute(array)]
// - Cloud: [idx, prompt_id, inputs, outputs_to_execute(array), metadata(object{create_time})]
const normalizeQueuePrompt = (prompt: any): any => {
if (!Array.isArray(prompt)) return prompt
// Ensure 5-tuple
const p = prompt.slice(0, 5)
const fourth = p[3]
const fifth = p[4]
// Cloud shape: 4th is array, 5th is metadata object
if (
Array.isArray(fourth) &&
fifth &&
typeof fifth === 'object' &&
!Array.isArray(fifth)
) {
const meta: any = fifth
const extraData = { ...meta }
return [p[0], p[1], p[2], extraData, fourth]
}
// V1 shape already: return as-is
return p
}
return {
// Running action uses a different endpoint for cancelling
Running: data.queue_running.map((prompt: Record<number, any>) => ({
taskType: 'Running',
prompt,
// prompt[1] is the prompt id
remove: { name: 'Cancel', cb: () => api.interrupt(prompt[1]) }
})),
Pending: data.queue_pending.map((prompt: Record<number, any>) => ({
Running: data.queue_running.map((prompt: any) => {
const np = normalizeQueuePrompt(prompt)
return {
taskType: 'Running',
prompt: np,
// prompt[1] is the prompt id
remove: { name: 'Cancel', cb: () => api.interrupt(np[1]) }
}
}),
Pending: data.queue_pending.map((prompt: any) => ({
taskType: 'Pending',
prompt
prompt: normalizeQueuePrompt(prompt)
}))
}
} catch (error) {

View File

@@ -17,9 +17,12 @@ import type {
ExecutedWsMessage,
ExecutionCachedWsMessage,
ExecutionErrorWsMessage,
ExecutionInterruptedWsMessage,
ExecutionStartWsMessage,
ExecutionSuccessWsMessage,
NodeError,
NodeProgressState,
NotificationWsMessage,
ProgressStateWsMessage,
ProgressTextWsMessage,
ProgressWsMessage
@@ -110,6 +113,16 @@ export const useExecutionStore = defineStore('execution', () => {
const lastExecutionError = ref<ExecutionErrorWsMessage | null>(null)
// This is the progress of all nodes in the currently executing workflow
const nodeProgressStates = ref<Record<string, NodeProgressState>>({})
const nodeProgressStatesByPrompt = ref<
Record<string, Record<string, NodeProgressState>>
>({})
/**
* Map of prompt_id to workflow ID for quick lookup across the app.
*/
const promptIdToWorkflowId = ref<Map<string, string>>(new Map())
const initializingPromptIds = ref<Set<string>>(new Set())
const mergeExecutionProgressStates = (
currentState: NodeProgressState | undefined,
@@ -241,6 +254,7 @@ export const useExecutionStore = defineStore('execution', () => {
})
function bindExecutionEvents() {
api.addEventListener('notification', handleNotification)
api.addEventListener('execution_start', handleExecutionStart)
api.addEventListener('execution_cached', handleExecutionCached)
api.addEventListener('execution_interrupted', handleExecutionInterrupted)
@@ -255,6 +269,7 @@ export const useExecutionStore = defineStore('execution', () => {
}
function unbindExecutionEvents() {
api.removeEventListener('notification', handleNotification)
api.removeEventListener('execution_start', handleExecutionStart)
api.removeEventListener('execution_cached', handleExecutionCached)
api.removeEventListener('execution_interrupted', handleExecutionInterrupted)
@@ -272,6 +287,7 @@ export const useExecutionStore = defineStore('execution', () => {
lastExecutionError.value = null
activePromptId.value = e.detail.prompt_id
queuedPrompts.value[activePromptId.value] ??= { nodes: {} }
clearInitializationByPromptId(activePromptId.value)
}
function handleExecutionCached(e: CustomEvent<ExecutionCachedWsMessage>) {
@@ -281,8 +297,13 @@ export const useExecutionStore = defineStore('execution', () => {
}
}
function handleExecutionInterrupted() {
resetExecutionState()
function handleExecutionInterrupted(
e: CustomEvent<ExecutionInterruptedWsMessage>
) {
const pid = e.detail.prompt_id
if (activePromptId.value)
clearInitializationByPromptId(activePromptId.value)
resetExecutionState(pid)
}
function handleExecuted(e: CustomEvent<ExecutedWsMessage>) {
@@ -290,13 +311,14 @@ export const useExecutionStore = defineStore('execution', () => {
activePrompt.value.nodes[e.detail.node] = true
}
function handleExecutionSuccess() {
function handleExecutionSuccess(e: CustomEvent<ExecutionSuccessWsMessage>) {
if (isCloud && activePromptId.value) {
useTelemetry()?.trackExecutionSuccess({
jobId: activePromptId.value
})
}
resetExecutionState()
const pid = e.detail.prompt_id
resetExecutionState(pid)
}
function handleExecuting(e: CustomEvent<NodeId | null>): void {
@@ -315,12 +337,13 @@ export const useExecutionStore = defineStore('execution', () => {
}
function handleProgressState(e: CustomEvent<ProgressStateWsMessage>) {
const { nodes } = e.detail
const { nodes, prompt_id: pid } = e.detail
// Revoke previews for nodes that are starting to execute
const previousForPrompt = nodeProgressStatesByPrompt.value[pid] || {}
for (const nodeId in nodes) {
const nodeState = nodes[nodeId]
if (nodeState.state === 'running' && !nodeProgressStates.value[nodeId]) {
if (nodeState.state === 'running' && !previousForPrompt[nodeId]) {
// This node just started executing, revoke its previews
// Note that we're doing the *actual* node id instead of the display node id
// here intentionally. That way, we don't clear the preview every time a new node
@@ -331,6 +354,10 @@ export const useExecutionStore = defineStore('execution', () => {
}
// Update the progress states for all nodes
nodeProgressStatesByPrompt.value = {
...nodeProgressStatesByPrompt.value,
[pid]: nodes
}
nodeProgressStates.value = nodes
// If we have progress for the currently executing node, update it for backwards compatibility
@@ -368,14 +395,55 @@ export const useExecutionStore = defineStore('execution', () => {
error: e.detail.exception_message
})
}
resetExecutionState()
const pid = e.detail?.prompt_id
// Clear initialization for errored prompt if present
if (e.detail?.prompt_id) clearInitializationByPromptId(e.detail.prompt_id)
resetExecutionState(pid)
}
/**
* Notification handler used for frontend/cloud initialization tracking.
* Marks a prompt as initializing when cloud notifies it is waiting for a machine.
*/
function handleNotification(e: CustomEvent<NotificationWsMessage>) {
const payload = e.detail
const text = payload?.value || ''
const id = payload?.id ? payload.id : ''
if (!id) return
// Until cloud implements a proper message
if (text.includes('Waiting for a machine')) {
const next = new Set(initializingPromptIds.value)
next.add(id)
initializingPromptIds.value = next
}
}
function clearInitializationByPromptId(promptId: string | null) {
if (!promptId) return
if (!initializingPromptIds.value.has(promptId)) return
const next = new Set(initializingPromptIds.value)
next.delete(promptId)
initializingPromptIds.value = next
}
function isPromptInitializing(
promptId: string | number | undefined
): boolean {
if (!promptId) return false
return initializingPromptIds.value.has(String(promptId))
}
/**
* Reset execution-related state after a run completes or is stopped.
*/
function resetExecutionState() {
function resetExecutionState(pid?: string | null) {
nodeProgressStates.value = {}
const promptId = pid ?? activePromptId.value ?? null
if (promptId) {
const map = { ...nodeProgressStatesByPrompt.value }
delete map[promptId]
nodeProgressStatesByPrompt.value = map
}
if (activePromptId.value) {
delete queuedPrompts.value[activePromptId.value]
}
@@ -421,6 +489,21 @@ export const useExecutionStore = defineStore('execution', () => {
...queuedPrompt.nodes
}
queuedPrompt.workflow = workflow
const wid = workflow?.activeState?.id ?? workflow?.initialState?.id
if (wid) {
promptIdToWorkflowId.value.set(String(id), String(wid))
}
}
/**
* Register or update a mapping from prompt_id to workflow ID.
*/
function registerPromptWorkflowIdMapping(
promptId: string,
workflowId: string
) {
if (!promptId || !workflowId) return
promptIdToWorkflowId.value.set(String(promptId), String(workflowId))
}
/**
@@ -435,6 +518,22 @@ export const useExecutionStore = defineStore('execution', () => {
return executionId
}
const runningPromptIds = computed<string[]>(() => {
const result: string[] = []
for (const [pid, nodes] of Object.entries(
nodeProgressStatesByPrompt.value
)) {
if (Object.values(nodes).some((n) => n.state === 'running')) {
result.push(pid)
}
}
return result
})
const runningWorkflowCount = computed<number>(
() => runningPromptIds.value.length
)
return {
isIdle,
clientId,
@@ -453,14 +552,21 @@ export const useExecutionStore = defineStore('execution', () => {
executingNodeProgress,
nodeProgressStates,
nodeLocationProgressStates,
nodeProgressStatesByPrompt,
runningPromptIds,
runningWorkflowCount,
initializingPromptIds,
isPromptInitializing,
bindExecutionEvents,
unbindExecutionEvents,
storePrompt,
registerPromptWorkflowIdMapping,
uniqueExecutingNodeIdStrings,
// Raw executing progress data for backward compatibility in ComfyApp.
_executingNodeProgress,
// NodeLocatorId conversion helpers
executionIdToNodeLocatorId,
nodeLocatorIdToExecutionId
nodeLocatorIdToExecutionId,
promptIdToWorkflowId
}
})

View File

@@ -23,6 +23,7 @@ import { api } from '@/scripts/api'
import type { ComfyApp } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useExecutionStore } from '@/stores/executionStore'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
// Task type used in the API.
@@ -528,6 +529,19 @@ export const useQueueStore = defineStore('queue', () => {
const currentHistory = toValue(historyTasks)
const appearedTasks = [...pendingTasks.value, ...runningTasks.value]
const executionStore = useExecutionStore()
appearedTasks.forEach((task) => {
const promptIdString = String(task.promptId)
const workflowId = task.workflow?.id
if (workflowId && promptIdString) {
executionStore.registerPromptWorkflowIdMapping(
promptIdString,
workflowId
)
}
})
const items = reconcileHistory(
history.History,
currentHistory.map((impl) => impl.toTaskItem()),

9
src/types/queue.ts Normal file
View File

@@ -0,0 +1,9 @@
/**
* Job execution state used across queue UI components.
*/
export type JobState =
| 'pending'
| 'initialization'
| 'running'
| 'completed'
| 'failed'

77
src/utils/dateTimeUtil.ts Normal file
View File

@@ -0,0 +1,77 @@
/**
* Return a local date key in YYYY-MM-DD format for grouping.
*
* @param ts Unix timestamp in milliseconds
* @returns Local date key string
*/
export const dateKey = (ts: number): string => {
const d = new Date(ts)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
/**
* Check if a timestamp is on the same local day as today.
*
* @param ts Unix timestamp in milliseconds
* @returns True if today
*/
export const isToday = (ts: number): boolean => {
const d = new Date(ts)
const now = new Date()
return (
d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate()
)
}
/**
* Check if a timestamp corresponds to yesterday in local time.
*
* @param ts Unix timestamp in milliseconds
* @returns True if yesterday
*/
export const isYesterday = (ts: number): boolean => {
const d = new Date(ts)
const now = new Date()
const yest = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
return (
d.getFullYear() === yest.getFullYear() &&
d.getMonth() === yest.getMonth() &&
d.getDate() === yest.getDate()
)
}
/**
* Localized short month + day label, e.g. "Jan 2".
*
* @param ts Unix timestamp in milliseconds
* @param locale BCP-47 locale string
* @returns Localized month/day label
*/
export const formatShortMonthDay = (ts: number, locale: string): string => {
const d = new Date(ts)
return new Intl.DateTimeFormat(locale, {
month: 'short',
day: 'numeric'
}).format(d)
}
/**
* Localized clock time, e.g. "10:05:06" with locale defaults for 12/24 hour.
*
* @param ts Unix timestamp in milliseconds
* @param locale BCP-47 locale string
* @returns Localized time string
*/
export const formatClockTime = (ts: number, locale: string): string => {
const d = new Date(ts)
return new Intl.DateTimeFormat(locale, {
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
}).format(d)
}

27
src/utils/numberUtil.ts Normal file
View File

@@ -0,0 +1,27 @@
import { clamp } from 'es-toolkit/math'
/**
* Clamp a numeric value to an integer percent in the range [0, 100].
*
* @param value Numeric value expected to be a percentage (0-100)
* @returns Integer percent between 0 and 100
*/
export const clampPercentInt = (value?: number): number => {
const v = Math.round(value ?? 0)
return clamp(v, 0, 100)
}
/**
* Format a percentage (0-100) using the provided locale with 0 fraction digits.
*
* @param locale BCP-47 locale string
* @param value0to100 Percent value in [0, 100]
* @returns Localized percent string, e.g. "42%"
*/
export const formatPercent0 = (locale: string, value0to100: number): string => {
const v = clampPercentInt(value0to100)
return new Intl.NumberFormat(locale, {
style: 'percent',
maximumFractionDigits: 0
}).format((v || 0) / 100)
}

150
src/utils/queueDisplay.ts Normal file
View File

@@ -0,0 +1,150 @@
import type { TaskItemImpl } from '@/stores/queueStore'
import type { JobState } from '@/types/queue'
import { clampPercentInt, formatPercent0 } from '@/utils/numberUtil'
type BuildJobDisplayCtx = {
t: (k: string, v?: Record<string, any>) => string
locale: string
formatClockTimeFn: (ts: number, locale: string) => string
isActive: boolean
totalPercent?: number
currentNodePercent?: number
currentNodeName?: string
showAddedHint?: boolean
}
type JobDisplay = {
iconName: string
iconImageUrl?: string
primary: string
secondary: string
showClear: boolean
}
export const iconForJobState = (state: JobState): string => {
switch (state) {
case 'pending':
return 'icon-[lucide--clock]'
case 'initialization':
return 'icon-[lucide--server-crash]'
case 'running':
return 'icon-[lucide--zap]'
case 'completed':
return 'icon-[lucide--check-check]'
case 'failed':
return 'icon-[lucide--alert-circle]'
default:
return 'icon-[lucide--circle]'
}
}
const buildTitle = (task: TaskItemImpl, t: (k: string) => string): string => {
const prefix = t('g.job')
const shortId = String(task.promptId ?? '').split('-')[0]
const idx = task.queueIndex
if (typeof idx === 'number') return `${prefix} #${idx}`
if (shortId) return `${prefix} ${shortId}`
return prefix
}
const buildQueuedTime = (
task: TaskItemImpl,
locale: string,
formatClockTimeFn: (ts: number, locale: string) => string
): string => {
const ts = task.createTime
return ts !== undefined ? formatClockTimeFn(ts, locale) : ''
}
export const buildJobDisplay = (
task: TaskItemImpl,
state: JobState,
ctx: BuildJobDisplayCtx
): JobDisplay => {
if (state === 'pending') {
if (ctx.showAddedHint) {
return {
iconName: 'icon-[lucide--check]',
primary: ctx.t('queue.jobAddedToQueue'),
secondary: buildQueuedTime(task, ctx.locale, ctx.formatClockTimeFn),
showClear: true
}
}
return {
iconName: iconForJobState(state),
primary: ctx.t('queue.inQueue'),
secondary: buildQueuedTime(task, ctx.locale, ctx.formatClockTimeFn),
showClear: true
}
}
if (state === 'initialization') {
return {
iconName: iconForJobState(state),
primary: ctx.t('queue.initializingAlmostReady'),
secondary: buildQueuedTime(task, ctx.locale, ctx.formatClockTimeFn),
showClear: true
}
}
if (state === 'running') {
if (ctx.isActive) {
const total = formatPercent0(
ctx.locale,
clampPercentInt(ctx.totalPercent)
)
const curr = formatPercent0(
ctx.locale,
clampPercentInt(ctx.currentNodePercent)
)
const primary = ctx.t('sideToolbar.queueProgressOverlay.total', {
percent: total
})
const right = ctx.currentNodeName
? `${ctx.currentNodeName} ${ctx.t(
'sideToolbar.queueProgressOverlay.colonPercent',
{ percent: curr }
)}`
: ''
return {
iconName: iconForJobState(state),
primary,
secondary: right,
showClear: true
}
}
return {
iconName: iconForJobState(state),
primary: ctx.t('g.running'),
secondary: '',
showClear: true
}
}
if (state === 'completed') {
const time = task.executionTimeInSeconds
const preview = task.previewOutput
const iconImageUrl = preview && preview.isImage ? preview.url : undefined
return {
iconName: iconForJobState(state),
iconImageUrl,
primary:
preview?.filename && preview.filename.length
? preview.filename
: buildTitle(task, ctx.t),
secondary: time !== undefined ? `${time.toFixed(2)}s` : '',
showClear: false
}
}
if (state === 'failed') {
return {
iconName: iconForJobState(state),
primary: ctx.t('g.failed'),
secondary: ctx.t('g.failed'),
showClear: true
}
}
return {
iconName: iconForJobState(state),
primary: buildTitle(task, ctx.t),
secondary: '',
showClear: true
}
}

29
src/utils/queueUtil.ts Normal file
View File

@@ -0,0 +1,29 @@
import type { TaskItemImpl } from '@/stores/queueStore'
import type { JobState } from '@/types/queue'
/**
* Map a task to a UI job state, including initialization override.
*
* @param task Task item from the queue store
* @param isInitializing True if the prompt is currently initializing
* @returns JobState for UI
*/
export const jobStateFromTask = (
task: TaskItemImpl,
isInitializing: boolean
): JobState => {
if (isInitializing) return 'initialization'
const status = task.displayStatus
switch (status) {
case 'Running':
return 'running'
case 'Pending':
return 'pending'
case 'Completed':
return 'completed'
case 'Failed':
case 'Cancelled':
return 'failed'
}
return 'failed'
}

View File

@@ -2,6 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed } from 'vue'
import { createI18n } from 'vue-i18n'
import TopMenuSection from '@/components/TopMenuSection.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
@@ -27,13 +28,32 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
}))
function createWrapper() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
sideToolbar: {
queueProgressOverlay: {
viewJobHistory: 'View job history',
expandCollapsedQueue: 'Expand collapsed queue'
}
}
}
}
})
return mount(TopMenuSection, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })],
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
stubs: {
SubgraphBreadcrumb: true,
QueueProgressOverlay: true,
CurrentUserButton: true,
LoginButton: true
},
directives: {
tooltip: () => {}
}
}
})

View File

@@ -0,0 +1,93 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import JobGroupsList from '@/components/queue/job/JobGroupsList.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
const QueueJobItemStub = defineComponent({
name: 'QueueJobItemStub',
props: {
jobId: { type: String, required: true },
workflowId: { type: String, default: undefined },
state: { type: String, required: true },
title: { type: String, required: true },
rightText: { type: String, default: '' },
iconName: { type: String, default: undefined },
iconImageUrl: { type: String, default: undefined },
showClear: { type: Boolean, default: undefined },
showMenu: { type: Boolean, default: undefined },
progressTotalPercent: { type: Number, default: undefined },
progressCurrentPercent: { type: Number, default: undefined },
runningNodeName: { type: String, default: undefined },
activeDetailsId: { type: String, default: null }
},
emits: ['cancel', 'delete', 'menu', 'view', 'details-enter', 'details-leave'],
template: '<div class="queue-job-item-stub"></div>'
})
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => ({
id: 'job-id',
title: 'Example job',
meta: 'Meta text',
state: 'running',
iconName: 'icon',
iconImageUrl: 'https://example.com/icon.png',
showClear: true,
taskRef: { workflow: { id: 'workflow-id' } },
progressTotalPercent: 60,
progressCurrentPercent: 30,
runningNodeName: 'Node A',
...overrides
})
const mountComponent = (groups: JobGroup[]) =>
mount(JobGroupsList, {
props: { displayedJobGroups: groups },
global: {
stubs: {
QueueJobItem: QueueJobItemStub
}
}
})
afterEach(() => {
vi.useRealTimers()
})
describe('JobGroupsList hover behavior', () => {
it('delays showing and hiding details while hovering over job rows', async () => {
vi.useFakeTimers()
const job = createJobItem({ id: 'job-d' })
const wrapper = mountComponent([
{ key: 'today', label: 'Today', items: [job] }
])
const jobItem = wrapper.findComponent(QueueJobItemStub)
jobItem.vm.$emit('details-enter', job.id)
vi.advanceTimersByTime(199)
await nextTick()
expect(
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBeNull()
vi.advanceTimersByTime(1)
await nextTick()
expect(
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBe(job.id)
wrapper.findComponent(QueueJobItemStub).vm.$emit('details-leave', job.id)
vi.advanceTimersByTime(149)
await nextTick()
expect(
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBe(job.id)
vi.advanceTimersByTime(1)
await nextTick()
expect(
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
).toBeNull()
})
})

View File

@@ -0,0 +1,190 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import type { ComputedRef } from 'vue'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import type { TaskItemImpl } from '@/stores/queueStore'
import type { JobErrorDialogService } from '@/components/queue/job/useJobErrorReporting'
import * as jobErrorReporting from '@/components/queue/job/useJobErrorReporting'
const createExecutionErrorMessage = (
overrides: Partial<ExecutionErrorWsMessage> = {}
): ExecutionErrorWsMessage => ({
prompt_id: 'prompt',
timestamp: 100,
node_id: 'node-1',
node_type: 'KSampler',
executed: [],
exception_message: 'default failure',
exception_type: 'RuntimeError',
traceback: ['Trace line'],
current_inputs: {},
current_outputs: {},
...overrides
})
const createTaskWithMessages = (
messages: Array<[string, unknown]> | undefined = []
): TaskItemImpl =>
({
status: {
status_str: 'error',
completed: false,
messages
}
}) as TaskItemImpl
describe('extractExecutionError', () => {
it('returns null when task has no execution error messages', () => {
expect(jobErrorReporting.extractExecutionError(null)).toBeNull()
expect(
jobErrorReporting.extractExecutionError({
status: undefined
} as TaskItemImpl)
).toBeNull()
expect(
jobErrorReporting.extractExecutionError({
status: {
status_str: 'error',
completed: false,
messages: {} as unknown as Array<[string, unknown]>
}
} as TaskItemImpl)
).toBeNull()
expect(
jobErrorReporting.extractExecutionError(createTaskWithMessages([]))
).toBeNull()
expect(
jobErrorReporting.extractExecutionError(
createTaskWithMessages([
['execution_start', { prompt_id: 'prompt', timestamp: 1 }]
] as Array<[string, unknown]>)
)
).toBeNull()
})
it('returns detail and message for execution_error entries', () => {
const detail = createExecutionErrorMessage({ exception_message: 'Kaboom' })
const result = jobErrorReporting.extractExecutionError(
createTaskWithMessages([
['execution_success', { prompt_id: 'prompt', timestamp: 2 }],
['execution_error', detail]
] as Array<[string, unknown]>)
)
expect(result).toEqual({
detail,
message: 'Kaboom'
})
})
it('falls back to an empty message when the tuple lacks detail', () => {
const result = jobErrorReporting.extractExecutionError(
createTaskWithMessages([
['execution_error'] as unknown as [string, ExecutionErrorWsMessage]
])
)
expect(result).toEqual({ detail: undefined, message: '' })
})
})
describe('useJobErrorReporting', () => {
let taskState = ref<TaskItemImpl | null>(null)
let taskForJob: ComputedRef<TaskItemImpl | null>
let copyToClipboard: ReturnType<typeof vi.fn>
let showExecutionErrorDialog: ReturnType<typeof vi.fn>
let showErrorDialog: ReturnType<typeof vi.fn>
let dialog: JobErrorDialogService
let composable: ReturnType<typeof jobErrorReporting.useJobErrorReporting>
beforeEach(() => {
taskState = ref<TaskItemImpl | null>(null)
taskForJob = computed(() => taskState.value)
copyToClipboard = vi.fn()
showExecutionErrorDialog = vi.fn()
showErrorDialog = vi.fn()
dialog = {
showExecutionErrorDialog,
showErrorDialog
}
composable = jobErrorReporting.useJobErrorReporting({
taskForJob,
copyToClipboard,
dialog
})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('exposes a computed message that reflects the current task error', () => {
taskState.value = createTaskWithMessages([
[
'execution_error',
createExecutionErrorMessage({ exception_message: 'First failure' })
]
])
expect(composable.errorMessageValue.value).toBe('First failure')
taskState.value = createTaskWithMessages([
[
'execution_error',
createExecutionErrorMessage({ exception_message: 'Second failure' })
]
])
expect(composable.errorMessageValue.value).toBe('Second failure')
})
it('only calls the copy handler when a message exists', () => {
taskState.value = createTaskWithMessages([
[
'execution_error',
createExecutionErrorMessage({ exception_message: 'Clipboard failure' })
]
])
composable.copyErrorMessage()
expect(copyToClipboard).toHaveBeenCalledTimes(1)
expect(copyToClipboard).toHaveBeenCalledWith('Clipboard failure')
copyToClipboard.mockClear()
taskState.value = createTaskWithMessages([])
composable.copyErrorMessage()
expect(copyToClipboard).not.toHaveBeenCalled()
})
it('prefers the detailed execution dialog when detail is available', () => {
const detail = createExecutionErrorMessage({
exception_message: 'Detailed failure'
})
taskState.value = createTaskWithMessages([['execution_error', detail]])
composable.reportJobError()
expect(showExecutionErrorDialog).toHaveBeenCalledTimes(1)
expect(showExecutionErrorDialog).toHaveBeenCalledWith(detail)
expect(showErrorDialog).not.toHaveBeenCalled()
})
it('shows a fallback dialog when only a message is available', () => {
const message = 'Queue job error'
taskState.value = createTaskWithMessages([])
const valueSpy = vi
.spyOn(composable.errorMessageValue, 'value', 'get')
.mockReturnValue(message)
expect(composable.errorMessageValue.value).toBe(message)
composable.reportJobError()
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
expect(showErrorDialog).toHaveBeenCalledTimes(1)
const [errorArg, optionsArg] = showErrorDialog.mock.calls[0]
expect(errorArg).toBeInstanceOf(Error)
expect(errorArg.message).toBe(message)
expect(optionsArg).toEqual({ reportType: 'queueJobError' })
valueSpy.mockRestore()
})
it('does nothing when no error could be extracted', () => {
taskState.value = createTaskWithMessages([])
composable.reportJobError()
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
expect(showErrorDialog).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,289 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive } from 'vue'
import { useCompletionSummary } from '@/composables/queue/useCompletionSummary'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
type MockTask = {
displayStatus: 'Completed' | 'Failed' | 'Cancelled' | 'Running' | 'Pending'
executionEndTimestamp?: number
previewOutput?: {
isImage: boolean
urlWithTimestamp: string
}
}
vi.mock('@/stores/queueStore', () => {
const state = reactive({
runningTasks: [] as MockTask[],
historyTasks: [] as MockTask[]
})
return {
useQueueStore: () => state
}
})
vi.mock('@/stores/executionStore', () => {
const state = reactive({
isIdle: true
})
return {
useExecutionStore: () => state
}
})
describe('useCompletionSummary', () => {
const queueStore = () =>
useQueueStore() as {
runningTasks: MockTask[]
historyTasks: MockTask[]
}
const executionStore = () => useExecutionStore() as { isIdle: boolean }
const resetState = () => {
queueStore().runningTasks = []
queueStore().historyTasks = []
executionStore().isIdle = true
}
const createTask = (
options: {
state?: MockTask['displayStatus']
ts?: number
previewUrl?: string
isImage?: boolean
} = {}
): MockTask => {
const {
state = 'Completed',
ts = Date.now(),
previewUrl,
isImage = true
} = options
const task: MockTask = {
displayStatus: state,
executionEndTimestamp: ts
}
if (previewUrl) {
task.previewOutput = {
isImage,
urlWithTimestamp: previewUrl
}
}
return task
}
const runBatch = async (options: {
start: number
finish: number
tasks: MockTask[]
}) => {
const { start, finish, tasks } = options
vi.setSystemTime(start)
executionStore().isIdle = false
await nextTick()
vi.setSystemTime(finish)
queueStore().historyTasks = tasks
executionStore().isIdle = true
await nextTick()
}
beforeEach(() => {
resetState()
vi.useFakeTimers()
vi.setSystemTime(0)
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
resetState()
})
it('summarizes the most recent batch and auto clears after the dismiss delay', async () => {
const { summary } = useCompletionSummary()
await nextTick()
const start = 1_000
const finish = 2_000
const tasks = [
createTask({ ts: start - 100, previewUrl: 'ignored-old' }),
createTask({ ts: start + 10, previewUrl: 'img-1' }),
createTask({ ts: start + 20, previewUrl: 'img-2' }),
createTask({ ts: start + 30, previewUrl: 'img-3' }),
createTask({ ts: start + 40, previewUrl: 'img-4' }),
createTask({ state: 'Failed', ts: start + 50 })
]
await runBatch({ start, finish, tasks })
expect(summary.value).toEqual({
mode: 'mixed',
completedCount: 4,
failedCount: 1,
thumbnailUrls: ['img-1', 'img-2', 'img-3']
})
vi.advanceTimersByTime(6000)
await nextTick()
expect(summary.value).toBeNull()
})
it('reports allFailed when every task in the batch failed', async () => {
const { summary } = useCompletionSummary()
await nextTick()
const start = 10_000
const finish = 10_200
await runBatch({
start,
finish,
tasks: [
createTask({ state: 'Failed', ts: start + 25 }),
createTask({ state: 'Failed', ts: start + 50 })
]
})
expect(summary.value).toEqual({
mode: 'allFailed',
completedCount: 0,
failedCount: 2,
thumbnailUrls: []
})
})
it('treats cancelled tasks as failures and skips non-image previews', async () => {
const { summary } = useCompletionSummary()
await nextTick()
const start = 15_000
const finish = 15_200
await runBatch({
start,
finish,
tasks: [
createTask({ ts: start + 25, previewUrl: 'img-1' }),
createTask({
state: 'Cancelled',
ts: start + 50,
previewUrl: 'thumb-ignore',
isImage: false
})
]
})
expect(summary.value).toEqual({
mode: 'mixed',
completedCount: 1,
failedCount: 1,
thumbnailUrls: ['img-1']
})
})
it('clearSummary dismisses the banner immediately and still tracks future batches', async () => {
const { summary, clearSummary } = useCompletionSummary()
await nextTick()
await runBatch({
start: 5_000,
finish: 5_100,
tasks: [createTask({ ts: 5_050, previewUrl: 'img-1' })]
})
expect(summary.value).toEqual({
mode: 'allSuccess',
completedCount: 1,
failedCount: 0,
thumbnailUrls: ['img-1']
})
clearSummary()
expect(summary.value).toBeNull()
await runBatch({
start: 6_000,
finish: 6_150,
tasks: [createTask({ ts: 6_075, previewUrl: 'img-2' })]
})
expect(summary.value).toEqual({
mode: 'allSuccess',
completedCount: 1,
failedCount: 0,
thumbnailUrls: ['img-2']
})
})
it('ignores batches that have no finished tasks after the active period started', async () => {
const { summary } = useCompletionSummary()
await nextTick()
const start = 20_000
const finish = 20_500
await runBatch({
start,
finish,
tasks: [createTask({ ts: start - 1, previewUrl: 'too-early' })]
})
expect(summary.value).toBeNull()
})
it('derives the active period from running tasks when execution is already idle', async () => {
const { summary } = useCompletionSummary()
await nextTick()
const start = 25_000
vi.setSystemTime(start)
queueStore().runningTasks = [
createTask({ state: 'Running', ts: start + 1 })
]
await nextTick()
const finish = start + 150
vi.setSystemTime(finish)
queueStore().historyTasks = [
createTask({ ts: finish - 10, previewUrl: 'img-running-trigger' })
]
queueStore().runningTasks = []
await nextTick()
expect(summary.value).toEqual({
mode: 'allSuccess',
completedCount: 1,
failedCount: 0,
thumbnailUrls: ['img-running-trigger']
})
})
it('does not emit a summary when every finished task is still running or pending', async () => {
const { summary } = useCompletionSummary()
await nextTick()
const start = 30_000
const finish = 30_300
await runBatch({
start,
finish,
tasks: [
createTask({ state: 'Running', ts: start + 20 }),
createTask({ state: 'Pending', ts: start + 40 })
]
})
expect(summary.value).toBeNull()
})
})

View File

@@ -0,0 +1,520 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { nextTick, reactive, ref } from 'vue'
import type { Ref } from 'vue'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobState } from '@/types/queue'
type TestTask = {
promptId: string
queueIndex: number
mockState: JobState
executionTime?: number
executionEndTimestamp?: number
createTime?: number
workflow?: { id?: string }
}
const translations: Record<string, string> = {
'queue.jobList.undated': 'Undated',
'g.emDash': '--',
'g.untitled': 'Untitled'
}
let localeRef: Ref<string>
let tMock: ReturnType<typeof vi.fn>
const ensureLocaleMocks = () => {
if (!localeRef) {
localeRef = ref('en-US') as Ref<string>
}
if (!tMock) {
tMock = vi.fn((key: string) => translations[key] ?? key)
}
return { localeRef, tMock }
}
vi.mock('vue-i18n', () => ({
useI18n: () => {
ensureLocaleMocks()
return {
t: tMock,
locale: localeRef
}
}
}))
let stMock: ReturnType<typeof vi.fn>
const ensureStMock = () => {
if (!stMock) {
stMock = vi.fn(
(key: string, fallback?: string) => `i18n(${key})-${fallback}`
)
}
return stMock
}
vi.mock('@/i18n', () => ({
st: (...args: any[]) => {
return ensureStMock()(...args)
}
}))
let totalPercent: Ref<number>
let currentNodePercent: Ref<number>
const ensureProgressRefs = () => {
if (!totalPercent) totalPercent = ref(0) as Ref<number>
if (!currentNodePercent) currentNodePercent = ref(0) as Ref<number>
return { totalPercent, currentNodePercent }
}
vi.mock('@/composables/queue/useQueueProgress', () => ({
useQueueProgress: () => {
ensureProgressRefs()
return {
totalPercent,
currentNodePercent
}
}
}))
let buildJobDisplayMock: ReturnType<typeof vi.fn>
const ensureBuildDisplayMock = () => {
if (!buildJobDisplayMock) {
buildJobDisplayMock = vi.fn((task: any, state: JobState, options: any) => ({
primary: `Job ${task.promptId}`,
secondary: `${state} meta`,
iconName: `${state}-icon`,
iconImageUrl: undefined,
showClear: state === 'failed',
options
}))
}
return buildJobDisplayMock
}
vi.mock('@/utils/queueDisplay', () => ({
buildJobDisplay: (...args: any[]) => {
return ensureBuildDisplayMock()(...args)
}
}))
let jobStateFromTaskMock: ReturnType<typeof vi.fn>
const ensureJobStateMock = () => {
if (!jobStateFromTaskMock) {
jobStateFromTaskMock = vi.fn(
(task: TestTask, isInitializing?: boolean): JobState =>
task.mockState ?? (isInitializing ? 'running' : 'completed')
)
}
return jobStateFromTaskMock
}
vi.mock('@/utils/queueUtil', () => ({
jobStateFromTask: (...args: any[]) => {
return ensureJobStateMock()(...args)
}
}))
let queueStoreMock: {
pendingTasks: TestTask[]
runningTasks: TestTask[]
historyTasks: TestTask[]
}
const ensureQueueStore = () => {
if (!queueStoreMock) {
queueStoreMock = reactive({
pendingTasks: [] as TestTask[],
runningTasks: [] as TestTask[],
historyTasks: [] as TestTask[]
})
}
return queueStoreMock
}
vi.mock('@/stores/queueStore', () => ({
useQueueStore: () => {
return ensureQueueStore()
}
}))
let executionStoreMock: {
activePromptId: string | null
executingNode: null | { title?: string; type?: string }
isPromptInitializing: (promptId?: string | number) => boolean
}
let isPromptInitializingMock: ReturnType<typeof vi.fn>
const ensureExecutionStore = () => {
if (!isPromptInitializingMock) {
isPromptInitializingMock = vi.fn(() => false)
}
if (!executionStoreMock) {
executionStoreMock = reactive({
activePromptId: null as string | null,
executingNode: null as null | { title?: string; type?: string },
isPromptInitializing: (promptId?: string | number) =>
isPromptInitializingMock(promptId)
})
}
return executionStoreMock
}
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => {
return ensureExecutionStore()
}
}))
let workflowStoreMock: {
activeWorkflow: null | { activeState?: { id?: string } }
}
const ensureWorkflowStore = () => {
if (!workflowStoreMock) {
workflowStoreMock = reactive({
activeWorkflow: null as null | { activeState?: { id?: string } }
})
}
return workflowStoreMock
}
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => {
return ensureWorkflowStore()
}
}))
const createTask = (
overrides: Partial<TestTask> & { mockState?: JobState } = {}
): TestTask => ({
promptId:
overrides.promptId ?? `task-${Math.random().toString(36).slice(2, 7)}`,
queueIndex: overrides.queueIndex ?? 0,
mockState: overrides.mockState ?? 'pending',
executionTime: overrides.executionTime,
executionEndTimestamp: overrides.executionEndTimestamp,
createTime: overrides.createTime,
workflow: overrides.workflow
})
const mountUseJobList = () => {
let composable: ReturnType<typeof useJobList>
const wrapper = mount({
template: '<div />',
setup() {
composable = useJobList()
return {}
}
})
return { wrapper, composable: composable! }
}
const resetStores = () => {
const queueStore = ensureQueueStore()
queueStore.pendingTasks = []
queueStore.runningTasks = []
queueStore.historyTasks = []
const executionStore = ensureExecutionStore()
executionStore.activePromptId = null
executionStore.executingNode = null
const workflowStore = ensureWorkflowStore()
workflowStore.activeWorkflow = null
ensureProgressRefs()
totalPercent.value = 0
currentNodePercent.value = 0
ensureLocaleMocks()
localeRef.value = 'en-US'
tMock.mockClear()
if (stMock) stMock.mockClear()
if (buildJobDisplayMock) buildJobDisplayMock.mockClear()
if (jobStateFromTaskMock) jobStateFromTaskMock.mockClear()
if (isPromptInitializingMock) {
isPromptInitializingMock.mockReset()
isPromptInitializingMock.mockReturnValue(false)
}
}
const flush = async () => {
await nextTick()
}
describe('useJobList', () => {
let wrapper: ReturnType<typeof mount> | null = null
let api: ReturnType<typeof useJobList> | null = null
beforeEach(() => {
resetStores()
wrapper?.unmount()
wrapper = null
api = null
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
api = null
vi.useRealTimers()
})
const initComposable = () => {
const mounted = mountUseJobList()
wrapper = mounted.wrapper
api = mounted.composable
return api!
}
it('tracks recently added pending jobs and clears the hint after expiry', async () => {
vi.useFakeTimers()
queueStoreMock.pendingTasks = [
createTask({ promptId: '1', queueIndex: 1, mockState: 'pending' })
]
const { jobItems } = initComposable()
await flush()
jobItems.value
expect(buildJobDisplayMock).toHaveBeenCalledWith(
expect.anything(),
'pending',
expect.objectContaining({ showAddedHint: true })
)
buildJobDisplayMock.mockClear()
await vi.advanceTimersByTimeAsync(3000)
await flush()
jobItems.value
expect(buildJobDisplayMock).toHaveBeenCalledWith(
expect.anything(),
'pending',
expect.objectContaining({ showAddedHint: false })
)
})
it('removes pending hint immediately when the task leaves the queue', async () => {
vi.useFakeTimers()
const taskId = '2'
queueStoreMock.pendingTasks = [
createTask({ promptId: taskId, queueIndex: 1, mockState: 'pending' })
]
const { jobItems } = initComposable()
await flush()
jobItems.value
queueStoreMock.pendingTasks = []
await flush()
expect(vi.getTimerCount()).toBe(0)
buildJobDisplayMock.mockClear()
queueStoreMock.pendingTasks = [
createTask({ promptId: taskId, queueIndex: 2, mockState: 'pending' })
]
await flush()
jobItems.value
expect(buildJobDisplayMock).toHaveBeenCalledWith(
expect.anything(),
'pending',
expect.objectContaining({ showAddedHint: true })
)
})
it('cleans up timeouts on unmount', async () => {
vi.useFakeTimers()
queueStoreMock.pendingTasks = [
createTask({ promptId: '3', queueIndex: 1, mockState: 'pending' })
]
initComposable()
await flush()
expect(vi.getTimerCount()).toBeGreaterThan(0)
wrapper?.unmount()
wrapper = null
await flush()
expect(vi.getTimerCount()).toBe(0)
})
it('sorts all tasks by queue index descending', async () => {
queueStoreMock.pendingTasks = [
createTask({ promptId: 'p', queueIndex: 1, mockState: 'pending' })
]
queueStoreMock.runningTasks = [
createTask({ promptId: 'r', queueIndex: 5, mockState: 'running' })
]
queueStoreMock.historyTasks = [
createTask({ promptId: 'h', queueIndex: 3, mockState: 'completed' })
]
const { allTasksSorted } = initComposable()
await flush()
expect(allTasksSorted.value.map((task) => task.promptId)).toEqual([
'r',
'h',
'p'
])
})
it('filters by job tab and resets failed tab when failures disappear', async () => {
queueStoreMock.historyTasks = [
createTask({ promptId: 'c', queueIndex: 3, mockState: 'completed' }),
createTask({ promptId: 'f', queueIndex: 2, mockState: 'failed' }),
createTask({ promptId: 'p', queueIndex: 1, mockState: 'pending' })
]
const instance = initComposable()
await flush()
instance.selectedJobTab.value = 'Completed'
await flush()
expect(instance.filteredTasks.value.map((t) => t.promptId)).toEqual(['c'])
instance.selectedJobTab.value = 'Failed'
await flush()
expect(instance.filteredTasks.value.map((t) => t.promptId)).toEqual(['f'])
expect(instance.hasFailedJobs.value).toBe(true)
queueStoreMock.historyTasks = [
createTask({ promptId: 'c', queueIndex: 3, mockState: 'completed' })
]
await flush()
expect(instance.hasFailedJobs.value).toBe(false)
expect(instance.selectedJobTab.value).toBe('All')
})
it('filters by active workflow when requested', async () => {
queueStoreMock.pendingTasks = [
createTask({
promptId: 'wf-1',
queueIndex: 2,
mockState: 'pending',
workflow: { id: 'workflow-1' }
}),
createTask({
promptId: 'wf-2',
queueIndex: 1,
mockState: 'pending',
workflow: { id: 'workflow-2' }
})
]
const instance = initComposable()
await flush()
instance.selectedWorkflowFilter.value = 'current'
await flush()
expect(instance.filteredTasks.value).toEqual([])
workflowStoreMock.activeWorkflow = { activeState: { id: 'workflow-1' } }
await flush()
expect(instance.filteredTasks.value.map((t) => t.promptId)).toEqual([
'wf-1'
])
})
it('hydrates job items with active progress and compute hours', async () => {
queueStoreMock.runningTasks = [
createTask({
promptId: 'active',
queueIndex: 3,
mockState: 'running',
executionTime: 7_200_000
}),
createTask({
promptId: 'other',
queueIndex: 2,
mockState: 'running',
executionTime: 3_600_000
})
]
executionStoreMock.activePromptId = 'active'
executionStoreMock.executingNode = { title: 'Render Node' }
totalPercent.value = 80
currentNodePercent.value = 40
const { jobItems } = initComposable()
await flush()
const [activeJob, otherJob] = jobItems.value
expect(activeJob.progressTotalPercent).toBe(80)
expect(activeJob.progressCurrentPercent).toBe(40)
expect(activeJob.runningNodeName).toBe('Render Node')
expect(activeJob.computeHours).toBeCloseTo(2)
expect(otherJob.progressTotalPercent).toBeUndefined()
expect(otherJob.progressCurrentPercent).toBeUndefined()
expect(otherJob.runningNodeName).toBeUndefined()
expect(otherJob.computeHours).toBeCloseTo(1)
})
it('derives current node name from execution store fallbacks', async () => {
const instance = initComposable()
await flush()
expect(instance.currentNodeName.value).toBe('--')
executionStoreMock.executingNode = { title: ' Visible Node ' }
await flush()
expect(instance.currentNodeName.value).toBe('Visible Node')
executionStoreMock.executingNode = {
title: ' ',
type: 'My Node Type'
}
await flush()
expect(instance.currentNodeName.value).toBe(
'i18n(nodeDefs.My Node Type.display_name)-My Node Type'
)
})
it('groups job items by date label and sorts by total generation time when requested', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-01-10T12:00:00Z'))
queueStoreMock.historyTasks = [
createTask({
promptId: 'today-small',
queueIndex: 4,
mockState: 'completed',
executionEndTimestamp: Date.now(),
executionTime: 2_000
}),
createTask({
promptId: 'today-large',
queueIndex: 3,
mockState: 'completed',
executionEndTimestamp: Date.now(),
executionTime: 5_000
}),
createTask({
promptId: 'yesterday',
queueIndex: 2,
mockState: 'failed',
executionEndTimestamp: Date.now() - 86_400_000,
executionTime: 1_000
}),
createTask({
promptId: 'undated',
queueIndex: 1,
mockState: 'pending'
})
]
const instance = initComposable()
instance.selectedSortMode.value = 'totalGenerationTime'
await flush()
const groups = instance.groupedJobItems.value
expect(groups.map((g) => g.label)).toEqual([
'Today',
'Yesterday',
'Undated'
])
const todayGroup = groups[0]
expect(todayGroup.items.map((item) => item.id)).toEqual([
'today-large',
'today-small'
])
})
})

View File

@@ -0,0 +1,728 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { Ref } from 'vue'
import type { JobListItem } from '@/composables/queue/useJobList'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
const downloadFileMock = vi.fn()
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: (...args: any[]) => downloadFileMock(...args)
}))
const copyToClipboardMock = vi.fn()
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({
copyToClipboard: (...args: any[]) => copyToClipboardMock(...args)
})
}))
const stMock = vi.fn((_: string, fallback?: string) => fallback ?? _)
const tMock = vi.fn((key: string) => `i18n:${key}`)
vi.mock('@/i18n', () => ({
st: (...args: Parameters<typeof stMock>) => stMock(...args),
t: (...args: Parameters<typeof tMock>) => tMock(...args)
}))
const mapTaskOutputToAssetItemMock = vi.fn()
vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
mapTaskOutputToAssetItem: (...args: any[]) =>
mapTaskOutputToAssetItemMock(...args)
}))
const mediaAssetActionsMock = {
confirmDelete: vi.fn()
}
vi.mock('@/platform/assets/composables/useMediaAssetActions', () => ({
useMediaAssetActions: () => mediaAssetActionsMock
}))
const settingStoreMock = {
get: vi.fn()
}
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => settingStoreMock
}))
const workflowServiceMock = {
openWorkflow: vi.fn()
}
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => workflowServiceMock
}))
const workflowStoreMock = {
createTemporary: vi.fn()
}
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => workflowStoreMock
}))
const interruptMock = vi.fn()
const deleteItemMock = vi.fn()
vi.mock('@/scripts/api', () => ({
api: {
interrupt: (...args: any[]) => interruptMock(...args),
deleteItem: (...args: any[]) => deleteItemMock(...args)
}
}))
const downloadBlobMock = vi.fn()
vi.mock('@/scripts/utils', () => ({
downloadBlob: (...args: any[]) => downloadBlobMock(...args)
}))
const dialogServiceMock = {
showExecutionErrorDialog: vi.fn(),
prompt: vi.fn()
}
vi.mock('@/services/dialogService', () => ({
useDialogService: () => dialogServiceMock
}))
const litegraphServiceMock = {
addNodeOnGraph: vi.fn(),
getCanvasCenter: vi.fn()
}
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => litegraphServiceMock
}))
const nodeDefStoreMock = {
nodeDefsByName: {} as Record<string, any>
}
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => nodeDefStoreMock
}))
const queueStoreMock = {
update: vi.fn(),
delete: vi.fn()
}
vi.mock('@/stores/queueStore', () => ({
useQueueStore: () => queueStoreMock
}))
const createAnnotatedPathMock = vi.fn()
vi.mock('@/utils/createAnnotatedPath', () => ({
createAnnotatedPath: (...args: any[]) => createAnnotatedPathMock(...args)
}))
const appendJsonExtMock = vi.fn((value: string) =>
value.toLowerCase().endsWith('.json') ? value : `${value}.json`
)
vi.mock('@/utils/formatUtil', () => ({
appendJsonExt: (...args: Parameters<typeof appendJsonExtMock>) =>
appendJsonExtMock(...args)
}))
import { useJobMenu } from '@/composables/queue/useJobMenu'
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => ({
id: overrides.id ?? 'job-1',
title: overrides.title ?? 'Test job',
meta: overrides.meta ?? 'meta',
state: overrides.state ?? 'completed',
taskRef: overrides.taskRef,
iconName: overrides.iconName,
iconImageUrl: overrides.iconImageUrl,
showClear: overrides.showClear,
progressCurrentPercent: overrides.progressCurrentPercent,
progressTotalPercent: overrides.progressTotalPercent,
runningNodeName: overrides.runningNodeName,
executionTimeMs: overrides.executionTimeMs,
computeHours: overrides.computeHours
})
let currentItem: Ref<JobListItem | null>
const mountJobMenu = (onInspectAsset?: (item: JobListItem) => void) =>
useJobMenu(() => currentItem.value, onInspectAsset)
const findActionEntry = (entries: MenuEntry[], key: string) =>
entries.find(
(entry): entry is Extract<MenuEntry, { kind?: 'item' }> =>
entry.key === key && entry.kind !== 'divider'
)
describe('useJobMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
currentItem = ref<JobListItem | null>(null)
settingStoreMock.get.mockReturnValue(false)
dialogServiceMock.prompt.mockResolvedValue(undefined)
litegraphServiceMock.getCanvasCenter.mockReturnValue([100, 200])
litegraphServiceMock.addNodeOnGraph.mockReturnValue(null)
workflowStoreMock.createTemporary.mockImplementation((filename, data) => ({
filename,
data
}))
queueStoreMock.update.mockResolvedValue(undefined)
queueStoreMock.delete.mockResolvedValue(undefined)
mediaAssetActionsMock.confirmDelete.mockResolvedValue(false)
mapTaskOutputToAssetItemMock.mockImplementation((task, output) => ({
task,
output
}))
createAnnotatedPathMock.mockReturnValue('annotated-path')
nodeDefStoreMock.nodeDefsByName = {
LoadImage: { id: 'LoadImage' },
LoadVideo: { id: 'LoadVideo' },
LoadAudio: { id: 'LoadAudio' }
}
})
const setCurrentItem = (item: JobListItem | null) => {
currentItem.value = item
}
it('opens workflow when workflow data exists', async () => {
const { openJobWorkflow } = mountJobMenu()
const workflow = { nodes: [] }
setCurrentItem(createJobItem({ id: '55', taskRef: { workflow } }))
await openJobWorkflow()
expect(workflowStoreMock.createTemporary).toHaveBeenCalledWith(
'Job 55.json',
workflow
)
expect(workflowServiceMock.openWorkflow).toHaveBeenCalledWith({
filename: 'Job 55.json',
data: workflow
})
})
it('does nothing when workflow is missing', async () => {
const { openJobWorkflow } = mountJobMenu()
setCurrentItem(createJobItem({ taskRef: {} }))
await openJobWorkflow()
expect(workflowStoreMock.createTemporary).not.toHaveBeenCalled()
expect(workflowServiceMock.openWorkflow).not.toHaveBeenCalled()
})
it('copies job id to clipboard', async () => {
const { copyJobId } = mountJobMenu()
setCurrentItem(createJobItem({ id: 'job-99' }))
await copyJobId()
expect(copyToClipboardMock).toHaveBeenCalledWith('job-99')
})
it('ignores copy job id when no selection', async () => {
const { copyJobId } = mountJobMenu()
await copyJobId()
expect(copyToClipboardMock).not.toHaveBeenCalled()
})
it.each([
['running', interruptMock, deleteItemMock],
['initialization', interruptMock, deleteItemMock]
])('cancels %s job via interrupt', async (state) => {
const { cancelJob } = mountJobMenu()
setCurrentItem(createJobItem({ state: state as any }))
await cancelJob()
expect(interruptMock).toHaveBeenCalledWith('job-1')
expect(deleteItemMock).not.toHaveBeenCalled()
expect(queueStoreMock.update).toHaveBeenCalled()
})
it('cancels pending job via deleteItem', async () => {
const { cancelJob } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'pending' }))
await cancelJob()
expect(deleteItemMock).toHaveBeenCalledWith('queue', 'job-1')
expect(queueStoreMock.update).toHaveBeenCalled()
})
it('still updates queue for uncancellable states', async () => {
const { cancelJob } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'completed' }))
await cancelJob()
expect(interruptMock).not.toHaveBeenCalled()
expect(deleteItemMock).not.toHaveBeenCalled()
expect(queueStoreMock.update).toHaveBeenCalled()
})
it('copies error message from failed job entry', async () => {
const { jobMenuEntries } = mountJobMenu()
const error = { exception_message: 'boom' }
setCurrentItem(
createJobItem({
state: 'failed',
taskRef: { status: { messages: [['execution_error', error]] } } as any
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'copy-error')
await entry?.onClick?.()
expect(copyToClipboardMock).toHaveBeenCalledWith('boom')
})
it('reports error via dialog when entry triggered', async () => {
const { jobMenuEntries } = mountJobMenu()
const error = { exception_message: 'bad', extra: 1 }
setCurrentItem(
createJobItem({
state: 'failed',
taskRef: { status: { messages: [['execution_error', error]] } } as any
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
entry?.onClick?.()
expect(dialogServiceMock.showExecutionErrorDialog).toHaveBeenCalledWith(
error
)
})
it('ignores error actions when message missing', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'failed', taskRef: { status: {} } }))
await nextTick()
const copyEntry = findActionEntry(jobMenuEntries.value, 'copy-error')
await copyEntry?.onClick?.()
const reportEntry = findActionEntry(jobMenuEntries.value, 'report-error')
await reportEntry?.onClick?.()
expect(copyToClipboardMock).not.toHaveBeenCalled()
expect(dialogServiceMock.showExecutionErrorDialog).not.toHaveBeenCalled()
})
const previewCases = [
{
label: 'image',
flags: { isImage: true },
expectedNode: 'LoadImage',
widget: 'image'
},
{
label: 'video',
flags: { isVideo: true },
expectedNode: 'LoadVideo',
widget: 'file'
},
{
label: 'audio',
flags: { isAudio: true },
expectedNode: 'LoadAudio',
widget: 'audio'
}
] as const
it.each(previewCases)(
'adds loader node for %s preview output',
async ({ flags, expectedNode, widget }) => {
const widgetCallback = vi.fn()
const node = {
widgets: [{ name: widget, value: null, callback: widgetCallback }],
graph: { setDirtyCanvas: vi.fn() }
}
litegraphServiceMock.addNodeOnGraph.mockReturnValueOnce(node)
const { jobMenuEntries } = mountJobMenu()
const preview = {
filename: 'foo.png',
subfolder: 'bar',
type: 'output',
url: 'http://asset',
...flags
}
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { previewOutput: preview }
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
await entry?.onClick?.()
expect(litegraphServiceMock.addNodeOnGraph).toHaveBeenCalledWith(
nodeDefStoreMock.nodeDefsByName[expectedNode],
{ pos: [100, 200] }
)
expect(node.widgets?.[0].value).toBe('annotated-path')
expect(widgetCallback).toHaveBeenCalledWith('annotated-path')
expect(node.graph?.setDirtyCanvas).toHaveBeenCalledWith(true, true)
}
)
it('skips adding node when no loader definition', async () => {
delete nodeDefStoreMock.nodeDefsByName.LoadImage
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: {
previewOutput: {
isImage: true,
filename: 'foo',
subfolder: '',
type: 'output'
}
}
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
await entry?.onClick?.()
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
})
it('skips adding node when preview output lacks media flags', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: {
previewOutput: {
filename: 'foo',
subfolder: 'bar',
type: 'output'
}
}
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
await entry?.onClick?.()
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
expect(createAnnotatedPathMock).not.toHaveBeenCalled()
})
it('skips annotating when litegraph node creation fails', async () => {
litegraphServiceMock.addNodeOnGraph.mockReturnValueOnce(null)
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: {
previewOutput: {
isImage: true,
filename: 'foo',
subfolder: '',
type: 'output'
}
}
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
await entry?.onClick?.()
expect(litegraphServiceMock.addNodeOnGraph).toHaveBeenCalled()
expect(createAnnotatedPathMock).not.toHaveBeenCalled()
})
it('ignores add-to-current entry when preview missing entirely', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} as any }))
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
await entry?.onClick?.()
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
})
it('downloads preview asset when requested', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: {
previewOutput: { url: 'https://asset', isImage: true }
}
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'download')
entry?.onClick?.()
expect(downloadFileMock).toHaveBeenCalledWith('https://asset')
})
it('ignores download request when preview missing', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} as any }))
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'download')
entry?.onClick?.()
expect(downloadFileMock).not.toHaveBeenCalled()
})
it('exports workflow with default filename when prompting disabled', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
id: '7',
state: 'completed',
taskRef: { workflow: { foo: 'bar' } }
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
await entry?.onClick?.()
expect(dialogServiceMock.prompt).not.toHaveBeenCalled()
expect(downloadBlobMock).toHaveBeenCalledTimes(1)
const [filename, blob] = downloadBlobMock.mock.calls[0]
expect(filename).toBe('Job 7.json')
await expect(blob.text()).resolves.toBe(
JSON.stringify({ foo: 'bar' }, null, 2)
)
})
it('prompts for filename when setting enabled', async () => {
settingStoreMock.get.mockReturnValue(true)
dialogServiceMock.prompt.mockResolvedValue('custom-name')
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { workflow: {} }
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
await entry?.onClick?.()
expect(dialogServiceMock.prompt).toHaveBeenCalledWith({
title: expect.stringContaining('workflowService.exportWorkflow'),
message: expect.stringContaining('workflowService.enterFilename'),
defaultValue: 'Job job-1.json'
})
const [filename] = downloadBlobMock.mock.calls[0]
expect(filename).toBe('custom-name.json')
})
it('keeps existing json extension when exporting workflow', async () => {
settingStoreMock.get.mockReturnValue(true)
dialogServiceMock.prompt.mockResolvedValue('existing.json')
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
id: '42',
state: 'completed',
taskRef: { workflow: { foo: 'bar' } }
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
await entry?.onClick?.()
expect(appendJsonExtMock).toHaveBeenCalledWith('existing.json')
const [filename] = downloadBlobMock.mock.calls[0]
expect(filename).toBe('existing.json')
})
it('abandons export when prompt cancelled', async () => {
settingStoreMock.get.mockReturnValue(true)
dialogServiceMock.prompt.mockResolvedValue('')
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { workflow: {} }
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
await entry?.onClick?.()
expect(downloadBlobMock).not.toHaveBeenCalled()
})
it('deletes preview asset when confirmed', async () => {
mediaAssetActionsMock.confirmDelete.mockResolvedValue(true)
const { jobMenuEntries } = mountJobMenu()
const preview = { filename: 'foo', subfolder: 'bar', type: 'output' }
const taskRef = { previewOutput: preview }
setCurrentItem(createJobItem({ state: 'completed', taskRef }))
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'delete')
await entry?.onClick?.()
expect(mapTaskOutputToAssetItemMock).toHaveBeenCalledWith(taskRef, preview)
expect(queueStoreMock.update).toHaveBeenCalled()
})
it('does not refresh queue when delete cancelled', async () => {
mediaAssetActionsMock.confirmDelete.mockResolvedValue(false)
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { previewOutput: {} }
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'delete')
await entry?.onClick?.()
expect(queueStoreMock.update).not.toHaveBeenCalled()
})
it('removes failed job via menu entry', async () => {
const taskRef = { id: 'task-1' }
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'failed', taskRef }))
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'delete')
await entry?.onClick?.()
expect(queueStoreMock.delete).toHaveBeenCalledWith(taskRef)
})
it('ignores failed job delete when taskRef missing', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'failed' }))
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'delete')
await entry?.onClick?.()
expect(queueStoreMock.delete).not.toHaveBeenCalled()
})
it('provides completed menu structure with delete option', async () => {
const inspectSpy = vi.fn()
const { jobMenuEntries } = mountJobMenu(inspectSpy)
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { previewOutput: {} }
})
)
await nextTick()
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
'inspect-asset',
'add-to-current',
'download',
'd1',
'open-workflow',
'export-workflow',
'd2',
'copy-id',
'd3',
'delete'
])
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
await inspectEntry?.onClick?.()
expect(inspectSpy).toHaveBeenCalledWith(currentItem.value)
})
it('omits inspect handler when callback missing', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { previewOutput: {} }
})
)
await nextTick()
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
expect(inspectEntry?.onClick).toBeUndefined()
})
it('omits delete asset entry when no preview exists', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} }))
await nextTick()
expect(jobMenuEntries.value.some((entry) => entry.key === 'delete')).toBe(
false
)
})
it('returns failed menu entries with error actions', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'failed', taskRef: { status: {} } }))
await nextTick()
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
'open-workflow',
'd1',
'copy-id',
'copy-error',
'report-error',
'd2',
'delete'
])
})
it('returns active job entries with cancel option', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'running' }))
await nextTick()
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
'open-workflow',
'd1',
'copy-id',
'd2',
'cancel-job'
])
})
it('provides pending job entries and triggers cancel action', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'pending' }))
await nextTick()
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
'open-workflow',
'd1',
'copy-id',
'd2',
'cancel-job'
])
const cancelEntry = findActionEntry(jobMenuEntries.value, 'cancel-job')
await cancelEntry?.onClick?.()
expect(deleteItemMock).toHaveBeenCalledWith('queue', 'job-1')
expect(queueStoreMock.update).toHaveBeenCalled()
})
it('returns empty menu when no job selected', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(null)
await nextTick()
expect(jobMenuEntries.value).toEqual([])
})
})

View File

@@ -0,0 +1,160 @@
import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { nextTick, ref } from 'vue'
import type { Ref } from 'vue'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { formatPercent0 } from '@/utils/numberUtil'
type ProgressValue = number | null
const localeRef: Ref<string> = ref('en-US') as Ref<string>
const executionProgressRef: Ref<ProgressValue> = ref(null)
const executingNodeProgressRef: Ref<ProgressValue> = ref(null)
const createExecutionStoreMock = () => ({
get executionProgress() {
return executionProgressRef.value ?? undefined
},
get executingNodeProgress() {
return executingNodeProgressRef.value ?? undefined
}
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({
locale: localeRef
})
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => createExecutionStoreMock()
}))
const mountedWrappers: VueWrapper[] = []
const mountUseQueueProgress = () => {
let composable: ReturnType<typeof useQueueProgress>
const wrapper = mount({
template: '<div />',
setup() {
composable = useQueueProgress()
return {}
}
})
mountedWrappers.push(wrapper)
return { wrapper, composable: composable! }
}
const setExecutionProgress = (value?: number | null) => {
executionProgressRef.value = value ?? null
}
const setExecutingNodeProgress = (value?: number | null) => {
executingNodeProgressRef.value = value ?? null
}
describe('useQueueProgress', () => {
beforeEach(() => {
localeRef.value = 'en-US'
setExecutionProgress(null)
setExecutingNodeProgress(null)
})
afterEach(() => {
mountedWrappers.splice(0).forEach((wrapper) => wrapper.unmount())
})
it.each([
{
description: 'defaults to 0% when execution store values are missing',
execution: undefined,
node: undefined,
expectedTotal: 0,
expectedNode: 0
},
{
description: 'rounds fractional progress to the nearest integer',
execution: 0.324,
node: 0.005,
expectedTotal: 32,
expectedNode: 1
},
{
description: 'clamps values below 0 and above 100%',
execution: 1.5,
node: -0.25,
expectedTotal: 100,
expectedNode: 0
},
{
description: 'caps near-complete totals at 100%',
execution: 0.999,
node: 0.731,
expectedTotal: 100,
expectedNode: 73
}
])('$description', ({ execution, node, expectedTotal, expectedNode }) => {
setExecutionProgress(execution ?? null)
setExecutingNodeProgress(node ?? null)
const { composable } = mountUseQueueProgress()
expect(composable.totalPercent.value).toBe(expectedTotal)
expect(composable.currentNodePercent.value).toBe(expectedNode)
expect(composable.totalPercentFormatted.value).toBe(
formatPercent0(localeRef.value, expectedTotal)
)
expect(composable.currentNodePercentFormatted.value).toBe(
formatPercent0(localeRef.value, expectedNode)
)
})
it('reformats output when the active locale changes', async () => {
setExecutionProgress(0.32)
setExecutingNodeProgress(0.58)
const { composable } = mountUseQueueProgress()
expect(composable.totalPercentFormatted.value).toBe(
formatPercent0('en-US', composable.totalPercent.value)
)
expect(composable.currentNodePercentFormatted.value).toBe(
formatPercent0('en-US', composable.currentNodePercent.value)
)
localeRef.value = 'fr-FR'
await nextTick()
expect(composable.totalPercentFormatted.value).toBe(
formatPercent0('fr-FR', composable.totalPercent.value)
)
expect(composable.currentNodePercentFormatted.value).toBe(
formatPercent0('fr-FR', composable.currentNodePercent.value)
)
})
it('builds progress bar styles that track store updates', async () => {
setExecutionProgress(0.1)
setExecutingNodeProgress(0.25)
const { composable } = mountUseQueueProgress()
expect(composable.totalProgressStyle.value).toEqual({
width: '10%',
background: 'var(--color-interface-panel-job-progress-primary)'
})
expect(composable.currentNodeProgressStyle.value).toEqual({
width: '25%',
background: 'var(--color-interface-panel-job-progress-secondary)'
})
setExecutionProgress(0.755)
setExecutingNodeProgress(0.02)
await nextTick()
expect(composable.totalProgressStyle.value.width).toBe('76%')
expect(composable.currentNodeProgressStyle.value.width).toBe('2%')
})
})

View File

@@ -0,0 +1,103 @@
import { describe, it, expect } from 'vitest'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import type { JobListItem } from '@/composables/queue/useJobList'
type PreviewLike = { url: string; supportsPreview: boolean }
const createPreview = (url: string, supportsPreview = true): PreviewLike => ({
url,
supportsPreview
})
const createTask = (preview?: PreviewLike) => ({
previewOutput: preview
})
const createJobItem = (id: string, preview?: PreviewLike): JobListItem =>
({
id,
title: `Job ${id}`,
meta: '',
state: 'completed',
showClear: false,
taskRef: preview ? { previewOutput: preview } : undefined
}) as JobListItem
describe('useResultGallery', () => {
it('collects only previewable outputs and preserves their order', () => {
const previewable = [createPreview('p-1'), createPreview('p-2')]
const tasks = [
createTask(previewable[0]),
createTask({ url: 'skip-me', supportsPreview: false }),
createTask(previewable[1]),
createTask()
]
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
() => tasks
)
onViewItem(createJobItem('job-1', previewable[0]))
expect(galleryItems.value).toEqual(previewable)
expect(galleryActiveIndex.value).toBe(0)
})
it('does not change state when there are no previewable tasks', () => {
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
() => []
)
onViewItem(createJobItem('job-missing'))
expect(galleryItems.value).toEqual([])
expect(galleryActiveIndex.value).toBe(-1)
})
it('activates the index that matches the viewed preview URL', () => {
const previewable = [
createPreview('p-1'),
createPreview('p-2'),
createPreview('p-3')
]
const tasks = previewable.map((preview) => createTask(preview))
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
() => tasks
)
onViewItem(createJobItem('job-2', createPreview('p-2')))
expect(galleryItems.value).toEqual(previewable)
expect(galleryActiveIndex.value).toBe(1)
})
it('defaults to the first entry when the clicked job lacks a preview', () => {
const previewable = [createPreview('p-1'), createPreview('p-2')]
const tasks = previewable.map((preview) => createTask(preview))
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
() => tasks
)
onViewItem(createJobItem('job-no-preview'))
expect(galleryItems.value).toEqual(previewable)
expect(galleryActiveIndex.value).toBe(0)
})
it('defaults to the first entry when no gallery item matches the preview URL', () => {
const previewable = [createPreview('p-1'), createPreview('p-2')]
const tasks = previewable.map((preview) => createTask(preview))
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
() => tasks
)
onViewItem(createJobItem('job-mismatch', createPreview('missing')))
expect(galleryItems.value).toEqual(previewable)
expect(galleryActiveIndex.value).toBe(0)
})
})