mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
## Summary - Move error red border from TopMenuSection/ComfyActionbar to ErrorOverlay - Add error indicator (outline + StatusBadge dot) on right side panel toggle button when errors are present, the panel/overlay are closed, and the errors tab setting is enabled - Replace technical group titles (e.g. "Missing Node Packs") with user-friendly i18n messages in ErrorOverlay - Dynamically change action button label based on single error type (e.g. "Show missing nodes" instead of "See Errors") - Remove unused `hasAnyError` prop from ComfyActionbar - Fix `type="secondary"` → `variant="secondary"` on panel toggle button - Pre-wire `missing_media` error type support for #10309 - Migrate ErrorOverlay E2E selectors from `getByText`/`getByRole` to `data-testid` - Update E2E screenshot snapshots affected by TopMenuSection error state design changes ## Test plan - [x] Trigger execution error → verify red border on ErrorOverlay, no red border on TopMenuSection/ComfyActionbar - [x] With errors and right side panel closed → verify red outline + dot on panel toggle button - [x] Open right side panel or error overlay → verify indicator disappears - [x] Disable `Comfy.RightSidePanel.ShowErrorsTab` → verify no indicator even with errors - [x] Load workflow with only missing nodes → verify "Show missing nodes" button label and friendly message - [x] Load workflow with only missing models → verify "Show missing models" button label and count message - [x] Load workflow with mixed errors → verify "See Errors" fallback label - [x] E2E: `pnpm test:browser:local -- --grep "Error overlay"` ## Screenshots <img width="498" height="381" alt="스크린샷 2026-03-26 230252" src="https://github.com/user-attachments/assets/034f0f3f-e6a1-4617-b8f6-cd4c145e3a47" /> <img width="550" height="303" alt="스크린샷 2026-03-26 230525" src="https://github.com/user-attachments/assets/2958914b-0ff0-461b-a6ea-7f2811bf33c2" /> <img width="551" height="87" alt="스크린샷 2026-03-26 230318" src="https://github.com/user-attachments/assets/396e9cb1-667e-44c4-83fe-ab113b313d16" /> --------- Co-authored-by: github-actions <github-actions@github.com> Co-authored-by: Dante <bunggl@naver.com>
346 lines
12 KiB
Vue
346 lines
12 KiB
Vue
<template>
|
|
<div
|
|
v-if="!workspaceStore.focusMode"
|
|
class="ml-1 flex flex-col gap-1 pt-1"
|
|
@mouseenter="isTopMenuHovered = true"
|
|
@mouseleave="isTopMenuHovered = false"
|
|
>
|
|
<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"
|
|
:aria-label="t('menu.manageExtensions')"
|
|
class="relative"
|
|
@click="openCustomNodeManager"
|
|
>
|
|
<i class="icon-[comfy--extensions-blocks] size-4" />
|
|
<span class="not-md:hidden">
|
|
{{ t('menu.manageExtensions') }}
|
|
</span>
|
|
<span
|
|
v-if="shouldShowRedDot"
|
|
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
|
|
/>
|
|
</Button>
|
|
</div>
|
|
|
|
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
|
|
<ActionBarButtons />
|
|
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
|
<div
|
|
ref="legacyCommandsContainerRef"
|
|
data-testid="legacy-topbar-container"
|
|
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
|
></div>
|
|
|
|
<ComfyActionbar
|
|
:top-menu-container="actionbarContainerRef"
|
|
:queue-overlay-expanded="isQueueOverlayExpanded"
|
|
@update:progress-target="updateProgressTarget"
|
|
/>
|
|
<CurrentUserButton
|
|
v-if="isLoggedIn && !isIntegratedTabBar"
|
|
class="shrink-0"
|
|
/>
|
|
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
|
|
<Button
|
|
v-if="isCloud && flags.workflowSharingEnabled"
|
|
v-tooltip.bottom="shareTooltipConfig"
|
|
variant="secondary"
|
|
:aria-label="t('actionbar.shareTooltip')"
|
|
@click="() => openShareDialog().catch(toastErrorHandler)"
|
|
@pointerenter="prefetchShareDialog"
|
|
>
|
|
<i class="icon-[comfy--send] size-4" />
|
|
<span class="not-md:hidden">
|
|
{{ t('actionbar.share') }}
|
|
</span>
|
|
</Button>
|
|
<div v-if="!isRightSidePanelOpen" class="relative">
|
|
<Button
|
|
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
|
:class="
|
|
cn(
|
|
showErrorIndicatorOnPanelButton &&
|
|
'outline-1 outline-destructive-background'
|
|
)
|
|
"
|
|
variant="secondary"
|
|
size="icon"
|
|
:aria-label="t('rightSidePanel.togglePanel')"
|
|
@click="rightSidePanelStore.togglePanel"
|
|
>
|
|
<i class="icon-[lucide--panel-right] size-4" />
|
|
</Button>
|
|
<StatusBadge
|
|
v-if="showErrorIndicatorOnPanelButton"
|
|
variant="dot"
|
|
severity="danger"
|
|
class="absolute -top-1 -right-1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<ErrorOverlay />
|
|
<QueueProgressOverlay
|
|
v-if="isQueueProgressOverlayEnabled"
|
|
v-model:expanded="isQueueOverlayExpanded"
|
|
:menu-hovered="isTopMenuHovered"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col items-end gap-1">
|
|
<Teleport
|
|
v-if="inlineProgressSummaryTarget"
|
|
:to="inlineProgressSummaryTarget"
|
|
>
|
|
<div
|
|
class="pointer-events-none absolute inset-x-0 top-full mt-1 flex justify-end pr-1"
|
|
>
|
|
<QueueInlineProgressSummary
|
|
:hidden="shouldHideInlineProgressSummary"
|
|
/>
|
|
</div>
|
|
</Teleport>
|
|
<QueueInlineProgressSummary
|
|
v-else-if="shouldShowInlineProgressSummary && !isActionbarFloating"
|
|
class="pr-1"
|
|
:hidden="shouldHideInlineProgressSummary"
|
|
/>
|
|
<QueueNotificationBannerHost
|
|
v-if="shouldShowQueueNotificationBanners"
|
|
class="pr-1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useLocalStorage, useMutationObserver } from '@vueuse/core'
|
|
import { storeToRefs } from 'pinia'
|
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
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 QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
|
|
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
|
|
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
|
|
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
|
|
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
|
import LoginButton from '@/components/topbar/LoginButton.vue'
|
|
import StatusBadge from '@/components/common/StatusBadge.vue'
|
|
import Button from '@/components/ui/button/Button.vue'
|
|
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
|
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
|
|
import { useErrorHandling } from '@/composables/useErrorHandling'
|
|
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'
|
|
import { isCloud, isDesktop } from '@/platform/distribution/types'
|
|
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
|
import {
|
|
openShareDialog,
|
|
prefetchShareDialog
|
|
} from '@/platform/workflow/sharing/composables/lazyShareDialog'
|
|
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
|
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
|
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
|
import { cn } from '@/utils/tailwindUtil'
|
|
|
|
const settingStore = useSettingStore()
|
|
const workspaceStore = useWorkspaceStore()
|
|
const rightSidePanelStore = useRightSidePanelStore()
|
|
const managerState = useManagerState()
|
|
const { flags } = useFeatureFlags()
|
|
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 } =
|
|
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
|
|
)
|
|
/**
|
|
* 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'
|
|
)
|
|
}
|
|
|
|
return cn(base, 'px-2', 'border-interface-stroke')
|
|
})
|
|
const isIntegratedTabBar = computed(
|
|
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
|
|
)
|
|
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
|
|
useQueueFeatureFlags()
|
|
const isQueueProgressOverlayEnabled = computed(
|
|
() => !isQueuePanelV2Enabled.value
|
|
)
|
|
const shouldShowInlineProgressSummary = computed(
|
|
() =>
|
|
isQueuePanelV2Enabled.value &&
|
|
isActionbarEnabled.value &&
|
|
isRunProgressBarEnabled.value
|
|
)
|
|
const shouldShowQueueNotificationBanners = computed(
|
|
() => 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 shouldHideInlineProgressSummary = computed(
|
|
() => isQueueProgressOverlayEnabled.value && isQueueOverlayExpanded.value
|
|
)
|
|
const customNodesManagerTooltipConfig = computed(() =>
|
|
buildTooltipConfig(t('menu.manageExtensions'))
|
|
)
|
|
const shareTooltipConfig = computed(() =>
|
|
buildTooltipConfig(t('actionbar.shareTooltip'))
|
|
)
|
|
|
|
const shouldShowRedDot = computed((): boolean => {
|
|
return shouldShowConflictRedDot.value
|
|
})
|
|
|
|
const { hasAnyError, isErrorOverlayOpen } = storeToRefs(executionErrorStore)
|
|
|
|
const isErrorsTabEnabled = computed(() =>
|
|
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
|
|
)
|
|
|
|
const showErrorIndicatorOnPanelButton = computed(
|
|
() =>
|
|
isErrorsTabEnabled.value &&
|
|
hasAnyError.value &&
|
|
!isRightSidePanelOpen.value &&
|
|
!isErrorOverlayOpen.value
|
|
)
|
|
|
|
// Right side panel toggle
|
|
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
|
|
const rightSidePanelTooltipConfig = computed(() =>
|
|
buildTooltipConfig(t('rightSidePanel.togglePanel'))
|
|
)
|
|
|
|
// Maintain support for legacy topbar elements attached by custom scripts
|
|
const legacyCommandsContainerRef = ref<HTMLElement>()
|
|
const hasLegacyContent = ref(false)
|
|
let legacyContentCheckRafId: number | null = null
|
|
|
|
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
|
|
}
|
|
|
|
function scheduleLegacyContentCheck() {
|
|
if (legacyContentCheckRafId !== null) return
|
|
|
|
legacyContentCheckRafId = requestAnimationFrame(() => {
|
|
legacyContentCheckRafId = null
|
|
checkLegacyContent()
|
|
})
|
|
}
|
|
|
|
useMutationObserver(legacyCommandsContainerRef, scheduleLegacyContentCheck, {
|
|
childList: true,
|
|
subtree: true
|
|
})
|
|
|
|
onMounted(() => {
|
|
if (legacyCommandsContainerRef.value) {
|
|
app.menu.element.style.width = 'fit-content'
|
|
legacyCommandsContainerRef.value.appendChild(app.menu.element)
|
|
checkLegacyContent()
|
|
}
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (legacyContentCheckRafId === null) return
|
|
|
|
cancelAnimationFrame(legacyContentCheckRafId)
|
|
legacyContentCheckRafId = null
|
|
})
|
|
|
|
const openCustomNodeManager = async () => {
|
|
try {
|
|
await managerState.openManager({
|
|
initialTab: ManagerTab.All,
|
|
showToastOnLegacyError: false
|
|
})
|
|
} catch (error) {
|
|
try {
|
|
toastErrorHandler(error)
|
|
} catch (toastError) {
|
|
console.error(error)
|
|
console.error(toastError)
|
|
}
|
|
}
|
|
}
|
|
</script>
|