mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-20 14:54:12 +00:00
feat(queue): introduce queue notification banners and remove completion summary flow (#8740)
## 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)
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<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) => {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
349
src/composables/queue/useQueueNotificationBanners.test.ts
Normal file
349
src/composables/queue/useQueueNotificationBanners.test.ts
Normal file
@@ -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<typeof useQueueNotificationBanners>
|
||||
const wrapper = mount({
|
||||
template: '<div />',
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
318
src/composables/queue/useQueueNotificationBanners.ts
Normal file
318
src/composables/queue/useQueueNotificationBanners.ts
Normal file
@@ -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<QueueNotificationBanner[]>([])
|
||||
const activeNotification = ref<QueueNotificationBanner | null>(null)
|
||||
const dismissTimer = ref<number | null>(null)
|
||||
const lastActiveStartTs = ref<number | null>(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<PromptQueueingEventPayload>
|
||||
) => {
|
||||
const payload = event.detail
|
||||
const count = sanitizeCount(payload?.batchCount)
|
||||
queueNotification(
|
||||
toQueueLifecycleNotification('queuedPending', count, payload?.requestId)
|
||||
)
|
||||
}
|
||||
|
||||
const handlePromptQueued = (event: CustomEvent<PromptQueuedEventPayload>) => {
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user