Merge branch 'main' into feat/node-color-persistence-v1

This commit is contained in:
Dante
2026-03-09 22:31:32 +09:00
committed by GitHub
16 changed files with 175 additions and 28 deletions

View File

@@ -34,17 +34,7 @@
</Button>
</div>
<div
ref="actionbarContainerRef"
:class="
cn(
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg px-2 shadow-interface',
hasAnyError
? 'border-destructive-background-hover'
: 'border-interface-stroke'
)
"
>
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
@@ -55,6 +45,7 @@
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
:has-any-error="hasAnyError"
@update:progress-target="updateProgressTarget"
/>
<CurrentUserButton
@@ -123,7 +114,7 @@
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { useLocalStorage, useMutationObserver } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -145,6 +136,7 @@ import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
import { useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -168,6 +160,7 @@ const { isLoggedIn } = useCurrentUser()
const { t } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const executionErrorStore = useExecutionErrorStore()
const actionBarButtonStore = useActionBarButtonStore()
const queueUIStore = useQueueUIStore()
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
@@ -182,6 +175,43 @@ const isActionbarEnabled = computed(
const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
/**
* Whether the actionbar container has any visible docked buttons
* (excluding ComfyActionbar, which uses position:fixed when floating
* and does not contribute to the container's visual layout).
*/
const hasDockedButtons = computed(() => {
if (actionBarButtonStore.buttons.length > 0) return true
if (hasLegacyContent.value) return true
if (isLoggedIn.value && !isIntegratedTabBar.value) return true
if (isDesktop && !isIntegratedTabBar.value) return true
if (isCloud && flags.workflowSharingEnabled) return true
if (!isRightSidePanelOpen.value) return true
return false
})
const isActionbarContainerEmpty = computed(
() => isActionbarFloating.value && !hasDockedButtons.value
)
const actionbarContainerClass = computed(() => {
const base =
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg shadow-interface'
if (isActionbarContainerEmpty.value) {
return cn(
base,
'-ml-2 w-0 min-w-0 border-transparent shadow-none',
'has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto',
'has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
)
}
const borderClass =
!isActionbarFloating.value && hasAnyError.value
? 'border-destructive-background-hover'
: 'border-interface-stroke'
return cn(base, 'px-2', borderClass)
})
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
)
@@ -233,6 +263,25 @@ const rightSidePanelTooltipConfig = computed(() =>
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()
const hasLegacyContent = ref(false)
function checkLegacyContent() {
const el = legacyCommandsContainerRef.value
if (!el) {
hasLegacyContent.value = false
return
}
// Mirror the CSS: [&:not(:has(*>*:not(:empty)))]:hidden
hasLegacyContent.value =
el.querySelector(':scope > * > *:not(:empty)') !== null
}
useMutationObserver(legacyCommandsContainerRef, checkLegacyContent, {
childList: true,
subtree: true,
characterData: true
})
onMounted(() => {
if (legacyCommandsContainerRef.value) {
app.menu.element.style.width = 'fit-content'

View File

@@ -119,9 +119,14 @@ import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
const {
topMenuContainer,
queueOverlayExpanded = false,
hasAnyError = false
} = defineProps<{
topMenuContainer?: HTMLElement | null
queueOverlayExpanded?: boolean
hasAnyError?: boolean
}>()
const emit = defineEmits<{
@@ -435,7 +440,12 @@ const panelClass = computed(() =>
isDragging.value && 'pointer-events-none select-none',
isDocked.value
? 'static border-none bg-transparent p-0'
: 'fixed shadow-interface'
: [
'fixed shadow-interface',
hasAnyError
? 'border-destructive-background-hover'
: 'border-interface-stroke'
]
)
)
</script>

View File

@@ -33,7 +33,7 @@
tabindex="0"
:aria-label="
t('assetBrowser.ariaLabel.assetCard', {
name: item.asset.name,
name: getAssetDisplayName(item.asset),
type: getAssetMediaType(item.asset)
})
"
@@ -44,7 +44,7 @@
)
"
:preview-url="getAssetPreviewUrl(item.asset)"
:preview-alt="item.asset.name"
:preview-alt="getAssetDisplayName(item.asset)"
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
:is-video-preview="isVideoAsset(item.asset)"
:primary-text="getAssetPrimaryText(item.asset)"
@@ -133,8 +133,12 @@ const listGridStyle = {
gap: '0.5rem'
}
function getAssetDisplayName(asset: AssetItem): string {
return asset.display_name || asset.name
}
function getAssetPrimaryText(asset: AssetItem): string {
return truncateFilename(asset.name)
return truncateFilename(getAssetDisplayName(asset))
}
function getAssetMediaType(asset: AssetItem) {

View File

@@ -569,7 +569,7 @@ const handleZoomClick = (asset: AssetItem) => {
const dialogStore = useDialogStore()
dialogStore.showDialog({
key: 'asset-3d-viewer',
title: asset.name,
title: asset.display_name || asset.name,
component: Load3dViewerContent,
props: {
modelUrl: asset.preview_url || ''

View File

@@ -186,7 +186,7 @@ const tooltipDelay = computed<number>(() =>
const { isLoading, error } = useImage({
src: asset.preview_url ?? '',
alt: asset.name
alt: asset.display_name || asset.name
})
function handleSelect() {

View File

@@ -5,7 +5,7 @@
:aria-label="
asset
? $t('assetBrowser.ariaLabel.assetCard', {
name: asset.name,
name: asset.display_name || asset.name,
type: fileKind
})
: $t('assetBrowser.ariaLabel.loadingAsset')
@@ -225,7 +225,7 @@ const canInspect = computed(() => isPreviewableMediaType(fileKind.value))
// Get filename without extension
const fileName = computed(() => {
return getFilenameDetails(asset?.name || '').filename
return getFilenameDetails(asset?.display_name || asset?.name || '').filename
})
// Adapt AssetItem to legacy AssetMeta format for existing components
@@ -234,6 +234,7 @@ const adaptedAsset = computed(() => {
return {
id: asset.id,
name: asset.name,
display_name: asset.display_name,
kind: fileKind.value,
src: asset.preview_url || '',
size: asset.size,

View File

@@ -6,7 +6,7 @@
<img
v-if="!error"
:src="asset.src"
:alt="asset.name"
:alt="asset.display_name || asset.name"
class="size-full object-contain transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
/>
<div
@@ -34,7 +34,7 @@ const emit = defineEmits<{
const { state, error, isReady } = useImage({
src: asset.src ?? '',
alt: asset.name
alt: asset.display_name || asset.name
})
whenever(

View File

@@ -39,6 +39,7 @@ export function mapTaskOutputToAssetItem(
return {
id: taskItem.jobId,
name: output.filename,
display_name: output.display_name,
size: 0,
created_at: taskItem.executionStartTimestamp
? new Date(taskItem.executionStartTimestamp).toISOString()

View File

@@ -68,7 +68,7 @@ export function useMediaAssetActions() {
if (!targetAsset) return
try {
const filename = targetAsset.name
const filename = targetAsset.display_name || targetAsset.name
// Prefer preview_url (already includes subfolder) with getAssetUrl as fallback
const downloadUrl = targetAsset.preview_url || getAssetUrl(targetAsset)
@@ -109,7 +109,7 @@ export function useMediaAssetActions() {
try {
assets.forEach((asset) => {
const filename = asset.name
const filename = asset.display_name || asset.name
const downloadUrl = asset.preview_url || getAssetUrl(asset)
downloadFile(downloadUrl, filename)
})

View File

@@ -9,6 +9,7 @@ const zAsset = z.object({
mime_type: z.string().nullish(),
tags: z.array(z.string()).optional().default([]),
preview_id: z.string().nullable().optional(),
display_name: z.string().optional(),
preview_url: z.string().optional(),
created_at: z.string().optional(),
updated_at: z.string().optional(),

View File

@@ -20,6 +20,7 @@ type OutputOverrides = Partial<{
subfolder: string
nodeId: string
url: string
display_name: string
}>
function createOutput(overrides: OutputOverrides = {}): ResultItemImpl {
@@ -32,7 +33,8 @@ function createOutput(overrides: OutputOverrides = {}): ResultItemImpl {
}
return {
...merged,
previewUrl: merged.url
previewUrl: merged.url,
display_name: merged.display_name
} as ResultItemImpl
}
@@ -125,6 +127,48 @@ describe('resolveOutputAssetItems', () => {
])
})
it('propagates display_name from output to asset item', async () => {
const output = createOutput({
filename: 'abc123hash.png',
nodeId: '1',
url: 'https://example.com/abc123hash.png',
display_name: 'ComfyUI_00001_.png'
})
const metadata: OutputAssetMetadata = {
jobId: 'job-dn',
nodeId: '1',
subfolder: 'sub',
outputCount: 1,
allOutputs: [output]
}
const results = await resolveOutputAssetItems(metadata)
expect(results).toHaveLength(1)
expect(results[0].name).toBe('abc123hash.png')
expect(results[0].display_name).toBe('ComfyUI_00001_.png')
})
it('omits display_name when not present in output', async () => {
const output = createOutput({
filename: 'file.png',
nodeId: '1',
url: 'https://example.com/file.png'
})
const metadata: OutputAssetMetadata = {
jobId: 'job-nodn',
nodeId: '1',
subfolder: 'sub',
outputCount: 1,
allOutputs: [output]
}
const results = await resolveOutputAssetItems(metadata)
expect(results).toHaveLength(1)
expect(results[0].display_name).toBeUndefined()
})
it('keeps root outputs with empty subfolders', async () => {
const output = createOutput({
filename: 'root.png',

View File

@@ -69,6 +69,7 @@ function mapOutputsToAssetItems({
items.push({
id: `${jobId}-${outputKey}`,
name: output.filename,
display_name: output.display_name,
size: 0,
created_at: createdAtValue,
tags: ['output'],

View File

@@ -23,7 +23,8 @@ const zPreviewOutput = z.object({
subfolder: z.string(),
type: resultItemType,
nodeId: z.string(),
mediaType: z.string()
mediaType: z.string(),
display_name: z.string().optional()
})
/**

View File

@@ -19,7 +19,8 @@ export type CustomNodesI18n = z.infer<typeof zCustomNodesI18n>
const zResultItem = z.object({
filename: z.string().optional(),
subfolder: z.string().optional(),
type: resultItemType.optional()
type: resultItemType.optional(),
display_name: z.string().optional()
})
export type ResultItem = z.infer<typeof zResultItem>
const zOutputs = z

View File

@@ -255,6 +255,35 @@ describe('jobOutputCache', () => {
expect(video?.mediaType).toBe('video')
})
it('preserves display_name from output items', async () => {
const { getPreviewableOutputsFromJobDetail } =
await import('@/services/jobOutputCache')
const jobDetail: JobDetail = {
id: 'job-display-name',
status: 'completed',
create_time: Date.now(),
priority: 0,
outputs: {
'node-1': {
images: [
{
filename: 'abc123hash.png',
subfolder: '',
type: 'output',
display_name: 'ComfyUI_00001_.png'
}
]
}
}
}
const result = getPreviewableOutputsFromJobDetail(jobDetail)
expect(result).toHaveLength(1)
expect(result[0].filename).toBe('abc123hash.png')
expect(result[0].display_name).toBe('ComfyUI_00001_.png')
})
it('filters non-previewable outputs and non-object items', async () => {
const { getPreviewableOutputsFromJobDetail } =
await import('@/services/jobOutputCache')

View File

@@ -37,6 +37,7 @@ interface ResultItemInit extends ResultItem {
mediaType: string
format?: string
frame_rate?: number
display_name?: string
}
export class ResultItemImpl {
@@ -48,6 +49,8 @@ export class ResultItemImpl {
// 'audio' | 'images' | ...
mediaType: string
display_name?: string
// VHS output specific fields
format?: string
frame_rate?: number
@@ -60,6 +63,8 @@ export class ResultItemImpl {
this.nodeId = obj.nodeId
this.mediaType = obj.mediaType
this.display_name = obj.display_name
this.format = obj.format
this.frame_rate = obj.frame_rate
}