diff --git a/browser_tests/tests/subgraph/subgraphPromotion.spec.ts b/browser_tests/tests/subgraph/subgraphPromotion.spec.ts
index b7ebc8fea3d..74230749ba7 100644
--- a/browser_tests/tests/subgraph/subgraphPromotion.spec.ts
+++ b/browser_tests/tests/subgraph/subgraphPromotion.spec.ts
@@ -94,6 +94,55 @@ test.describe(
'Promoted Widget Visibility in Vue Mode',
{ tag: ['@vue-nodes'] },
() => {
+ test(
+ 'Promoted advanced widget remains visible when global advanced widgets are disabled',
+ { tag: ['@node'] },
+ async ({ comfyPage }) => {
+ await comfyPage.settings.setSetting(
+ 'Comfy.Node.AlwaysShowAdvancedWidgets',
+ false
+ )
+ const modelSamplingNode = await comfyPage.nodeOps.addNode(
+ 'ModelSamplingFlux',
+ {},
+ { x: 500, y: 200 }
+ )
+ await comfyPage.nextFrame()
+ await expect(
+ comfyPage.vueNodes.getNodeLocator(String(modelSamplingNode.id))
+ ).toBeVisible()
+
+ await modelSamplingNode.click('title')
+ const subgraphNode = await modelSamplingNode.convertToSubgraph()
+ const subgraphNodeId = String(subgraphNode.id)
+
+ await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
+ const interiorNode =
+ comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
+ await expect(interiorNode).toBeVisible()
+ await interiorNode
+ .getByText('Show advanced inputs', { exact: true })
+ .click()
+ await expect(
+ interiorNode.getByLabel('max_shift', { exact: true })
+ ).toBeVisible()
+ await comfyPage.subgraph.promoteWidget(interiorNode, 'max_shift')
+ await comfyPage.subgraph.exitViaBreadcrumb()
+
+ await expectPromotedWidgetNamesToContain(
+ comfyPage,
+ subgraphNodeId,
+ 'max_shift'
+ )
+
+ await expect(
+ comfyPage.vueNodes
+ .getNodeLocator(subgraphNodeId)
+ .getByLabel('max_shift', { exact: true })
+ ).toBeVisible()
+ }
+ )
+
test('Promoted text widget renders and enters the subgraph in Vue mode', async ({
comfyPage
}) => {
diff --git a/src/components/builder/AppModeWidgetList.test.ts b/src/components/builder/AppModeWidgetList.test.ts
new file mode 100644
index 00000000000..2c9c15c895b
--- /dev/null
+++ b/src/components/builder/AppModeWidgetList.test.ts
@@ -0,0 +1,154 @@
+import { createTestingPinia } from '@pinia/testing'
+import { render, screen } from '@testing-library/vue'
+import { fromAny } from '@total-typescript/shoehorn'
+import { setActivePinia } from 'pinia'
+import { createI18n } from 'vue-i18n'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
+import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
+import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
+import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
+import { toNodeId } from '@/types/nodeId'
+import type { WidgetId } from '@/types/widgetId'
+
+import AppModeWidgetList from './AppModeWidgetList.vue'
+
+const mocks = vi.hoisted(() => ({
+ extractVueNodeData: vi.fn(),
+ resolvedInputs: { value: [] as unknown[] }
+}))
+
+vi.mock('@/components/builder/useResolvedSelectedInputs', () => ({
+ useResolvedSelectedInputs: () => mocks.resolvedInputs
+}))
+
+vi.mock('@/composables/graph/useGraphNodeManager', () => ({
+ extractVueNodeData: mocks.extractVueNodeData
+}))
+
+vi.mock('@/composables/maskeditor/useMaskEditor', () => ({
+ useMaskEditor: () => ({ openMaskEditor: vi.fn() })
+}))
+
+vi.mock('@/renderer/core/canvas/canvasStore', () => ({
+ useCanvasStore: () => ({
+ canvas: { graph: { rootGraph: { id: 'graph-test' } } }
+ })
+}))
+
+vi.mock(
+ '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers',
+ () => ({
+ useNodeEventHandlers: () => ({ handleNodeRightClick: vi.fn() })
+ })
+)
+
+vi.mock('@/renderer/extensions/vueNodes/composables/useNodeTooltips', () => ({
+ useNodeTooltips: () => ({
+ createTooltipConfig: () => ({}),
+ getWidgetTooltip: () => ''
+ })
+}))
+
+vi.mock(
+ '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry',
+ () => ({
+ getComponent: () => ({
+ props: ['widget'],
+ template: '
'
+ }),
+ shouldExpand: () => false,
+ shouldRenderAsVue: () => true
+ })
+)
+
+vi.mock('@/scripts/app', () => ({
+ app: {
+ isGraphReady: false,
+ rootGraph: { id: 'graph-test' }
+ }
+}))
+
+const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: {
+ en: {
+ g: { remove: 'Remove', rename: 'Rename' }
+ }
+ }
+})
+
+describe('AppModeWidgetList', () => {
+ beforeEach(() => {
+ const widgetId = 'graph-test:1:max_shift' as WidgetId
+ const widget = fromAny({
+ label: 'Max shift',
+ name: 'max_shift',
+ widgetId
+ })
+ const node = fromAny({
+ id: toNodeId(1),
+ mode: LGraphEventMode.ALWAYS,
+ title: 'Subgraph',
+ type: 'SubgraphNode'
+ })
+ const nodeData: VueNodeData = {
+ executing: false,
+ id: toNodeId(1),
+ inputs: [],
+ mode: LGraphEventMode.ALWAYS,
+ outputs: [],
+ selected: false,
+ title: 'Subgraph',
+ type: 'SubgraphNode',
+ widgets: [
+ {
+ name: 'max_shift',
+ options: { advanced: true },
+ slotMetadata: {
+ index: 0,
+ linked: false,
+ promoted: true,
+ type: 'FLOAT'
+ },
+ type: 'number',
+ widgetId
+ }
+ ]
+ }
+
+ mocks.resolvedInputs.value = [
+ {
+ displayName: 'max_shift',
+ node,
+ status: 'resolved',
+ widget,
+ widgetId
+ }
+ ]
+ mocks.extractVueNodeData.mockReturnValue(nodeData)
+ })
+
+ it('renders a selected promoted advanced widget', () => {
+ const pinia = createTestingPinia({ stubActions: false })
+ setActivePinia(pinia)
+
+ render(AppModeWidgetList, {
+ global: {
+ directives: { tooltip: { mounted: () => {} } },
+ plugins: [pinia, i18n],
+ stubs: {
+ Button: { template: '' },
+ DropZone: { template: '
' },
+ InputSlot: true,
+ Popover: { template: '
' },
+ WidgetDescription: true
+ }
+ }
+ })
+
+ expect(screen.getByTestId('advanced-widget-control')).toBeVisible()
+ })
+})
diff --git a/src/components/builder/AppModeWidgetList.vue b/src/components/builder/AppModeWidgetList.vue
index 61de44ba4e0..914a519c142 100644
--- a/src/components/builder/AppModeWidgetList.vue
+++ b/src/components/builder/AppModeWidgetList.vue
@@ -77,8 +77,11 @@ const mappedSelections = computed((): WidgetEntry[] => {
})
if (!matchingWidget) return []
- matchingWidget.slotMetadata = undefined
- matchingWidget.nodeId = node.id
+ const projectedWidget = {
+ ...matchingWidget,
+ slotMetadata: undefined,
+ nodeId: node.id
+ }
return [
{
@@ -87,7 +90,8 @@ const mappedSelections = computed((): WidgetEntry[] => {
description: config?.description,
nodeData: {
...fullNodeData,
- widgets: [matchingWidget]
+ showAdvanced: true,
+ widgets: [projectedWidget]
},
action: { widget, node }
}
diff --git a/src/composables/graph/useGraphNodeManager.test.ts b/src/composables/graph/useGraphNodeManager.test.ts
index 742f99f0f2e..d50f8a7bf64 100644
--- a/src/composables/graph/useGraphNodeManager.test.ts
+++ b/src/composables/graph/useGraphNodeManager.test.ts
@@ -112,7 +112,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
return { graph, node, upstream, linkId: link.id }
}
- it('sets slotMetadata.linked to true when input has a link', () => {
+ it('identifies a linked regular widget input as unpromoted', () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
@@ -121,6 +121,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
expect(widgetData?.slotMetadata).toBeDefined()
expect(widgetData?.slotMetadata?.linked).toBe(true)
+ expect(widgetData?.slotMetadata?.promoted).toBe(false)
})
it('updates slotMetadata.linked to false after link disconnect event', async () => {
@@ -236,6 +237,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
expect(widgetData).toBeDefined()
expect(widgetData?.sourceWidgetName).toBe('prompt')
expect(widgetData?.slotMetadata).toBeDefined()
+ expect(widgetData?.slotMetadata?.promoted).toBe(true)
})
it('clears stale slotMetadata when input no longer matches widget', async () => {
diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts
index 60e41bbed8e..d0ae72cbb82 100644
--- a/src/composables/graph/useGraphNodeManager.ts
+++ b/src/composables/graph/useGraphNodeManager.ts
@@ -48,6 +48,7 @@ export interface WidgetSlotMetadata {
linked: boolean
originNodeId?: NodeId
originOutputName?: string
+ promoted: boolean
type: string
}
@@ -352,6 +353,7 @@ function buildSlotMetadata(
linked: input.link != null,
originNodeId,
originOutputName,
+ promoted: input.widgetId !== undefined,
type: String(input.type)
}
if (input.name) metadata.set(input.name, slotInfo)
diff --git a/src/lib/litegraph/src/interfaces.ts b/src/lib/litegraph/src/interfaces.ts
index 54be0fc9b13..26e0fcd4193 100644
--- a/src/lib/litegraph/src/interfaces.ts
+++ b/src/lib/litegraph/src/interfaces.ts
@@ -365,6 +365,7 @@ export interface IWidgetLocator {
export interface INodeInputSlot extends INodeSlot {
link: LinkId | null
widget?: IWidgetLocator
+ /** Host-owned promoted widget identity for a subgraph input (ADR 0009). */
widgetId?: WidgetId
alwaysVisible?: boolean
diff --git a/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts b/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts
index b0fd2733012..771d5cda45f 100644
--- a/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts
+++ b/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.test.ts
@@ -165,11 +165,11 @@ describe('isWidgetVisible', () => {
expect(isWidgetVisible({ advanced: true }, true)).toBe(true)
})
- it('keeps advanced widgets visible when linked and showAdvanced is false', () => {
+ it('keeps advanced widgets visible when advanced filtering is ignored', () => {
expect(isWidgetVisible({ advanced: true }, false, true)).toBe(true)
})
- it('keeps hidden widgets hidden when linked', () => {
+ it('keeps hidden widgets hidden when advanced filtering is ignored', () => {
expect(isWidgetVisible({ hidden: true }, false, true)).toBe(false)
})
})
@@ -408,6 +408,30 @@ describe('computeProcessedWidgets missing media', () => {
})
})
+describe('computeProcessedWidgets visibility', () => {
+ it('keeps a promoted advanced widget visible without source metadata', () => {
+ const widget = createMockWidget({
+ name: 'max_shift',
+ type: 'number',
+ options: { advanced: true },
+ slotMetadata: {
+ index: 0,
+ linked: false,
+ promoted: true,
+ type: 'FLOAT'
+ },
+ sourceExecutionId: undefined,
+ sourceWidgetName: undefined
+ })
+
+ const promotedWidget = processWidgets([widget])[0]
+
+ expect(promotedWidget).toBeDefined()
+ expect(promotedWidget?.visible).toBe(true)
+ expect(promotedWidget?.simplified.borderStyle).toBeDefined()
+ })
+})
+
describe('computeProcessedWidgets borderStyle', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
diff --git a/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts b/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts
index 664280d3cb2..5dd7ad6a9b3 100644
--- a/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts
+++ b/src/renderer/extensions/vueNodes/composables/useProcessedWidgets.ts
@@ -213,11 +213,11 @@ function getWidgetNodeLocatorId(
export function isWidgetVisible(
options: IWidgetOptions,
showAdvanced: boolean,
- linked = false
+ ignoreAdvanced = false
): boolean {
const hidden = options.hidden ?? false
const advanced = options.advanced ?? false
- return !hidden && (!advanced || showAdvanced || linked)
+ return !hidden && (!advanced || showAdvanced || ignoreAdvanced)
}
export function computeProcessedWidgets({
@@ -275,7 +275,7 @@ export function computeProcessedWidgets({
const visible = isWidgetVisible(
mergedOptions,
showAdvanced,
- widget.slotMetadata?.linked
+ widget.slotMetadata?.linked || widget.slotMetadata?.promoted
)
if (!identity.dedupeIdentity) {
uniqueWidgets.push({