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

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