From fcb4341c986e2842c546dc7fe731a45ca2554dcb Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Sat, 14 Feb 2026 12:14:55 -0800 Subject: [PATCH] feat(queue): introduce queue notification banners and remove completion summary flow (#8740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Replace the old completion-summary overlay path with queue notification banners for queueing/completed/failed lifecycle feedback. ## Key changes - Added `QueueNotificationBanner`, `QueueNotificationBannerHost`, stories, and tests. - Added `useQueueNotificationBanners` to handle: - immediate `queuedPending` on `promptQueueing` - transition to `queued` on `promptQueued` (request-id aware) - completed/failed notification sequencing from finished batch history - timed notification queueing/dismissal - Removed completion-summary implementation: - `useCompletionSummary` - `CompletionSummaryBanner` - `QueueOverlayEmpty` - Simplified `QueueProgressOverlay` to `hidden | active | expanded` states. - Top menu behavior: - restored `QueueInlineProgressSummary` as separate UI - ordering is inline summary first, notification banner below - notification banner remains under the top menu section (not teleported to floating actionbar target) - Kept established API-event signaling pattern (`promptQueueing`/`promptQueued`) instead of introducing a separate bus. - Updated tests for top-menu visibility/ordering and notification behavior across QPOV2 enabled/disabled. ## Notes - Completion notifications now support stacked thumbnails (cap: 3). - https://www.figma.com/design/LVilZgHGk5RwWOkVN6yCEK/Queue-Progress-Modal?node-id=3843-20314&m=dev ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8740-feat-Queue-Notification-Toasts-3016d73d3650814c8a50d9567a40f44d) by [Unito](https://www.unito.io) --- src/components/TopMenuSection.test.ts | 121 +++++- src/components/TopMenuSection.vue | 10 +- .../queue/CompletionSummaryBanner.stories.ts | 73 ---- .../queue/CompletionSummaryBanner.test.ts | 91 ----- .../queue/CompletionSummaryBanner.vue | 109 ------ .../queue/QueueNotificationBanner.stories.ts | 140 +++++++ .../queue/QueueNotificationBanner.test.ts | 136 +++++++ .../queue/QueueNotificationBanner.vue | 154 ++++++++ .../queue/QueueNotificationBannerHost.vue | 18 + .../queue/QueueOverlayEmpty.test.ts | 69 ---- src/components/queue/QueueOverlayEmpty.vue | 27 -- src/components/queue/QueueProgressOverlay.vue | 24 +- .../queue/useCompletionSummary.test.ts | 289 --------------- src/composables/queue/useCompletionSummary.ts | 116 ------ src/composables/queue/useJobList.test.ts | 73 ---- .../queue/useQueueNotificationBanners.test.ts | 349 ++++++++++++++++++ .../queue/useQueueNotificationBanners.ts | 318 ++++++++++++++++ src/locales/en/main.json | 4 + src/scripts/api.ts | 6 +- src/scripts/app.ts | 24 +- 20 files changed, 1267 insertions(+), 884 deletions(-) delete mode 100644 src/components/queue/CompletionSummaryBanner.stories.ts delete mode 100644 src/components/queue/CompletionSummaryBanner.test.ts delete mode 100644 src/components/queue/CompletionSummaryBanner.vue create mode 100644 src/components/queue/QueueNotificationBanner.stories.ts create mode 100644 src/components/queue/QueueNotificationBanner.test.ts create mode 100644 src/components/queue/QueueNotificationBanner.vue create mode 100644 src/components/queue/QueueNotificationBannerHost.vue delete mode 100644 src/components/queue/QueueOverlayEmpty.test.ts delete mode 100644 src/components/queue/QueueOverlayEmpty.vue delete mode 100644 src/composables/queue/useCompletionSummary.test.ts delete mode 100644 src/composables/queue/useCompletionSummary.ts create mode 100644 src/composables/queue/useQueueNotificationBanners.test.ts create mode 100644 src/composables/queue/useQueueNotificationBanners.ts diff --git a/src/components/TopMenuSection.test.ts b/src/components/TopMenuSection.test.ts index d1387e779a..c9693caedf 100644 --- a/src/components/TopMenuSection.test.ts +++ b/src/components/TopMenuSection.test.ts @@ -7,6 +7,7 @@ import type { Component } from 'vue' import { createI18n } from 'vue-i18n' import TopMenuSection from '@/components/TopMenuSection.vue' +import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue' import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue' import LoginButton from '@/components/topbar/LoginButton.vue' import type { @@ -113,6 +114,7 @@ function createWrapper({ SubgraphBreadcrumb: true, QueueProgressOverlay: true, QueueInlineProgressSummary: true, + QueueNotificationBannerHost: true, CurrentUserButton: true, LoginButton: true, ContextMenu: { @@ -142,6 +144,18 @@ function createTask(id: string, status: JobStatus): TaskItemImpl { return new TaskItemImpl(createJob(id, status)) } +function createComfyActionbarStub(actionbarTarget: HTMLElement) { + return defineComponent({ + name: 'ComfyActionbar', + setup(_, { emit }) { + onMounted(() => { + emit('update:progressTarget', actionbarTarget) + }) + return () => h('div') + } + }) +} + describe('TopMenuSection', () => { beforeEach(() => { vi.resetAllMocks() @@ -316,15 +330,7 @@ describe('TopMenuSection', () => { const executionStore = useExecutionStore(pinia) executionStore.activePromptId = 'prompt-1' - const ComfyActionbarStub = defineComponent({ - name: 'ComfyActionbar', - setup(_, { emit }) { - onMounted(() => { - emit('update:progressTarget', actionbarTarget) - }) - return () => h('div') - } - }) + const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget) const wrapper = createWrapper({ pinia, @@ -346,6 +352,103 @@ describe('TopMenuSection', () => { }) }) + describe(QueueNotificationBannerHost, () => { + const configureSettings = ( + pinia: ReturnType, + qpoV2Enabled: boolean + ) => { + const settingStore = useSettingStore(pinia) + vi.mocked(settingStore.get).mockImplementation((key) => { + if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled + if (key === 'Comfy.UseNewMenu') return 'Top' + return undefined + }) + } + + it('renders queue notification banners when QPO V2 is enabled', async () => { + const pinia = createTestingPinia({ createSpy: vi.fn }) + configureSettings(pinia, true) + + const wrapper = createWrapper({ pinia }) + + await nextTick() + + expect( + wrapper.findComponent({ name: 'QueueNotificationBannerHost' }).exists() + ).toBe(true) + }) + + it('renders queue notification banners when QPO V2 is disabled', async () => { + const pinia = createTestingPinia({ createSpy: vi.fn }) + configureSettings(pinia, false) + + const wrapper = createWrapper({ pinia }) + + await nextTick() + + expect( + wrapper.findComponent({ name: 'QueueNotificationBannerHost' }).exists() + ).toBe(true) + }) + + it('renders inline summary above banners when both are visible', async () => { + const pinia = createTestingPinia({ createSpy: vi.fn }) + configureSettings(pinia, true) + const wrapper = createWrapper({ pinia }) + + await nextTick() + + const html = wrapper.html() + const inlineSummaryIndex = html.indexOf( + 'queue-inline-progress-summary-stub' + ) + const queueBannerIndex = html.indexOf( + 'queue-notification-banner-host-stub' + ) + + expect(inlineSummaryIndex).toBeGreaterThan(-1) + expect(queueBannerIndex).toBeGreaterThan(-1) + expect(inlineSummaryIndex).toBeLessThan(queueBannerIndex) + }) + + it('does not teleport queue notification banners when actionbar is floating', async () => { + localStorage.setItem('Comfy.MenuPosition.Docked', 'false') + const actionbarTarget = document.createElement('div') + document.body.appendChild(actionbarTarget) + const pinia = createTestingPinia({ createSpy: vi.fn }) + configureSettings(pinia, true) + const executionStore = useExecutionStore(pinia) + executionStore.activePromptId = 'prompt-1' + + const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget) + + const wrapper = createWrapper({ + pinia, + attachTo: document.body, + stubs: { + ComfyActionbar: ComfyActionbarStub, + QueueNotificationBannerHost: true + } + }) + + try { + await nextTick() + + expect( + actionbarTarget.querySelector('queue-notification-banner-host-stub') + ).toBeNull() + expect( + wrapper + .findComponent({ name: 'QueueNotificationBannerHost' }) + .exists() + ).toBe(true) + } finally { + wrapper.unmount() + actionbarTarget.remove() + } + }) + }) + it('disables the clear queue context menu item when no queued jobs exist', () => { const wrapper = createWrapper() const menu = wrapper.findComponent({ name: 'ContextMenu' }) diff --git a/src/components/TopMenuSection.vue b/src/components/TopMenuSection.vue index 5c3ce1f6e1..5dc780b62f 100644 --- a/src/components/TopMenuSection.vue +++ b/src/components/TopMenuSection.vue @@ -105,7 +105,7 @@ -
+
+
@@ -136,6 +140,7 @@ import { useI18n } from 'vue-i18n' import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue' import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue' import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue' +import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue' import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue' import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue' import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue' @@ -204,6 +209,9 @@ const isQueueProgressOverlayEnabled = computed( const shouldShowInlineProgressSummary = computed( () => isQueuePanelV2Enabled.value && isActionbarEnabled.value ) +const shouldShowQueueNotificationBanners = computed( + () => isActionbarEnabled.value +) const progressTarget = ref(null) function updateProgressTarget(target: HTMLElement | null) { progressTarget.value = target diff --git a/src/components/queue/CompletionSummaryBanner.stories.ts b/src/components/queue/CompletionSummaryBanner.stories.ts deleted file mode 100644 index 51e09dd5e8..0000000000 --- a/src/components/queue/CompletionSummaryBanner.stories.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/vue3-vite' - -import CompletionSummaryBanner from './CompletionSummaryBanner.vue' - -const meta: Meta = { - title: 'Queue/CompletionSummaryBanner', - component: CompletionSummaryBanner, - parameters: { - layout: 'padded' - } -} - -export default meta -type Story = StoryObj - -const thumb = (hex: string) => - `data:image/svg+xml;utf8,` - -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: [] - } -} diff --git a/src/components/queue/CompletionSummaryBanner.test.ts b/src/components/queue/CompletionSummaryBanner.test.ts deleted file mode 100644 index d80b304667..0000000000 --- a/src/components/queue/CompletionSummaryBanner.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -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) => - 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) - }) -}) diff --git a/src/components/queue/CompletionSummaryBanner.vue b/src/components/queue/CompletionSummaryBanner.vue deleted file mode 100644 index c0bf7aeb91..0000000000 --- a/src/components/queue/CompletionSummaryBanner.vue +++ /dev/null @@ -1,109 +0,0 @@ - - - diff --git a/src/components/queue/QueueNotificationBanner.stories.ts b/src/components/queue/QueueNotificationBanner.stories.ts new file mode 100644 index 0000000000..a8b553deb5 --- /dev/null +++ b/src/components/queue/QueueNotificationBanner.stories.ts @@ -0,0 +1,140 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import type { QueueNotificationBanner as QueueNotificationBannerItem } from '@/composables/queue/useQueueNotificationBanners' + +import QueueNotificationBanner from './QueueNotificationBanner.vue' + +const meta: Meta = { + title: 'Queue/QueueNotificationBanner', + component: QueueNotificationBanner, + parameters: { + layout: 'padded' + } +} + +export default meta +type Story = StoryObj + +const thumbnail = (hex: string) => + `data:image/svg+xml;utf8,` + +const args = (notification: QueueNotificationBannerItem) => ({ notification }) + +export const Queueing: Story = { + args: args({ + type: 'queuedPending', + count: 1 + }) +} + +export const QueueingMultiple: Story = { + args: args({ + type: 'queuedPending', + count: 3 + }) +} + +export const Queued: Story = { + args: args({ + type: 'queued', + count: 1 + }) +} + +export const QueuedMultiple: Story = { + args: args({ + type: 'queued', + count: 4 + }) +} + +export const Completed: Story = { + args: args({ + type: 'completed', + count: 1, + thumbnailUrl: thumbnail('4dabf7') + }) +} + +export const CompletedMultiple: Story = { + args: args({ + type: 'completed', + count: 4 + }) +} + +export const CompletedMultipleWithThumbnail: Story = { + args: args({ + type: 'completed', + count: 4, + thumbnailUrls: [ + thumbnail('ff6b6b'), + thumbnail('4dabf7'), + thumbnail('51cf66') + ] + }) +} + +export const Failed: Story = { + args: args({ + type: 'failed', + count: 1 + }) +} + +export const Gallery: Story = { + render: () => ({ + components: { QueueNotificationBanner }, + setup() { + const queueing = args({ + type: 'queuedPending', + count: 1 + }) + const queued = args({ + type: 'queued', + count: 2 + }) + const completed = args({ + type: 'completed', + count: 1, + thumbnailUrl: thumbnail('ff6b6b') + }) + const completedMultiple = args({ + type: 'completed', + count: 4 + }) + const completedMultipleWithThumbnail = args({ + type: 'completed', + count: 4, + thumbnailUrls: [ + thumbnail('51cf66'), + thumbnail('ffd43b'), + thumbnail('ff922b') + ] + }) + const failed = args({ + type: 'failed', + count: 2 + }) + + return { + queueing, + queued, + completed, + completedMultiple, + completedMultipleWithThumbnail, + failed + } + }, + template: ` +
+ + + + + + +
+ ` + }) +} diff --git a/src/components/queue/QueueNotificationBanner.test.ts b/src/components/queue/QueueNotificationBanner.test.ts new file mode 100644 index 0000000000..629758447e --- /dev/null +++ b/src/components/queue/QueueNotificationBanner.test.ts @@ -0,0 +1,136 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { createI18n } from 'vue-i18n' + +import QueueNotificationBanner from '@/components/queue/QueueNotificationBanner.vue' +import type { QueueNotificationBanner as QueueNotificationBannerItem } from '@/composables/queue/useQueueNotificationBanners' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + queue: { + jobAddedToQueue: 'Job added to queue', + jobQueueing: 'Job queueing' + }, + sideToolbar: { + queueProgressOverlay: { + preview: 'Preview', + jobCompleted: 'Job completed', + jobFailed: 'Job failed', + jobsAddedToQueue: + '{count} job added to queue | {count} jobs added to queue', + jobsCompleted: '{count} job completed | {count} jobs completed', + jobsFailed: '{count} job failed | {count} jobs failed' + } + } + } + } +}) + +const mountComponent = (notification: QueueNotificationBannerItem) => + mount(QueueNotificationBanner, { + props: { notification }, + global: { + plugins: [i18n] + } + }) + +describe(QueueNotificationBanner, () => { + it('renders singular queued message without count prefix', () => { + const wrapper = mountComponent({ + type: 'queued', + count: 1 + }) + + expect(wrapper.text()).toContain('Job added to queue') + expect(wrapper.text()).not.toContain('1 job') + }) + + it('renders queued message with pluralization', () => { + const wrapper = mountComponent({ + type: 'queued', + count: 2 + }) + + expect(wrapper.text()).toContain('2 jobs added to queue') + expect(wrapper.html()).toContain('icon-[lucide--check]') + }) + + it('renders queued pending message with spinner icon', () => { + const wrapper = mountComponent({ + type: 'queuedPending', + count: 1 + }) + + expect(wrapper.text()).toContain('Job queueing') + expect(wrapper.html()).toContain('icon-[lucide--loader-circle]') + expect(wrapper.html()).toContain('animate-spin') + }) + + it('renders failed message and alert icon', () => { + const wrapper = mountComponent({ + type: 'failed', + count: 1 + }) + + expect(wrapper.text()).toContain('Job failed') + expect(wrapper.html()).toContain('icon-[lucide--circle-alert]') + }) + + it('renders completed message with thumbnail preview when provided', () => { + const wrapper = mountComponent({ + type: 'completed', + count: 3, + thumbnailUrls: ['https://example.com/preview.png'] + }) + + expect(wrapper.text()).toContain('3 jobs completed') + const image = wrapper.get('img') + expect(image.attributes('src')).toBe('https://example.com/preview.png') + expect(image.attributes('alt')).toBe('Preview') + }) + + it('renders two completion thumbnail previews', () => { + const wrapper = mountComponent({ + type: 'completed', + count: 4, + thumbnailUrls: [ + 'https://example.com/preview-1.png', + 'https://example.com/preview-2.png' + ] + }) + + const images = wrapper.findAll('img') + expect(images.length).toBe(2) + expect(images[0].attributes('src')).toBe( + 'https://example.com/preview-1.png' + ) + expect(images[1].attributes('src')).toBe( + 'https://example.com/preview-2.png' + ) + }) + + it('caps completion thumbnail previews at two', () => { + const wrapper = mountComponent({ + type: 'completed', + count: 4, + thumbnailUrls: [ + 'https://example.com/preview-1.png', + 'https://example.com/preview-2.png', + 'https://example.com/preview-3.png', + 'https://example.com/preview-4.png' + ] + }) + + const images = wrapper.findAll('img') + expect(images.length).toBe(2) + expect(images[0].attributes('src')).toBe( + 'https://example.com/preview-1.png' + ) + expect(images[1].attributes('src')).toBe( + 'https://example.com/preview-2.png' + ) + }) +}) diff --git a/src/components/queue/QueueNotificationBanner.vue b/src/components/queue/QueueNotificationBanner.vue new file mode 100644 index 0000000000..d2b71a677a --- /dev/null +++ b/src/components/queue/QueueNotificationBanner.vue @@ -0,0 +1,154 @@ + + + diff --git a/src/components/queue/QueueNotificationBannerHost.vue b/src/components/queue/QueueNotificationBannerHost.vue new file mode 100644 index 0000000000..48fb3e2247 --- /dev/null +++ b/src/components/queue/QueueNotificationBannerHost.vue @@ -0,0 +1,18 @@ + + + diff --git a/src/components/queue/QueueOverlayEmpty.test.ts b/src/components/queue/QueueOverlayEmpty.test.ts deleted file mode 100644 index 76509dc3d9..0000000000 --- a/src/components/queue/QueueOverlayEmpty.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -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: '' -} - -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) - }) -}) diff --git a/src/components/queue/QueueOverlayEmpty.vue b/src/components/queue/QueueOverlayEmpty.vue deleted file mode 100644 index 83a55aec20..0000000000 --- a/src/components/queue/QueueOverlayEmpty.vue +++ /dev/null @@ -1,27 +0,0 @@ - - - diff --git a/src/components/queue/QueueProgressOverlay.vue b/src/components/queue/QueueProgressOverlay.vue index 1024dc0241..92ff793eb3 100644 --- a/src/components/queue/QueueProgressOverlay.vue +++ b/src/components/queue/QueueProgressOverlay.vue @@ -44,12 +44,6 @@ @clear-queued="cancelQueuedWorkflows" @view-all-jobs="viewAllJobs" /> - - @@ -64,11 +58,9 @@ 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' @@ -84,7 +76,7 @@ import { useExecutionStore } from '@/stores/executionStore' import { useQueueStore } from '@/stores/queueStore' import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore' -type OverlayState = 'hidden' | 'empty' | 'active' | 'expanded' +type OverlayState = 'hidden' | 'active' | 'expanded' const props = withDefaults( defineProps<{ @@ -130,9 +122,6 @@ const isExpanded = computed({ } }) -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) @@ -142,14 +131,12 @@ const activeJobsCount = computed(() => runningCount.value + queuedCount.value) const overlayState = computed(() => { 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' && isOverlayHovered.value) ) @@ -230,19 +217,10 @@ const setExpanded = (expanded: boolean) => { isExpanded.value = expanded } -const openExpandedFromEmpty = () => { - setExpanded(true) -} - const viewAllJobs = () => { setExpanded(true) } -const onSummaryClick = () => { - openExpandedFromEmpty() - clearSummary() -} - const openAssetsSidebar = () => { sidebarTabStore.activeSidebarTabId = 'assets' } diff --git a/src/composables/queue/useCompletionSummary.test.ts b/src/composables/queue/useCompletionSummary.test.ts deleted file mode 100644 index 34ec42ff6c..0000000000 --- a/src/composables/queue/useCompletionSummary.test.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { nextTick, reactive } from 'vue' - -import { useCompletionSummary } from '@/composables/queue/useCompletionSummary' -import { useExecutionStore } from '@/stores/executionStore' -import { useQueueStore } from '@/stores/queueStore' - -type MockTask = { - displayStatus: 'Completed' | 'Failed' | 'Cancelled' | 'Running' | 'Pending' - executionEndTimestamp?: number - previewOutput?: { - isImage: boolean - urlWithTimestamp: string - } -} - -vi.mock('@/stores/queueStore', () => { - const state = reactive({ - runningTasks: [] as MockTask[], - historyTasks: [] as MockTask[] - }) - - return { - useQueueStore: () => state - } -}) - -vi.mock('@/stores/executionStore', () => { - const state = reactive({ - isIdle: true - }) - - return { - useExecutionStore: () => state - } -}) - -describe('useCompletionSummary', () => { - const queueStore = () => - useQueueStore() as { - runningTasks: MockTask[] - historyTasks: MockTask[] - } - const executionStore = () => useExecutionStore() as { isIdle: boolean } - - const resetState = () => { - queueStore().runningTasks = [] - queueStore().historyTasks = [] - executionStore().isIdle = true - } - - const createTask = ( - options: { - state?: MockTask['displayStatus'] - ts?: number - previewUrl?: string - isImage?: boolean - } = {} - ): MockTask => { - const { - state = 'Completed', - ts = Date.now(), - previewUrl, - isImage = true - } = options - - const task: MockTask = { - displayStatus: state, - executionEndTimestamp: ts - } - - if (previewUrl) { - task.previewOutput = { - isImage, - urlWithTimestamp: previewUrl - } - } - - return task - } - - const runBatch = async (options: { - start: number - finish: number - tasks: MockTask[] - }) => { - const { start, finish, tasks } = options - - vi.setSystemTime(start) - executionStore().isIdle = false - await nextTick() - - vi.setSystemTime(finish) - queueStore().historyTasks = tasks - executionStore().isIdle = true - await nextTick() - } - - beforeEach(() => { - resetState() - vi.useFakeTimers() - vi.setSystemTime(0) - }) - - afterEach(() => { - vi.runOnlyPendingTimers() - vi.useRealTimers() - resetState() - }) - - it('summarizes the most recent batch and auto clears after the dismiss delay', async () => { - const { summary } = useCompletionSummary() - await nextTick() - - const start = 1_000 - const finish = 2_000 - - const tasks = [ - createTask({ ts: start - 100, previewUrl: 'ignored-old' }), - createTask({ ts: start + 10, previewUrl: 'img-1' }), - createTask({ ts: start + 20, previewUrl: 'img-2' }), - createTask({ ts: start + 30, previewUrl: 'img-3' }), - createTask({ ts: start + 40, previewUrl: 'img-4' }), - createTask({ state: 'Failed', ts: start + 50 }) - ] - - await runBatch({ start, finish, tasks }) - - expect(summary.value).toEqual({ - mode: 'mixed', - completedCount: 4, - failedCount: 1, - thumbnailUrls: ['img-1', 'img-2', 'img-3'] - }) - - vi.advanceTimersByTime(6000) - await nextTick() - expect(summary.value).toBeNull() - }) - - it('reports allFailed when every task in the batch failed', async () => { - const { summary } = useCompletionSummary() - await nextTick() - - const start = 10_000 - const finish = 10_200 - - await runBatch({ - start, - finish, - tasks: [ - createTask({ state: 'Failed', ts: start + 25 }), - createTask({ state: 'Failed', ts: start + 50 }) - ] - }) - - expect(summary.value).toEqual({ - mode: 'allFailed', - completedCount: 0, - failedCount: 2, - thumbnailUrls: [] - }) - }) - - it('treats cancelled tasks as failures and skips non-image previews', async () => { - const { summary } = useCompletionSummary() - await nextTick() - - const start = 15_000 - const finish = 15_200 - - await runBatch({ - start, - finish, - tasks: [ - createTask({ ts: start + 25, previewUrl: 'img-1' }), - createTask({ - state: 'Cancelled', - ts: start + 50, - previewUrl: 'thumb-ignore', - isImage: false - }) - ] - }) - - expect(summary.value).toEqual({ - mode: 'mixed', - completedCount: 1, - failedCount: 1, - thumbnailUrls: ['img-1'] - }) - }) - - it('clearSummary dismisses the banner immediately and still tracks future batches', async () => { - const { summary, clearSummary } = useCompletionSummary() - await nextTick() - - await runBatch({ - start: 5_000, - finish: 5_100, - tasks: [createTask({ ts: 5_050, previewUrl: 'img-1' })] - }) - - expect(summary.value).toEqual({ - mode: 'allSuccess', - completedCount: 1, - failedCount: 0, - thumbnailUrls: ['img-1'] - }) - - clearSummary() - expect(summary.value).toBeNull() - - await runBatch({ - start: 6_000, - finish: 6_150, - tasks: [createTask({ ts: 6_075, previewUrl: 'img-2' })] - }) - - expect(summary.value).toEqual({ - mode: 'allSuccess', - completedCount: 1, - failedCount: 0, - thumbnailUrls: ['img-2'] - }) - }) - - it('ignores batches that have no finished tasks after the active period started', async () => { - const { summary } = useCompletionSummary() - await nextTick() - - const start = 20_000 - const finish = 20_500 - - await runBatch({ - start, - finish, - tasks: [createTask({ ts: start - 1, previewUrl: 'too-early' })] - }) - - expect(summary.value).toBeNull() - }) - - it('derives the active period from running tasks when execution is already idle', async () => { - const { summary } = useCompletionSummary() - await nextTick() - - const start = 25_000 - vi.setSystemTime(start) - queueStore().runningTasks = [ - createTask({ state: 'Running', ts: start + 1 }) - ] - await nextTick() - - const finish = start + 150 - vi.setSystemTime(finish) - queueStore().historyTasks = [ - createTask({ ts: finish - 10, previewUrl: 'img-running-trigger' }) - ] - queueStore().runningTasks = [] - await nextTick() - - expect(summary.value).toEqual({ - mode: 'allSuccess', - completedCount: 1, - failedCount: 0, - thumbnailUrls: ['img-running-trigger'] - }) - }) - - it('does not emit a summary when every finished task is still running or pending', async () => { - const { summary } = useCompletionSummary() - await nextTick() - - const start = 30_000 - const finish = 30_300 - - await runBatch({ - start, - finish, - tasks: [ - createTask({ state: 'Running', ts: start + 20 }), - createTask({ state: 'Pending', ts: start + 40 }) - ] - }) - - expect(summary.value).toBeNull() - }) -}) diff --git a/src/composables/queue/useCompletionSummary.ts b/src/composables/queue/useCompletionSummary.ts deleted file mode 100644 index 4964664a20..0000000000 --- a/src/composables/queue/useCompletionSummary.ts +++ /dev/null @@ -1,116 +0,0 @@ -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(null) - const _summary = ref(null) - const dismissTimer = ref(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) => { - const ts = 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 - } -} diff --git a/src/composables/queue/useJobList.test.ts b/src/composables/queue/useJobList.test.ts index 684ffa6364..5d4a59e27b 100644 --- a/src/composables/queue/useJobList.test.ts +++ b/src/composables/queue/useJobList.test.ts @@ -5,7 +5,6 @@ import type { Ref } from 'vue' import { useJobList } from '@/composables/queue/useJobList' import type { JobState } from '@/types/queue' -import { buildJobDisplay } from '@/utils/queueDisplay' import type { BuildJobDisplayCtx } from '@/utils/queueDisplay' import type { TaskItemImpl } from '@/stores/queueStore' @@ -256,78 +255,6 @@ describe('useJobList', () => { return api! } - it('tracks recently added pending jobs and clears the hint after expiry', async () => { - vi.useFakeTimers() - queueStoreMock.pendingTasks = [ - createTask({ promptId: '1', queueIndex: 1, mockState: 'pending' }) - ] - - const { jobItems } = initComposable() - await flush() - - jobItems.value - expect(buildJobDisplay).toHaveBeenCalledWith( - expect.anything(), - 'pending', - expect.objectContaining({ showAddedHint: true }) - ) - - vi.mocked(buildJobDisplay).mockClear() - await vi.advanceTimersByTimeAsync(3000) - await flush() - - jobItems.value - expect(buildJobDisplay).toHaveBeenCalledWith( - expect.anything(), - 'pending', - expect.objectContaining({ showAddedHint: false }) - ) - }) - - it('removes pending hint immediately when the task leaves the queue', async () => { - vi.useFakeTimers() - const taskId = '2' - queueStoreMock.pendingTasks = [ - createTask({ promptId: taskId, queueIndex: 1, mockState: 'pending' }) - ] - - const { jobItems } = initComposable() - await flush() - jobItems.value - - queueStoreMock.pendingTasks = [] - await flush() - expect(vi.getTimerCount()).toBe(0) - - vi.mocked(buildJobDisplay).mockClear() - queueStoreMock.pendingTasks = [ - createTask({ promptId: taskId, queueIndex: 2, mockState: 'pending' }) - ] - await flush() - jobItems.value - expect(buildJobDisplay).toHaveBeenCalledWith( - expect.anything(), - 'pending', - expect.objectContaining({ showAddedHint: true }) - ) - }) - - it('cleans up timeouts on unmount', async () => { - vi.useFakeTimers() - queueStoreMock.pendingTasks = [ - createTask({ promptId: '3', queueIndex: 1, mockState: 'pending' }) - ] - - initComposable() - await flush() - expect(vi.getTimerCount()).toBeGreaterThan(0) - - wrapper?.unmount() - wrapper = null - await flush() - expect(vi.getTimerCount()).toBe(0) - }) - it('sorts all tasks by create time', async () => { queueStoreMock.pendingTasks = [ createTask({ diff --git a/src/composables/queue/useQueueNotificationBanners.test.ts b/src/composables/queue/useQueueNotificationBanners.test.ts new file mode 100644 index 0000000000..73eb557ffa --- /dev/null +++ b/src/composables/queue/useQueueNotificationBanners.test.ts @@ -0,0 +1,349 @@ +import { mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick, reactive } from 'vue' + +import { useQueueNotificationBanners } from '@/composables/queue/useQueueNotificationBanners' +import { api } from '@/scripts/api' +import { useExecutionStore } from '@/stores/executionStore' +import { useQueueStore } from '@/stores/queueStore' + +const mockApi = vi.hoisted(() => new EventTarget()) + +vi.mock('@/scripts/api', () => ({ + api: mockApi +})) + +type MockTask = { + displayStatus: 'Completed' | 'Failed' | 'Cancelled' | 'Running' | 'Pending' + executionEndTimestamp?: number + previewOutput?: { + isImage: boolean + urlWithTimestamp: string + } +} + +vi.mock('@/stores/queueStore', () => { + const state = reactive({ + pendingTasks: [] as MockTask[], + runningTasks: [] as MockTask[], + historyTasks: [] as MockTask[] + }) + + return { + useQueueStore: () => state + } +}) + +vi.mock('@/stores/executionStore', () => { + const state = reactive({ + isIdle: true + }) + + return { + useExecutionStore: () => state + } +}) + +const mountComposable = () => { + let composable: ReturnType + const wrapper = mount({ + template: '
', + setup() { + composable = useQueueNotificationBanners() + return {} + } + }) + return { wrapper, composable: composable! } +} + +describe(useQueueNotificationBanners, () => { + const queueStore = () => + useQueueStore() as { + pendingTasks: MockTask[] + runningTasks: MockTask[] + historyTasks: MockTask[] + } + const executionStore = () => useExecutionStore() as { isIdle: boolean } + + const resetState = () => { + queueStore().pendingTasks = [] + queueStore().runningTasks = [] + queueStore().historyTasks = [] + executionStore().isIdle = true + } + + const createTask = ( + options: { + state?: MockTask['displayStatus'] + ts?: number + previewUrl?: string + isImage?: boolean + } = {} + ): MockTask => { + const { + state = 'Completed', + ts = Date.now(), + previewUrl, + isImage = true + } = options + + const task: MockTask = { + displayStatus: state, + executionEndTimestamp: ts + } + + if (previewUrl) { + task.previewOutput = { + isImage, + urlWithTimestamp: previewUrl + } + } + + return task + } + + const runBatch = async (options: { + start: number + finish: number + tasks: MockTask[] + }) => { + const { start, finish, tasks } = options + + vi.setSystemTime(start) + executionStore().isIdle = false + await nextTick() + + vi.setSystemTime(finish) + queueStore().historyTasks = tasks + executionStore().isIdle = true + await nextTick() + } + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(0) + resetState() + }) + + afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() + resetState() + }) + + it('shows queued notifications from promptQueued events', async () => { + const { wrapper, composable } = mountComposable() + + try { + ;(api as unknown as EventTarget).dispatchEvent( + new CustomEvent('promptQueued', { detail: { batchCount: 4 } }) + ) + await nextTick() + + expect(composable.currentNotification.value).toEqual({ + type: 'queued', + count: 4 + }) + + await vi.advanceTimersByTimeAsync(4000) + await nextTick() + expect(composable.currentNotification.value).toBeNull() + } finally { + wrapper.unmount() + } + }) + + it('shows queued pending then queued confirmation', async () => { + const { wrapper, composable } = mountComposable() + + try { + ;(api as unknown as EventTarget).dispatchEvent( + new CustomEvent('promptQueueing', { + detail: { requestId: 1, batchCount: 2 } + }) + ) + await nextTick() + + expect(composable.currentNotification.value).toEqual({ + type: 'queuedPending', + count: 2, + requestId: 1 + }) + + ;(api as unknown as EventTarget).dispatchEvent( + new CustomEvent('promptQueued', { + detail: { requestId: 1, batchCount: 2 } + }) + ) + await nextTick() + + expect(composable.currentNotification.value).toEqual({ + type: 'queued', + count: 2, + requestId: 1 + }) + } finally { + wrapper.unmount() + } + }) + + it('falls back to 1 when queued batch count is invalid', async () => { + const { wrapper, composable } = mountComposable() + + try { + ;(api as unknown as EventTarget).dispatchEvent( + new CustomEvent('promptQueued', { detail: { batchCount: 0 } }) + ) + await nextTick() + + expect(composable.currentNotification.value).toEqual({ + type: 'queued', + count: 1 + }) + } finally { + wrapper.unmount() + } + }) + + it('shows a completed notification from a finished batch', async () => { + const { wrapper, composable } = mountComposable() + + try { + await runBatch({ + start: 1_000, + finish: 1_200, + tasks: [ + createTask({ + ts: 1_050, + previewUrl: 'https://example.com/preview.png' + }) + ] + }) + + expect(composable.currentNotification.value).toEqual({ + type: 'completed', + count: 1, + thumbnailUrls: ['https://example.com/preview.png'] + }) + } finally { + wrapper.unmount() + } + }) + + it('shows one completion notification when history updates after queue becomes idle', async () => { + const { wrapper, composable } = mountComposable() + + try { + vi.setSystemTime(4_000) + executionStore().isIdle = false + await nextTick() + + vi.setSystemTime(4_100) + executionStore().isIdle = true + queueStore().historyTasks = [] + await nextTick() + + expect(composable.currentNotification.value).toBeNull() + + queueStore().historyTasks = [ + createTask({ + ts: 4_050, + previewUrl: 'https://example.com/race-preview.png' + }) + ] + await nextTick() + + expect(composable.currentNotification.value).toEqual({ + type: 'completed', + count: 1, + thumbnailUrls: ['https://example.com/race-preview.png'] + }) + + await vi.advanceTimersByTimeAsync(4000) + await nextTick() + expect(composable.currentNotification.value).toBeNull() + + await vi.advanceTimersByTimeAsync(4000) + await nextTick() + expect(composable.currentNotification.value).toBeNull() + } finally { + wrapper.unmount() + } + }) + + it('queues both completed and failed notifications for mixed batches', async () => { + const { wrapper, composable } = mountComposable() + + try { + await runBatch({ + start: 2_000, + finish: 2_200, + tasks: [ + createTask({ + ts: 2_050, + previewUrl: 'https://example.com/result.png' + }), + createTask({ ts: 2_060 }), + createTask({ ts: 2_070 }), + createTask({ state: 'Failed', ts: 2_080 }) + ] + }) + + expect(composable.currentNotification.value).toEqual({ + type: 'completed', + count: 3, + thumbnailUrls: ['https://example.com/result.png'] + }) + + await vi.advanceTimersByTimeAsync(4000) + await nextTick() + + expect(composable.currentNotification.value).toEqual({ + type: 'failed', + count: 1 + }) + } finally { + wrapper.unmount() + } + }) + + it('uses up to two completion thumbnails for notification icon previews', async () => { + const { wrapper, composable } = mountComposable() + + try { + await runBatch({ + start: 3_000, + finish: 3_300, + tasks: [ + createTask({ + ts: 3_050, + previewUrl: 'https://example.com/preview-1.png' + }), + createTask({ + ts: 3_060, + previewUrl: 'https://example.com/preview-2.png' + }), + createTask({ + ts: 3_070, + previewUrl: 'https://example.com/preview-3.png' + }), + createTask({ + ts: 3_080, + previewUrl: 'https://example.com/preview-4.png' + }) + ] + }) + + expect(composable.currentNotification.value).toEqual({ + type: 'completed', + count: 4, + thumbnailUrls: [ + 'https://example.com/preview-1.png', + 'https://example.com/preview-2.png' + ] + }) + } finally { + wrapper.unmount() + } + }) +}) diff --git a/src/composables/queue/useQueueNotificationBanners.ts b/src/composables/queue/useQueueNotificationBanners.ts new file mode 100644 index 0000000000..b94aac24da --- /dev/null +++ b/src/composables/queue/useQueueNotificationBanners.ts @@ -0,0 +1,318 @@ +import { computed, nextTick, onUnmounted, ref, watch } from 'vue' + +import { api } from '@/scripts/api' +import type { + PromptQueuedEventPayload, + PromptQueueingEventPayload +} from '@/scripts/api' +import { useExecutionStore } from '@/stores/executionStore' +import { useQueueStore } from '@/stores/queueStore' +import { jobStateFromTask } from '@/utils/queueUtil' + +const BANNER_DISMISS_DELAY_MS = 4000 +const MAX_COMPLETION_THUMBNAILS = 2 + +type QueueQueuedNotificationType = 'queuedPending' | 'queued' + +type QueueQueuedNotification = { + type: QueueQueuedNotificationType + count: number + requestId?: number +} + +type QueueCompletedNotification = { + type: 'completed' + count: number + thumbnailUrl?: string + thumbnailUrls?: string[] +} + +type QueueFailedNotification = { + type: 'failed' + count: number +} + +export type QueueNotificationBanner = + | QueueQueuedNotification + | QueueCompletedNotification + | QueueFailedNotification + +const sanitizeCount = (value: number | undefined) => { + if (value === undefined || Number.isNaN(value) || value <= 0) { + return 1 + } + return Math.floor(value) +} + +export const useQueueNotificationBanners = () => { + const queueStore = useQueueStore() + const executionStore = useExecutionStore() + + const pendingNotifications = ref([]) + const activeNotification = ref(null) + const dismissTimer = ref(null) + const lastActiveStartTs = ref(null) + let stopIdleHistoryWatch: (() => void) | null = null + let idleCompletionScheduleToken = 0 + const isQueueActive = computed( + () => + queueStore.pendingTasks.length > 0 || + queueStore.runningTasks.length > 0 || + !executionStore.isIdle + ) + + const clearIdleCompletionHooks = () => { + idleCompletionScheduleToken++ + if (!stopIdleHistoryWatch) { + return + } + stopIdleHistoryWatch() + stopIdleHistoryWatch = null + } + + const clearDismissTimer = () => { + if (dismissTimer.value === null) { + return + } + clearTimeout(dismissTimer.value) + dismissTimer.value = null + } + + const dismissActiveNotification = () => { + activeNotification.value = null + dismissTimer.value = null + showNextNotification() + } + + const showNextNotification = () => { + if (activeNotification.value !== null) { + return + } + const [nextNotification, ...rest] = pendingNotifications.value + pendingNotifications.value = rest + if (!nextNotification) { + return + } + + activeNotification.value = nextNotification + clearDismissTimer() + dismissTimer.value = window.setTimeout( + dismissActiveNotification, + BANNER_DISMISS_DELAY_MS + ) + } + + const queueNotification = (notification: QueueNotificationBanner) => { + pendingNotifications.value = [...pendingNotifications.value, notification] + showNextNotification() + } + + const toQueueLifecycleNotification = ( + type: QueueQueuedNotificationType, + count: number, + requestId?: number + ): QueueQueuedNotification => { + if (requestId === undefined) { + return { + type, + count + } + } + return { + type, + count, + requestId + } + } + + const toCompletedNotification = ( + count: number, + thumbnailUrls: string[] + ): QueueCompletedNotification => ({ + type: 'completed', + count, + thumbnailUrls: thumbnailUrls.slice(0, MAX_COMPLETION_THUMBNAILS) + }) + + const toFailedNotification = (count: number): QueueFailedNotification => ({ + type: 'failed', + count + }) + + const convertQueuedPendingToQueued = ( + requestId: number | undefined, + count: number + ) => { + if ( + activeNotification.value?.type === 'queuedPending' && + (requestId === undefined || + activeNotification.value.requestId === requestId) + ) { + activeNotification.value = toQueueLifecycleNotification( + 'queued', + count, + requestId + ) + return true + } + + const pendingIndex = pendingNotifications.value.findIndex( + (notification) => + notification.type === 'queuedPending' && + (requestId === undefined || notification.requestId === requestId) + ) + + if (pendingIndex === -1) { + return false + } + + const queuedPendingNotification = pendingNotifications.value[pendingIndex] + if ( + queuedPendingNotification === undefined || + queuedPendingNotification.type !== 'queuedPending' + ) { + return false + } + + pendingNotifications.value = [ + ...pendingNotifications.value.slice(0, pendingIndex), + toQueueLifecycleNotification( + 'queued', + count, + queuedPendingNotification.requestId + ), + ...pendingNotifications.value.slice(pendingIndex + 1) + ] + + return true + } + + const handlePromptQueueing = ( + event: CustomEvent + ) => { + const payload = event.detail + const count = sanitizeCount(payload?.batchCount) + queueNotification( + toQueueLifecycleNotification('queuedPending', count, payload?.requestId) + ) + } + + const handlePromptQueued = (event: CustomEvent) => { + const payload = event.detail + const count = sanitizeCount(payload?.batchCount) + const handled = convertQueuedPendingToQueued(payload?.requestId, count) + if (!handled) { + queueNotification( + toQueueLifecycleNotification('queued', count, payload?.requestId) + ) + } + } + + api.addEventListener('promptQueueing', handlePromptQueueing) + api.addEventListener('promptQueued', handlePromptQueued) + + const queueCompletionBatchNotifications = () => { + const startTs = lastActiveStartTs.value ?? 0 + const finishedTasks = queueStore.historyTasks.filter((task) => { + const ts = task.executionEndTimestamp + return typeof ts === 'number' && ts >= startTs + }) + + if (!finishedTasks.length) { + return false + } + + let completedCount = 0 + let failedCount = 0 + const imagePreviews: string[] = [] + + for (const task of finishedTasks) { + 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) { + queueNotification(toCompletedNotification(completedCount, imagePreviews)) + } + + if (failedCount > 0) { + queueNotification(toFailedNotification(failedCount)) + } + + return completedCount > 0 || failedCount > 0 + } + + const scheduleIdleCompletionBatchNotifications = () => { + clearIdleCompletionHooks() + const scheduleToken = idleCompletionScheduleToken + const startTsSnapshot = lastActiveStartTs.value + + const isStillSameIdleWindow = () => + scheduleToken === idleCompletionScheduleToken && + !isQueueActive.value && + lastActiveStartTs.value === startTsSnapshot + + stopIdleHistoryWatch = watch( + () => queueStore.historyTasks, + () => { + if (!isStillSameIdleWindow()) { + clearIdleCompletionHooks() + return + } + queueCompletionBatchNotifications() + clearIdleCompletionHooks() + } + ) + + void nextTick(() => { + if (!isStillSameIdleWindow()) { + clearIdleCompletionHooks() + return + } + + const hasShownNotifications = queueCompletionBatchNotifications() + if (hasShownNotifications) { + clearIdleCompletionHooks() + } + }) + } + + watch( + isQueueActive, + (active, prev) => { + if (!prev && active) { + clearIdleCompletionHooks() + lastActiveStartTs.value = Date.now() + return + } + if (prev && !active) { + scheduleIdleCompletionBatchNotifications() + } + }, + { immediate: true } + ) + + onUnmounted(() => { + api.removeEventListener('promptQueueing', handlePromptQueueing) + api.removeEventListener('promptQueued', handlePromptQueued) + clearIdleCompletionHooks() + clearDismissTimer() + pendingNotifications.value = [] + activeNotification.value = null + lastActiveStartTs.value = null + }) + + const currentNotification = computed(() => activeNotification.value) + + return { + currentNotification + } +} diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 163563dd1a..415002f700 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -818,8 +818,11 @@ "viewJobHistory": "View active jobs (right-click to clear queue)", "noActiveJobs": "No active jobs", "stubClipTextEncode": "CLIP Text Encode:", + "jobCompleted": "Job completed", + "jobFailed": "Job failed", "jobsCompleted": "{count} job completed | {count} jobs completed", "jobsFailed": "{count} job failed | {count} jobs failed", + "jobsAddedToQueue": "{count} job added to queue | {count} jobs added to queue", "cancelJobTooltip": "Cancel job", "clearQueueTooltip": "Clear queue", "clearHistoryDialogTitle": "Clear your job queue history?", @@ -1113,6 +1116,7 @@ "initializingAlmostReady": "Initializing - Almost ready", "inQueue": "In queue...", "jobAddedToQueue": "Job added to queue", + "jobQueueing": "Job queueing", "completedIn": "Finished in {duration}", "jobMenu": { "openAsWorkflowNewTab": "Open as workflow in new tab", diff --git a/src/scripts/api.ts b/src/scripts/api.ts index 3bacedd8e3..f6a2af139d 100644 --- a/src/scripts/api.ts +++ b/src/scripts/api.ts @@ -131,12 +131,16 @@ interface QueuePromptOptions { /** Dictionary of Frontend-generated API calls */ interface FrontendApiCalls { graphChanged: ComfyWorkflowJSON - promptQueued: { number: number; batchCount: number } + promptQueueing: { requestId: number; batchCount: number; number?: number } + promptQueued: { number: number; batchCount: number; requestId?: number } graphCleared: never reconnecting: never reconnected: never } +export type PromptQueueingEventPayload = FrontendApiCalls['promptQueueing'] +export type PromptQueuedEventPayload = FrontendApiCalls['promptQueued'] + /** Dictionary of calls originating from ComfyUI core */ interface BackendApiCalls { progress: ProgressWsMessage diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 6fc3e74f1f..26dc66ea2b 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -146,8 +146,10 @@ export class ComfyApp { private queueItems: { number: number batchCount: number + requestId: number queueNodeIds?: NodeExecutionId[] }[] = [] + private nextQueueRequestId = 1 /** * If the queue is currently being processed */ @@ -1379,7 +1381,12 @@ export class ComfyApp { batchCount: number = 1, queueNodeIds?: NodeExecutionId[] ): Promise { - this.queueItems.push({ number, batchCount, queueNodeIds }) + const requestId = this.nextQueueRequestId++ + this.queueItems.push({ number, batchCount, queueNodeIds, requestId }) + api.dispatchCustomEvent('promptQueueing', { + requestId, + batchCount + }) // Only have one action process the items so each one gets a unique seed correctly if (this.processingQueue) { @@ -1396,7 +1403,9 @@ export class ComfyApp { try { while (this.queueItems.length) { - const { number, batchCount, queueNodeIds } = this.queueItems.pop()! + const { number, batchCount, queueNodeIds, requestId } = + this.queueItems.pop()! + let queuedCount = 0 const previewMethod = useSettingStore().get( 'Comfy.Execution.PreviewMethod' ) @@ -1462,6 +1471,8 @@ export class ComfyApp { break } + queuedCount++ + // Allow widgets to run callbacks after a prompt has been queued // e.g. random seed after every gen executeWidgetsCallback(queuedNodes, 'afterQueued', { @@ -1470,11 +1481,18 @@ export class ComfyApp { this.canvas.draw(true, true) await this.ui.queue.update() } + + if (queuedCount > 0) { + api.dispatchCustomEvent('promptQueued', { + number, + batchCount: queuedCount, + requestId + }) + } } } finally { this.processingQueue = false } - api.dispatchCustomEvent('promptQueued', { number, batchCount }) return !executionStore.lastNodeErrors }