Skip to content

Commit 4597b7e

Browse files
[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>
1 parent 6782d04 commit 4597b7e

6 files changed

Lines changed: 92 additions & 22 deletions

File tree

src/components/TopMenuSection.vue

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
<template>
2-
<div v-if="!workspaceStore.focusMode" class="ml-1 flex gap-x-0.5 pt-1">
2+
<div
3+
v-if="!workspaceStore.focusMode"
4+
class="ml-1 flex gap-x-0.5 pt-1"
5+
@mouseenter="isTopMenuHovered = true"
6+
@mouseleave="isTopMenuHovered = false"
7+
>
38
<div class="min-w-0 flex-1">
49
<SubgraphBreadcrumb />
510
</div>
@@ -40,7 +45,10 @@
4045
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
4146
<LoginButton v-else-if="isDesktop" />
4247
</div>
43-
<QueueProgressOverlay v-model:expanded="isQueueOverlayExpanded" />
48+
<QueueProgressOverlay
49+
v-model:expanded="isQueueOverlayExpanded"
50+
:menu-hovered="isTopMenuHovered"
51+
/>
4452
</div>
4553
</div>
4654
</template>
@@ -69,6 +77,7 @@ const isDesktop = isElectron()
6977
const { t } = useI18n()
7078
const isQueueOverlayExpanded = ref(false)
7179
const queueStore = useQueueStore()
80+
const isTopMenuHovered = ref(false)
7281
const queuedCount = computed(() => queueStore.pendingTasks.length)
7382
const queueHistoryTooltipConfig = computed(() =>
7483
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))

src/components/queue/QueueOverlayActive.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
v-tooltip.top="cancelJobTooltip"
4848
type="secondary"
4949
size="sm"
50-
class="size-6 bg-secondary-background hover:bg-destructive-background"
50+
class="size-6 bg-destructive-background hover:bg-destructive-background-hover"
5151
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
5252
@click="$emit('interruptAll')"
5353
>

src/components/queue/QueueProgressOverlay.vue

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
</template>
6161

