mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
## Summary Fix four bugs in the subgraph promoted widget panel where linked promotions were not distinguished from independent ones, causing incorrect UI state in both the SubgraphEditor (Settings) panel and the Parameters tab WidgetActions menu. ## Changes - **What**: Add `isLinkedPromotion` helper to correctly identify widgets driven by subgraph input connections. Fix `disambiguatingSourceNodeId` lookup mismatch that broke `isWidgetShownOnParents` and `handleHideInput` for non-nested promoted widgets. Replace fragile CSS icon selectors with `data-testid` attributes. ## Bugs fixed Companion fix PR for #10502 (red-green test PR). All 4 E2E tests from #10502 now pass: | Bug | Root cause | Fix | |-----|-----------|-----| | Linked promoted widgets have hide toggle enabled | `SubgraphEditor` only checked `node.id === -1` (physical) — linked promotions from subgraph input connections were not detected | Added `isLinkedPromotion` helper that checks `input._widget` bindings; `SubgraphNodeWidget` `:is-physical` prop now covers both physical and linked cases | | Linked promoted widgets show eye icon instead of link icon | Same root cause as above — `isPhysical` prop was only true for `node.id === -1` | Extended the `:is-physical` condition to include `isLinkedPromotion` check | | Widget labels show raw names instead of renamed values | `SubgraphEditor` passed `widget.name` instead of `widget.label \|\| widget.name` | Changed `:widget-name` binding to prefer `widget.label` | | WidgetActions menu shows Hide/Show for linked promotions | `v-if="hasParents"` didn't exclude linked promotions | Added `canToggleVisibility` computed that combines `hasParents` with `!isLinked` check via `isLinkedPromotion` | ### Additional bugs discovered and fixed | Bug | Root cause | Fix | |-----|-----------|-----| | "Show input" always displayed instead of "Hide input" for promoted widgets | `SectionWidgets.isWidgetShownOnParents` used `getSourceNodeId(widget)` which falls back to `widget.sourceNodeId` when `disambiguatingSourceNodeId` is undefined — this mismatches the promotion store key (`undefined`) | Changed to `widget.disambiguatingSourceNodeId` directly | | "Hide input" click does nothing | `WidgetActions.handleHideInput` used `getSourceNodeId(widget)` for the same reason — `demote()` couldn't find the entry to remove | Same fix — use `widget.disambiguatingSourceNodeId` directly | ## Tests added ### E2E (Playwright) — `browser_tests/tests/subgraphPromotedWidgetPanel.spec.ts` | Test | What it verifies | |------|-----------------| | linked promoted widgets have hide toggle disabled | All toggle buttons in SubgraphEditor shown section are disabled for linked widgets (covers 1-level and 2-level nested promotions via `subgraph-nested-promotion` fixture) | | linked promoted widgets show link icon instead of eye icon | Link icons appear for linked widgets, no eye icons present | | widget labels display renamed values instead of raw names | `widget.label` is displayed when set, not `widget.name` | | linked promoted widget menu should not show Hide/Show input | Three-dot menu on Parameters tab omits Hide/Show options for linked promotions, Rename is still available | ### Unit (Vitest) — `src/core/graph/subgraph/promotionUtils.test.ts` 7 tests covering `isLinkedPromotion`: basic matching, negative cases, nested subgraph with `disambiguatingSourceNodeId`, multiple inputs, and mixed linked/independent state. ### Unit (Vitest) — `src/components/rightSidePanel/parameters/WidgetActions.test.ts` - Added `isSubgraphNode: () => false` to mock nodes to prevent crash from new `isLinked` computed ## Review Focus - `isLinkedPromotion` reads `input._widget` (WeakRef-backed, non-reactive) directly in the template. This is intentional — `_widget` bindings are set during subgraph initialization before the user opens the panel, so stale reads don't occur in practice. A computed-based approach was attempted but reverted because `_widget` changes cannot trigger Vue reactivity. - `getSourceNodeId` removal in `SectionWidgets` and `WidgetActions` is intentional — the old fallback (`?? widget.sourceNodeId`) caused key mismatches with the promotion store for non-nested widgets. ## Screenshots Before <img width="723" height="935" alt="image" src="https://github.com/user-attachments/assets/09862578-a0d1-45b4-929c-f22f7494ebe2" /> After <img width="999" height="952" alt="image" src="https://github.com/user-attachments/assets/ed8fe604-6b44-46b9-a315-6da31d6b405a" />
384 lines
12 KiB
Vue
384 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { storeToRefs } from 'pinia'
|
|
import { computed, provide, ref, watchEffect } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
import EditableText from '@/components/common/EditableText.vue'
|
|
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 { app } from '@/scripts/app'
|
|
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
|
|
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 { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
|
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
|
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
|
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
|
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
|
|
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
|
import { cn } from '@/utils/tailwindUtil'
|
|
import { isGroupNode } from '@/utils/executableGroupNodeDto'
|
|
|
|
import TabInfo from './info/TabInfo.vue'
|
|
import TabGlobalParameters from './parameters/TabGlobalParameters.vue'
|
|
import TabNodes from './parameters/TabNodes.vue'
|
|
import TabNormalInputs from './parameters/TabNormalInputs.vue'
|
|
import TabSubgraphInputs from './parameters/TabSubgraphInputs.vue'
|
|
import TabGlobalSettings from './settings/TabGlobalSettings.vue'
|
|
import TabSettings from './settings/TabSettings.vue'
|
|
import {
|
|
GetNodeParentGroupKey,
|
|
useFlatAndCategorizeSelectedItems
|
|
} from './shared'
|
|
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
|
|
import TabErrors from './errors/TabErrors.vue'
|
|
|
|
const canvasStore = useCanvasStore()
|
|
const executionErrorStore = useExecutionErrorStore()
|
|
const missingModelStore = useMissingModelStore()
|
|
const missingNodesErrorStore = useMissingNodesErrorStore()
|
|
const rightSidePanelStore = useRightSidePanelStore()
|
|
const settingStore = useSettingStore()
|
|
const { t } = useI18n()
|
|
|
|
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionErrorStore)
|
|
|
|
const activeMissingNodeGraphIds = computed<Set<string>>(() => {
|
|
if (!app.isGraphReady) return new Set()
|
|
return getActiveGraphNodeIds(
|
|
app.rootGraph,
|
|
canvasStore.currentGraph ?? app.rootGraph,
|
|
missingNodesErrorStore.missingAncestorExecutionIds
|
|
)
|
|
})
|
|
|
|
const { activeMissingModelGraphIds } = storeToRefs(missingModelStore)
|
|
|
|
const { findParentGroup } = useGraphHierarchy()
|
|
|
|
const { selectedItems: directlySelectedItems } = storeToRefs(canvasStore)
|
|
const { activeTab, isEditingSubgraph } = storeToRefs(rightSidePanelStore)
|
|
|
|
const sidebarLocation = computed<'left' | 'right'>(() =>
|
|
settingStore.get('Comfy.Sidebar.Location')
|
|
)
|
|
|
|
// Panel is on the left when sidebar is on the right, and vice versa
|
|
const panelIcon = computed(() =>
|
|
sidebarLocation.value === 'right'
|
|
? 'icon-[lucide--panel-left]'
|
|
: 'icon-[lucide--panel-right]'
|
|
)
|
|
|
|
const { flattedItems, selectedNodes, selectedGroups, nodeToParentGroup } =
|
|
useFlatAndCategorizeSelectedItems(directlySelectedItems)
|
|
|
|
const shouldShowGroupNames = computed(() => {
|
|
return !(
|
|
directlySelectedItems.value.length === 1 &&
|
|
(selectedGroups.value.length === 1 || selectedNodes.value.length === 1)
|
|
)
|
|
})
|
|
|
|
provide(GetNodeParentGroupKey, (node: LGraphNode) => {
|
|
if (!shouldShowGroupNames.value) return null
|
|
return nodeToParentGroup.value.get(node) ?? findParentGroup(node)
|
|
})
|
|
|
|
const hasSelection = computed(() => flattedItems.value.length > 0)
|
|
|
|
const selectedSingleNode = computed(() => {
|
|
return selectedNodes.value.length === 1 && flattedItems.value.length === 1
|
|
? selectedNodes.value[0]
|
|
: null
|
|
})
|
|
|
|
const isSingleSubgraphNode = computed(() => {
|
|
return selectedSingleNode.value instanceof SubgraphNode
|
|
})
|
|
|
|
function closePanel() {
|
|
rightSidePanelStore.closePanel()
|
|
}
|
|
|
|
type RightSidePanelTabList = Array<{
|
|
label: () => string
|
|
value: RightSidePanelTab
|
|
icon?: string
|
|
}>
|
|
|
|
const hasDirectNodeError = computed(() =>
|
|
selectedNodes.value.some((node) =>
|
|
executionErrorStore.activeGraphErrorNodeIds.has(String(node.id))
|
|
)
|
|
)
|
|
|
|
const hasContainerInternalError = computed(() => {
|
|
if (allErrorExecutionIds.value.length === 0) return false
|
|
return selectedNodes.value.some((node) => {
|
|
if (!(node instanceof SubgraphNode || isGroupNode(node))) return false
|
|
return executionErrorStore.isContainerWithInternalError(node)
|
|
})
|
|
})
|
|
|
|
const hasMissingNodeSelected = computed(
|
|
() =>
|
|
hasSelection.value &&
|
|
selectedNodes.value.some((node) =>
|
|
activeMissingNodeGraphIds.value.has(String(node.id))
|
|
)
|
|
)
|
|
|
|
const hasMissingModelSelected = computed(
|
|
() =>
|
|
hasSelection.value &&
|
|
selectedNodes.value.some((node) =>
|
|
activeMissingModelGraphIds.value.has(String(node.id))
|
|
)
|
|
)
|
|
|
|
const hasRelevantErrors = computed(() => {
|
|
if (!hasSelection.value) return hasAnyError.value
|
|
return (
|
|
hasDirectNodeError.value ||
|
|
hasContainerInternalError.value ||
|
|
hasMissingNodeSelected.value ||
|
|
hasMissingModelSelected.value
|
|
)
|
|
})
|
|
|
|
const tabs = computed<RightSidePanelTabList>(() => {
|
|
const list: RightSidePanelTabList = []
|
|
|
|
if (
|
|
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab') &&
|
|
hasRelevantErrors.value
|
|
) {
|
|
list.push({
|
|
label: () => t('rightSidePanel.errors'),
|
|
value: 'errors',
|
|
icon: 'icon-[lucide--octagon-alert] bg-node-stroke-error ml-1'
|
|
})
|
|
}
|
|
|
|
list.push({
|
|
label: () =>
|
|
flattedItems.value.length > 1
|
|
? t('rightSidePanel.nodes')
|
|
: t('rightSidePanel.parameters'),
|
|
value: 'parameters'
|
|
})
|
|
|
|
if (!hasSelection.value) {
|
|
list.push({
|
|
label: () => t('rightSidePanel.nodes'),
|
|
value: 'nodes'
|
|
})
|
|
}
|
|
|
|
if (hasSelection.value) {
|
|
if (selectedSingleNode.value && !isSingleSubgraphNode.value) {
|
|
list.push({
|
|
label: () => t('rightSidePanel.info'),
|
|
value: 'info'
|
|
})
|
|
}
|
|
}
|
|
|
|
list.push({
|
|
label: () =>
|
|
hasSelection.value
|
|
? t('g.settings')
|
|
: t('rightSidePanel.globalSettings.title'),
|
|
value: 'settings'
|
|
})
|
|
|
|
return list
|
|
})
|
|
|
|
// Use global state for activeTab and ensure it's valid
|
|
watchEffect(() => {
|
|
if (
|
|
!tabs.value.some((tab) => tab.value === activeTab.value) &&
|
|
!(activeTab.value === 'subgraph' && isSingleSubgraphNode.value)
|
|
) {
|
|
rightSidePanelStore.openPanel(tabs.value[0].value)
|
|
}
|
|
})
|
|
|
|
function resolveTitle() {
|
|
const items = flattedItems.value
|
|
const nodes = selectedNodes.value
|
|
const groups = selectedGroups.value
|
|
|
|
if (items.length === 0) {
|
|
return t('rightSidePanel.workflowOverview')
|
|
}
|
|
if (directlySelectedItems.value.length === 1) {
|
|
if (groups.length === 1) {
|
|
return groups[0].title || t('rightSidePanel.fallbackGroupTitle')
|
|
}
|
|
if (nodes.length === 1) {
|
|
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
|
|
return resolveNodeDisplayName(nodes[0], {
|
|
emptyLabel: fallbackNodeTitle,
|
|
untitledLabel: fallbackNodeTitle,
|
|
st
|
|
})
|
|
}
|
|
}
|
|
return t('rightSidePanel.title', { count: items.length })
|
|
}
|
|
|
|
const panelTitle = ref(resolveTitle())
|
|
watchEffect(() => (panelTitle.value = resolveTitle()))
|
|
|
|
const isEditing = ref(false)
|
|
|
|
const allowTitleEdit = computed(() => {
|
|
return (
|
|
directlySelectedItems.value.length === 1 &&
|
|
(selectedGroups.value.length === 1 || selectedNodes.value.length === 1)
|
|
)
|
|
})
|
|
|
|
function handleTitleEdit(newTitle: string) {
|
|
isEditing.value = false
|
|
|
|
const trimmedTitle = newTitle.trim()
|
|
if (!trimmedTitle) return
|
|
|
|
const node = selectedGroups.value[0] || selectedNodes.value[0]
|
|
if (!node) return
|
|
|
|
if (trimmedTitle === node.title) return
|
|
|
|
node.title = trimmedTitle
|
|
panelTitle.value = trimmedTitle
|
|
canvasStore.canvas?.setDirty(true, true)
|
|
}
|
|
|
|
function handleTitleCancel() {
|
|
isEditing.value = false
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
data-testid="properties-panel"
|
|
class="flex size-full flex-col bg-comfy-menu-bg"
|
|
>
|
|
<!-- Panel Header -->
|
|
<section class="pt-1">
|
|
<div class="flex items-center justify-between pr-3 pl-4">
|
|
<h3 class="my-3.5 line-clamp-2 cursor-default text-sm font-semibold">
|
|
<template v-if="allowTitleEdit">
|
|
<EditableText
|
|
:model-value="panelTitle"
|
|
:is-editing="isEditing"
|
|
:input-attrs="{ 'data-testid': 'node-title-input' }"
|
|
class="cursor-text"
|
|
@edit="handleTitleEdit"
|
|
@cancel="handleTitleCancel"
|
|
@click="isEditing = true"
|
|
/>
|
|
<i
|
|
v-if="!isEditing"
|
|
class="relative top-[2px] ml-2 icon-[lucide--pencil] size-4 shrink-0 cursor-pointer content-center text-muted-foreground hover:text-base-foreground"
|
|
@click="isEditing = true"
|
|
/>
|
|
</template>
|
|
<template v-else>
|
|
{{ panelTitle }}
|
|
</template>
|
|
</h3>
|
|
|
|
<div class="flex gap-2">
|
|
<Button
|
|
v-if="isSingleSubgraphNode"
|
|
variant="secondary"
|
|
size="icon"
|
|
data-testid="subgraph-editor-toggle"
|
|
:class="cn(isEditingSubgraph && 'bg-secondary-background-selected')"
|
|
@click="
|
|
rightSidePanelStore.openPanel(
|
|
isEditingSubgraph ? 'parameters' : 'subgraph'
|
|
)
|
|
"
|
|
>
|
|
<i class="icon-[lucide--settings-2]" />
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="icon"
|
|
:aria-pressed="rightSidePanelStore.isOpen"
|
|
:aria-label="t('rightSidePanel.togglePanel')"
|
|
@click="closePanel"
|
|
>
|
|
<i :class="cn(panelIcon, 'size-4')" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<nav class="overflow-x-auto px-4 pt-1 pb-2">
|
|
<TabList
|
|
:model-value="activeTab"
|
|
@update:model-value="
|
|
(newTab: RightSidePanelTab) => {
|
|
rightSidePanelStore.openPanel(newTab)
|
|
}
|
|
"
|
|
>
|
|
<Tab
|
|
v-for="tab in tabs"
|
|
:key="tab.value"
|
|
class="px-2 py-1 font-inter text-sm transition-all active:scale-95"
|
|
:value="tab.value"
|
|
>
|
|
{{ tab.label() }}
|
|
<i
|
|
v-if="tab.icon"
|
|
aria-hidden="true"
|
|
:class="cn(tab.icon, 'size-4')"
|
|
/>
|
|
</Tab>
|
|
</TabList>
|
|
</nav>
|
|
</section>
|
|
|
|
<!-- Panel Content -->
|
|
<div class="scrollbar-thin flex-1 overflow-y-auto">
|
|
<TabErrors v-if="activeTab === 'errors'" />
|
|
<template v-else-if="!hasSelection">
|
|
<TabGlobalParameters v-if="activeTab === 'parameters'" />
|
|
<TabNodes v-else-if="activeTab === 'nodes'" />
|
|
<TabGlobalSettings v-else-if="activeTab === 'settings'" />
|
|
</template>
|
|
<SubgraphEditor
|
|
v-else-if="isSingleSubgraphNode && isEditingSubgraph"
|
|
:node="selectedSingleNode"
|
|
/>
|
|
<template v-else>
|
|
<TabSubgraphInputs
|
|
v-if="activeTab === 'parameters' && isSingleSubgraphNode"
|
|
:node="selectedSingleNode as SubgraphNode"
|
|
/>
|
|
<TabNormalInputs
|
|
v-else-if="activeTab === 'parameters'"
|
|
:nodes="selectedNodes"
|
|
:must-show-node-title="selectedGroups.length > 0"
|
|
/>
|
|
<TabInfo v-else-if="activeTab === 'info'" :nodes="selectedNodes" />
|
|
<TabSettings
|
|
v-else-if="activeTab === 'settings'"
|
|
:nodes="flattedItems"
|
|
/>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|