Add queue overlay inline progress and controls

This commit is contained in:
Benjamin Lu
2025-12-10 19:36:16 -08:00
parent f548af2f1d
commit 9863aa6321
13 changed files with 650 additions and 346 deletions

View File

@@ -1,59 +1,56 @@
<template>
<div v-if="!workspaceStore.focusMode" class="ml-1 flex gap-x-0.5 pt-1">
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
<div v-if="!workspaceStore.focusMode" class="ml-1 flex flex-col gap-1 pt-1">
<div class="flex gap-x-0.5">
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
</div>
<div class="mx-1 flex flex-col items-end gap-1">
<div
ref="actionbarContainerRef"
class="actionbar-container relative pointer-events-auto flex h-12 items-center overflow-hidden rounded-lg border border-interface-stroke px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar
v-model:docked="isActionbarDocked"
v-model:queue-overlay-expanded="isQueueOverlayExpanded"
:top-menu-container="actionbarContainerRef"
/>
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
<IconButton
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="transparent"
size="sm"
class="mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</IconButton>
</div>
<QueueProgressOverlay v-model:expanded="isQueueOverlayExpanded" />
</div>
</div>
<div class="mx-1 flex flex-col items-end gap-1">
<div
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-interface-stroke px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<IconButton
v-tooltip.bottom="queueHistoryTooltipConfig"
type="transparent"
size="sm"
class="relative mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-pressed="isQueueOverlayExpanded"
:aria-label="
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
"
@click="toggleQueueOverlay"
>
<i class="icon-[lucide--history] size-4" />
<span
v-if="queuedCount > 0"
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
>
{{ queuedCount }}
</span>
</IconButton>
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
<IconButton
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="transparent"
size="sm"
class="mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</IconButton>
</div>
<QueueProgressOverlay v-model:expanded="isQueueOverlayExpanded" />
<div>
<QueueInlineProgressSummary
v-if="!isActionbarFloating"
class="pr-1"
:hidden="isQueueOverlayExpanded"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -61,32 +58,39 @@ import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import IconButton from '@/components/button/IconButton.vue'
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useQueueStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingsStore = useSettingStore()
const { isLoggedIn } = useCurrentUser()
const isDesktop = isElectron()
const { t } = useI18n()
const isQueueOverlayExpanded = ref(false)
const queueStore = useQueueStore()
const queuedCount = computed(() => queueStore.pendingTasks.length)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
const actionbarContainerRef = ref<HTMLElement>()
const isActionbarDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
const actionbarPosition = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const isActionbarEnabled = computed(
() => actionbarPosition.value !== 'Disabled'
)
const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
// Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
const rightSidePanelTooltipConfig = computed(() =>
buildTooltipConfig(t('rightSidePanel.togglePanel'))
)
@@ -99,10 +103,6 @@ onMounted(() => {
legacyCommandsContainerRef.value.appendChild(app.menu.element)
}
})
const toggleQueueOverlay = () => {
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
}
</script>
<style scoped>

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex h-full items-center">
<div
v-if="isDragging && !isDocked"
v-if="isDragging && !docked"
:class="actionbarClass"
@mouseenter="onMouseEnterDropZone"
@mouseleave="onMouseLeaveDropZone"
@@ -9,45 +9,93 @@
{{ t('actionbar.dockToTop') }}
</div>
<Panel
class="pointer-events-auto"
:style="style"
<div
ref="actionbarWrapperRef"
:class="panelClass"
:pt="{
header: { class: 'hidden' },
content: { class: isDocked ? 'p-0' : 'p-1' }
}"
:style="style"
class="flex flex-col items-stretch"
>
<div ref="panelRef" class="flex items-center select-none">
<span
ref="dragHandleRef"
:class="
cn(
'drag-handle cursor-grab w-3 h-max mr-2',
isDragging && 'cursor-grabbing'
)
"
/>
<Panel
ref="panelRef"
:class="panelRootClass"
:pt="{
header: { class: 'hidden' },
content: { class: docked ? 'p-0' : 'p-1' }
}"
>
<div class="flex flex-col">
<div class="flex items-center select-none">
<span
ref="dragHandleRef"
:class="
cn(
'drag-handle cursor-grab w-3 h-max mr-2',
isDragging && 'cursor-grabbing'
)
"
/>
<ComfyRunButton />
<IconButton
v-tooltip.bottom="cancelJobTooltipConfig"
type="transparent"
size="sm"
class="ml-2 bg-destructive-background text-base-foreground transition-colors duration-200 ease-in-out hover:bg-destructive-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-destructive-background"
:disabled="isExecutionIdle"
:aria-label="t('menu.interrupt')"
@click="cancelCurrentJob"
>
<i class="icon-[lucide--x] size-4" />
</IconButton>
<ComfyRunButton />
<IconButton
v-tooltip.bottom="cancelJobTooltipConfig"
type="transparent"
size="sm"
class="ml-2 bg-destructive-background text-base-foreground transition-colors duration-200 ease-in-out hover:bg-destructive-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-destructive-background"
:disabled="isExecutionIdle"
:aria-label="t('menu.interrupt')"
@click="cancelCurrentJob"
>
<i class="icon-[lucide--x] size-4" />
</IconButton>
<IconTextButton
v-tooltip.bottom="queueHistoryTooltipConfig"
size="sm"
type="secondary"
icon-position="right"
data-testid="queue-toggle-button"
class="ml-2 h-8 border-0 px-3 text-sm font-medium text-base-foreground cursor-pointer"
:aria-pressed="props.queueOverlayExpanded"
:aria-label="queueToggleLabel"
:label="queueToggleLabel"
@click="toggleQueueOverlay"
>
<!-- Custom implementation for static 1-2 digit shifts -->
<span class="flex items-center gap-1">
<span
class="inline-flex min-w-[2ch] justify-center tabular-nums text-center"
>
{{ queuedCount }}
</span>
<span>{{ queuedSuffix }}</span>
</span>
<template #icon>
<i class="icon-[lucide--chevron-down] size-4" />
</template>
</IconTextButton>
</div>
</div>
</Panel>
<div v-if="isFloating" class="flex justify-end pt-1 pr-1">
<QueueInlineProgressSummary
class="pr-1"
:hidden="props.queueOverlayExpanded"
/>
</div>
</Panel>
</div>
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
<QueueInlineProgress
:hidden="props.queueOverlayExpanded"
data-testid="queue-inline-progress"
/>
</Teleport>
</div>
</template>
<script lang="ts" setup>
import {
unrefElement,
useDraggable,
useEventListener,
useLocalStorage,
@@ -56,35 +104,65 @@ import {
import { clamp } from 'es-toolkit/compat'
import { storeToRefs } from 'pinia'
import Panel from 'primevue/panel'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { computed, nextTick, onMounted, ref, watch, watchEffect } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const props = defineProps<{
queueOverlayExpanded: boolean
topMenuContainer?: HTMLElement | null
docked?: boolean
}>()
const emit = defineEmits<{
(e: 'update:queueOverlayExpanded', value: boolean): void
(e: 'update:docked', value: boolean): void
}>()
const settingsStore = useSettingStore()
const executionStore = useExecutionStore()
const commandStore = useCommandStore()
const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
const queueStore = useQueueStore()
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
const tabContainer = document.querySelector('.workflow-tabs-container')
const panelRef = ref<HTMLElement | null>(null)
const actionbarWrapperRef = ref<HTMLElement | null>(null)
const panelRef = ref<HTMLElement | ComponentPublicInstance | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
const docked = computed({
get: () => props.docked ?? false,
set: (value) => emit('update:docked', value)
})
const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
x: 0,
y: 0
})
const { x, y, style, isDragging } = useDraggable(panelRef, {
watchEffect(() => emit('update:docked', docked.value))
const wrapperElement = computed(() => {
const element = unrefElement(actionbarWrapperRef)
return element instanceof HTMLElement ? element : null
})
const panelElement = computed(() => {
const element = unrefElement(panelRef)
return element instanceof HTMLElement ? element : null
})
const { x, y, style, isDragging } = useDraggable(wrapperElement, {
initialValue: { x: 0, y: 0 },
handle: dragHandleRef,
containerElement: document.body,
@@ -97,6 +175,33 @@ const { x, y, style, isDragging } = useDraggable(panelRef, {
}
})
// Queue and Execution logic
const { isIdle: isExecutionIdle } = storeToRefs(executionStore)
const queuedCount = computed(() => queueStore.pendingTasks.length)
const queueToggleLabel = computed(() =>
t('sideToolbar.queueProgressOverlay.toggleLabel', {
count: queuedCount.value
})
)
const queuedSuffix = computed(() =>
t('sideToolbar.queueProgressOverlay.queuedSuffix')
)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
const cancelJobTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.interrupt'))
)
const toggleQueueOverlay = () => {
emit('update:queueOverlayExpanded', !props.queueOverlayExpanded)
}
const cancelCurrentJob = async () => {
if (isExecutionIdle.value) return
await commandStore.execute('Comfy.Interrupt')
}
// Update storedPosition when x or y changes
watchDebounced(
[x, y],
@@ -108,11 +213,12 @@ watchDebounced(
// Set initial position to bottom center
const setInitialPosition = () => {
if (panelRef.value) {
const containerEl = wrapperElement.value
if (containerEl) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
const menuWidth = containerEl.offsetWidth
const menuHeight = containerEl.offsetHeight
if (menuWidth === 0 || menuHeight === 0) {
return
@@ -181,11 +287,12 @@ watch(
)
const adjustMenuPosition = () => {
if (panelRef.value) {
const containerEl = wrapperElement.value
if (containerEl) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
const menuWidth = containerEl.offsetWidth
const menuHeight = containerEl.offsetHeight
// Calculate distances to all edges
const distanceLeft = lastDragState.value.x
@@ -256,31 +363,27 @@ const onMouseLeaveDropZone = () => {
watch(isDragging, (dragging) => {
if (dragging) {
// Starting to drag - undock if docked
if (isDocked.value) {
isDocked.value = false
if (docked.value) {
docked.value = false
}
} else {
// Stopped dragging - dock if mouse is over drop zone
if (isMouseOverDropZone.value) {
isDocked.value = true
docked.value = true
}
// Reset drop zone state
isMouseOverDropZone.value = false
}
})
const cancelJobTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.interrupt'))
)
const cancelCurrentJob = async () => {
if (isExecutionIdle.value) return
await commandStore.execute('Comfy.Interrupt')
}
const isFloating = computed(() => visible.value && !docked.value)
const inlineProgressTarget = computed(() => {
if (!visible.value) return null
if (isFloating.value) return panelElement.value
return props.topMenuContainer ?? null
})
const actionbarClass = computed(() =>
cn(
'w-[200px] border-dashed border-blue-500 opacity-80',
'w-[300px] border-dashed border-blue-500 opacity-80',
'm-1.5 flex items-center justify-center self-stretch',
'rounded-md before:w-50 before:-ml-50 before:h-full',
'pointer-events-auto',
@@ -290,11 +393,21 @@ const actionbarClass = computed(() =>
)
const panelClass = computed(() =>
cn(
'actionbar pointer-events-auto z-1300',
isDragging.value && 'select-none pointer-events-none',
isDocked.value
? 'p-0 static mr-2 border-none bg-transparent'
: 'fixed shadow-interface'
'actionbar z-1300 overflow-hidden rounded-[var(--p-panel-border-radius)]',
docked.value ? 'p-0 static mr-2 border-none bg-transparent' : 'fixed',
isDragging.value ? 'select-none pointer-events-none' : 'pointer-events-auto'
)
)
const panelRootClass = computed(() =>
cn(
'relative overflow-hidden rounded-[var(--p-panel-border-radius)]',
docked.value
? 'border-none shadow-none bg-transparent'
: 'border border-interface-stroke shadow-interface'
)
)
defineExpose({
isFloating
})
</script>

View File

@@ -7,14 +7,15 @@
@click="onClick"
>
<slot v-if="iconPosition !== 'right'" name="icon"></slot>
<span>{{ label }}</span>
<slot v-if="hasDefaultSlot"></slot>
<span v-else>{{ label }}</span>
<slot v-if="iconPosition === 'right'" name="icon"></slot>
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { computed, useSlots } from 'vue'
import type { BaseButtonProps } from '@/types/buttonTypes'
import {
@@ -46,6 +47,9 @@ const {
onClick
} = defineProps<IconTextButtonProps>()
const slots = useSlots()
const hasDefaultSlot = computed(() => Boolean(slots.default?.().length))
const buttonStyle = computed(() => {
const baseClasses = `${getBaseButtonClasses()} justify-start gap-2`
const sizeClasses = getButtonSizeClasses(size)

View File

@@ -0,0 +1,33 @@
<template>
<div
v-if="shouldShow"
aria-hidden="true"
class="pointer-events-none absolute inset-x-0 bottom-0 h-[3px]"
>
<div
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
:style="{ width: `${totalPercent}%` }"
/>
<div
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
:style="{ width: `${currentNodePercent}%` }"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
const props = defineProps<{
hidden?: boolean
}>()
const { totalPercent, currentNodePercent } = useQueueProgress()
const shouldShow = computed(
() =>
!props.hidden && (totalPercent.value > 0 || currentNodePercent.value > 0)
)
</script>

View File

@@ -0,0 +1,60 @@
<template>
<div v-if="shouldShow" class="flex justify-end">
<div
class="flex items-center gap-4 whitespace-nowrap text-[0.75rem] leading-[normal] drop-shadow-[1px_1px_8px_rgba(0,0,0,0.4)]"
aria-hidden="true"
>
<div class="flex items-center gap-1 text-base-foreground">
<span class="font-normal">
{{ t('sideToolbar.queueProgressOverlay.inlineTotalLabel') }}:
</span>
<span class="w-[5ch] shrink-0 text-right font-bold tabular-nums">
{{ totalPercentFormatted }}
</span>
</div>
<div class="flex items-center gap-1 text-muted-foreground">
<span
class="w-[16ch] shrink-0 truncate text-right"
:title="currentNodeName"
>
{{ currentNodeName }}:
</span>
<span class="w-[5ch] shrink-0 text-right tabular-nums">
{{ currentNodePercentFormatted }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCurrentNodeName } from '@/composables/queue/useCurrentNodeName'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useExecutionStore } from '@/stores/executionStore'
const props = defineProps<{
hidden?: boolean
}>()
const { t } = useI18n()
const executionStore = useExecutionStore()
const { currentNodeName } = useCurrentNodeName()
const {
totalPercent,
totalPercentFormatted,
currentNodePercent,
currentNodePercentFormatted
} = useQueueProgress()
const shouldShow = computed(
() =>
!props.hidden &&
(!executionStore.isIdle ||
totalPercent.value > 0 ||
currentNodePercent.value > 0)
)
</script>

View File

@@ -1,47 +1,35 @@
<template>
<div class="flex w-full flex-col gap-4">
<div class="flex w-full flex-col gap-2">
<QueueOverlayHeader
:header-title="headerTitle"
:show-concurrent-indicator="showConcurrentIndicator"
:concurrent-workflow-count="concurrentWorkflowCount"
@clear-history="$emit('clearHistory')"
@close="$emit('close')"
/>
<div class="flex items-center justify-between px-3">
<IconTextButton
class="grow gap-1 p-2 text-center font-inter text-[12px] leading-none hover:opacity-90 justify-center"
type="secondary"
:label="t('sideToolbar.queueProgressOverlay.showAssets')"
:aria-label="t('sideToolbar.queueProgressOverlay.showAssets')"
@click="$emit('showAssets')"
<div
class="flex h-8 items-center justify-between px-3 text-[12px] leading-none"
>
<span class="text-muted-foreground">
{{ activeJobsCount }}
{{ t('sideToolbar.queueProgressOverlay.activeJobsSuffix') }}
</span>
<div
v-if="queuedCount > 0"
class="inline-flex items-center gap-2 text-text-primary"
>
<template #icon>
<div
class="pointer-events-none block size-4 shrink-0 leading-none icon-[comfy--image-ai-edit]"
aria-hidden="true"
/>
</template>
</IconTextButton>
<div class="ml-4 inline-flex items-center">
<div
class="inline-flex h-6 items-center text-[12px] leading-none text-text-primary opacity-90"
>
<span class="font-bold">{{ queuedCount }}</span>
<span class="ml-1">{{
t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span>
</div>
<span class="opacity-90">
{{ t('sideToolbar.queueProgressOverlay.clearQueue') }}
</span>
<IconButton
v-if="queuedCount > 0"
class="group ml-2 size-6 bg-secondary-background hover:bg-destructive-background"
type="secondary"
type="transparent"
size="sm"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
class="size-8 rounded-lg bg-destructive-background text-base-foreground hover:bg-destructive-background-hover transition-colors"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueue')"
@click="$emit('clearQueued')"
>
<i
class="pointer-events-none icon-[lucide--list-x] block size-4 leading-none text-text-primary transition-colors group-hover:text-base-background"
/>
<i class="icon-[lucide--list-x] size-4" />
</IconButton>
</div>
</div>
@@ -69,7 +57,6 @@ import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import { useJobMenu } from '@/composables/queue/useJobMenu'
@@ -83,13 +70,14 @@ defineProps<{
showConcurrentIndicator: boolean
concurrentWorkflowCount: number
queuedCount: number
activeJobsCount: number
displayedJobGroups: JobGroup[]
}>()
const emit = defineEmits<{
(e: 'showAssets'): void
(e: 'clearHistory'): void
(e: 'clearQueued'): void
(e: 'close'): void
(e: 'cancelItem', item: JobListItem): void
(e: 'deleteItem', item: JobListItem): void
(e: 'viewItem', item: JobListItem): void

View File

@@ -62,6 +62,19 @@
</IconTextButton>
</div>
</Popover>
<IconButton
v-tooltip.top="closeTooltipConfig"
type="transparent"
size="sm"
class="size-6 bg-transparent hover:bg-secondary-background hover:opacity-100"
:aria-label="t('g.close')"
data-testid="queue-overlay-close-button"
@click="onCloseClick"
>
<i
class="icon-[lucide--x] block size-4 leading-none text-text-secondary"
/>
</IconButton>
</div>
</div>
</template>
@@ -84,16 +97,19 @@ defineProps<{
const emit = defineEmits<{
(e: 'clearHistory'): void
(e: 'close'): void
}>()
const { t } = useI18n()
const morePopoverRef = ref<PopoverMethods | null>(null)
const closeTooltipConfig = computed(() => buildTooltipConfig(t('g.close')))
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const onMoreClick = (event: MouseEvent) => {
morePopoverRef.value?.toggle(event)
}
const onCloseClick = () => emit('close')
const onClearHistoryFromMenu = () => {
morePopoverRef.value?.hide()
emit('clearHistory')

View File

@@ -6,6 +6,7 @@
<div
class="pointer-events-auto flex w-[350px] min-w-[310px] max-h-[60vh] flex-col overflow-hidden rounded-lg border font-inter transition-colors duration-200 ease-in-out"
:class="containerClass"
data-testid="queue-overlay"
>
<!-- Expanded state -->
<QueueOverlayExpanded
@@ -14,11 +15,12 @@
:header-title="headerTitle"
:show-concurrent-indicator="showConcurrentIndicator"
:concurrent-workflow-count="concurrentWorkflowCount"
:active-jobs-count="activeJobsCount"
:queued-count="queuedCount"
:displayed-job-groups="displayedJobGroups"
@show-assets="openAssetsSidebar"
@clear-history="onClearHistoryFromMenu"
@clear-queued="cancelQueuedWorkflows"
@close="closeOverlay"
@cancel-item="onCancelItem"
@delete-item="onDeleteItem"
@view-item="inspectJobAsset"
@@ -132,7 +134,7 @@ const showConcurrentIndicator = computed(
() => concurrentWorkflowCount.value > 1
)
const { filteredTasks, groupedJobItems } = useJobList()
const { orderedTasks, groupedJobItems } = useJobList()
const displayedJobGroups = computed(() => groupedJobItems.value)
@@ -151,12 +153,16 @@ const {
galleryActiveIndex,
galleryItems,
onViewItem: openResultGallery
} = useResultGallery(() => filteredTasks.value)
} = useResultGallery(() => orderedTasks.value)
const setExpanded = (expanded: boolean) => {
isExpanded.value = expanded
}
const closeOverlay = () => {
setExpanded(false)
}
const openExpandedFromEmpty = () => {
setExpanded(true)
}

View File

@@ -2,8 +2,12 @@
<div
ref="rowRef"
class="relative"
@mouseenter="onRowEnter"
@mouseleave="onRowLeave"
data-testid="queue-job-item"
:data-job-id="props.jobId"
:data-job-state="props.state"
:data-running-node="props.runningNodeName"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@contextmenu.stop.prevent="onContextMenu"
>
<Teleport to="body">
@@ -42,158 +46,92 @@
/>
</div>
</Teleport>
<div
class="relative flex items-center justify-between gap-2 overflow-hidden rounded-lg border border-secondary-background bg-secondary-background p-1 text-[12px] text-text-primary transition-colors duration-150 ease-in-out hover:border-secondary-background-hover hover:bg-secondary-background-hover"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
<div class="flex items-center gap-1">
<div
v-if="
props.state === 'running' &&
(props.progressTotalPercent !== undefined ||
props.progressCurrentPercent !== undefined)
"
class="absolute inset-0"
class="relative flex min-w-0 flex-1 items-center gap-2 overflow-hidden rounded-lg border border-secondary-background bg-secondary-background p-1 text-[12px] text-text-primary transition-colors duration-150 ease-in-out hover:border-secondary-background-hover hover:bg-secondary-background-hover"
>
<div
v-if="props.progressTotalPercent !== undefined"
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
:style="{ width: `${props.progressTotalPercent}%` }"
/>
<div
v-if="props.progressCurrentPercent !== undefined"
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
:style="{ width: `${props.progressCurrentPercent}%` }"
/>
</div>
<div class="relative z-[1] flex items-center gap-1">
<div class="relative inline-flex items-center justify-center">
v-if="
props.state === 'running' &&
(props.progressTotalPercent !== undefined ||
props.progressCurrentPercent !== undefined)
"
class="absolute inset-0"
>
<div
class="absolute left-1/2 top-1/2 size-10 -translate-x-1/2 -translate-y-1/2"
@mouseenter.stop="onIconEnter"
@mouseleave.stop="onIconLeave"
v-if="props.progressTotalPercent !== undefined"
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
:style="{ width: `${props.progressTotalPercent}%` }"
/>
<div
class="inline-flex h-6 w-6 items-center justify-center overflow-hidden rounded-[6px]"
>
<img
v-if="iconImageUrl"
:src="iconImageUrl"
class="h-full w-full object-cover"
/>
<i
v-else
:class="cn(iconClass, 'size-4', shouldSpin && 'animate-spin')"
v-if="props.progressCurrentPercent !== undefined"
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
:style="{ width: `${props.progressCurrentPercent}%` }"
/>
</div>
<div class="relative z-[1] flex items-center gap-1">
<div class="relative inline-flex items-center justify-center">
<div
class="absolute left-1/2 top-1/2 size-10 -translate-x-1/2 -translate-y-1/2"
@mouseenter.stop="onIconEnter"
@mouseleave.stop="onIconLeave"
/>
<div
class="inline-flex h-6 w-6 items-center justify-center overflow-hidden rounded-[6px]"
>
<img
v-if="iconImageUrl"
:src="iconImageUrl"
class="h-full w-full object-cover"
/>
<i
v-else
:class="cn(iconClass, 'size-4', shouldSpin && 'animate-spin')"
/>
</div>
</div>
</div>
<div class="relative z-[1] min-w-0 flex-1">
<div class="truncate opacity-90" :title="props.title">
<slot name="primary">{{ props.title }}</slot>
</div>
</div>
<div class="relative z-[1] shrink-0 pr-2 text-text-secondary">
<slot name="secondary">{{ props.rightText }}</slot>
</div>
</div>
<div class="relative z-[1] min-w-0 flex-1">
<div class="truncate opacity-90" :title="props.title">
<slot name="primary">{{ props.title }}</slot>
</div>
</div>
<!--
TODO: Refactor action buttons to use a declarative config system.
Instead of hardcoding button visibility logic in the template, define an array of
action button configs with properties like:
- icon, label, action, tooltip
- visibleStates: JobState[] (which job states show this button)
- alwaysVisible: boolean (show without hover)
- destructive: boolean (use destructive styling)
Then render buttons in two groups:
1. Always-visible buttons (outside Transition)
2. Hover-only buttons (inside Transition)
This would eliminate the current duplication where the cancel button exists
both outside (for running) and inside (for pending) the Transition.
-->
<div class="relative z-[1] flex items-center gap-2 text-text-secondary">
<Transition
mode="out-in"
enter-active-class="transition-opacity transition-transform duration-150 ease-out"
leave-active-class="transition-opacity transition-transform duration-150 ease-in"
enter-from-class="opacity-0 translate-y-0.5"
enter-to-class="opacity-100 translate-y-0"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-0.5"
>
<div
v-if="isHovered"
key="actions"
class="inline-flex items-center gap-2 pr-1"
<div
v-if="visibleActions.length"
class="relative z-[1] flex items-center gap-1 text-text-secondary"
>
<template v-for="action in visibleActions" :key="action.key">
<IconButton
v-if="action.type === 'icon'"
v-tooltip.top="action.tooltip"
:type="action.buttonType"
size="sm"
:class="actionButtonClass"
:aria-label="action.ariaLabel"
:data-testid="`job-action-${action.key}`"
@click.stop="action.onClick?.($event)"
>
<IconButton
v-if="props.state === 'failed' && computedShowClear"
v-tooltip.top="deleteTooltipConfig"
type="transparent"
size="sm"
class="size-6 transform gap-1 rounded bg-destructive-background text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background-hover hover:opacity-95"
:aria-label="t('g.delete')"
@click.stop="onDeleteClick"
>
<i class="icon-[lucide--trash-2] size-4" />
</IconButton>
<IconButton
v-else-if="
props.state !== 'completed' &&
props.state !== 'running' &&
computedShowClear
"
v-tooltip.top="cancelTooltipConfig"
type="transparent"
size="sm"
class="size-6 transform gap-1 rounded bg-destructive-background text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background-hover hover:opacity-95"
:aria-label="t('g.cancel')"
@click.stop="onCancelClick"
>
<i class="icon-[lucide--x] size-4" />
</IconButton>
<TextButton
v-else-if="props.state === 'completed'"
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-2 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
type="transparent"
:label="t('menuLabels.View')"
:aria-label="t('menuLabels.View')"
@click.stop="emit('view')"
/>
<IconButton
v-if="props.showMenu !== undefined ? props.showMenu : true"
v-tooltip.top="moreTooltipConfig"
type="transparent"
size="sm"
class="size-6 transform gap-1 rounded bg-modal-card-button-surface text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
:aria-label="t('g.more')"
@click.stop="emit('menu', $event)"
>
<i class="icon-[lucide--more-horizontal] size-4" />
</IconButton>
</div>
<div
v-else-if="props.state !== 'running'"
key="secondary"
class="pr-2"
>
<slot name="secondary">{{ props.rightText }}</slot>
</div>
</Transition>
<!-- Running job cancel button - always visible -->
<IconButton
v-if="props.state === 'running' && computedShowClear"
v-tooltip.top="cancelTooltipConfig"
type="transparent"
size="sm"
class="size-6 transform gap-1 rounded bg-destructive-background text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background-hover hover:opacity-95"
:aria-label="t('g.cancel')"
@click.stop="onCancelClick"
>
<i class="icon-[lucide--x] size-4" />
</IconButton>
<i :class="cn(action.iconClass, 'size-4')" />
</IconButton>
<TextButton
v-else
class="h-8 gap-1 rounded-lg bg-modal-card-button-surface px-3 py-0 text-text-primary transition duration-150 ease-in-out hover:opacity-95"
type="transparent"
:label="action.label"
:aria-label="action.ariaLabel"
:data-testid="`job-action-${action.key}`"
@click.stop="action.onClick?.($event)"
/>
</template>
</div>
</div>
</div>
@@ -225,6 +163,7 @@ const props = withDefaults(
showMenu?: boolean
progressTotalPercent?: number
progressCurrentPercent?: number
runningNodeName?: string
activeDetailsId?: string | null
}>(),
{
@@ -255,6 +194,9 @@ const { t } = useI18n()
const cancelTooltipConfig = computed(() => buildTooltipConfig(t('g.cancel')))
const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const viewTooltipConfig = computed(() =>
buildTooltipConfig(t('menuLabels.View'))
)
const rowRef = ref<HTMLDivElement | null>(null)
const showDetails = computed(() => props.activeDetailsId === props.jobId)
@@ -323,6 +265,32 @@ const isAnyPopoverVisible = computed(
() => showDetails.value || (isPreviewVisible.value && canShowPreview.value)
)
type ActionVariant = 'neutral' | 'destructive'
type ActionMode = 'hover' | 'always'
type BaseActionConfig = {
key: string
variant: ActionVariant
mode: ActionMode
ariaLabel: string
tooltip?: ReturnType<typeof buildTooltipConfig>
isVisible: () => boolean
onClick?: (event?: MouseEvent) => void
}
type IconActionConfig = BaseActionConfig & {
type: 'icon'
iconClass: string
buttonType: 'secondary' | 'destructive'
}
type TextActionConfig = BaseActionConfig & {
type: 'text'
label: string
}
type ActionConfig = IconActionConfig | TextActionConfig
watch(
isAnyPopoverVisible,
(visible) => {
@@ -337,6 +305,114 @@ watch(
const isHovered = ref(false)
const computedShowClear = computed(() => {
if (props.showClear !== undefined) return props.showClear
return props.state !== 'completed'
})
const baseActions = computed<ActionConfig[]>(() => {
const showMenu = props.showMenu !== undefined ? props.showMenu : true
return [
{
key: 'menu',
type: 'icon',
variant: 'neutral',
buttonType: 'secondary',
mode: 'hover',
iconClass: 'icon-[lucide--more-horizontal]',
ariaLabel: t('g.more'),
tooltip: moreTooltipConfig.value,
isVisible: () => showMenu,
onClick: (event?: MouseEvent) => {
if (event) emit('menu', event)
}
},
{
key: 'delete',
type: 'icon',
variant: 'destructive',
buttonType: 'destructive',
mode: 'hover',
iconClass: 'icon-[lucide--trash-2]',
ariaLabel: t('g.delete'),
tooltip: deleteTooltipConfig.value,
isVisible: () => props.state === 'failed' && computedShowClear.value,
onClick: () => {
onRowLeave()
emit('delete')
}
},
{
key: 'cancel-hover',
type: 'icon',
variant: 'destructive',
buttonType: 'destructive',
mode: 'hover',
iconClass: 'icon-[lucide--x]',
ariaLabel: t('g.cancel'),
tooltip: cancelTooltipConfig.value,
isVisible: () =>
props.state !== 'completed' &&
props.state !== 'running' &&
props.state !== 'failed' &&
computedShowClear.value,
onClick: () => {
onRowLeave()
emit('cancel')
}
},
{
key: 'view',
type: 'icon',
variant: 'neutral',
buttonType: 'secondary',
mode: 'hover',
iconClass: 'icon-[lucide--zoom-in]',
ariaLabel: t('menuLabels.View'),
tooltip: viewTooltipConfig.value,
isVisible: () => props.state === 'completed',
onClick: () => emit('view')
},
{
key: 'cancel-running',
type: 'icon',
variant: 'destructive',
buttonType: 'destructive',
mode: 'always',
iconClass: 'icon-[lucide--x]',
ariaLabel: t('g.cancel'),
tooltip: cancelTooltipConfig.value,
isVisible: () => props.state === 'running' && computedShowClear.value,
onClick: () => {
onRowLeave()
emit('cancel')
}
}
]
})
const visibleActions = computed(() =>
baseActions.value.filter(
(action) =>
action.isVisible() &&
(action.mode === 'always' || (action.mode === 'hover' && isHovered.value))
)
)
const handleMouseEnter = () => {
isHovered.value = true
onRowEnter()
}
const handleMouseLeave = () => {
isHovered.value = false
onRowLeave()
}
const actionButtonClass =
'h-8 min-w-8 gap-1 rounded-lg text-text-primary transition duration-150 ease-in-out hover:opacity-95'
const iconClass = computed(() => {
if (props.iconName) return props.iconName
return iconForJobState(props.state)
@@ -349,23 +425,6 @@ const shouldSpin = computed(
!props.iconImageUrl
)
const computedShowClear = computed(() => {
if (props.showClear !== undefined) return props.showClear
return props.state !== 'completed'
})
const emitDetailsLeave = () => emit('details-leave', props.jobId)
const onCancelClick = () => {
emitDetailsLeave()
emit('cancel')
}
const onDeleteClick = () => {
emitDetailsLeave()
emit('delete')
}
const onContextMenu = (event: MouseEvent) => {
const shouldShowMenu = props.showMenu !== undefined ? props.showMenu : true
if (shouldShowMenu) emit('menu', event)

View File

@@ -0,0 +1,23 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { st } from '@/i18n'
import { useExecutionStore } from '@/stores/executionStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
export function useCurrentNodeName() {
const { t } = useI18n()
const executionStore = useExecutionStore()
const currentNodeName = computed(() => {
const node = executionStore.executingNode
if (!node) return t('g.emDash')
const title = (node.title ?? '').toString().trim()
if (title) return title
const nodeType = (node.type ?? '').toString().trim() || t('g.untitled')
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
return st(key, nodeType)
})
return { currentNodeName }
}

View File

@@ -1,8 +1,8 @@
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCurrentNodeName } from '@/composables/queue/useCurrentNodeName'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { st } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
@@ -15,7 +15,6 @@ import {
isToday,
isYesterday
} from '@/utils/dateTimeUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildJobDisplay } from '@/utils/queueDisplay'
import { jobStateFromTask } from '@/utils/queueUtil'
@@ -80,7 +79,9 @@ type TaskWithState = {
state: JobState
}
/** Builds the reactive job list and grouped view for the queue overlay. */
/**
* Builds the reactive job list and grouped view for the queue overlay.
*/
export function useJobList() {
const { t, locale } = useI18n()
const queueStore = useQueueStore()
@@ -157,6 +158,7 @@ export function useJobList() {
})
const { totalPercent, currentNodePercent } = useQueueProgress()
const { currentNodeName } = useCurrentNodeName()
const relativeTimeFormatter = computed(() => {
const localeValue = locale.value
@@ -172,17 +174,7 @@ export function useJobList() {
const isJobInitializing = (promptId: string | number | undefined) =>
executionStore.isPromptInitializing(promptId)
const currentNodeName = computed(() => {
const node = executionStore.executingNode
if (!node) return t('g.emDash')
const title = (node.title ?? '').toString().trim()
if (title) return title
const nodeType = (node.type ?? '').toString().trim() || t('g.untitled')
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
return st(key, nodeType)
})
const allTasksSorted = computed<TaskItemImpl[]>(() => {
const orderedTasks = computed<TaskItemImpl[]>(() => {
const all = [
...queueStore.pendingTasks,
...queueStore.runningTasks,
@@ -190,24 +182,18 @@ export function useJobList() {
]
return all.sort((a, b) => b.queueIndex - a.queueIndex)
})
// Backward-compatible alias used by existing tests/consumers.
const allTasksSorted = orderedTasks
const tasksWithJobState = computed<TaskWithState[]>(() =>
allTasksSorted.value.map((task) => ({
orderedTasks.value.map((task) => ({
task,
state: jobStateFromTask(task, isJobInitializing(task?.promptId))
}))
)
const filteredTaskEntries = computed<TaskWithState[]>(
() => tasksWithJobState.value
)
const filteredTasks = computed<TaskItemImpl[]>(() =>
filteredTaskEntries.value.map(({ task }) => task)
)
const jobItems = computed<JobListItem[]>(() => {
return filteredTaskEntries.value.map(({ task, state }) => {
return tasksWithJobState.value.map(({ task, state }) => {
const isActive =
String(task.promptId ?? '') ===
String(executionStore.activePromptId ?? '')
@@ -261,7 +247,7 @@ export function useJobList() {
const groups: JobGroup[] = []
const index = new Map<string, number>()
const localeValue = locale.value
for (const { task, state } of filteredTaskEntries.value) {
for (const { task, state } of tasksWithJobState.value) {
let ts: number | undefined
if (state === 'completed' || state === 'failed') {
ts = task.executionEndTimestamp
@@ -292,7 +278,7 @@ export function useJobList() {
return {
allTasksSorted,
filteredTasks,
orderedTasks,
jobItems,
groupedJobItems,
currentNodeName

View File

@@ -707,6 +707,8 @@
"title": "Queue Progress",
"total": "Total: {percent}",
"colonPercent": ": {percent}",
"inlineTotalLabel": "Total",
"inlineCurrentNodeLabel": "Current node",
"currentNode": "Current node:",
"viewAllJobs": "View all jobs",
"running": "running",
@@ -716,6 +718,8 @@
"showAssets": "Show assets",
"showAssetsPanel": "Show assets panel",
"queuedSuffix": "queued",
"toggleLabel": "{count} queued",
"clearQueue": "Clear queue",
"clearQueued": "Clear queued",
"clearHistory": "Clear job queue history",
"filterJobs": "Filter jobs",
@@ -2392,4 +2396,4 @@
"recentReleases": "Recent releases",
"helpCenterMenu": "Help Center Menu"
}
}
}

View File

@@ -2,7 +2,12 @@ import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
export type ButtonSize = 'full-width' | 'fit-content' | 'sm' | 'md'
type ButtonType = 'primary' | 'secondary' | 'transparent' | 'accent'
type ButtonType =
| 'primary'
| 'secondary'
| 'transparent'
| 'accent'
| 'destructive'
type ButtonBorder = boolean
export interface BaseButtonProps {
@@ -33,7 +38,10 @@ export const getButtonTypeClasses = (type: ButtonType = 'primary') => {
'bg-transparent border-none text-muted-foreground hover:bg-secondary-background-hover'
),
accent:
'bg-primary-background hover:bg-primary-background-hover border-none text-white font-bold'
'bg-primary-background hover:bg-primary-background-hover border-none text-white font-bold',
destructive: cn(
'bg-destructive-background hover:bg-destructive-background-hover border-none text-base-foreground'
)
} as const
return baseByType[type]
@@ -47,14 +55,18 @@ export const getBorderButtonTypeClasses = (type: ButtonType = 'primary') => {
'bg-transparent text-base-foreground hover:bg-secondary-background-hover'
),
accent:
'bg-primary-background hover:bg-primary-background-hover text-white font-bold'
'bg-primary-background hover:bg-primary-background-hover text-white font-bold',
destructive: cn(
'bg-destructive-background hover:bg-destructive-background-hover text-base-foreground'
)
} as const
const borderByType = {
primary: 'border border-solid border-base-background',
secondary: 'border border-solid border-base-foreground',
transparent: 'border border-solid border-base-foreground',
accent: 'border border-solid border-primary-background'
accent: 'border border-solid border-primary-background',
destructive: 'border border-solid border-destructive-background'
} as const
return `${baseByType[type]} ${borderByType[type]}`