Compare commits

...

26 Commits

Author SHA1 Message Date
Benjamin Lu
cff7e01eec feat: show live previews for active jobs 2026-01-24 14:01:35 -08:00
Benjamin Lu
4ae93806c9 feat: add output stacks to list view 2026-01-24 08:07:25 -08:00
Benjamin Lu
2782d316a5 feat: add output stack composable 2026-01-24 08:05:32 -08:00
Benjamin Lu
77ea40b43e feat: reconcile asset selection 2026-01-24 08:02:24 -08:00
Benjamin Lu
b7a64b991e feat: centralize output asset resolution 2026-01-24 07:58:38 -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
39 changed files with 1679 additions and 220 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

56
plan.md Normal file
View File

@@ -0,0 +1,56 @@
# Implementation Plan: Active Job Previews in Assets Sidebar
## Goal
Expose mid-generation preview thumbnails (KSampler-style) in Assets sidebar active job cards and list items, honoring the existing preview setting (no new setting).
## Scope
Frontend files:
- src/components/sidebar/tabs/AssetsSidebarGridView.vue
- src/components/sidebar/tabs/AssetsSidebarListView.vue
- src/platform/assets/components/ActiveMediaAssetCard.vue
- src/platform/assets/components/AssetsListItem.vue
- Supporting composables/stores/utils as needed
Backend files:
- ../ComfyUI (local backend) settings + jobs/preview metadata
- ../cloud (cloud backend) settings defaults
## Plan
1) Investigate current preview signal paths
- Locate where live previews are received (websocket b_preview_with_metadata).
- Identify mapping from preview metadata to job/task (prompt_id, display_node_id).
- Verify existing job display data includes iconImageUrl only for completed jobs.
2) Use existing preview setting
- Gate live previews on `Comfy.Execution.PreviewMethod`.
- Treat `none` as disabled; `default`, `auto`, `latent2rgb`, `taesd` as enabled.
- No new setting or backend defaults.
3) Create a live-preview mapping store/composable
- Subscribe to websocket preview events and capture latest preview image per prompt_id.
- Use prompt_id from preview metadata to associate preview with running job.
- Store latest preview URL per job, and revoke old object URLs to avoid leaks.
- Respect the new setting: no capture/use when disabled.
4) Extend job list items with live preview URL
- In useJobList, read the live-preview store and add a new field (e.g., livePreviewUrl) to JobListItem.
- Ensure only active/running jobs and when preview setting enabled receive the preview.
- Keep iconImageUrl for completed jobs intact.
5) Wire UI components
- ActiveMediaAssetCard: prefer livePreviewUrl for running jobs; fallback to iconImageUrl.
- AssetsSidebarListView: pass livePreviewUrl to AssetsListItem for active jobs.
- AssetsListItem: allow explicit preview URL override for job rows without affecting asset rows.
6) Backend support
- ComfyUI: verify preview websocket metadata includes prompt_id (already present) and preview method is respected.
- Cloud: no settings changes expected; confirm preview metadata is available in websocket.
7) Tests
- Add/extend unit tests for job list (useJobList) to validate preview field wiring and setting gating.
- No settings default tests expected.
8) Manual verification checklist
- Start a generation; confirm active job cards/list items show live previews.
- Toggle setting off; confirm previews disappear.
- Completed jobs still show final preview where applicable.

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

