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:
Benjamin Lu
2026-02-14 12:14:55 -08:00
committed by GitHub
parent 27da781029
commit fcb4341c98
20 changed files with 1267 additions and 884 deletions

View File

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

View File

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

View File

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

View 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()
}
})
})

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