Compare commits

...

22 Commits

Author SHA1 Message Date
Benjamin Lu
deaf27e0b6 fix: tighten output item guard 2026-01-24 08:23:29 -08:00
Benjamin Lu
80c0ec6c47 test: fix job output cache instance check 2026-01-24 07:54:14 -08:00
Benjamin Lu
0dddc76719 feat: add job output preview helper 2026-01-24 07:50:27 -08:00
Benjamin Lu
f4f0f6a70f Don't use stubbed 2026-01-23 23:37:24 -08:00
Benjamin Lu
32252143b7 Unify usage of resolveNodeDisplayName 2026-01-23 23:08:00 -08:00
Benjamin Lu
2a9e31a5aa Use VueUse unrefElement 2026-01-23 22:18:31 -08:00
Benjamin Lu
4234e2b50e Emit progress target instead of expose 2026-01-23 22:07:21 -08:00
Benjamin Lu
e0cdbe3cbd Knip 2026-01-23 19:01:37 -08:00
Benjamin Lu
9a2383d965 Deduplicate node title resolving 2026-01-23 18:58:49 -08:00
Benjamin Lu
4eb547b661 Merge remote-tracking branch 'origin/main' into bl-inline-progress 2026-01-23 18:42:47 -08:00
github-actions
15229ba91d [automated] Update test expectations 2026-01-23 22:20:13 +00:00
Benjamin Lu
4db59b75f5 test: stabilize queue inline progress mock 2026-01-23 13:42:44 -08:00
Benjamin Lu
573eb8c7e8 test: cover inline queue progress UI 2026-01-23 13:36:52 -08:00
Benjamin Lu
c60114f935 fix: anchor floating inline progress summary 2026-01-23 03:57:36 -08:00
Benjamin Lu
aef4ddb5cc Perfectly align inline progress 2026-01-23 03:50:43 -08:00
Benjamin Lu
ce86b53e87 Fix location when floating 2026-01-23 03:36:33 -08:00
Benjamin Lu
76b7137fa1 Adjust spacing 2026-01-23 02:49:30 -08:00
Benjamin Lu
c63e27f4bb Add inline progress text and progress bar 2026-01-23 01:46:22 -08:00
Benjamin Lu
76095542f4 test: cover queue button behavior by QPO setting 2026-01-22 20:13:26 -08:00
Benjamin Lu
fd0724588f Merge remote-tracking branch 'origin/main' into fix/qpo-v2-queue-button-assets
# Conflicts:
#	src/components/TopMenuSection.test.ts
2026-01-22 19:30:00 -08:00
Benjamin Lu
9fb11a1a7e fix: toggle assets tab from queue button 2026-01-22 19:24:23 -08:00
Benjamin Lu
8c74a03471 fix: open assets tab when QPO V2 enabled 2026-01-22 19:00:55 -08:00
20 changed files with 682 additions and 117 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -2,7 +2,8 @@ import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick } from 'vue'
import { computed, defineComponent, h, nextTick, onMounted } from 'vue'
import type { Component } from 'vue'
import { createI18n } from 'vue-i18n'
import TopMenuSection from '@/components/TopMenuSection.vue'
@@ -14,6 +15,7 @@ import type {
} from '@/platform/remote/comfyui/jobs/jobTypes'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isElectron } from '@/utils/envUtil'
@@ -36,7 +38,17 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
}))
}))
function createWrapper(pinia = createTestingPinia({ createSpy: vi.fn })) {
type WrapperOptions = {
pinia?: ReturnType<typeof createTestingPinia>
stubs?: Record<string, boolean | Component>
attachTo?: HTMLElement
}
function createWrapper({
pinia = createTestingPinia({ createSpy: vi.fn }),
stubs = {},
attachTo
}: WrapperOptions = {}) {
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -55,18 +67,21 @@ function createWrapper(pinia = createTestingPinia({ createSpy: vi.fn })) {
})
return mount(TopMenuSection, {
attachTo,
global: {
plugins: [pinia, i18n],
stubs: {
SubgraphBreadcrumb: true,
QueueProgressOverlay: true,
QueueInlineProgressSummary: true,
CurrentUserButton: true,
LoginButton: true,
ContextMenu: {
name: 'ContextMenu',
props: ['model'],
template: '<div />'
}
},
...stubs
},
directives: {
tooltip: () => {}
@@ -91,6 +106,7 @@ function createTask(id: string, status: JobStatus): TaskItemImpl {
describe('TopMenuSection', () => {
beforeEach(() => {
vi.resetAllMocks()
localStorage.clear()
})
describe('authentication state', () => {
@@ -151,7 +167,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper(pinia)
const wrapper = createWrapper({ pinia })
await nextTick()
@@ -169,7 +185,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
const wrapper = createWrapper(pinia)
const wrapper = createWrapper({ pinia })
const commandStore = useCommandStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
@@ -185,7 +201,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper(pinia)
const wrapper = createWrapper({ pinia })
const sidebarTabStore = useSidebarTabStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
@@ -199,7 +215,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper(pinia)
const wrapper = createWrapper({ pinia })
const sidebarTabStore = useSidebarTabStore(pinia)
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
@@ -210,6 +226,84 @@ describe('TopMenuSection', () => {
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
})
describe('inline progress summary', () => {
const configureSettings = (
pinia: ReturnType<typeof createTestingPinia>,
qpoV2Enabled: boolean
) => {
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
if (key === 'Comfy.UseNewMenu') return 'Top'
return undefined
})
}
it('renders inline progress summary when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(true)
})
it('does not render inline progress summary when QPO V2 is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, false)
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(false)
})
it('teleports inline progress summary when actionbar is floating', async () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
const actionbarTarget = document.createElement('div')
document.body.appendChild(actionbarTarget)
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const executionStore = useExecutionStore(pinia)
executionStore.activePromptId = 'prompt-1'
const ComfyActionbarStub = defineComponent({
name: 'ComfyActionbar',
setup(_, { emit }) {
onMounted(() => {
emit('update:progressTarget', actionbarTarget)
})
return () => h('div')
}
})
const wrapper = createWrapper({
pinia,
attachTo: document.body,
stubs: {
ComfyActionbar: ComfyActionbarStub,
QueueInlineProgressSummary: false
}
})
try {
await nextTick()
expect(actionbarTarget.querySelector('[role="status"]')).not.toBeNull()
} finally {
wrapper.unmount()
actionbarTarget.remove()
}
})
})
it('disables the clear queue context menu item when no queued jobs exist', () => {
const wrapper = createWrapper()
const menu = wrapper.findComponent({ name: 'ContextMenu' })

View File

@@ -1,101 +1,130 @@
<template>
<div
v-if="!workspaceStore.focusMode"
class="ml-1 flex gap-x-0.5 pt-1"
class="ml-1 flex flex-col gap-1 pt-1"
@mouseenter="isTopMenuHovered = true"
@mouseleave="isTopMenuHovered = false"
>
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
<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 class="flex items-center gap-2">
<div
v-if="managerState.shouldShowManagerButtons.value"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<Button
v-tooltip.bottom="customNodesManagerTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('menu.customNodesManager')"
class="relative"
@click="openCustomNodeManager"
>
<i class="icon-[lucide--puzzle] size-4" />
<span
v-if="shouldShowRedDot"
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
/>
</Button>
</div>
<div
ref="actionbarContainerRef"
class="actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg 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
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
@update:progress-target="updateProgressTarget"
/>
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'assets'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
"
class="px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu
ref="queueContextMenu"
:model="queueContextMenuItems"
/>
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
</div>
</div>
<QueueProgressOverlay
v-if="isQueueProgressOverlayEnabled"
v-model:expanded="isQueueOverlayExpanded"
:menu-hovered="isTopMenuHovered"
/>
</div>
</div>
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div>
<Teleport
v-if="inlineProgressSummaryTarget"
:to="inlineProgressSummaryTarget"
>
<div
v-if="managerState.shouldShowManagerButtons.value"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
class="pointer-events-none absolute left-0 right-0 top-full mt-1 flex justify-end pr-1"
>
<Button
v-tooltip.bottom="customNodesManagerTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('menu.customNodesManager')"
class="relative"
@click="openCustomNodeManager"
>
<i class="icon-[lucide--puzzle] size-4" />
<span
v-if="shouldShowRedDot"
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
/>
</Button>
<QueueInlineProgressSummary :hidden="isQueueOverlayExpanded" />
</div>
<div
class="actionbar-container pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg 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 />
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'assets'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
"
class="px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
</div>
</div>
<QueueProgressOverlay
v-if="isQueueProgressOverlayEnabled"
v-model:expanded="isQueueOverlayExpanded"
:menu-hovered="isTopMenuHovered"
</Teleport>
<QueueInlineProgressSummary
v-else-if="shouldShowInlineProgressSummary && !isActionbarFloating"
class="pr-1"
:hidden="isQueueOverlayExpanded"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
@@ -104,6 +133,7 @@ import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.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'
@@ -147,6 +177,15 @@ const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false)
const actionbarContainerRef = ref<HTMLElement>()
const isActionbarDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
const actionbarPosition = computed(() => settingStore.get('Comfy.UseNewMenu'))
const isActionbarEnabled = computed(
() => actionbarPosition.value !== 'Disabled'
)
const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
@@ -164,6 +203,19 @@ const isQueuePanelV2Enabled = computed(() =>
const isQueueProgressOverlayEnabled = computed(
() => !isQueuePanelV2Enabled.value
)
const shouldShowInlineProgressSummary = computed(
() => isQueuePanelV2Enabled.value && isActionbarEnabled.value
)
const progressTarget = ref<HTMLElement | null>(null)
function updateProgressTarget(target: HTMLElement | null) {
progressTarget.value = target
}
const inlineProgressSummaryTarget = computed(() => {
if (!shouldShowInlineProgressSummary.value || !isActionbarFloating.value) {
return null
}
return progressTarget.value
})
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)

View File

@@ -10,6 +10,7 @@
</div>
<Panel
ref="panelRef"
class="pointer-events-auto"
:style="style"
:class="panelClass"
@@ -18,7 +19,7 @@
content: { class: isDocked ? 'p-0' : 'p-1' }
}"
>
<div ref="panelRef" class="flex items-center select-none gap-2">
<div class="relative flex items-center select-none gap-2">
<span
ref="dragHandleRef"
:class="
@@ -43,6 +44,14 @@
</Button>
</div>
</Panel>
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
<QueueInlineProgress
:hidden="queueOverlayExpanded"
:radius-class="cn(isDocked ? 'rounded-[7px]' : 'rounded-[5px]')"
data-testid="queue-inline-progress"
/>
</Teleport>
</div>
</template>
@@ -51,14 +60,17 @@ import {
useDraggable,
useEventListener,
useLocalStorage,
unrefElement,
watchDebounced
} from '@vueuse/core'
import { clamp } from 'es-toolkit/compat'
import { storeToRefs } from 'pinia'
import Panel from 'primevue/panel'
import { computed, nextTick, ref, watch } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -69,6 +81,15 @@ import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
topMenuContainer?: HTMLElement | null
queueOverlayExpanded?: boolean
}>()
const emit = defineEmits<{
(event: 'update:progressTarget', target: HTMLElement | null): void
}>()
const settingsStore = useSettingStore()
const commandStore = useCommandStore()
const { t } = useI18n()
@@ -76,15 +97,22 @@ const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
const isQueuePanelV2Enabled = computed(() =>
settingsStore.get('Comfy.Queue.QPOV2')
)
const panelRef = ref<HTMLElement | null>(null)
const panelRef = ref<ComponentPublicInstance | null>(null)
const panelElement = computed<HTMLElement | null>(() => {
const element = unrefElement(panelRef)
return element instanceof HTMLElement ? element : null
})
const dragHandleRef = ref<HTMLElement | null>(null)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
x: 0,
y: 0
})
const { x, y, style, isDragging } = useDraggable(panelRef, {
const { x, y, style, isDragging } = useDraggable(panelElement, {
initialValue: { x: 0, y: 0 },
handle: dragHandleRef,
containerElement: document.body
@@ -101,11 +129,12 @@ watchDebounced(
// Set initial position to bottom center
const setInitialPosition = () => {
if (panelRef.value) {
const panel = panelElement.value
if (panel) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
const menuWidth = panel.offsetWidth
const menuHeight = panel.offsetHeight
if (menuWidth === 0 || menuHeight === 0) {
return
@@ -181,11 +210,12 @@ watch(
)
const adjustMenuPosition = () => {
if (panelRef.value) {
const panel = panelElement.value
if (panel) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
const menuWidth = panel.offsetWidth
const menuHeight = panel.offsetHeight
// Calculate distances to all edges
const distanceLeft = lastDragState.value.x
@@ -252,6 +282,19 @@ const onMouseLeaveDropZone = () => {
}
}
const inlineProgressTarget = computed(() => {
if (!visible.value || !isQueuePanelV2Enabled.value) return null
if (isDocked.value) return topMenuContainer ?? null
return panelElement.value
})
watch(
panelElement,
(target) => {
emit('update:progressTarget', target)
},
{ immediate: true }
)
// Handle drag state changes
watch(isDragging, (dragging) => {
if (dragging) {

View File

@@ -0,0 +1,75 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { Ref } from 'vue'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
const mockProgress = vi.hoisted(() => ({
totalPercent: null as unknown as Ref<number>,
currentNodePercent: null as unknown as Ref<number>
}))
vi.mock('@/composables/queue/useQueueProgress', () => ({
useQueueProgress: () => ({
totalPercent: mockProgress.totalPercent,
currentNodePercent: mockProgress.currentNodePercent
})
}))
const createWrapper = (props: { hidden?: boolean } = {}) =>
mount(QueueInlineProgress, { props })
describe('QueueInlineProgress', () => {
beforeEach(() => {
mockProgress.totalPercent = ref(0)
mockProgress.currentNodePercent = ref(0)
})
it('renders when total progress is non-zero', () => {
mockProgress.totalPercent.value = 12
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
})
it('renders when current node progress is non-zero', () => {
mockProgress.currentNodePercent.value = 33
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
})
it('does not render when hidden', () => {
mockProgress.totalPercent.value = 45
const wrapper = createWrapper({ hidden: true })
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
})
it('shows when progress becomes non-zero', async () => {
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
mockProgress.totalPercent.value = 10
await nextTick()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
})
it('hides when progress returns to zero', async () => {
mockProgress.totalPercent.value = 10
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
mockProgress.totalPercent.value = 0
mockProgress.currentNodePercent.value = 0
await nextTick()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
})
})

View File

@@ -0,0 +1,36 @@
<template>
<div
v-if="shouldShow"
aria-hidden="true"
:class="
cn('pointer-events-none absolute inset-0 overflow-hidden', radiusClass)
"
>
<div
class="pointer-events-none absolute bottom-0 left-0 h-[3px] bg-interface-panel-job-progress-primary transition-[width]"
:style="{ width: `${totalPercent}%` }"
/>
<div
class="pointer-events-none absolute bottom-0 left-0 h-[3px] 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'
import { cn } from '@/utils/tailwindUtil'
const { hidden = false, radiusClass = 'rounded-[7px]' } = defineProps<{
hidden?: boolean
radiusClass?: string
}>()
const { totalPercent, currentNodePercent } = useQueueProgress()
const shouldShow = computed(
() => !hidden && (totalPercent.value > 0 || currentNodePercent.value > 0)
)
</script>

View File

@@ -0,0 +1,70 @@
<template>
<div v-if="shouldShow" class="flex justify-end">
<div
class="flex items-center whitespace-nowrap text-[0.75rem] leading-[normal] drop-shadow-[1px_1px_8px_rgba(0,0,0,0.4)]"
role="status"
aria-live="polite"
aria-atomic="true"
>
<div class="flex items-center 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 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 { st } from '@/i18n'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useExecutionStore } from '@/stores/executionStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
const props = defineProps<{
hidden?: boolean
}>()
const { t } = useI18n()
const executionStore = useExecutionStore()
const {
totalPercent,
totalPercentFormatted,
currentNodePercent,
currentNodePercentFormatted
} = useQueueProgress()
const currentNodeName = computed(() => {
return resolveNodeDisplayName(executionStore.executingNode, {
emptyLabel: t('g.emDash'),
untitledLabel: t('g.untitled'),
st
})
})
const shouldShow = computed(
() =>
!props.hidden &&
(!executionStore.isIdle ||
totalPercent.value > 0 ||
currentNodePercent.value > 0)
)
</script>

View File

@@ -8,12 +8,14 @@ import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
import { st } from '@/i18n'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import TabInfo from './info/TabInfo.vue'
@@ -146,9 +148,12 @@ function resolveTitle() {
return groups[0].title || t('rightSidePanel.fallbackGroupTitle')
}
if (nodes.length === 1) {
return (
nodes[0].title || nodes[0].type || t('rightSidePanel.fallbackNodeTitle')
)
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
return resolveNodeDisplayName(nodes[0], {
emptyLabel: fallbackNodeTitle,
untitledLabel: fallbackNodeTitle,
st
})
}
}
return t('rightSidePanel.title', { count: items.length })

View File

@@ -1,9 +1,11 @@
<script setup lang="ts">
import { computed, customRef, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getSharedWidgetEnhancements } from '@/composables/graph/useGraphNodeManager'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -15,6 +17,7 @@ import {
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import { renameWidget } from '@/utils/widgetUtil'
@@ -38,6 +41,7 @@ const {
isShownOnParents?: boolean
}>()
const { t } = useI18n()
const canvasStore = useCanvasStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const isEditing = ref(false)
@@ -59,7 +63,13 @@ const sourceNodeName = computed((): string | null => {
const { graph, nodeId } = widget._overlay
sourceNode = getNodeByExecutionId(graph, nodeId)
}
return sourceNode ? sourceNode.title || sourceNode.type : null
if (!sourceNode) return null
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
return resolveNodeDisplayName(sourceNode, {
emptyLabel: fallbackNodeTitle,
untitledLabel: fallbackNodeTitle,
st
})
})
const hasParents = computed(() => parents?.length > 0)

View File

@@ -17,7 +17,7 @@ import {
isToday,
isYesterday
} from '@/utils/dateTimeUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { buildJobDisplay } from '@/utils/queueDisplay'
import { jobStateFromTask } from '@/utils/queueUtil'
@@ -185,13 +185,11 @@ export function useJobList() {
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)
return resolveNodeDisplayName(executionStore.executingNode, {
emptyLabel: t('g.emDash'),
untitledLabel: t('g.untitled'),
st
})
})
const selectedJobTab = ref<JobTab>('All')

View File

@@ -740,6 +740,7 @@
"title": "Queue Progress",
"total": "Total: {percent}",
"colonPercent": ": {percent}",
"inlineTotalLabel": "Total",
"currentNode": "Current node:",
"viewAllJobs": "View all jobs",
"viewList": "List view",

View File

@@ -108,11 +108,11 @@ import NodeBadge from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
import { app } from '@/scripts/app'
import { normalizeI18nKey } from '@/utils/formatUtil'
import {
getLocatorIdFromNodeData,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import type { NodeBadgeProps } from './NodeBadge.vue'
@@ -160,12 +160,12 @@ const enterSubgraphTooltipConfig = computed(() => {
})
const resolveTitle = (info: VueNodeData | undefined) => {
const title = (info?.title ?? '').trim()
if (title.length > 0) return title
const nodeType = (info?.type ?? '').trim() || 'Untitled'
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
return st(key, nodeType)
const untitledLabel = st('g.untitled', 'Untitled')
return resolveNodeDisplayName(info ?? null, {
emptyLabel: untitledLabel,
untitledLabel,
st
})
}
// Local state for title to provide immediate feedback

View File

@@ -180,6 +180,89 @@ describe('jobOutputCache', () => {
})
})
describe('getPreviewableOutputsFromJobDetail', () => {
it('returns empty array when job detail or outputs are missing', async () => {
const { getPreviewableOutputsFromJobDetail } =
await import('@/services/jobOutputCache')
expect(getPreviewableOutputsFromJobDetail(undefined)).toEqual([])
const jobDetail: JobDetail = {
id: 'job-empty',
status: 'completed',
create_time: Date.now(),
priority: 0
}
expect(getPreviewableOutputsFromJobDetail(jobDetail)).toEqual([])
})
it('maps previewable outputs and skips animated/text entries', async () => {
const { getPreviewableOutputsFromJobDetail } =
await import('@/services/jobOutputCache')
const jobDetail: JobDetail = {
id: 'job-previewable',
status: 'completed',
create_time: Date.now(),
priority: 0,
outputs: {
'node-1': {
images: [
{ filename: 'image.png', subfolder: '', type: 'output' },
{ filename: 'image.webp', subfolder: '', type: 'temp' }
],
animated: [true],
text: 'hello'
},
'node-2': {
video: [{ filename: 'clip.mp4', subfolder: '', type: 'output' }],
audio: [{ filename: 'sound.mp3', subfolder: '', type: 'output' }]
}
}
}
const result = getPreviewableOutputsFromJobDetail(jobDetail)
expect(result).toHaveLength(4)
expect(result.map((item) => item.filename).sort()).toEqual(
['image.png', 'image.webp', 'clip.mp4', 'sound.mp3'].sort()
)
const image = result.find((item) => item.filename === 'image.png')
const video = result.find((item) => item.filename === 'clip.mp4')
const { ResultItemImpl: ResultItemImplClass } =
await import('@/stores/queueStore')
expect(image).toBeInstanceOf(ResultItemImplClass)
expect(image?.nodeId).toBe('node-1')
expect(image?.mediaType).toBe('images')
expect(video?.nodeId).toBe('node-2')
expect(video?.mediaType).toBe('video')
})
it('filters non-previewable outputs and non-object items', async () => {
const { getPreviewableOutputsFromJobDetail } =
await import('@/services/jobOutputCache')
const jobDetail: JobDetail = {
id: 'job-filter',
status: 'completed',
create_time: Date.now(),
priority: 0,
outputs: {
'node-3': {
images: [{ filename: 'valid.png', subfolder: '', type: 'output' }],
text: ['not-object'],
unknown: [{ filename: 'data.bin', subfolder: '', type: 'output' }]
}
}
}
const result = getPreviewableOutputsFromJobDetail(jobDetail)
expect(result.map((item) => item.filename)).toEqual(['valid.png'])
})
})
describe('getJobDetail', () => {
it('fetches and caches job detail', async () => {
const { getJobDetail } = await import('@/services/jobOutputCache')

View File

@@ -11,6 +11,8 @@ import QuickLRU from '@alloc/quick-lru'
import type { JobDetail } from '@/platform/remote/comfyui/jobs/jobTypes'
import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { resultItemType } from '@/schemas/apiSchema'
import type { ResultItem, TaskOutput } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { ResultItemImpl } from '@/stores/queueStore'
import type { TaskItemImpl } from '@/stores/queueStore'
@@ -75,6 +77,67 @@ export async function getOutputsForTask(
}
}
function getPreviewableOutputs(outputs?: TaskOutput): ResultItemImpl[] {
if (!outputs) return []
const resultItems = Object.entries(outputs).flatMap(([nodeId, nodeOutputs]) =>
Object.entries(nodeOutputs)
.filter(([mediaType, items]) => mediaType !== 'animated' && items)
.flatMap(([mediaType, items]) => {
if (!Array.isArray(items)) {
return []
}
return items.filter(isResultItem).map(
(item) =>
new ResultItemImpl({
...item,
nodeId,
mediaType
})
)
})
)
return ResultItemImpl.filterPreviewable(resultItems)
}
function isResultItem(item: unknown): item is ResultItem {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return false
}
const candidate = item as Record<string, unknown>
if (
candidate.filename !== undefined &&
typeof candidate.filename !== 'string'
) {
return false
}
if (
candidate.subfolder !== undefined &&
typeof candidate.subfolder !== 'string'
) {
return false
}
if (
candidate.type !== undefined &&
!resultItemType.safeParse(candidate.type).success
) {
return false
}
return true
}
export function getPreviewableOutputsFromJobDetail(
jobDetail?: JobDetail
): ResultItemImpl[] {
return getPreviewableOutputs(jobDetail?.outputs)
}
// ===== Job Detail Caching =====
export async function getJobDetail(

View File

@@ -1,12 +1,14 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { st } from '@/i18n'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
/**
@@ -216,7 +218,12 @@ export const useFavoritedWidgetsStore = defineStore('favoritedWidgets', () => {
}
}
const nodeTitle = node.title || node.type || 'Node'
const fallbackNodeTitle = st('rightSidePanel.fallbackNodeTitle', 'Node')
const nodeTitle = resolveNodeDisplayName(node, {
emptyLabel: fallbackNodeTitle,
untitledLabel: fallbackNodeTitle,
st
})
const widgetLabel = widget.label || widget.name
return {
...id,

View File

@@ -0,0 +1,28 @@
import { normalizeI18nKey } from '@/utils/formatUtil'
type NodeTitleInfo = {
title?: string | number | null
type?: string | number | null
}
type StaticTranslate = (key: string, fallbackMessage: string) => string
type ResolveNodeDisplayNameOptions = {
emptyLabel: string
untitledLabel: string
st: StaticTranslate
}
export function resolveNodeDisplayName(
node: NodeTitleInfo | null | undefined,
options: ResolveNodeDisplayNameOptions
): string {
if (!node) return options.emptyLabel
const title = (node.title ?? '').toString().trim()
if (title.length > 0) return title
const nodeType = (node.type ?? '').toString().trim() || options.untitledLabel
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
return options.st(key, nodeType)
}