@@ -264,6 +264,7 @@ const focusAssetInSidebar = async (item: JobListItem) => {
throw new Error('Asset not found in media assets panel')
}
assetSelectionStore.setSelection([assetId])
assetSelectionStore.setLastSelectedAssetId(assetId)
}
const inspectJobAsset = wrapWithErrorHandlingAsync(

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

@@ -1,7 +1,9 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { computed } from 'vue'
import type { JobAction } from '@/composables/queue/useJobActions'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { setMockJobActions } from '@/storybook/mocks/useJobActions'
import { setMockJobItems } from '@/storybook/mocks/useJobList'
@@ -138,16 +140,33 @@ function renderAssetsSidebarListView(args: StoryArgs) {
setup() {
setMockJobItems(args.jobs)
setMockJobActions(args.actionsByJobId ?? {})
const { assetItems, selectableAssets, isStackExpanded, toggleStack } =
useOutputStacks({
assets: computed(() => args.assets)
})
const selectedIds = new Set(args.selectedAssetIds ?? [])
function isSelected(assetId: string) {
return selectedIds.has(assetId)
}
return { args, isSelected }
return {
args,
assetItems,
selectableAssets,
isSelected,
isStackExpanded,
toggleStack
}
},
template: `
<div class="h-[520px] w-[320px] overflow-hidden rounded-lg border border-panel-border">
<AssetsSidebarListView :assets="args.assets" :is-selected="isSelected" />
<AssetsSidebarListView
:asset-items="assetItems"
:selectable-assets="selectableAssets"
:is-selected="isSelected"
:is-stack-expanded="isStackExpanded"
:toggle-stack="toggleStack"
/>
</div>
`
}

View File

@@ -13,7 +13,7 @@
'cursor-default'
)
"
:preview-url="job.iconImageUrl"
:preview-url="job.livePreviewUrl ?? job.iconImageUrl"
:preview-alt="job.title"
:icon-name="job.iconName"
:icon-class="getJobIconClass(job)"
@@ -40,7 +40,7 @@
</div>
<div
v-if="assets.length"
v-if="assetItems.length"
:class="cn('px-2', activeJobItems.length && 'mt-2')"
>
<div
@@ -72,7 +72,12 @@
type: getMediaTypeFromFilename(item.asset.name)
})
"
:class="getAssetCardClass(isSelected(item.asset.id))"
:class="
cn(
getAssetCardClass(isSelected(item.asset.id)),
item.isChild && 'pl-6'
)
"
:preview-url="item.asset.preview_url"
:preview-alt="item.asset.name"
:icon-name="
@@ -80,10 +85,14 @@
"
:primary-text="getAssetPrimaryText(item.asset)"
:secondary-text="getAssetSecondaryText(item.asset)"
:stack-count="getStackCount(item.asset)"
:stack-indicator-label="t('mediaAsset.actions.seeMoreOutputs')"
:stack-expanded="isStackExpanded(item.asset)"
@mouseenter="onAssetEnter(item.asset.id)"
@mouseleave="onAssetLeave(item.asset.id)"
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)"
@click.stop="emit('select-asset', item.asset)"
@click.stop="emit('select-asset', item.asset, selectableAssets)"
@stack-toggle="toggleStack(item.asset)"
>
<template v-if="hoveredAssetId === item.asset.id" #actions>
<Button
@@ -111,6 +120,7 @@ import { useJobActions } from '@/composables/queue/useJobActions'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useJobList } from '@/composables/queue/useJobList'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
@@ -125,17 +135,23 @@ import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
const {
assets,
assetItems,
selectableAssets,
isSelected,
isStackExpanded,
toggleStack,
assetType = 'output'
} = defineProps<{
assets: AssetItem[]
assetItems: OutputStackListItem[]
selectableAssets: AssetItem[]
isSelected: (assetId: string) => boolean
isStackExpanded: (asset: AssetItem) => boolean
toggleStack: (asset: AssetItem) => void
assetType?: 'input' | 'output'
}>()
const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem): void
(e: 'select-asset', asset: AssetItem, assets?: AssetItem[]): void
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
(e: 'approach-end'): void
}>()
@@ -144,9 +160,6 @@ const { t } = useI18n()
const { jobItems } = useJobList()
const hoveredJobId = ref<string | null>(null)
const hoveredAssetId = ref<string | null>(null)
type AssetListItem = { key: string; asset: AssetItem }
const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state))
)
@@ -158,13 +171,6 @@ const hoveredJob = computed(() =>
)
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob)
const assetItems = computed<AssetListItem[]>(() =>
assets.map((asset) => ({
key: `asset-${asset.id}`,
asset
}))
)
const listGridStyle = {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr)',
@@ -194,6 +200,19 @@ function getAssetSecondaryText(asset: AssetItem): string {
return ''
}
function getStackCount(asset: AssetItem): number | undefined {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (typeof metadata?.outputCount === 'number') {
return metadata.outputCount
}
if (Array.isArray(metadata?.allOutputs)) {
return metadata.allOutputs.length
}
return undefined
}
function getAssetCardClass(selected: boolean): string {
return cn(
'w-full text-text-primary transition-colors hover:bg-secondary-background-hover',

View File

@@ -98,8 +98,11 @@
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
<AssetsSidebarListView
v-if="isListView"
:assets="displayAssets"
:asset-items="listViewAssetItems"
:is-selected="isSelected"
:selectable-assets="listViewSelectableAssets"
:is-stack-expanded="isListViewStackExpanded"
:toggle-stack="toggleListViewStack"
:asset-type="activeTab"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@@ -225,12 +228,13 @@ import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAsse
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { getJobDetail } from '@/services/jobOutputCache'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
@@ -238,12 +242,6 @@ import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
interface JobOutputItem {
filename: string
subfolder: string
type: string
}
const { t, n } = useI18n()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
@@ -315,6 +313,7 @@ const {
hasSelection,
clearSelection,
getSelectedAssets,
reconcileSelection,
getOutputCount,
getTotalOutputCount,
activate: activateSelection,
@@ -384,7 +383,21 @@ const displayAssets = computed(() => {
return filteredAssets.value
})
const selectedAssets = computed(() => getSelectedAssets(displayAssets.value))
const {
assetItems: listViewAssetItems,
selectableAssets: listViewSelectableAssets,
isStackExpanded: isListViewStackExpanded,
toggleStack: toggleListViewStack
} = useOutputStacks({
assets: computed(() => displayAssets.value)
})
const visibleAssets = computed(() => {
if (!isListView.value) return displayAssets.value
return listViewSelectableAssets.value
})
const selectedAssets = computed(() => getSelectedAssets(visibleAssets.value))
const isBulkMode = computed(
() => hasSelection.value && selectedAssets.value.length > 1
@@ -404,7 +417,10 @@ const showEmptyState = computed(
activeJobsCount.value === 0
)
watch(displayAssets, (newAssets) => {
watch(visibleAssets, (newAssets) => {
// Alternative: keep hidden selections and surface them in UI; for now prune
// so selection stays consistent with what this view can act on.
reconcileSelection(newAssets)
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
const newIndex = newAssets.findIndex(
(asset) => asset.id === currentGalleryAssetId.value
@@ -422,7 +438,7 @@ watch(galleryActiveIndex, (index) => {
})
const galleryItems = computed(() => {
return displayAssets.value.map((asset) => {
return visibleAssets.value.map((asset) => {
const mediaType = getMediaTypeFromFilename(asset.name)
const resultItem = new ResultItemImpl({
filename: asset.name,
@@ -462,9 +478,10 @@ watch(
{ immediate: true }
)
const handleAssetSelect = (asset: AssetItem) => {
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
handleAssetClick(asset, index, displayAssets.value)
const handleAssetSelect = (asset: AssetItem, assets?: AssetItem[]) => {
const assetList = assets ?? visibleAssets.value
const index = assetList.findIndex((a) => a.id === asset.id)
handleAssetClick(asset, index, assetList)
}
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
@@ -537,7 +554,7 @@ const handleZoomClick = (asset: AssetItem) => {
}
currentGalleryAssetId.value = asset.id
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
const index = visibleAssets.value.findIndex((a) => a.id === asset.id)
if (index !== -1) {
galleryActiveIndex.value = index
}
@@ -550,7 +567,7 @@ const enterFolderView = async (asset: AssetItem) => {
return
}
const { promptId, allOutputs, executionTimeInSeconds, outputCount } = metadata
const { promptId, executionTimeInSeconds } = metadata
if (!promptId) {
console.warn('Missing required folder view data')
@@ -560,62 +577,16 @@ const enterFolderView = async (asset: AssetItem) => {
folderPromptId.value = promptId
folderExecutionTime.value = executionTimeInSeconds
// Determine which outputs to display
let outputsToDisplay = allOutputs ?? []
const folderItems = await resolveOutputAssetItems(metadata, {
createdAt: asset.created_at
})
// If outputCount indicates more outputs than we have, fetch full outputs
const needsFullOutputs =
typeof outputCount === 'number' &&
outputCount > 1 &&
outputsToDisplay.length < outputCount
if (needsFullOutputs) {
try {
const jobDetail = await getJobDetail(promptId)
if (jobDetail?.outputs) {
// Convert job outputs to ResultItemImpl array
outputsToDisplay = Object.entries(jobDetail.outputs).flatMap(
([nodeId, nodeOutputs]) =>
Object.entries(nodeOutputs).flatMap(([mediaType, items]) =>
(items as JobOutputItem[])
.map(
(item) =>
new ResultItemImpl({
...item,
nodeId,
mediaType
})
)
.filter((r) => r.supportsPreview)
)
)
}
} catch (error) {
console.error('Failed to fetch job detail for folder view:', error)
outputsToDisplay = []
}
}
if (outputsToDisplay.length === 0) {
if (folderItems.length === 0) {
console.warn('No outputs available for folder view')
return
}
folderAssets.value = outputsToDisplay.map((output) => ({
id: `${output.nodeId}-${output.filename}`,
name: output.filename,
size: 0,
created_at: asset.created_at,
tags: ['output'],
preview_url: output.url,
user_metadata: {
promptId,
nodeId: output.nodeId,
subfolder: output.subfolder,
executionTimeInSeconds,
workflow: metadata.workflow
}
}))
folderAssets.value = folderItems
}
const exitFolderView = () => {

View File

@@ -134,6 +134,25 @@ vi.mock('@/stores/executionStore', () => ({
}
}))
let jobPreviewStoreMock: {
previewsByPromptId: Record<string, string>
isPreviewEnabled: boolean
}
const ensureJobPreviewStore = () => {
if (!jobPreviewStoreMock) {
jobPreviewStoreMock = reactive({
previewsByPromptId: {} as Record<string, string>,
isPreviewEnabled: true
})
}
return jobPreviewStoreMock
}
vi.mock('@/stores/jobPreviewStore', () => ({
useJobPreviewStore: () => {
return ensureJobPreviewStore()
}
}))
let workflowStoreMock: {
activeWorkflow: null | { activeState?: { id?: string } }
}
@@ -186,6 +205,10 @@ const resetStores = () => {
executionStore.activePromptId = null
executionStore.executingNode = null
const jobPreviewStore = ensureJobPreviewStore()
jobPreviewStore.previewsByPromptId = {}
jobPreviewStore.isPreviewEnabled = true
const workflowStore = ensureWorkflowStore()
workflowStore.activeWorkflow = null
@@ -437,6 +460,44 @@ describe('useJobList', () => {
expect(otherJob.computeHours).toBeCloseTo(1)
})
it('assigns live preview urls for running jobs when previews enabled', async () => {
queueStoreMock.runningTasks = [
createTask({
promptId: 'live-preview',
queueIndex: 1,
mockState: 'running'
})
]
jobPreviewStoreMock.previewsByPromptId = {
'live-preview': 'blob:preview-url'
}
jobPreviewStoreMock.isPreviewEnabled = true
const { jobItems } = initComposable()
await flush()
expect(jobItems.value[0].livePreviewUrl).toBe('blob:preview-url')
})
it('omits live previews when previews are disabled', async () => {
queueStoreMock.runningTasks = [
createTask({
promptId: 'disabled-preview',
queueIndex: 1,
mockState: 'running'
})
]
jobPreviewStoreMock.previewsByPromptId = {
'disabled-preview': 'blob:preview-url'
}
jobPreviewStoreMock.isPreviewEnabled = false
const { jobItems } = initComposable()
await flush()
expect(jobItems.value[0].livePreviewUrl).toBeUndefined()
})
it('derives current node name from execution store fallbacks', async () => {
const instance = initComposable()
await flush()

View File

@@ -7,6 +7,7 @@ import { st } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
import { useQueueStore } from '@/stores/queueStore'
import type { TaskItemImpl } from '@/stores/queueStore'
import type { JobState } from '@/types/queue'
@@ -17,7 +18,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'
@@ -38,6 +39,7 @@ export type JobListItem = {
state: JobState
iconName?: string
iconImageUrl?: string
livePreviewUrl?: string
showClear?: boolean
taskRef?: TaskItemImpl
progressTotalPercent?: number
@@ -96,6 +98,7 @@ export function useJobList() {
const { t, locale } = useI18n()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const jobPreviewStore = useJobPreviewStore()
const workflowStore = useWorkflowStore()
const seenPendingIds = ref<Set<string>>(new Set())
@@ -185,13 +188,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')
@@ -258,6 +259,11 @@ export function useJobList() {
String(task.promptId ?? '') ===
String(executionStore.activePromptId ?? '')
const showAddedHint = shouldShowAddedHint(task, state)
const promptKey = taskIdToKey(task.promptId)
const livePreviewUrl =
state === 'running' && jobPreviewStore.isPreviewEnabled && promptKey
? jobPreviewStore.previewsByPromptId[promptKey]
: undefined
const display = buildJobDisplay(task, state, {
t,
@@ -278,6 +284,7 @@ export function useJobList() {
state,
iconName: display.iconName,
iconImageUrl: display.iconImageUrl,
livePreviewUrl,
showClear: display.showClear,
taskRef: task,
progressTotalPercent:

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

@@ -8,8 +8,8 @@
<div class="relative aspect-square overflow-hidden rounded-lg">
<!-- Running state with preview image -->
<img
v-if="isRunning && job.iconImageUrl"
:src="job.iconImageUrl"
v-if="isRunning && previewUrl"
:src="previewUrl"
:alt="statusText"
class="size-full object-cover"
/>
@@ -76,6 +76,7 @@ const { progressBarPrimaryClass, hasProgressPercent, progressPercentStyle } =
const statusText = computed(() => job.title)
const progressPercent = computed(() => job.progressTotalPercent)
const previewUrl = computed(() => job.livePreviewUrl ?? job.iconImageUrl)
const isQueued = computed(
() => job.state === 'pending' || job.state === 'initialization'

View File

@@ -74,13 +74,46 @@
<div v-if="$slots.actions" class="relative z-1 flex items-center gap-2">
<slot name="actions" />
</div>
<div
v-if="typeof stackCount === 'number' && stackCount > 1"
class="relative z-1 flex shrink-0 items-center"
>
<Button
variant="secondary"
size="md"
class="gap-1 font-bold"
:aria-label="stackIndicatorLabel || undefined"
:aria-expanded="stackExpanded"
@click.stop="emit('stack-toggle')"
>
<i aria-hidden="true" class="icon-[lucide--layers] size-4" />
<span class="text-xs leading-none">{{ stackCount }}</span>
<i
aria-hidden="true"
:class="
cn(
stackExpanded
? 'icon-[lucide--chevron-down]'
: 'icon-[lucide--chevron-right]',
'size-3'
)
"
/>
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const emit = defineEmits<{
'stack-toggle': []
}>()
const {
previewUrl,
previewAlt = '',
@@ -90,6 +123,9 @@ const {
iconWrapperClass,
primaryText,
secondaryText,
stackCount,
stackIndicatorLabel,
stackExpanded = false,
progressTotalPercent,
progressCurrentPercent
} = defineProps<{
@@ -101,6 +137,9 @@ const {
iconWrapperClass?: string
primaryText?: string
secondaryText?: string
stackCount?: number
stackIndicatorLabel?: string
stackExpanded?: boolean
progressTotalPercent?: number
progressCurrentPercent?: number
}>()

View File

@@ -0,0 +1,89 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetSelection } from './useAssetSelection'
import { useAssetSelectionStore } from './useAssetSelectionStore'
vi.mock('@vueuse/core', () => ({
useKeyModifier: vi.fn(() => ref(false))
}))
describe('useAssetSelection', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('prunes selection to visible assets', () => {
const selection = useAssetSelection()
const store = useAssetSelectionStore()
const assets: AssetItem[] = [
{ id: 'a', name: 'a.png', tags: [] },
{ id: 'b', name: 'b.png', tags: [] }
]
store.setSelection(['a', 'b'])
store.setLastSelectedIndex(1)
store.setLastSelectedAssetId('b')
selection.reconcileSelection([assets[1]])
expect(Array.from(store.selectedAssetIds)).toEqual(['b'])
expect(store.lastSelectedIndex).toBe(0)
expect(store.lastSelectedAssetId).toBe('b')
})
it('clears selection when no visible assets remain', () => {
const selection = useAssetSelection()
const store = useAssetSelectionStore()
store.setSelection(['a'])
store.setLastSelectedIndex(0)
store.setLastSelectedAssetId('a')
selection.reconcileSelection([])
expect(store.selectedAssetIds.size).toBe(0)
expect(store.lastSelectedIndex).toBe(-1)
expect(store.lastSelectedAssetId).toBeNull()
})
it('recomputes the anchor index when assets reorder', () => {
const selection = useAssetSelection()
const store = useAssetSelectionStore()
const assets: AssetItem[] = [
{ id: 'a', name: 'a.png', tags: [] },
{ id: 'b', name: 'b.png', tags: [] }
]
store.setSelection(['a'])
store.setLastSelectedIndex(0)
store.setLastSelectedAssetId('a')
selection.reconcileSelection([assets[1], assets[0]])
expect(store.lastSelectedIndex).toBe(1)
expect(store.lastSelectedAssetId).toBe('a')
})
it('clears anchor when the anchored asset is no longer visible', () => {
const selection = useAssetSelection()
const store = useAssetSelectionStore()
const assets: AssetItem[] = [
{ id: 'a', name: 'a.png', tags: [] },
{ id: 'b', name: 'b.png', tags: [] }
]
store.setSelection(['a', 'b'])
store.setLastSelectedIndex(0)
store.setLastSelectedAssetId('a')
selection.reconcileSelection([assets[1]])
expect(Array.from(store.selectedAssetIds)).toEqual(['b'])
expect(store.lastSelectedIndex).toBe(-1)
expect(store.lastSelectedAssetId).toBeNull()
})
})

View File

@@ -21,6 +21,25 @@ export function useAssetSelection() {
const metaKey = computed(() => isActive.value && metaKeyRaw.value)
const cmdOrCtrlKey = computed(() => ctrlKey.value || metaKey.value)
function setAnchor(index: number, assetId: string | null) {
selectionStore.setLastSelectedIndex(index)
selectionStore.setLastSelectedAssetId(assetId)
}
function syncAnchorFromAssets(assets: AssetItem[]) {
const anchorId = selectionStore.lastSelectedAssetId
const anchorIndex = anchorId
? assets.findIndex((asset) => asset.id === anchorId)
: -1
if (anchorIndex !== -1) {
selectionStore.setLastSelectedIndex(anchorIndex)
return
}
setAnchor(-1, null)
}
/**
* Handle asset click with modifier keys for selection
* @param asset The clicked asset
@@ -60,14 +79,14 @@ export function useAssetSelection() {
// Ctrl/Cmd + Click: Toggle individual selection
if (cmdOrCtrlKey.value) {
selectionStore.toggleSelection(assetId)
selectionStore.setLastSelectedIndex(index)
setAnchor(index, assetId)
return
}
// Normal Click: Single selection
selectionStore.clearSelection()
selectionStore.addToSelection(assetId)
selectionStore.setLastSelectedIndex(index)
setAnchor(index, assetId)
}
/**
@@ -77,7 +96,8 @@ export function useAssetSelection() {
const allIds = allAssets.map((a) => a.id)
selectionStore.setSelection(allIds)
if (allAssets.length > 0) {
selectionStore.setLastSelectedIndex(allAssets.length - 1)
const lastIndex = allAssets.length - 1
setAnchor(lastIndex, allAssets[lastIndex].id)
}
}
@@ -88,6 +108,39 @@ export function useAssetSelection() {
return allAssets.filter((asset) => selectionStore.isSelected(asset.id))
}
function reconcileSelection(assets: AssetItem[]) {
if (selectionStore.selectedAssetIds.size === 0) {
return
}
if (assets.length === 0) {
selectionStore.clearSelection()
return
}
const visibleIds = new Set(assets.map((asset) => asset.id))
const nextSelectedIds: string[] = []
for (const id of selectionStore.selectedAssetIds) {
if (visibleIds.has(id)) {
nextSelectedIds.push(id)
}
}
if (nextSelectedIds.length === selectionStore.selectedAssetIds.size) {
syncAnchorFromAssets(assets)
return
}
if (nextSelectedIds.length === 0) {
selectionStore.clearSelection()
return
}
selectionStore.setSelection(nextSelectedIds)
syncAnchorFromAssets(assets)
}
/**
* Get the output count for a single asset
* Same logic as in AssetsSidebarTab.vue
@@ -117,7 +170,7 @@ export function useAssetSelection() {
function deactivate() {
isActive.value = false
// Reset selection state to ensure clean state when deactivated
selectionStore.reset()
selectionStore.clearSelection()
}
return {
@@ -132,10 +185,9 @@ export function useAssetSelection() {
selectAll,
clearSelection: () => selectionStore.clearSelection(),
getSelectedAssets,
reconcileSelection,
getOutputCount,
getTotalOutputCount,
reset: () => selectionStore.reset(),
// Lifecycle management
activate,
deactivate,

View File

@@ -5,6 +5,7 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
// State
const selectedAssetIds = ref<Set<string>>(new Set())
const lastSelectedIndex = ref<number>(-1)
const lastSelectedAssetId = ref<string | null>(null)
// Getters
const selectedCount = computed(() => selectedAssetIds.value.size)
@@ -34,6 +35,7 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
function clearSelection() {
selectedAssetIds.value.clear()
lastSelectedIndex.value = -1
lastSelectedAssetId.value = null
}
function toggleSelection(assetId: string) {
@@ -52,16 +54,15 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
lastSelectedIndex.value = index
}
// Reset function for cleanup
function reset() {
selectedAssetIds.value.clear()
lastSelectedIndex.value = -1
function setLastSelectedAssetId(assetId: string | null) {
lastSelectedAssetId.value = assetId
}
return {
// State
selectedAssetIds: computed(() => selectedAssetIds.value),
lastSelectedIndex: computed(() => lastSelectedIndex.value),
lastSelectedAssetId: computed(() => lastSelectedAssetId.value),
// Getters
selectedCount,
@@ -76,6 +77,6 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
toggleSelection,
isSelected,
setLastSelectedIndex,
reset
setLastSelectedAssetId
}
})

View File

@@ -45,7 +45,7 @@ export function useMediaAssetActions() {
): Promise<void> => {
if (assetType === 'output') {
const promptId =
asset.id || getOutputAssetMetadata(asset.user_metadata)?.promptId
getOutputAssetMetadata(asset.user_metadata)?.promptId || asset.id
if (!promptId) {
throw new Error('Unable to extract prompt ID from asset')
}
@@ -203,9 +203,10 @@ export function useMediaAssetActions() {
const targetAsset = asset ?? mediaContext?.asset.value
if (!targetAsset) return
// Try asset.id first (OSS), then fall back to metadata (Cloud)
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
const promptId = targetAsset.id || metadata?.promptId
const promptId =
metadata?.promptId ||
(getAssetType(targetAsset) === 'output' ? targetAsset.id : undefined)
if (!promptId) {
toast.add({

View File

@@ -0,0 +1,200 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
const mocks = vi.hoisted(() => ({
resolveOutputAssetItems: vi.fn()
}))
vi.mock('@/platform/assets/utils/outputAssetUtil', () => ({
resolveOutputAssetItems: mocks.resolveOutputAssetItems
}))
type Deferred<T> = {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason?: unknown) => void
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolveFn, rejectFn) => {
resolve = resolveFn
reject = rejectFn
})
return { promise, resolve, reject }
}
function createAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-1',
name: 'parent.png',
tags: [],
created_at: '2025-01-01T00:00:00.000Z',
user_metadata: {
promptId: 'prompt-1',
nodeId: 'node-1',
subfolder: 'outputs'
},
...overrides
}
}
describe('useOutputStacks', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('expands stacks and exposes children as selectable assets', async () => {
const parent = createAsset({ id: 'parent', name: 'parent.png' })
const childA = createAsset({
id: 'child-a',
name: 'child-a.png',
user_metadata: undefined
})
const childB = createAsset({
id: 'child-b',
name: 'child-b.png',
user_metadata: undefined
})
vi.mocked(mocks.resolveOutputAssetItems).mockResolvedValue([childA, childB])
const { assetItems, isStackExpanded, selectableAssets, toggleStack } =
useOutputStacks({ assets: ref([parent]) })
await toggleStack(parent)
expect(mocks.resolveOutputAssetItems).toHaveBeenCalledWith(
expect.objectContaining({ promptId: 'prompt-1' }),
{
createdAt: parent.created_at,
excludeOutputKey: parent.name
}
)
expect(isStackExpanded(parent)).toBe(true)
expect(assetItems.value.map((item) => item.asset.id)).toEqual([
parent.id,
childA.id,
childB.id
])
expect(assetItems.value[1]).toMatchObject({
asset: childA,
isChild: true
})
expect(assetItems.value[2]).toMatchObject({
asset: childB,
isChild: true
})
expect(selectableAssets.value).toEqual([parent, childA, childB])
})
it('collapses an expanded stack when toggled again', async () => {
const parent = createAsset({ id: 'parent', name: 'parent.png' })
const child = createAsset({
id: 'child',
name: 'child.png',
user_metadata: undefined
})
vi.mocked(mocks.resolveOutputAssetItems).mockResolvedValue([child])
const { assetItems, isStackExpanded, toggleStack } = useOutputStacks({
assets: ref([parent])
})
await toggleStack(parent)
await toggleStack(parent)
expect(isStackExpanded(parent)).toBe(false)
expect(assetItems.value.map((item) => item.asset.id)).toEqual([parent.id])
})
it('ignores assets without stack metadata', async () => {
const asset = createAsset({
id: 'no-meta',
name: 'no-meta.png',
user_metadata: undefined
})
const { assetItems, isStackExpanded, toggleStack } = useOutputStacks({
assets: ref([asset])
})
await toggleStack(asset)
expect(mocks.resolveOutputAssetItems).not.toHaveBeenCalled()
expect(isStackExpanded(asset)).toBe(false)
expect(assetItems.value).toHaveLength(1)
expect(assetItems.value[0].asset).toMatchObject(asset)
})
it('does not expand when no children are resolved', async () => {
const parent = createAsset({ id: 'parent', name: 'parent.png' })
vi.mocked(mocks.resolveOutputAssetItems).mockResolvedValue([])
const { assetItems, isStackExpanded, toggleStack } = useOutputStacks({
assets: ref([parent])
})
await toggleStack(parent)
expect(isStackExpanded(parent)).toBe(false)
expect(assetItems.value.map((item) => item.asset.id)).toEqual([parent.id])
})
it('does not expand when resolving children throws', async () => {
const parent = createAsset({ id: 'parent', name: 'parent.png' })
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(mocks.resolveOutputAssetItems).mockRejectedValue(
new Error('resolve failed')
)
const { assetItems, isStackExpanded, toggleStack } = useOutputStacks({
assets: ref([parent])
})
await toggleStack(parent)
expect(isStackExpanded(parent)).toBe(false)
expect(assetItems.value.map((item) => item.asset.id)).toEqual([parent.id])
errorSpy.mockRestore()
})
it('guards against duplicate loads while a stack is resolving', async () => {
const parent = createAsset({ id: 'parent', name: 'parent.png' })
const child = createAsset({
id: 'child',
name: 'child.png',
user_metadata: undefined
})
const deferred = createDeferred<AssetItem[]>()
vi.mocked(mocks.resolveOutputAssetItems).mockReturnValue(deferred.promise)
const { assetItems, toggleStack } = useOutputStacks({
assets: ref([parent])
})
const firstToggle = toggleStack(parent)
const secondToggle = toggleStack(parent)
expect(mocks.resolveOutputAssetItems).toHaveBeenCalledTimes(1)
deferred.resolve([child])
await firstToggle
await secondToggle
expect(assetItems.value.map((item) => item.asset.id)).toEqual([
parent.id,
child.id
])
})
})

View File

@@ -0,0 +1,127 @@
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
export type OutputStackListItem = {
key: string
asset: AssetItem
isChild?: boolean
}
type UseOutputStacksOptions = {
assets: Ref<AssetItem[]>
}
export function useOutputStacks({ assets }: UseOutputStacksOptions) {
const expandedStackPromptIds = ref<Set<string>>(new Set())
const stackChildrenByPromptId = ref<Record<string, AssetItem[]>>({})
const loadingStackPromptIds = ref<Set<string>>(new Set())
const assetItems = computed<OutputStackListItem[]>(() => {
const items: OutputStackListItem[] = []
for (const asset of assets.value) {
const promptId = getStackPromptId(asset)
items.push({
key: `asset-${asset.id}`,
asset
})
if (!promptId || !expandedStackPromptIds.value.has(promptId)) {
continue
}
const children = stackChildrenByPromptId.value[promptId] ?? []
for (const child of children) {
items.push({
key: `asset-${child.id}`,
asset: child,
isChild: true
})
}
}
return items
})
const selectableAssets = computed(() =>
assetItems.value.map((item) => item.asset)
)
function getStackPromptId(asset: AssetItem): string | null {
const metadata = getOutputAssetMetadata(asset.user_metadata)
return metadata?.promptId ?? null
}
function isStackExpanded(asset: AssetItem): boolean {
const promptId = getStackPromptId(asset)
if (!promptId) return false
return expandedStackPromptIds.value.has(promptId)
}
async function toggleStack(asset: AssetItem) {
const promptId = getStackPromptId(asset)
if (!promptId) return
if (expandedStackPromptIds.value.has(promptId)) {
const next = new Set(expandedStackPromptIds.value)
next.delete(promptId)
expandedStackPromptIds.value = next
return
}
if (!stackChildrenByPromptId.value[promptId]?.length) {
if (loadingStackPromptIds.value.has(promptId)) {
return
}
const nextLoading = new Set(loadingStackPromptIds.value)
nextLoading.add(promptId)
loadingStackPromptIds.value = nextLoading
const children = await resolveStackChildren(asset)
const afterLoading = new Set(loadingStackPromptIds.value)
afterLoading.delete(promptId)
loadingStackPromptIds.value = afterLoading
if (!children.length) {
return
}
stackChildrenByPromptId.value = {
...stackChildrenByPromptId.value,
[promptId]: children
}
}
const nextExpanded = new Set(expandedStackPromptIds.value)
nextExpanded.add(promptId)
expandedStackPromptIds.value = nextExpanded
}
async function resolveStackChildren(asset: AssetItem): Promise<AssetItem[]> {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (!metadata) {
return []
}
try {
return await resolveOutputAssetItems(metadata, {
createdAt: asset.created_at,
excludeOutputKey: asset.name
})
} catch (error) {
console.error('Failed to resolve stack children:', error)
return []
}
}
return {
assetItems,
selectableAssets,
isStackExpanded,
toggleStack
}
}

View File

@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { ResultItemImpl } from '@/stores/queueStore'
import { resolveOutputAssetItems } from './outputAssetUtil'
const mocks = vi.hoisted(() => ({
getJobDetail: vi.fn(),
getPreviewableOutputsFromJobDetail: vi.fn()
}))
vi.mock('@/services/jobOutputCache', () => ({
getJobDetail: mocks.getJobDetail,
getPreviewableOutputsFromJobDetail: mocks.getPreviewableOutputsFromJobDetail
}))
type OutputOverrides = Partial<{
filename: string
subfolder: string
nodeId: string
url: string
}>
function createOutput(overrides: OutputOverrides = {}): ResultItemImpl {
return {
filename: 'file.png',
subfolder: 'sub',
nodeId: '1',
url: 'https://example.com/file.png',
...overrides
} as ResultItemImpl
}
describe('resolveOutputAssetItems', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('maps outputs and excludes a filename', async () => {
const outputA = createOutput({
filename: 'a.png',
nodeId: '1',
url: 'https://example.com/a.png'
})
const outputB = createOutput({
filename: 'b.png',
nodeId: '2',
url: 'https://example.com/b.png'
})
const metadata: OutputAssetMetadata = {
promptId: 'prompt-1',
nodeId: '1',
subfolder: 'sub',
executionTimeInSeconds: 12.5,
outputCount: 2,
allOutputs: [outputA, outputB]
}
const results = await resolveOutputAssetItems(metadata, {
createdAt: '2025-01-01T00:00:00.000Z',
excludeOutputKey: 'b.png'
})
expect(mocks.getJobDetail).not.toHaveBeenCalled()
expect(results).toHaveLength(1)
expect(results[0]).toEqual(
expect.objectContaining({
id: 'prompt-1-1-a.png',
name: 'a.png',
created_at: '2025-01-01T00:00:00.000Z',
tags: ['output'],
preview_url: 'https://example.com/a.png'
})
)
expect(results[0].user_metadata).toEqual(
expect.objectContaining({
promptId: 'prompt-1',
nodeId: '1',
subfolder: 'sub',
executionTimeInSeconds: 12.5
})
)
})
it('loads full outputs when metadata indicates more outputs', async () => {
const previewOutput = createOutput({
filename: 'preview.png',
nodeId: '1',
url: 'https://example.com/preview.png'
})
const fullOutput = createOutput({
filename: 'full.png',
nodeId: '2',
url: 'https://example.com/full.png'
})
const metadata: OutputAssetMetadata = {
promptId: 'prompt-2',
nodeId: '1',
subfolder: 'sub',
outputCount: 3,
allOutputs: [previewOutput]
}
const jobDetail = { id: 'job-1' }
mocks.getJobDetail.mockResolvedValue(jobDetail)
mocks.getPreviewableOutputsFromJobDetail.mockReturnValue([
fullOutput,
previewOutput
])
const results = await resolveOutputAssetItems(metadata)
expect(mocks.getJobDetail).toHaveBeenCalledWith('prompt-2')
expect(mocks.getPreviewableOutputsFromJobDetail).toHaveBeenCalledWith(
jobDetail
)
expect(results.map((asset) => asset.name)).toEqual([
'full.png',
'preview.png'
])
})
})

View File

@@ -0,0 +1,84 @@
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getJobDetail,
getPreviewableOutputsFromJobDetail
} from '@/services/jobOutputCache'
import type { ResultItemImpl } from '@/stores/queueStore'
type OutputAssetMapOptions = {
promptId: string
outputs: readonly ResultItemImpl[]
createdAt?: string
executionTimeInSeconds?: number
workflow?: OutputAssetMetadata['workflow']
excludeFilename?: string
}
type ResolveOutputAssetItemsOptions = {
createdAt?: string
excludeOutputKey?: string
}
function shouldLoadFullOutputs(
outputCount: OutputAssetMetadata['outputCount'],
outputsLength: number
): boolean {
return (
typeof outputCount === 'number' &&
outputCount > 1 &&
outputsLength < outputCount
)
}
function mapOutputsToAssetItems({
promptId,
outputs,
createdAt,
executionTimeInSeconds,
workflow,
excludeFilename
}: OutputAssetMapOptions): AssetItem[] {
const createdAtValue = createdAt ?? new Date().toISOString()
return outputs
.filter((output) => output.filename && output.filename !== excludeFilename)
.map((output) => ({
id: `${promptId}-${output.nodeId}-${output.filename}`,
name: output.filename,
size: 0,
created_at: createdAtValue,
tags: ['output'],
preview_url: output.url,
user_metadata: {
promptId,
nodeId: output.nodeId,
subfolder: output.subfolder,
executionTimeInSeconds,
workflow
}
}))
}
export async function resolveOutputAssetItems(
metadata: OutputAssetMetadata,
{ createdAt, excludeOutputKey }: ResolveOutputAssetItemsOptions = {}
): Promise<AssetItem[]> {
let outputsToDisplay = metadata.allOutputs ?? []
if (shouldLoadFullOutputs(metadata.outputCount, outputsToDisplay.length)) {
const jobDetail = await getJobDetail(metadata.promptId)
const previewableOutputs = getPreviewableOutputsFromJobDetail(jobDetail)
if (previewableOutputs.length) {
outputsToDisplay = previewableOutputs
}
}
return mapOutputsToAssetItems({
promptId: metadata.promptId,
outputs: outputsToDisplay,
createdAt,
executionTimeInSeconds: metadata.executionTimeInSeconds,
workflow: metadata.workflow,
excludeFilename: excludeOutputKey
})
}

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

@@ -59,6 +59,7 @@ import { useExecutionStore } from '@/stores/executionStore'
import { useExtensionStore } from '@/stores/extensionStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
import { KeyComboImpl, useKeybindingStore } from '@/stores/keybindingStore'
import { useModelStore } from '@/stores/modelStore'
import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
@@ -695,9 +696,10 @@ export class ComfyApp {
api.addEventListener('b_preview_with_metadata', ({ detail }) => {
// Enhanced preview with explicit node context
const { blob, displayNodeId } = detail
const { blob, displayNodeId, promptId } = detail
const { setNodePreviewsByExecutionId, revokePreviewsByExecutionId } =
useNodeOutputStore()
useJobPreviewStore().setPreviewFromBlob(promptId, blob)
// Ensure clean up if `executing` event is missed.
revokePreviewsByExecutionId(displayNodeId)
const blobUrl = URL.createObjectURL(blob)

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,7 @@ 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 type { ResultItem, TaskOutput } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { ResultItemImpl } from '@/stores/queueStore'
import type { TaskItemImpl } from '@/stores/queueStore'
@@ -75,6 +76,40 @@ 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 {
return typeof item === 'object' && item !== null
}
export function getPreviewableOutputsFromJobDetail(
jobDetail?: JobDetail
): ResultItemImpl[] {
return getPreviewableOutputs(jobDetail?.outputs)
}
// ===== Job Detail Caching =====
export async function getJobDetail(

View File

@@ -30,6 +30,7 @@ import type {
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil'
@@ -454,6 +455,7 @@ export const useExecutionStore = defineStore('execution', () => {
const map = { ...nodeProgressStatesByPrompt.value }
delete map[promptId]
nodeProgressStatesByPrompt.value = map
useJobPreviewStore().clearPreview(String(promptId))
}
if (activePromptId.value) {
delete queuedPrompts.value[activePromptId.value]

View File

@@ -0,0 +1,65 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
type PromptPreviewMap = Record<string, string>
export const useJobPreviewStore = defineStore('jobPreview', () => {
const settingStore = useSettingStore()
const previewsByPromptId = ref<PromptPreviewMap>({})
const previewMethod = computed(() =>
settingStore.get('Comfy.Execution.PreviewMethod')
)
const isPreviewEnabled = computed(() => previewMethod.value !== 'none')
function revokePreviewUrl(url: string | undefined) {
if (!url) return
URL.revokeObjectURL(url)
}
function setPreviewUrl(promptId: string, url: string) {
const current = previewsByPromptId.value[promptId]
if (current) revokePreviewUrl(current)
previewsByPromptId.value = {
...previewsByPromptId.value,
[promptId]: url
}
}
function setPreviewFromBlob(promptId: string | undefined, blob: Blob) {
if (!promptId || !isPreviewEnabled.value) return
const url = URL.createObjectURL(blob)
setPreviewUrl(promptId, url)
}
function clearPreview(promptId: string | undefined) {
if (!promptId) return
const current = previewsByPromptId.value[promptId]
if (!current) return
revokePreviewUrl(current)
const next = { ...previewsByPromptId.value }
delete next[promptId]
previewsByPromptId.value = next
}
function clearAllPreviews() {
Object.values(previewsByPromptId.value).forEach((url) =>
revokePreviewUrl(url)
)
previewsByPromptId.value = {}
}
watch(isPreviewEnabled, (enabled) => {
if (!enabled) clearAllPreviews()
})
return {
previewsByPromptId,
isPreviewEnabled,
setPreviewFromBlob,
clearPreview,
clearAllPreviews
}
})

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