From 24cbf4f68cbeeb21f6cb7f656bf309d6b30302fe Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:05:09 -0800 Subject: [PATCH 1/8] add support for dynamic combos on subgraphs --- src/core/graph/subgraph/proxyWidget.ts | 29 +++- src/core/graph/subgraph/proxyWidgetUtils.ts | 169 +++++++++++++++++--- src/core/graph/widgets/dynamicWidgets.ts | 16 ++ src/lib/litegraph/src/types/widgets.ts | 10 ++ 4 files changed, 204 insertions(+), 20 deletions(-) diff --git a/src/core/graph/subgraph/proxyWidget.ts b/src/core/graph/subgraph/proxyWidget.ts index 60f6d456aa5..075faeee1b2 100644 --- a/src/core/graph/subgraph/proxyWidget.ts +++ b/src/core/graph/subgraph/proxyWidget.ts @@ -1,5 +1,6 @@ import { demoteWidget, + isDynamicComboChild, promoteRecommendedWidgets } from '@/core/graph/subgraph/proxyWidgetUtils' import { parseProxyWidgets } from '@/core/schemas/proxyWidget' @@ -37,6 +38,10 @@ type Overlay = Partial & { widgetName: string isProxyWidget: boolean node?: LGraphNode + /** Hidden state for disconnected dynamic combo children */ + hidden?: boolean + /** Flag to trigger re-resolution when source node's widgets change */ + needsResolve?: boolean } // A ProxyWidget can be treated like a normal widget. // the _overlay property can be used to directly access the Overlay object @@ -169,7 +174,7 @@ function resolveLinkedWidget( const n = getNodeByExecutionId(graph, nodeId) if (!n) return [undefined, undefined] const widget = n.widgets?.find((w: IBaseWidget) => w.name === widgetName) - //Slightly hacky. Force recursive resolution of nested widgets + // Slightly hacky. Force recursive resolution of nested widgets if (widget && isProxyWidget(widget) && isDisconnectedWidget(widget)) widget.computedHeight = 20 return [n, widget] @@ -188,6 +193,18 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) { } }) } + + function updateHiddenState() { + const shouldHide = + backingWidget === disconnectedWidget && + linkedNode !== undefined && + isDynamicComboChild(linkedNode, overlay.widgetName) + if (overlay.hidden !== shouldHide) { + overlay.hidden = shouldHide + subgraphNode.setDirtyCanvas(true, true) + } + } + /** * A set of handlers which define widget interaction * Many arguments are shared between function calls @@ -201,6 +218,13 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) { */ const handler = { get(_t: IBaseWidget, property: string, receiver: object) { + // Re-resolve when marked dirty (source node's widgets changed) + if (property === 'hidden' && overlay.needsResolve) { + ;[linkedNode, linkedWidget] = resolveLinkedWidget(overlay) + backingWidget = linkedWidget ?? disconnectedWidget + overlay.needsResolve = false + updateHiddenState() + } let redirectedTarget: object = backingWidget let redirectedReceiver = receiver if (property == '_overlay') return overlay @@ -220,9 +244,10 @@ function newProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) { if (linkedNode && linkedWidget?.computedDisabled) { demoteWidget(linkedNode, linkedWidget, [subgraphNode]) } - //update linkage regularly, but no more than once per frame + // Update linkage regularly, but no more than once per frame ;[linkedNode, linkedWidget] = resolveLinkedWidget(overlay) backingWidget = linkedWidget ?? disconnectedWidget + updateHiddenState() } if (Object.prototype.hasOwnProperty.call(overlay, property)) { redirectedTarget = overlay diff --git a/src/core/graph/subgraph/proxyWidgetUtils.ts b/src/core/graph/subgraph/proxyWidgetUtils.ts index eafb0f1dd4e..9f50a8840ff 100644 --- a/src/core/graph/subgraph/proxyWidgetUtils.ts +++ b/src/core/graph/subgraph/proxyWidgetUtils.ts @@ -16,31 +16,106 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useLitegraphService } from '@/services/litegraphService' import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore' -type PartialNode = Pick +type PartialNode = Pick export type WidgetItem = [PartialNode, IBaseWidget] function getProxyWidgets(node: SubgraphNode) { return parseProxyWidgets(node.properties.proxyWidgets) } -export function promoteWidget( + +/** + * Find all child widgets of a dynamic combo parent by name. + */ +function getChildWidgets( node: PartialNode, - widget: IBaseWidget, + parentWidgetName: string +): IBaseWidget[] { + return ( + node.widgets?.filter((w) => w.dynamicWidgetParent === parentWidgetName) ?? + [] + ) +} + +/** + * Check if a widget is a child of a dynamic combo root. + */ +export function isDynamicComboChild( + node: LGraphNode, + widgetName: string +): boolean { + const widget = node.widgets?.find((w) => w.name === widgetName) + if (widget) return !!widget.dynamicWidgetParent + + // Widget doesn't exist (disconnected) - parse name to find parent + // because the widget doesnt exist, we dont have any concrete flag for if + // this is a child of a dynamic combo, so we need to parse the name to find the parent + const dotIndex = widgetName.indexOf('.') + if (dotIndex === -1) return false + const parentName = widgetName.slice(0, dotIndex) + const parentWidget = node.widgets?.find((w) => w.name === parentName) + return !!parentWidget?.dynamicWidgetRoot +} + +/** + * Check if a widget is a child of a promoted dynamic combo. + */ +function isChildOfPromotedDynamicCombo( + node: LGraphNode, + widget: IBaseWidget +): boolean { + if (!widget.dynamicWidgetParent) return false + const parentWidget = node.widgets?.find( + (w) => w.name === widget.dynamicWidgetParent + ) + return !!parentWidget?.promoted +} + +/** + * Get a widget and all its dynamic combo children (if it's a root). + */ +function getWidgetWithChildren( + node: PartialNode, + widget: IBaseWidget +): IBaseWidget[] { + const widgets = [widget] + if (widget.dynamicWidgetRoot && node.widgets) { + widgets.push(...getChildWidgets(node, widget.name)) + } + return widgets +} + +/** + * Batch promote multiple widgets to proxy on all parent SubgraphNodes. + * Only adds widgets that don't already exist in proxyWidgets. + */ +function promoteWidgetsToProxy( + node: PartialNode, + widgets: IBaseWidget[], parents: SubgraphNode[] ) { for (const parent of parents) { - const existingProxyWidgets = getProxyWidgets(parent) - // Prevent duplicate promotion - if (existingProxyWidgets.some(matchesPropertyItem([node, widget]))) { - continue - } - const proxyWidgets = [ - ...existingProxyWidgets, - widgetItemToProperty([node, widget]) + const existing = getProxyWidgets(parent) + const toAdd = widgets.filter( + (w) => !existing.some(matchesPropertyItem([node, w])) + ) + if (!toAdd.length) continue + parent.properties.proxyWidgets = [ + ...existing, + ...toAdd.map((w) => widgetItemToProperty([node, w])) ] - parent.properties.proxyWidgets = proxyWidgets } - widget.promoted = true + for (const w of widgets) { + w.promoted = true + } +} + +export function promoteWidget( + node: PartialNode, + widget: IBaseWidget, + parents: SubgraphNode[] +) { + promoteWidgetsToProxy(node, getWidgetWithChildren(node, widget), parents) } export function demoteWidget( @@ -48,13 +123,17 @@ export function demoteWidget( widget: IBaseWidget, parents: SubgraphNode[] ) { + const widgetsToDemote = getWidgetWithChildren(node, widget) for (const parent of parents) { const proxyWidgets = getProxyWidgets(parent).filter( - (widgetItem) => !matchesPropertyItem([node, widget])(widgetItem) + (widgetItem) => + !widgetsToDemote.some((w) => matchesPropertyItem([node, w])(widgetItem)) ) parent.properties.proxyWidgets = proxyWidgets } - widget.promoted = false + for (const w of widgetsToDemote) { + w.promoted = false + } } export function matchesWidgetItem([nodeId, widgetName]: [string, string]) { @@ -68,7 +147,56 @@ export function widgetItemToProperty([n, w]: WidgetItem): [string, string] { return [`${n.id}`, w.name] } -function getParentNodes(): SubgraphNode[] { +/** + * Get all SubgraphNodes that contain the given node's graph. + * Returns empty array if node is in root graph or graph is undefined. + */ +function getSubgraphParents(node: LGraphNode): SubgraphNode[] { + const graph = node.graph + if (!graph || graph.isRootGraph) return [] + + return graph.rootGraph.nodes.filter( + (n): n is SubgraphNode => n.type === graph.id && n.isSubgraphNode() + ) +} + +/** + * Mark proxy widgets pointing to this node as needing re-checking. + * Called when a node's widgets change (e.g., dynamic combo value change). + */ +export function invalidateProxyWidgetsForNode(node: LGraphNode) { + const parents = getSubgraphParents(node) + const nodeId = `${node.id}` + + for (const parent of parents) { + for (const widget of parent.widgets) { + if (isProxyWidget(widget) && widget._overlay.nodeId === nodeId) { + widget._overlay.needsResolve = true + } + } + } +} + +/** + * Auto-promote child widgets of a dynamic combo when the parent is promoted. + */ +export function autoPromoteDynamicChildren( + node: LGraphNode, + parentWidget: IBaseWidget +) { + if (!parentWidget.promoted) return + + const parents = getSubgraphParents(node) + if (!parents.length) return + + const childWidgets = getChildWidgets(node, parentWidget.name) + promoteWidgetsToProxy(node, childWidgets, parents) +} + +/** + * Get parent SubgraphNodes based on current navigation context. + */ +export function getParentNodes(): SubgraphNode[] { //NOTE: support for determining parents of a subgraph is limited //This function will require rework to properly support linked subgraphs //Either by including actual parents in the navigation stack, @@ -108,6 +236,8 @@ export function addWidgetPromotionOptions( } }) else { + if (isChildOfPromotedDynamicCombo(node, widget)) return + options.unshift({ content: `Un-Promote Widget: ${widget.label ?? widget.name}`, callback: () => { @@ -127,9 +257,12 @@ export function tryToggleWidgetPromotion() { const promotableParents = parents.filter( (s) => !getProxyWidgets(s).some(matchesPropertyItem([node, widget])) ) - if (promotableParents.length > 0) + if (promotableParents.length > 0) { promoteWidget(node, widget, promotableParents) - else demoteWidget(node, widget, parents) + } else { + if (isChildOfPromotedDynamicCombo(node, widget)) return + demoteWidget(node, widget, parents) + } } const recommendedNodes = [ 'CLIPTextEncode', diff --git a/src/core/graph/widgets/dynamicWidgets.ts b/src/core/graph/widgets/dynamicWidgets.ts index 7e3f7fd71b5..86c335470a2 100644 --- a/src/core/graph/widgets/dynamicWidgets.ts +++ b/src/core/graph/widgets/dynamicWidgets.ts @@ -2,6 +2,10 @@ import { remove } from 'es-toolkit' import { shallowReactive } from 'vue' import { useChainCallback } from '@/composables/functional/useChainCallback' +import { + autoPromoteDynamicChildren, + invalidateProxyWidgetsForNode +} from '@/core/graph/subgraph/proxyWidgetUtils' import type { ISlotType, INodeInputSlot, @@ -126,7 +130,13 @@ function dynamicComboWidget( ensureWidgetForInput(node, newInput) } } + + const childWidgets = node.widgets!.filter(isInGroup) + for (const child of childWidgets) { + child.dynamicWidgetParent = widget.name + } }) + widget.dynamicWidgetRoot = true const inputInsertionPoint = node.inputs.findIndex((i) => i.name === widget.name) + 1 @@ -179,6 +189,12 @@ function dynamicComboWidget( if (!node.graph) return node._setConcreteSlots() node.arrange() + + // Auto-promote new child widgets if parent dynamic combo is promoted + autoPromoteDynamicChildren(node, widget) + // Mark proxy widgets as needing re-checking for hidden state + invalidateProxyWidgetsForNode(node) + app.canvas?.setDirty(true, true) } //A little hacky, but onConfigure won't work. diff --git a/src/lib/litegraph/src/types/widgets.ts b/src/lib/litegraph/src/types/widgets.ts index d3c249ba36a..12a21e37203 100644 --- a/src/lib/litegraph/src/types/widgets.ts +++ b/src/lib/litegraph/src/types/widgets.ts @@ -361,6 +361,16 @@ export interface IBaseWidget< tooltip?: string + /** + * If true, this widget is a dynamic combo root that can have child widgets. + */ + dynamicWidgetRoot?: boolean + + /** + * The name of the parent dynamic combo widget that owns this child widget. + */ + dynamicWidgetParent?: string + // TODO: Confirm this format callback?( value: unknown, From e7e26ce28bc104441c2f7fadf200767290904a09 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:26:45 -0800 Subject: [PATCH 2/8] Add tests Fix bug on reload with promote flag not set --- browser_tests/fixtures/ComfyPage.ts | 42 ++- .../fixtures/components/PropertiesPanel.ts | 97 ++++++ browser_tests/fixtures/components/Topbar.ts | 5 + .../fixtures/utils/litegraphUtils.ts | 60 +++- .../tests/dynamicWidgetsSubgraph.spec.ts | 322 ++++++++++++++++++ src/core/graph/subgraph/proxyWidgetUtils.ts | 17 +- 6 files changed, 525 insertions(+), 18 deletions(-) create mode 100644 browser_tests/fixtures/components/PropertiesPanel.ts create mode 100644 browser_tests/tests/dynamicWidgetsSubgraph.spec.ts diff --git a/browser_tests/fixtures/ComfyPage.ts b/browser_tests/fixtures/ComfyPage.ts index fe439af207a..a0901b5b562 100644 --- a/browser_tests/fixtures/ComfyPage.ts +++ b/browser_tests/fixtures/ComfyPage.ts @@ -13,6 +13,7 @@ import { ComfyTemplates } from '../helpers/templates' import { ComfyMouse } from './ComfyMouse' import { VueNodeHelpers } from './VueNodeHelpers' import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox' +import { PropertiesPanel } from './components/PropertiesPanel' import { SettingDialog } from './components/SettingDialog' import { NodeLibrarySidebarTab, @@ -26,32 +27,20 @@ dotenv.config() type WorkspaceStore = ReturnType -class ComfyPropertiesPanel { - readonly root: Locator - readonly panelTitle: Locator - readonly searchBox: Locator - - constructor(readonly page: Page) { - this.root = page.getByTestId('properties-panel') - this.panelTitle = this.root.locator('h3') - this.searchBox = this.root.getByPlaceholder('Search...') - } -} - class ComfyMenu { private _nodeLibraryTab: NodeLibrarySidebarTab | null = null private _workflowsTab: WorkflowsSidebarTab | null = null private _topbar: Topbar | null = null public readonly sideToolbar: Locator - public readonly propertiesPanel: ComfyPropertiesPanel + public readonly propertiesPanel: PropertiesPanel public readonly themeToggleButton: Locator public readonly saveButton: Locator constructor(public readonly page: Page) { this.sideToolbar = page.locator('.side-tool-bar-container') this.themeToggleButton = page.locator('.comfy-vue-theme-toggle') - this.propertiesPanel = new ComfyPropertiesPanel(page) + this.propertiesPanel = new PropertiesPanel(page) this.saveButton = page .locator('button[title="Save the current workflow"]') .nth(0) @@ -1583,6 +1572,31 @@ export class ComfyPage { return window['app'].graph.nodes }) } + + async isInSubgraph(): Promise { + return await this.page.evaluate(() => { + const graph = window['app'].canvas.graph + return graph?.constructor?.name === 'Subgraph' + }) + } + + async createNode( + nodeType: string, + position: Position = { x: 200, y: 200 } + ): Promise { + const nodeId = await this.page.evaluate( + ({ nodeType, pos }) => { + const node = window['LiteGraph'].createNode(nodeType) + if (!node) throw new Error(`Failed to create node: ${nodeType}`) + window['app'].graph.add(node) + node.pos = [pos.x, pos.y] + return node.id + }, + { nodeType, pos: position } + ) + await this.nextFrame() + return this.getNodeRefById(nodeId) + } async waitForGraphNodes(count: number) { await this.page.waitForFunction((count) => { return window['app']?.canvas.graph?.nodes?.length === count diff --git a/browser_tests/fixtures/components/PropertiesPanel.ts b/browser_tests/fixtures/components/PropertiesPanel.ts new file mode 100644 index 00000000000..05782495e60 --- /dev/null +++ b/browser_tests/fixtures/components/PropertiesPanel.ts @@ -0,0 +1,97 @@ +import type { Locator, Page } from '@playwright/test' + +export class PropertiesPanel { + readonly root: Locator + readonly panelTitle: Locator + readonly searchBox: Locator + + constructor(readonly page: Page) { + this.root = page.getByTestId('properties-panel') + this.panelTitle = this.root.locator('h3') + this.searchBox = this.root.getByPlaceholder('Search...') + } + + async ensureOpen() { + const isOpen = await this.root.isVisible() + if (!isOpen) { + await this.page.getByLabel('Toggle properties panel').click() + await this.root.waitFor({ state: 'visible' }) + } + } + + async close() { + const isOpen = await this.root.isVisible() + if (isOpen) { + await this.page.getByLabel('Toggle properties panel').click() + await this.root.waitFor({ state: 'hidden' }) + } + } + + async promoteWidget(widgetName: string) { + await this.ensureOpen() + + // Click on Advanced Inputs to expand it + const advancedInputsButton = this.root + .getByRole('button') + .filter({ hasText: /advanced inputs/i }) + await advancedInputsButton.click() + + // Find the widget row and click the more options button + const widgetRow = this.root + .locator('[class*="widget-item"], [class*="input-item"]') + .filter({ hasText: widgetName }) + .first() + + const moreButton = widgetRow.locator('button').filter({ + has: this.page.locator('[class*="lucide--more-vertical"]') + }) + await moreButton.click() + + // Click "Show input" to promote the widget + await this.page.getByText('Show input').click() + + // Close and reopen panel to refresh the UI state + await this.page.getByLabel('Toggle properties panel').click() + await this.page.getByLabel('Toggle properties panel').click() + } + + async demoteWidget(widgetName: string) { + await this.ensureOpen() + + // Check if INPUTS section content is already visible + const inputsContent = this.root.locator('div').filter({ + hasText: new RegExp(`^${widgetName}$`) + }) + const isInputsExpanded = await inputsContent.first().isVisible() + + if (!isInputsExpanded) { + // Click on INPUTS section to expand it (where promoted widgets appear) + const inputsButton = this.root + .getByRole('button') + .filter({ hasText: /^inputs$/i }) + await inputsButton.click() + } + + // Find the widget row and click the more options button + const widgetRow = this.root + .locator('div') + .filter({ hasText: new RegExp(`^${widgetName}$`) }) + .first() + + await widgetRow.waitFor({ state: 'visible', timeout: 5000 }) + + // Find the more options button (the vertical dots icon button) + const moreButton = widgetRow + .locator('..') + .locator('button') + .filter({ + has: this.page.locator('[class*="more-vertical"], [class*="lucide"]') + }) + .first() + + await moreButton.click() + + // Click "Hide input" to demote the widget + await this.page.getByText('Hide input').click() + } +} diff --git a/browser_tests/fixtures/components/Topbar.ts b/browser_tests/fixtures/components/Topbar.ts index c5e7c81550b..49758b17202 100644 --- a/browser_tests/fixtures/components/Topbar.ts +++ b/browser_tests/fixtures/components/Topbar.ts @@ -60,6 +60,11 @@ export class Topbar { await tab.locator('.close-button').click({ force: true }) } + async switchToTab(index: number) { + const tabs = this.page.locator('.workflow-tabs button') + await tabs.nth(index).click() + } + getSaveDialog(): Locator { return this.page.locator('.p-dialog-content input') } diff --git a/browser_tests/fixtures/utils/litegraphUtils.ts b/browser_tests/fixtures/utils/litegraphUtils.ts index ea5a0b78f3a..d804f7720a6 100644 --- a/browser_tests/fixtures/utils/litegraphUtils.ts +++ b/browser_tests/fixtures/utils/litegraphUtils.ts @@ -263,6 +263,26 @@ class NodeWidgetReference { [this.node.id, this.index] as const ) } + + async setValue(value: unknown, useCanvasGraph = false) { + await this.node.comfyPage.page.evaluate( + ([id, index, val, useCanvas]) => { + const graph = useCanvas + ? window['app'].canvas.graph + : window['app'].graph + const node = graph.getNodeById(id) + if (!node) throw new Error(`Node ${id} not found.`) + const widget = node.widgets[index] + if (!widget) throw new Error(`Widget ${index} not found.`) + widget.value = val + if (widget.callback) { + widget.callback(val, window['app'].canvas, node, null, null) + } + }, + [this.node.id, this.index, value, useCanvasGraph] as const + ) + await this.node.comfyPage.nextFrame() + } } export class NodeReference { constructor( @@ -339,8 +359,43 @@ export class NodeReference { async getWidget(index: number) { return new NodeWidgetReference(index, this) } + + async getWidgetByName( + name: string, + useCanvasGraph = false + ): Promise { + const index = await this.comfyPage.page.evaluate( + ([id, widgetName, useCanvas]) => { + const graph = useCanvas + ? window['app'].canvas.graph + : window['app'].graph + const node = graph.getNodeById(id) + if (!node?.widgets) return -1 + return node.widgets.findIndex( + (w: { name: string }) => w.name === widgetName + ) + }, + [this.id, name, useCanvasGraph] as const + ) + if (index === -1) return null + return new NodeWidgetReference(index, this) + } + + async getWidgets(): Promise< + Array<{ name: string; visible: boolean; value: unknown }> + > { + return await this.comfyPage.page.evaluate((id) => { + const node = window['app'].graph.getNodeById(id) + if (!node?.widgets) return [] + + return node.widgets.map((w) => { + const isHidden = w.hidden === true || w.options?.hidden === true + return { name: w.name, visible: !isHidden, value: w.value } + }) + }, this.id) + } async click( - position: 'title' | 'collapse', + position: 'title' | 'collapse' | 'subgraph', options?: Parameters[1] & { moveMouseToEmptyArea?: boolean } ) { const nodePos = await this.getPosition() @@ -353,6 +408,9 @@ export class NodeReference { case 'collapse': clickPos = { x: nodePos.x + 5, y: nodePos.y - 10 } break + case 'subgraph': + clickPos = { x: nodePos.x + nodeSize.width - 15, y: nodePos.y - 15 } + break default: throw new Error(`Invalid click position ${position}`) } diff --git a/browser_tests/tests/dynamicWidgetsSubgraph.spec.ts b/browser_tests/tests/dynamicWidgetsSubgraph.spec.ts new file mode 100644 index 00000000000..b4f992143c5 --- /dev/null +++ b/browser_tests/tests/dynamicWidgetsSubgraph.spec.ts @@ -0,0 +1,322 @@ +import { expect } from '@playwright/test' + +import type { ComfyPage } from '../fixtures/ComfyPage' +import { comfyPageFixture as test } from '../fixtures/ComfyPage' + +test.describe('Dynamic Combo Widgets in Subgraphs', () => { + const TEST_NODE_TYPE = 'TestDynamicComboNode' + + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Top') + await comfyPage.setSetting('Comfy.Workflow.WorkflowTabsPosition', 'Topbar') + await comfyPage.setSetting('Comfy.ConfirmClear', false) + }) + + function subgraphWidgetName(widgetName: string): string { + return `1: ${widgetName}` + } + + function widget(name: string, visible: boolean, value: unknown) { + return { + name: subgraphWidgetName(name), + visible, + value + } + } + + async function clearGraph(comfyPage: ComfyPage) { + await comfyPage.executeCommand('Comfy.ClearWorkflow') + await comfyPage.nextFrame() + } + + async function getSubgraphNode(comfyPage: ComfyPage) { + const nodes = await comfyPage.getNodeRefsByTitle('New Subgraph') + return nodes[0] + } + + async function createTestNodeAsSubgraph( + comfyPage: ComfyPage, + mode: 'none' | 'one' | 'two' | 'three' = 'none' + ) { + const testNode = await comfyPage.createNode(TEST_NODE_TYPE) + + if (mode !== 'none') { + const widget = await testNode.getWidgetByName('dynamic_combo') + if (widget) await widget.setValue(mode) + } + + await testNode.click('title') + await comfyPage.nextFrame() + + return await testNode.convertToSubgraph() + } + + test('Promoted dynamic combo promotes all children with it', async ({ + comfyPage + }) => { + await clearGraph(comfyPage) + + const subgraphNode = await createTestNodeAsSubgraph(comfyPage, 'two') + + await subgraphNode.click('title') + await comfyPage.nextFrame() + await comfyPage.menu.propertiesPanel.promoteWidget('dynamic_combo') + + expect(await subgraphNode.getWidgets()).toEqual([ + widget('dynamic_combo', true, 'two'), + widget('dynamic_combo.w1', true, 0), + widget('dynamic_combo.w2', true, 0) + ]) + }) + + test('Demoted dynamic combo unpromotes all children with it', async ({ + comfyPage + }) => { + await clearGraph(comfyPage) + + const subgraphNode = await createTestNodeAsSubgraph(comfyPage, 'two') + + await subgraphNode.click('title') + await comfyPage.nextFrame() + await comfyPage.menu.propertiesPanel.promoteWidget('dynamic_combo') + + expect(await subgraphNode.getWidgets()).toEqual([ + widget('dynamic_combo', true, 'two'), + widget('dynamic_combo.w1', true, 0), + widget('dynamic_combo.w2', true, 0) + ]) + + await comfyPage.menu.propertiesPanel.demoteWidget('dynamic_combo') + + const widgets = await subgraphNode.getWidgets() + const visibleWidgets = widgets.filter((w) => w.visible) + expect(visibleWidgets).toEqual([]) + }) + + test('Promoted combo widgets hide and show based on combo value', async ({ + comfyPage + }) => { + await clearGraph(comfyPage) + + const subgraphNode = await createTestNodeAsSubgraph(comfyPage, 'none') + + await subgraphNode.click('title') + await comfyPage.nextFrame() + await comfyPage.menu.propertiesPanel.promoteWidget('dynamic_combo') + + expect(await subgraphNode.getWidgets()).toEqual([ + widget('dynamic_combo', true, 'none') + ]) + + const comboWidget = await subgraphNode.getWidgetByName( + subgraphWidgetName('dynamic_combo') + ) + + await comboWidget!.setValue('one') + expect(await subgraphNode.getWidgets()).toEqual([ + widget('dynamic_combo', true, 'one'), + widget('dynamic_combo.w1', true, 0) + ]) + + await comboWidget!.setValue('two') + expect(await subgraphNode.getWidgets()).toEqual([ + widget('dynamic_combo', true, 'two'), + widget('dynamic_combo.w1', true, 0), + widget('dynamic_combo.w2', true, 0) + ]) + + await comboWidget!.setValue('three') + expect(await subgraphNode.getWidgets()).toEqual([ + widget('dynamic_combo', true, 'three'), + widget('dynamic_combo.w1', true, 0), + widget('dynamic_combo.w2', true, 0), + widget('dynamic_combo.w3', true, 0) + ]) + + await comboWidget!.setValue('two') + expect(await subgraphNode.getWidgets()).toEqual([ + widget('dynamic_combo', true, 'two'), + widget('dynamic_combo.w1', true, 0), + widget('dynamic_combo.w2', true, 0), + widget('dynamic_combo.w3', false, undefined) + ]) + + await comboWidget!.setValue('one') + expect(await subgraphNode.getWidgets()).toEqual([ + widget('dynamic_combo', true, 'one'), + widget('dynamic_combo.w1', true, 0), + widget('dynamic_combo.w2', false, undefined), + widget('dynamic_combo.w3', false, undefined) + ]) + + await comboWidget!.setValue('none') + expect(await subgraphNode.getWidgets()).toEqual([ + widget('dynamic_combo', true, 'none'), + widget('dynamic_combo.w1', false, undefined), + widget('dynamic_combo.w2', false, undefined), + widget('dynamic_combo.w3', false, undefined) + ]) + }) + + test('Promoted combo maintains state after workflow reload', async ({ + comfyPage + }) => { + await clearGraph(comfyPage) + + const subgraphNode = await createTestNodeAsSubgraph(comfyPage, 'two') + + await subgraphNode.click('title') + await comfyPage.nextFrame() + + await comfyPage.menu.propertiesPanel.promoteWidget('dynamic_combo') + + const w1 = await subgraphNode.getWidgetByName( + subgraphWidgetName('dynamic_combo.w1') + ) + const w2 = await subgraphNode.getWidgetByName( + subgraphWidgetName('dynamic_combo.w2') + ) + await w1!.setValue(123) + await w2!.setValue(456) + + expect(await subgraphNode.getWidgets()).toEqual([ + widget('dynamic_combo', true, 'two'), + widget('dynamic_combo.w1', true, 123), + widget('dynamic_combo.w2', true, 456) + ]) + + // Click on node to ensure changes are committed before switching + await subgraphNode.click('title') + await comfyPage.nextFrame() + + await comfyPage.executeCommand('Comfy.NewBlankWorkflow') + await comfyPage.nextFrame() + await comfyPage.menu.topbar.switchToTab(0) + await comfyPage.nextFrame() + + const reloadedSubgraph = await getSubgraphNode(comfyPage) + expect(await reloadedSubgraph.getWidgets()).toEqual([ + widget('dynamic_combo', true, 'two'), + widget('dynamic_combo.w1', true, 123), + widget('dynamic_combo.w2', true, 456) + ]) + }) + + test('Hidden children remain hidden after workflow reload when combo is none', async ({ + comfyPage + }) => { + await clearGraph(comfyPage) + + const subgraphNode = await createTestNodeAsSubgraph(comfyPage, 'two') + + await subgraphNode.click('title') + await comfyPage.nextFrame() + await comfyPage.menu.propertiesPanel.promoteWidget('dynamic_combo') + + const comboWidget = await subgraphNode.getWidgetByName( + subgraphWidgetName('dynamic_combo') + ) + await comboWidget!.setValue('none') + + expect(await subgraphNode.getWidgets()).toEqual([ + widget('dynamic_combo', true, 'none'), + widget('dynamic_combo.w1', false, undefined), + widget('dynamic_combo.w2', false, undefined) + ]) + + // Click on node to ensure changes are committed before switching + await subgraphNode.click('title') + await comfyPage.nextFrame() + + await comfyPage.executeCommand('Comfy.NewBlankWorkflow') + await comfyPage.nextFrame() + await comfyPage.menu.topbar.switchToTab(0) + await comfyPage.nextFrame() + + const reloadedSubgraph = await getSubgraphNode(comfyPage) + expect(await reloadedSubgraph.getWidgets()).toEqual([ + widget('dynamic_combo', true, 'none'), + widget('dynamic_combo.w1', false, undefined), + widget('dynamic_combo.w2', false, undefined) + ]) + }) + + test('Children appear when combo changes after workflow reload', async ({ + comfyPage + }) => { + await clearGraph(comfyPage) + + const subgraphNode = await createTestNodeAsSubgraph(comfyPage, 'none') + + await subgraphNode.click('title') + await comfyPage.nextFrame() + await comfyPage.menu.propertiesPanel.promoteWidget('dynamic_combo') + + expect(await subgraphNode.getWidgets()).toEqual([ + widget('dynamic_combo', true, 'none') + ]) + + await comfyPage.executeCommand('Comfy.NewBlankWorkflow') + await comfyPage.nextFrame() + await comfyPage.menu.topbar.switchToTab(0) + await comfyPage.nextFrame() + + const reloadedSubgraph = await getSubgraphNode(comfyPage) + const comboWidget = await reloadedSubgraph.getWidgetByName( + subgraphWidgetName('dynamic_combo') + ) + await comboWidget!.setValue('two') + await comfyPage.page.waitForTimeout(500) + + expect(await reloadedSubgraph.getWidgets()).toEqual([ + widget('dynamic_combo', true, 'two'), + widget('dynamic_combo.w1', true, 0), + widget('dynamic_combo.w2', true, 0) + ]) + }) + + test('Dynamic combo children created inside subgraph are auto-promoted', async ({ + comfyPage + }) => { + await clearGraph(comfyPage) + + const testNode = await comfyPage.createNode(TEST_NODE_TYPE) + await testNode.click('title') + await comfyPage.nextFrame() + + const subgraphNode = await testNode.convertToSubgraph() + await comfyPage.page.waitForTimeout(500) + + await subgraphNode.click('title') + await comfyPage.nextFrame() + await comfyPage.menu.propertiesPanel.promoteWidget('dynamic_combo') + + expect(await subgraphNode.getWidgets()).toEqual([ + widget('dynamic_combo', true, 'none') + ]) + + await subgraphNode.click('subgraph') + await expect + .poll(() => comfyPage.isInSubgraph(), { timeout: 5000 }) + .toBe(true) + + const innerNodes = await comfyPage.getNodeRefsByType(TEST_NODE_TYPE, true) + const innerNode = innerNodes[0] + const innerComboWidget = await innerNode.getWidgetByName( + 'dynamic_combo', + true + ) + await innerComboWidget!.setValue('two', true) + + await comfyPage.page.keyboard.press('Escape') + await comfyPage.nextFrame() + expect(await comfyPage.isInSubgraph()).toBe(false) + + const outerSubgraph = await getSubgraphNode(comfyPage) + expect(await outerSubgraph.getWidgets()).toEqual([ + widget('dynamic_combo', true, 'two'), + widget('dynamic_combo.w1', true, 0), + widget('dynamic_combo.w2', true, 0) + ]) + }) +}) diff --git a/src/core/graph/subgraph/proxyWidgetUtils.ts b/src/core/graph/subgraph/proxyWidgetUtils.ts index 9f50a8840ff..2a901aaf352 100644 --- a/src/core/graph/subgraph/proxyWidgetUtils.ts +++ b/src/core/graph/subgraph/proxyWidgetUtils.ts @@ -184,13 +184,24 @@ export function autoPromoteDynamicChildren( node: LGraphNode, parentWidget: IBaseWidget ) { - if (!parentWidget.promoted) return - const parents = getSubgraphParents(node) if (!parents.length) return + // Check if the parent widget is actually promoted on any parent SubgraphNode. + // This is more reliable than checking parentWidget.promoted, which may not + // be set after workflow reload (the flag is only synced when navigating into + // the subgraph). + const nodeId = String(node.id) + const promotedOnParents = parents.filter((parent) => + getProxyWidgets(parent).some( + ([id, name]) => id === nodeId && name === parentWidget.name + ) + ) + + if (!promotedOnParents.length) return + const childWidgets = getChildWidgets(node, parentWidget.name) - promoteWidgetsToProxy(node, childWidgets, parents) + promoteWidgetsToProxy(node, childWidgets, promotedOnParents) } /** From 1807e0db6dfe609e6fc23e2fbb12ad0c246e558e Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Tue, 27 Jan 2026 19:11:58 -0800 Subject: [PATCH 3/8] Group dynamic widgets together for drag operations --- .../parameters/SectionWidgets.vue | 56 +++++++-- .../parameters/TabSubgraphInputs.vue | 92 +++++++++++++- .../rightSidePanel/parameters/WidgetGroup.vue | 26 ++++ .../rightSidePanel/parameters/WidgetItem.vue | 27 +---- src/components/rightSidePanel/shared.test.ts | 112 +++++++++++++++++- src/components/rightSidePanel/shared.ts | 35 ++++++ src/core/graph/subgraph/proxyWidget.ts | 20 +++- 7 files changed, 325 insertions(+), 43 deletions(-) create mode 100644 src/components/rightSidePanel/parameters/WidgetGroup.vue diff --git a/src/components/rightSidePanel/parameters/SectionWidgets.vue b/src/components/rightSidePanel/parameters/SectionWidgets.vue index b1905a5f034..7bddd405335 100644 --- a/src/components/rightSidePanel/parameters/SectionWidgets.vue +++ b/src/components/rightSidePanel/parameters/SectionWidgets.vue @@ -14,7 +14,8 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue' -import { GetNodeParentGroupKey } from '../shared' +import { GetNodeParentGroupKey, getWidgetGroupKey } from '../shared' +import WidgetGroup from './WidgetGroup.vue' import WidgetItem from './WidgetItem.vue' const { @@ -84,6 +85,34 @@ function isWidgetShownOnParents( const isEmpty = computed(() => widgets.value.length === 0) +type WidgetEntry = { widget: IBaseWidget; node: LGraphNode } +type WidgetGroup = { + key: string + items: WidgetEntry[] +} + +/** + * Group widgets by their group key (for dynamic widget grouping). + * Widgets with the same group key are placed together in a single group. + */ +const groupedWidgets = computed((): WidgetGroup[] => { + const groups: WidgetGroup[] = [] + const keyToGroup = new Map() + + for (const entry of widgets.value) { + const key = getWidgetGroupKey(entry.widget) + let group = keyToGroup.get(key) + if (!group) { + group = { key, items: [] } + keyToGroup.set(key, group) + groups.push(group) + } + group.items.push(entry) + } + + return groups +}) + const displayLabel = computed( () => label ?? (node ? node.title : t('rightSidePanel.inputs')) ) @@ -167,17 +196,22 @@ defineExpose({ class="space-y-2 rounded-lg px-4 pt-1 relative" > - + > + + diff --git a/src/components/rightSidePanel/parameters/TabSubgraphInputs.vue b/src/components/rightSidePanel/parameters/TabSubgraphInputs.vue index a6fe94635d9..c9e93ca3614 100644 --- a/src/components/rightSidePanel/parameters/TabSubgraphInputs.vue +++ b/src/components/rightSidePanel/parameters/TabSubgraphInputs.vue @@ -24,7 +24,7 @@ import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/f import { DraggableList } from '@/scripts/ui/draggableList' import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore' -import { searchWidgets } from '../shared' +import { getWidgetGroupKey, searchWidgets } from '../shared' import type { NodeWidgetsList } from '../shared' import SectionWidgets from './SectionWidgets.vue' @@ -103,6 +103,57 @@ const widgetsList = computed((): NodeWidgetsList => { return result }) +/** + * Get the group key for a widget by its proxyWidgets entry. + * Returns the parent widget name if this is a child, otherwise the widget's own name. + */ +function getGroupKeyForEntry(widgetName: string): string { + const { widgets = [] } = node + + // Find the actual widget to check dynamicWidgetParent + const widget = widgets.find((w) => { + if (isProxyWidget(w)) { + return w._overlay.widgetName === widgetName + } + return w.name === widgetName + }) + + if (widget) { + return getWidgetGroupKey(widget) + } + return widgetName +} + +type ProxyWidgetGroup = { + key: string + indices: number[] +} + +/** + * Build a list of groups from proxyWidgets. + * Each group contains the indices of widgets that belong together. + * Groups are ordered by the first occurrence of their members. + */ +function buildProxyWidgetGroups(pw: [string, string][]): ProxyWidgetGroup[] { + const groups: ProxyWidgetGroup[] = [] + const keyToGroup = new Map() + + for (let i = 0; i < pw.length; i++) { + const [, widgetName] = pw[i] + const key = getGroupKeyForEntry(widgetName) + + let group = keyToGroup.get(key) + if (!group) { + group = { key, indices: [] } + keyToGroup.set(key, group) + groups.push(group) + } + group.indices.push(i) + } + + return groups +} + const advancedInputsWidgets = computed((): NodeWidgetsList => { const interiorNodes = node.subgraph.nodes const proxyWidgetsValue = parseProxyWidgets(node.properties.proxyWidgets) @@ -178,11 +229,42 @@ function setDraggableState() { this.draggableItem as HTMLElement ) - // Update proxyWidgets order + // Build groups from proxyWidgets + // Each draggable item corresponds to a group (container or single widget) const pw = proxyWidgets.value - const [w] = pw.splice(oldPosition, 1) - pw.splice(newPosition, 0, w) - proxyWidgets.value = pw + const groups = buildProxyWidgetGroups(pw) + + if (oldPosition >= groups.length || newPosition >= groups.length) { + console.error('[TabSubgraphInputs] position out of bounds') + return + } + + // Get the group being moved + const movedGroup = groups[oldPosition] + const movedIndices = movedGroup.indices + + // Extract the entries being moved (in their original order) + const movedEntries: [string, string][] = movedIndices.map((i) => pw[i]) + + const newPw: [string, string][] = [] + const reorderedGroups = [...groups] + reorderedGroups.splice(oldPosition, 1) + reorderedGroups.splice(newPosition, 0, movedGroup) + + // Flatten back to proxyWidgets, preserving entry order within each group + for (const group of reorderedGroups) { + if (group === movedGroup) { + // Use the entries we extracted earlier + newPw.push(...movedEntries) + } else { + // Add entries from this group in their original order + for (const idx of group.indices) { + newPw.push(pw[idx]) + } + } + } + + proxyWidgets.value = newPw canvasStore.canvas?.setDirty(true, true) triggerRef(proxyWidgets) } diff --git a/src/components/rightSidePanel/parameters/WidgetGroup.vue b/src/components/rightSidePanel/parameters/WidgetGroup.vue new file mode 100644 index 00000000000..db508daa41f --- /dev/null +++ b/src/components/rightSidePanel/parameters/WidgetGroup.vue @@ -0,0 +1,26 @@ + + + diff --git a/src/components/rightSidePanel/parameters/WidgetItem.vue b/src/components/rightSidePanel/parameters/WidgetItem.vue index 6a2eca74873..4155473e10a 100644 --- a/src/components/rightSidePanel/parameters/WidgetItem.vue +++ b/src/components/rightSidePanel/parameters/WidgetItem.vue @@ -23,7 +23,6 @@ import WidgetActions from './WidgetActions.vue' const { widget, node, - isDraggable = false, hiddenFavoriteIndicator = false, showNodeName = false, parents = [], @@ -31,7 +30,6 @@ const { } = defineProps<{ widget: IBaseWidget node: LGraphNode - isDraggable?: boolean hiddenFavoriteIndicator?: boolean showNodeName?: boolean parents?: SubgraphNode[] @@ -104,22 +102,11 @@ const displayLabel = customRef((track, trigger) => { diff --git a/src/components/rightSidePanel/shared.test.ts b/src/components/rightSidePanel/shared.test.ts index 37a8f3aebf6..a7f8f80579c 100644 --- a/src/components/rightSidePanel/shared.test.ts +++ b/src/components/rightSidePanel/shared.test.ts @@ -2,7 +2,11 @@ import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup' import { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import type { Positionable } from '@/lib/litegraph/src/interfaces' import { describe, expect, it, beforeEach } from 'vitest' -import { flatAndCategorizeSelectedItems, searchWidgets } from './shared' +import { + flatAndCategorizeSelectedItems, + getWidgetGroupKey, + searchWidgets +} from './shared' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' describe('searchWidgets', () => { @@ -188,3 +192,109 @@ describe('flatAndCategorizeSelectedItems', () => { expect(result.all).not.toContain(unknownItem) }) }) + +describe('getWidgetGroupKey', () => { + it('should return parent name for child widgets', () => { + const widget = { + name: 'dynamic_combo.w1', + type: 'number', + dynamicWidgetParent: 'dynamic_combo' + } as IBaseWidget + + expect(getWidgetGroupKey(widget)).toBe('dynamic_combo') + }) + + it('should return widget name for parent widgets (dynamic combo roots)', () => { + const widget = { + name: 'dynamic_combo', + type: 'combo', + dynamicWidgetRoot: true + } as IBaseWidget + + expect(getWidgetGroupKey(widget)).toBe('dynamic_combo') + }) + + it('should return widget name for regular widgets', () => { + const widget = { + name: 'regular_widget', + type: 'number' + } as IBaseWidget + + expect(getWidgetGroupKey(widget)).toBe('regular_widget') + }) + + it('should return widget name if dynamicWidgetParent is empty string', () => { + const widget = { + name: 'some_widget', + type: 'number', + dynamicWidgetParent: '' + } as IBaseWidget + + // Empty string is falsy, so widget is treated as its own group + expect(getWidgetGroupKey(widget)).toBe('some_widget') + }) + + it('should use _overlay.widgetName for proxy widgets (parent)', () => { + // Proxy widgets have names with node ID prefix like "1: dynamic_combo" + const proxyWidget = { + name: '1: dynamic_combo', + type: 'combo', + dynamicWidgetRoot: true, + _overlay: { widgetName: 'dynamic_combo', nodeId: '1' } + } as unknown as IBaseWidget + + // Should return base name so it matches children's dynamicWidgetParent + expect(getWidgetGroupKey(proxyWidget)).toBe('dynamic_combo') + }) + + it('should group proxy parent and children together', () => { + // Parent proxy widget + const parentProxy = { + name: '1: dynamic_combo', + type: 'combo', + dynamicWidgetRoot: true, + _overlay: { widgetName: 'dynamic_combo', nodeId: '1' } + } as unknown as IBaseWidget + + // Child proxy widget + const childProxy = { + name: '1: dynamic_combo.w1', + type: 'number', + dynamicWidgetParent: 'dynamic_combo', + _overlay: { widgetName: 'dynamic_combo.w1', nodeId: '1' } + } as unknown as IBaseWidget + + // Both should return 'dynamic_combo' so they're in the same group + expect(getWidgetGroupKey(parentProxy)).toBe('dynamic_combo') + expect(getWidgetGroupKey(childProxy)).toBe('dynamic_combo') + }) + + it('should group disconnected child widgets using overlay.dynamicWidgetParent', () => { + // Disconnected child widget - dynamicWidgetParent is stored in overlay + // because the backing widget (disconnectedWidget) doesn't have this property + const disconnectedChild = { + name: '1: dynamic_combo.child_widget', + type: 'button', // disconnectedWidget type + // No dynamicWidgetParent on widget itself + _overlay: { + widgetName: 'dynamic_combo.child_widget', + nodeId: '1', + dynamicWidgetParent: 'dynamic_combo' // Stored in overlay + } + } as unknown as IBaseWidget + + // Should use overlay.dynamicWidgetParent + expect(getWidgetGroupKey(disconnectedChild)).toBe('dynamic_combo') + }) + + it('should not group widgets without dynamicWidgetParent in overlay', () => { + // Regular widget without dynamicWidgetParent in widget or overlay + const regularWidget = { + name: 'some_widget', + type: 'number', + _overlay: { widgetName: 'some_widget', nodeId: '1' } + } as unknown as IBaseWidget + + expect(getWidgetGroupKey(regularWidget)).toBe('some_widget') + }) +}) diff --git a/src/components/rightSidePanel/shared.ts b/src/components/rightSidePanel/shared.ts index 0d70d6d34b3..7ef4b66559d 100644 --- a/src/components/rightSidePanel/shared.ts +++ b/src/components/rightSidePanel/shared.ts @@ -250,6 +250,41 @@ function repeatItems(items: T[]): T[] { return result } +/** + * Get the base widget name, stripping any node ID prefix. + * Proxy widgets on SubgraphNodes have names like "1: widgetName". + */ +function getBaseWidgetName(widget: IBaseWidget): string { + // Check if it's a proxy widget with _overlay + const overlay = (widget as { _overlay?: { widgetName?: string } })._overlay + if (overlay?.widgetName) { + return overlay.widgetName + } + return widget.name +} + +export function getWidgetGroupKey(widget: IBaseWidget): string { + // Check dynamicWidgetParent on the widget (works for connected widgets) + if (widget.dynamicWidgetParent) { + return widget.dynamicWidgetParent + } + + // For proxy widgets, check the overlay for dynamicWidgetParent + // This handles disconnected widgets where the backing widget doesn't have the property + // as the actual widget doesn't exist, and is the disconnected widget. + const overlay = ( + widget as { + _overlay?: { dynamicWidgetParent?: string; widgetName?: string } + } + )._overlay + if (overlay?.dynamicWidgetParent) { + return overlay.dynamicWidgetParent + } + + // Use base name to match children's dynamicWidgetParent values + return getBaseWidgetName(widget) +} + export function computedSectionDataList(nodes: MaybeRefOrGetter) { const settingStore = useSettingStore() diff --git a/src/core/graph/subgraph/proxyWidget.ts b/src/core/graph/subgraph/proxyWidget.ts index 075faeee1b2..2f0e72d2a07 100644 --- a/src/core/graph/subgraph/proxyWidget.ts +++ b/src/core/graph/subgraph/proxyWidget.ts @@ -42,6 +42,8 @@ type Overlay = Partial & { hidden?: boolean /** Flag to trigger re-resolution when source node's widgets change */ needsResolve?: boolean + /** Cached dynamicWidgetParent for grouping when widget is disconnected */ + dynamicWidgetParent?: string } // A ProxyWidget can be treated like a normal widget. // the _overlay property can be used to directly access the Overlay object @@ -147,11 +149,21 @@ function newProxyWidget( widgetName: string ) { const name = `${nodeId}: ${widgetName}` - const overlay = { + + // Determine dynamicWidgetParent from widget name pattern (parentName.childName) + // This ensures grouping works even when the backing widget is disconnected + let dynamicWidgetParent: string | undefined + const dotIndex = widgetName.indexOf('.') + if (dotIndex !== -1) { + dynamicWidgetParent = widgetName.slice(0, dotIndex) + } + + const overlay: Overlay = { //items specific for proxy management nodeId, graph: subgraphNode.subgraph, widgetName, + dynamicWidgetParent, //Items which normally exist on widgets afterQueued: undefined, computedHeight: undefined, @@ -177,6 +189,12 @@ function resolveLinkedWidget( // Slightly hacky. Force recursive resolution of nested widgets if (widget && isProxyWidget(widget) && isDisconnectedWidget(widget)) widget.computedHeight = 20 + + // Cache dynamicWidgetParent in overlay for use when widget becomes disconnected + if (widget?.dynamicWidgetParent) { + overlay.dynamicWidgetParent = widget.dynamicWidgetParent + } + return [n, widget] } From bd38a094e4ac3257fa3139c8c339507d083868f7 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Tue, 27 Jan 2026 19:13:24 -0800 Subject: [PATCH 4/8] unused export --- src/core/graph/subgraph/proxyWidgetUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/graph/subgraph/proxyWidgetUtils.ts b/src/core/graph/subgraph/proxyWidgetUtils.ts index 2a901aaf352..eac90ddd1cc 100644 --- a/src/core/graph/subgraph/proxyWidgetUtils.ts +++ b/src/core/graph/subgraph/proxyWidgetUtils.ts @@ -207,7 +207,7 @@ export function autoPromoteDynamicChildren( /** * Get parent SubgraphNodes based on current navigation context. */ -export function getParentNodes(): SubgraphNode[] { +function getParentNodes(): SubgraphNode[] { //NOTE: support for determining parents of a subgraph is limited //This function will require rework to properly support linked subgraphs //Either by including actual parents in the navigation stack, From 946aea1f4708eaa58a369f281bfe220155acaf84 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Tue, 27 Jan 2026 19:30:15 -0800 Subject: [PATCH 5/8] Fix node name --- browser_tests/tests/dynamicWidgetsSubgraph.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/browser_tests/tests/dynamicWidgetsSubgraph.spec.ts b/browser_tests/tests/dynamicWidgetsSubgraph.spec.ts index b4f992143c5..b5f1013345f 100644 --- a/browser_tests/tests/dynamicWidgetsSubgraph.spec.ts +++ b/browser_tests/tests/dynamicWidgetsSubgraph.spec.ts @@ -4,7 +4,7 @@ import type { ComfyPage } from '../fixtures/ComfyPage' import { comfyPageFixture as test } from '../fixtures/ComfyPage' test.describe('Dynamic Combo Widgets in Subgraphs', () => { - const TEST_NODE_TYPE = 'TestDynamicComboNode' + const TEST_NODE_TYPE = 'DevToolsDynamicComboNode' test.beforeEach(async ({ comfyPage }) => { await comfyPage.setSetting('Comfy.UseNewMenu', 'Top') From 67f366d734c2bdc688f085112dde4535b79c6c68 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Tue, 27 Jan 2026 19:41:03 -0800 Subject: [PATCH 6/8] Add node --- tools/devtools/dev_nodes.py | 2 + tools/devtools/nodes/__init__.py | 2 + tools/devtools/nodes/inputs.py | 88 ++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+) diff --git a/tools/devtools/dev_nodes.py b/tools/devtools/dev_nodes.py index 660518d846b..57ecf311745 100644 --- a/tools/devtools/dev_nodes.py +++ b/tools/devtools/dev_nodes.py @@ -22,6 +22,7 @@ NodeWithUnionInput, NodeWithValidation, NodeWithV2ComboInput, + NodeWithDynamicCombo, ObjectPatchNode, RemoteWidgetNode, RemoteWidgetNodeWithControlAfterRefresh, @@ -55,6 +56,7 @@ "NodeWithUnionInput", "NodeWithValidation", "NodeWithV2ComboInput", + "NodeWithDynamicCombo", "ObjectPatchNode", "RemoteWidgetNode", "RemoteWidgetNodeWithControlAfterRefresh", diff --git a/tools/devtools/nodes/__init__.py b/tools/devtools/nodes/__init__.py index f0ac2d8ee8f..254e171d793 100644 --- a/tools/devtools/nodes/__init__.py +++ b/tools/devtools/nodes/__init__.py @@ -22,6 +22,7 @@ NodeWithUnionInput, NodeWithValidation, NodeWithV2ComboInput, + NodeWithDynamicCombo, SimpleSlider, NODE_CLASS_MAPPINGS as inputs_class_mappings, NODE_DISPLAY_NAME_MAPPINGS as inputs_display_name_mappings, @@ -81,6 +82,7 @@ "NodeWithUnionInput", "NodeWithValidation", "NodeWithV2ComboInput", + "NodeWithDynamicCombo", "ObjectPatchNode", "RemoteWidgetNode", "RemoteWidgetNodeWithControlAfterRefresh", diff --git a/tools/devtools/nodes/inputs.py b/tools/devtools/nodes/inputs.py index ac31056cad8..9bf6f32d75b 100644 --- a/tools/devtools/nodes/inputs.py +++ b/tools/devtools/nodes/inputs.py @@ -303,6 +303,91 @@ def node_with_v2_combo_input(self, combo_input: str): return (combo_input,) +class NodeWithDynamicCombo: + """ + Test node with DynamicCombo that shows/hides widgets based on selection. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "first_widget": ( + "INT", + {}, + ), + "dynamic_combo": ( + "COMFY_DYNAMICCOMBO_V3", + { + "options": [ + { + "key": "none", + "inputs": {"required": {}}, + }, + { + "key": "one", + "inputs": { + "required": { + "w1": ( + "INT", + {}, + ), + } + }, + }, + { + "key": "two", + "inputs": { + "required": { + "w1": ( + "INT", + {}, + ), + "w2": ( + "INT", + {}, + ), + } + }, + }, + { + "key": "three", + "inputs": { + "required": { + "w1": ( + "INT", + {}, + ), + "w2": ( + "INT", + {}, + ), + "w3": ( + "INT", + {}, + ), + } + }, + }, + ], + }, + ), + "last_widget": ( + "INT", + {}, + ), + } + } + + RETURN_TYPES = ("INT",) + FUNCTION = "execute" + CATEGORY = "DevTools/Testing" + DESCRIPTION = "Test node for dynamic combo widget behavior" + + def execute(self, **kwargs): + print(kwargs) + return (1,) + NODE_CLASS_MAPPINGS = { "DevToolsLongComboDropdown": LongComboDropdown, "DevToolsNodeWithOptionalInput": NodeWithOptionalInput, @@ -318,6 +403,7 @@ def node_with_v2_combo_input(self, combo_input: str): "DevToolsNodeWithSeedInput": NodeWithSeedInput, "DevToolsNodeWithValidation": NodeWithValidation, "DevToolsNodeWithV2ComboInput": NodeWithV2ComboInput, + "DevToolsDynamicComboNode": NodeWithDynamicCombo, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -335,6 +421,7 @@ def node_with_v2_combo_input(self, combo_input: str): "DevToolsNodeWithSeedInput": "Node With Seed Input", "DevToolsNodeWithValidation": "Node With Validation", "DevToolsNodeWithV2ComboInput": "Node With V2 Combo Input", + "DevToolsDynamicComboNode": "Dynamic Combo Node", } __all__ = [ @@ -352,6 +439,7 @@ def node_with_v2_combo_input(self, combo_input: str): "NodeWithSeedInput", "NodeWithValidation", "NodeWithV2ComboInput", + "NodeWithDynamicCombo", "NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", ] From 6efa56daa7215633b492a2e53cc290ef91a47d54 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Tue, 27 Jan 2026 20:22:43 -0800 Subject: [PATCH 7/8] review feedback --- .../fixtures/components/PropertiesPanel.ts | 39 +++++++++---------- .../tests/dynamicWidgetsSubgraph.spec.ts | 4 +- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/browser_tests/fixtures/components/PropertiesPanel.ts b/browser_tests/fixtures/components/PropertiesPanel.ts index 05782495e60..b5fde90e992 100644 --- a/browser_tests/fixtures/components/PropertiesPanel.ts +++ b/browser_tests/fixtures/components/PropertiesPanel.ts @@ -30,18 +30,23 @@ export class PropertiesPanel { async promoteWidget(widgetName: string) { await this.ensureOpen() - // Click on Advanced Inputs to expand it - const advancedInputsButton = this.root - .getByRole('button') - .filter({ hasText: /advanced inputs/i }) - await advancedInputsButton.click() - - // Find the widget row and click the more options button + // Check if widget is already visible in Advanced Inputs section const widgetRow = this.root .locator('[class*="widget-item"], [class*="input-item"]') .filter({ hasText: widgetName }) .first() + const isAdvancedExpanded = await widgetRow.isVisible() + if (!isAdvancedExpanded) { + // Click on Advanced Inputs to expand it + const advancedInputsButton = this.root + .getByRole('button') + .filter({ hasText: /advanced inputs/i }) + await advancedInputsButton.click() + await widgetRow.waitFor({ state: 'visible', timeout: 5000 }) + } + + // Find and click the more options button const moreButton = widgetRow.locator('button').filter({ has: this.page.locator('[class*="lucide--more-vertical"]') }) @@ -51,18 +56,16 @@ export class PropertiesPanel { await this.page.getByText('Show input').click() // Close and reopen panel to refresh the UI state - await this.page.getByLabel('Toggle properties panel').click() - await this.page.getByLabel('Toggle properties panel').click() + await this.close() + await this.ensureOpen() } async demoteWidget(widgetName: string) { await this.ensureOpen() // Check if INPUTS section content is already visible - const inputsContent = this.root.locator('div').filter({ - hasText: new RegExp(`^${widgetName}$`) - }) - const isInputsExpanded = await inputsContent.first().isVisible() + const widgetRow = this.root.locator('span').getByText(widgetName).first() + const isInputsExpanded = await widgetRow.isVisible() if (!isInputsExpanded) { // Click on INPUTS section to expand it (where promoted widgets appear) @@ -72,17 +75,11 @@ export class PropertiesPanel { await inputsButton.click() } - // Find the widget row and click the more options button - const widgetRow = this.root - .locator('div') - .filter({ hasText: new RegExp(`^${widgetName}$`) }) - .first() - await widgetRow.waitFor({ state: 'visible', timeout: 5000 }) - // Find the more options button (the vertical dots icon button) + // Find the more options button in the widget-item-header const moreButton = widgetRow - .locator('..') + .locator('xpath=ancestor::*[contains(@class, "widget-item-header")]') .locator('button') .filter({ has: this.page.locator('[class*="more-vertical"], [class*="lucide"]') diff --git a/browser_tests/tests/dynamicWidgetsSubgraph.spec.ts b/browser_tests/tests/dynamicWidgetsSubgraph.spec.ts index b5f1013345f..416999e9b3f 100644 --- a/browser_tests/tests/dynamicWidgetsSubgraph.spec.ts +++ b/browser_tests/tests/dynamicWidgetsSubgraph.spec.ts @@ -266,7 +266,7 @@ test.describe('Dynamic Combo Widgets in Subgraphs', () => { subgraphWidgetName('dynamic_combo') ) await comboWidget!.setValue('two') - await comfyPage.page.waitForTimeout(500) + await comfyPage.nextFrame() expect(await reloadedSubgraph.getWidgets()).toEqual([ widget('dynamic_combo', true, 'two'), @@ -285,7 +285,7 @@ test.describe('Dynamic Combo Widgets in Subgraphs', () => { await comfyPage.nextFrame() const subgraphNode = await testNode.convertToSubgraph() - await comfyPage.page.waitForTimeout(500) + await comfyPage.nextFrame() await subgraphNode.click('title') await comfyPage.nextFrame() From b053516eecf168373ddeab924edbcc7aefdc1b39 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Tue, 27 Jan 2026 20:29:39 -0800 Subject: [PATCH 8/8] poll feedback --- browser_tests/tests/dynamicWidgetsSubgraph.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/browser_tests/tests/dynamicWidgetsSubgraph.spec.ts b/browser_tests/tests/dynamicWidgetsSubgraph.spec.ts index 416999e9b3f..62b6a4d647e 100644 --- a/browser_tests/tests/dynamicWidgetsSubgraph.spec.ts +++ b/browser_tests/tests/dynamicWidgetsSubgraph.spec.ts @@ -310,7 +310,9 @@ test.describe('Dynamic Combo Widgets in Subgraphs', () => { await comfyPage.page.keyboard.press('Escape') await comfyPage.nextFrame() - expect(await comfyPage.isInSubgraph()).toBe(false) + await expect + .poll(() => comfyPage.isInSubgraph(), { timeout: 5000 }) + .toBe(false) const outerSubgraph = await getSubgraphNode(comfyPage) expect(await outerSubgraph.getWidgets()).toEqual([