mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 11:11:53 +00:00
[feat] Improve queue job item UX based on design feedback (#6893)
## Summary
- Running jobs now show cancel button at all times (always visible, not
just on hover)
- Cancel/delete buttons use destructive red styling by default with
hover state
- Changed pending job icon from clock to loader-circle with spin
animation
- Fixed icon buttons to be square (size-6) instead of rectangular
- Added TODO comment for future declarative button config system
- Pending hint ("Job added to queue") now shows only once per entry and
no longer resets when other jobs update
- Spinner animation now applies only to the pending loader icon;
completed/check icons no longer spin
- Queue overlay hover/active state also triggers when hovering the top
menu bar so controls stay visible
## Design Spec
https://www.notion.so/comfy-org/Design-Queue-Dialog-Job-Ordering-and-Cancel-Button-Visibility-2b46d73d365081748a43d5cc9fbe2639
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6893-feat-Improve-queue-job-item-UX-based-on-design-feedback-2b56d73d365081a2bc7ef6f6fea1c739)
by [Unito](https://www.unito.io)
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="!workspaceStore.focusMode" class="ml-1 flex gap-x-0.5 pt-1">
|
<div
|
||||||
|
v-if="!workspaceStore.focusMode"
|
||||||
|
class="ml-1 flex gap-x-0.5 pt-1"
|
||||||
|
@mouseenter="isTopMenuHovered = true"
|
||||||
|
@mouseleave="isTopMenuHovered = false"
|
||||||
|
>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<SubgraphBreadcrumb />
|
<SubgraphBreadcrumb />
|
||||||
</div>
|
</div>
|
||||||
@@ -40,7 +45,10 @@
|
|||||||
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
|
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
|
||||||
<LoginButton v-else-if="isDesktop" />
|
<LoginButton v-else-if="isDesktop" />
|
||||||
</div>
|
</div>
|
||||||
<QueueProgressOverlay v-model:expanded="isQueueOverlayExpanded" />
|
<QueueProgressOverlay
|
||||||
|
v-model:expanded="isQueueOverlayExpanded"
|
||||||
|
:menu-hovered="isTopMenuHovered"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -69,6 +77,7 @@ const isDesktop = isElectron()
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const isQueueOverlayExpanded = ref(false)
|
const isQueueOverlayExpanded = ref(false)
|
||||||
const queueStore = useQueueStore()
|
const queueStore = useQueueStore()
|
||||||
|
const isTopMenuHovered = ref(false)
|
||||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||||
const queueHistoryTooltipConfig = computed(() =>
|
const queueHistoryTooltipConfig = computed(() =>
|
||||||
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
v-tooltip.top="cancelJobTooltip"
|
v-tooltip.top="cancelJobTooltip"
|
||||||
type="secondary"
|
type="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="size-6 bg-secondary-background hover:bg-destructive-background"
|
class="size-6 bg-destructive-background hover:bg-destructive-background-hover"
|
||||||
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
|
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
|
||||||
@click="$emit('interruptAll')"
|
@click="$emit('interruptAll')"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, ref } from 'vue'
|
import { computed, nextTick, ref, withDefaults } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
|
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
|
||||||
@@ -85,9 +85,15 @@ import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
|||||||
|
|
||||||
type OverlayState = 'hidden' | 'empty' | 'active' | 'expanded'
|
type OverlayState = 'hidden' | 'empty' | 'active' | 'expanded'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
expanded?: boolean
|
defineProps<{
|
||||||
}>()
|
expanded?: boolean
|
||||||
|
menuHovered?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
menuHovered: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:expanded', value: boolean): void
|
(e: 'update:expanded', value: boolean): void
|
||||||
@@ -110,6 +116,7 @@ const {
|
|||||||
currentNodeProgressStyle
|
currentNodeProgressStyle
|
||||||
} = useQueueProgress()
|
} = useQueueProgress()
|
||||||
const isHovered = ref(false)
|
const isHovered = ref(false)
|
||||||
|
const isOverlayHovered = computed(() => isHovered.value || props.menuHovered)
|
||||||
const internalExpanded = ref(false)
|
const internalExpanded = ref(false)
|
||||||
const isExpanded = computed({
|
const isExpanded = computed({
|
||||||
get: () =>
|
get: () =>
|
||||||
@@ -142,7 +149,7 @@ const showBackground = computed(
|
|||||||
() =>
|
() =>
|
||||||
overlayState.value === 'expanded' ||
|
overlayState.value === 'expanded' ||
|
||||||
overlayState.value === 'empty' ||
|
overlayState.value === 'empty' ||
|
||||||
(overlayState.value === 'active' && isHovered.value)
|
(overlayState.value === 'active' && isOverlayHovered.value)
|
||||||
)
|
)
|
||||||
|
|
||||||
const isVisible = computed(() => overlayState.value !== 'hidden')
|
const isVisible = computed(() => overlayState.value !== 'hidden')
|
||||||
@@ -156,7 +163,7 @@ const containerClass = computed(() =>
|
|||||||
const bottomRowClass = computed(
|
const bottomRowClass = computed(
|
||||||
() =>
|
() =>
|
||||||
`flex items-center justify-end gap-4 transition-opacity duration-200 ease-in-out ${
|
`flex items-center justify-end gap-4 transition-opacity duration-200 ease-in-out ${
|
||||||
overlayState.value === 'active' && isHovered.value
|
overlayState.value === 'active' && isOverlayHovered.value
|
||||||
? 'opacity-100 pointer-events-auto'
|
? 'opacity-100 pointer-events-auto'
|
||||||
: 'opacity-0 pointer-events-none'
|
: 'opacity-0 pointer-events-none'
|
||||||
}`
|
}`
|
||||||
|
|||||||
@@ -82,7 +82,10 @@
|
|||||||
:src="iconImageUrl"
|
:src="iconImageUrl"
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
<i v-else :class="[iconClass, 'size-4']" />
|
<i
|
||||||
|
v-else
|
||||||
|
:class="cn(iconClass, 'size-4', shouldSpin && 'animate-spin')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,6 +96,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="relative z-[1] flex items-center gap-2 text-text-secondary">
|
||||||
<Transition
|
<Transition
|
||||||
mode="out-in"
|
mode="out-in"
|
||||||
@@ -113,18 +133,22 @@
|
|||||||
v-tooltip.top="deleteTooltipConfig"
|
v-tooltip.top="deleteTooltipConfig"
|
||||||
type="transparent"
|
type="transparent"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
|
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')"
|
:aria-label="t('g.delete')"
|
||||||
@click.stop="emit('delete')"
|
@click.stop="emit('delete')"
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--trash-2] size-4" />
|
<i class="icon-[lucide--trash-2] size-4" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
v-else-if="props.state !== 'completed' && computedShowClear"
|
v-else-if="
|
||||||
|
props.state !== 'completed' &&
|
||||||
|
props.state !== 'running' &&
|
||||||
|
computedShowClear
|
||||||
|
"
|
||||||
v-tooltip.top="cancelTooltipConfig"
|
v-tooltip.top="cancelTooltipConfig"
|
||||||
type="transparent"
|
type="transparent"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
|
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')"
|
:aria-label="t('g.cancel')"
|
||||||
@click.stop="emit('cancel')"
|
@click.stop="emit('cancel')"
|
||||||
>
|
>
|
||||||
@@ -143,17 +167,33 @@
|
|||||||
v-tooltip.top="moreTooltipConfig"
|
v-tooltip.top="moreTooltipConfig"
|
||||||
type="transparent"
|
type="transparent"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
|
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')"
|
:aria-label="t('g.more')"
|
||||||
@click.stop="emit('menu', $event)"
|
@click.stop="emit('menu', $event)"
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--more-horizontal] size-4" />
|
<i class="icon-[lucide--more-horizontal] size-4" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
<div v-else key="secondary" class="pr-2">
|
<div
|
||||||
|
v-else-if="props.state !== 'running'"
|
||||||
|
key="secondary"
|
||||||
|
class="pr-2"
|
||||||
|
>
|
||||||
<slot name="secondary">{{ props.rightText }}</slot>
|
<slot name="secondary">{{ props.rightText }}</slot>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</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="emit('cancel')"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--x] size-4" />
|
||||||
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,6 +210,7 @@ import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
|
|||||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||||
import type { JobState } from '@/types/queue'
|
import type { JobState } from '@/types/queue'
|
||||||
import { iconForJobState } from '@/utils/queueDisplay'
|
import { iconForJobState } from '@/utils/queueDisplay'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -302,6 +343,13 @@ const iconClass = computed(() => {
|
|||||||
return iconForJobState(props.state)
|
return iconForJobState(props.state)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const shouldSpin = computed(
|
||||||
|
() =>
|
||||||
|
props.state === 'pending' &&
|
||||||
|
iconClass.value === iconForJobState('pending') &&
|
||||||
|
!props.iconImageUrl
|
||||||
|
)
|
||||||
|
|
||||||
const computedShowClear = computed(() => {
|
const computedShowClear = computed(() => {
|
||||||
if (props.showClear !== undefined) return props.showClear
|
if (props.showClear !== undefined) return props.showClear
|
||||||
return props.state !== 'completed'
|
return props.state !== 'completed'
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export function useJobList() {
|
|||||||
const executionStore = useExecutionStore()
|
const executionStore = useExecutionStore()
|
||||||
const workflowStore = useWorkflowStore()
|
const workflowStore = useWorkflowStore()
|
||||||
|
|
||||||
|
const seenPendingIds = ref<Set<string>>(new Set())
|
||||||
const recentlyAddedPendingIds = ref<Set<string>>(new Set())
|
const recentlyAddedPendingIds = ref<Set<string>>(new Set())
|
||||||
const addedHintTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
const addedHintTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
@@ -126,23 +127,27 @@ export function useJobList() {
|
|||||||
.filter((id): id is string => !!id),
|
.filter((id): id is string => !!id),
|
||||||
(pendingIds) => {
|
(pendingIds) => {
|
||||||
const pendingSet = new Set(pendingIds)
|
const pendingSet = new Set(pendingIds)
|
||||||
const next = new Set(recentlyAddedPendingIds.value)
|
const nextAdded = new Set(recentlyAddedPendingIds.value)
|
||||||
|
const nextSeen = new Set(seenPendingIds.value)
|
||||||
|
|
||||||
pendingIds.forEach((id) => {
|
pendingIds.forEach((id) => {
|
||||||
if (!next.has(id)) {
|
if (!nextSeen.has(id)) {
|
||||||
next.add(id)
|
nextSeen.add(id)
|
||||||
|
nextAdded.add(id)
|
||||||
scheduleAddedHintExpiry(id)
|
scheduleAddedHintExpiry(id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const id of Array.from(next)) {
|
for (const id of Array.from(nextSeen)) {
|
||||||
if (!pendingSet.has(id)) {
|
if (!pendingSet.has(id)) {
|
||||||
next.delete(id)
|
nextSeen.delete(id)
|
||||||
|
nextAdded.delete(id)
|
||||||
clearAddedHintTimeout(id)
|
clearAddedHintTimeout(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
recentlyAddedPendingIds.value = next
|
recentlyAddedPendingIds.value = nextAdded
|
||||||
|
seenPendingIds.value = nextSeen
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
@@ -157,6 +162,7 @@ export function useJobList() {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
addedHintTimeouts.forEach((timeoutId) => clearTimeout(timeoutId))
|
addedHintTimeouts.forEach((timeoutId) => clearTimeout(timeoutId))
|
||||||
addedHintTimeouts.clear()
|
addedHintTimeouts.clear()
|
||||||
|
seenPendingIds.value = new Set<string>()
|
||||||
recentlyAddedPendingIds.value = new Set<string>()
|
recentlyAddedPendingIds.value = new Set<string>()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ type JobDisplay = {
|
|||||||
export const iconForJobState = (state: JobState): string => {
|
export const iconForJobState = (state: JobState): string => {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return 'icon-[lucide--clock]'
|
return 'icon-[lucide--loader-circle]'
|
||||||
case 'initialization':
|
case 'initialization':
|
||||||
return 'icon-[lucide--server-crash]'
|
return 'icon-[lucide--server-crash]'
|
||||||
case 'running':
|
case 'running':
|
||||||
|
|||||||
Reference in New Issue
Block a user