6262
<script setup lang="ts">
63-
import { computed, nextTick, ref } from 'vue'
63+
import { computed, nextTick, ref, withDefaults } from 'vue'
6464
import { useI18n } from 'vue-i18n'
6565
6666
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
@@ -85,9 +85,15 @@ import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
8585
8686
type OverlayState = 'hidden' | 'empty' | 'active' | 'expanded'
8787
88-
const props = defineProps<{
89-
expanded?: boolean
90-
}>()
88+
const props = withDefaults(
89+
defineProps<{
90+
expanded?: boolean
91+
menuHovered?: boolean
92+
}>(),
93+
{
94+
menuHovered: false
95+
}
96+
)
9197
9298
const emit = defineEmits<{
9399
(e: 'update:expanded', value: boolean): void
@@ -110,6 +116,7 @@ const {
110116
currentNodeProgressStyle
111117
} = useQueueProgress()
112118
const isHovered = ref(false)
119+
const isOverlayHovered = computed(() => isHovered.value || props.menuHovered)
113120
const internalExpanded = ref(false)
114121
const isExpanded = computed({
115122
get: () =>
@@ -142,7 +149,7 @@ const showBackground = computed(
142149
() =>
143150
overlayState.value === 'expanded' ||
144151
overlayState.value === 'empty' ||
145-
(overlayState.value === 'active' && isHovered.value)
152+
(overlayState.value === 'active' && isOverlayHovered.value)
146153
)
147154
148155
const isVisible = computed(() => overlayState.value !== 'hidden')
@@ -156,7 +163,7 @@ const containerClass = computed(() =>
156163
const bottomRowClass = computed(
157164
() =>
158165
`flex items-center justify-end gap-4 transition-opacity duration-200 ease-in-out ${
159-
overlayState.value === 'active' && isHovered.value
166+
overlayState.value === 'active' && isOverlayHovered.value
160167
? 'opacity-100 pointer-events-auto'
161168
: 'opacity-0 pointer-events-none'
162169
}`

src/components/queue/job/QueueJobItem.vue

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,10 @@
8282
:src="iconImageUrl"
8383
class="h-full w-full object-cover"
8484
/>
85-
<i v-else :class="[iconClass, 'size-4']" />
85+
<i
86+
v-else
87+
:class="cn(iconClass, 'size-4', shouldSpin && 'animate-spin')"
88+
/>
8689
</div>
8790
</div>
8891
</div>
@@ -93,6 +96,23 @@
9396
</div>
9497
</div>
9598

99+
<!--
100+
TODO: Refactor action buttons to use a declarative config system.
101+
102+
Instead of hardcoding button visibility logic in the template, define an array of
103+
action button configs with properties like:
104+
- icon, label, action, tooltip
105+
- visibleStates: JobState[] (which job states show this button)
106+
- alwaysVisible: boolean (show without hover)
107+
- destructive: boolean (use destructive styling)
108+
109+
Then render buttons in two groups:
110+
1. Always-visible buttons (outside Transition)
111+
2. Hover-only buttons (inside Transition)
112+
113+
This would eliminate the current duplication where the cancel button exists
114+
both outside (for running) and inside (for pending) the Transition.
115+
-->
96116
<div class="relative z-[1] flex items-center gap-2 text-text-secondary">
97117
<Transition
98118
mode="out-in"
@@ -113,18 +133,22 @@
113133
v-tooltip.top="deleteTooltipConfig"
114134
type="transparent"
115135
size="sm"
116-
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"
136+
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"
117137
:aria-label="t('g.delete')"
118138
@click.stop="emit('delete')"
119139
>
120140
<i class="icon-[lucide--trash-2] size-4" />
121141
</IconButton>
122142
<IconButton
123-
v-else-if="props.state !== 'completed' && computedShowClear"
143+
v-else-if="
144+
props.state !== 'completed' &&
145+
props.state !== 'running' &&
146+
computedShowClear
147+
"
124148
v-tooltip.top="cancelTooltipConfig"
125149
type="transparent"
126150
size="sm"
127-
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"
151+
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"
128152
:aria-label="t('g.cancel')"
129153
@click.stop="emit('cancel')"
130154
>
@@ -143,17 +167,33 @@
143167
v-tooltip.top="moreTooltipConfig"
144168
type="transparent"
145169
size="sm"
146-
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"
170+
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"
147171
:aria-label="t('g.more')"
148172
@click.stop="emit('menu', $event)"
149173
>
150174
<i class="icon-[lucide--more-horizontal] size-4" />
151175
</IconButton>
152176
</div>
153-
<div v-else key="secondary" class="pr-2">
177+
<div
178+
v-else-if="props.state !== 'running'"
179+
key="secondary"
180+
class="pr-2"
181+
>
154182
<slot name="secondary">{{ props.rightText }}</slot>
155183
</div>
156184
</Transition>
185+
<!-- Running job cancel button - always visible -->
186+
<IconButton
187+
v-if="props.state === 'running' && computedShowClear"
188+
v-tooltip.top="cancelTooltipConfig"
189+
type="transparent"
190+
size="sm"
191+
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"
192+
:aria-label="t('g.cancel')"
193+
@click.stop="emit('cancel')"
194+
>
195+
<i class="icon-[lucide--x] size-4" />
196+
</IconButton>
157197
</div>
158198
</div>
159199
</div>
@@ -170,6 +210,7 @@ import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
170210
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
171211
import type { JobState } from '@/types/queue'
172212
import { iconForJobState } from '@/utils/queueDisplay'
213+
import { cn } from '@/utils/tailwindUtil'
173214
174215
const props = withDefaults(
175216
defineProps<{
@@ -302,6 +343,13 @@ const iconClass = computed(() => {
302343
return iconForJobState(props.state)
303344
})
304345
346+
const shouldSpin = computed(
347+
() =>
348+
props.state === 'pending' &&
349+
iconClass.value === iconForJobState('pending') &&
350+
!props.iconImageUrl
351+
)
352+
305353
const computedShowClear = computed(() => {
306354
if (props.showClear !== undefined) return props.showClear
307355
return props.state !== 'completed'

src/composables/queue/useJobList.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export function useJobList() {
9696
const executionStore = useExecutionStore()
9797
const workflowStore = useWorkflowStore()
9898

99+
const seenPendingIds = ref<Set<string>>(new Set())
99100
const recentlyAddedPendingIds = ref<Set<string>>(new Set())
100101
const addedHintTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
101102

@@ -126,23 +127,27 @@ export function useJobList() {
126127
.filter((id): id is string => !!id),
127128
(pendingIds) => {
128129
const pendingSet = new Set(pendingIds)
129-
const next = new Set(recentlyAddedPendingIds.value)
130+
const nextAdded = new Set(recentlyAddedPendingIds.value)
131+
const nextSeen = new Set(seenPendingIds.value)
130132

131133
pendingIds.forEach((id) => {
132-
if (!next.has(id)) {
133-
next.add(id)
134+
if (!nextSeen.has(id)) {
135+
nextSeen.add(id)
136+
nextAdded.add(id)
134137
scheduleAddedHintExpiry(id)
135138
}
136139
})
137140

138-
for (const id of Array.from(next)) {
141+
for (const id of Array.from(nextSeen)) {
139142
if (!pendingSet.has(id)) {
140-
next.delete(id)
143+
nextSeen.delete(id)
144+
nextAdded.delete(id)
141145
clearAddedHintTimeout(id)
142146
}
143147
}
144148

145-
recentlyAddedPendingIds.value = next
149+
recentlyAddedPendingIds.value = nextAdded
150+
seenPendingIds.value = nextSeen
146151
},
147152
{ immediate: true }
148153
)
@@ -157,6 +162,7 @@ export function useJobList() {
157162
onUnmounted(() => {
158163
addedHintTimeouts.forEach((timeoutId) => clearTimeout(timeoutId))
159164
addedHintTimeouts.clear()
165+
seenPendingIds.value = new Set<string>()
160166
recentlyAddedPendingIds.value = new Set<string>()
161167
})
162168

src/utils/queueDisplay.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ type JobDisplay = {
2424
export const iconForJobState = (state: JobState): string => {
2525
switch (state) {
2626
case 'pending':
27-
return 'icon-[lucide--clock]'
27+
return 'icon-[lucide--loader-circle]'
2828
case 'initialization':
2929
return 'icon-[lucide--server-crash]'
3030
case 'running':

0 commit comments

Comments
 (0)