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

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