refactor(appMode): resolve selectedInputs once at the computed boundary

Introduce `useResolvedSelectedInputs` that maps each persisted
`[entityId, displayName, config?]` entry to a discriminated union of
`{ status: 'resolved', node, widget, ... } | { status: 'unknown', ... }`.
Handlers (rename, remove, resize, bounding) now receive widget instances
directly instead of re-walking `rootGraph.nodes` per interaction.

- Delete `src/world/widgetLookup.ts`; the lone remaining caller
  (`upgradeAndValidateInput` in `appModeStore.ts`) inlines the lookup as a
  module-private function.
- `inlineRenameInput` deleted; the template calls `renameWidget(widget,
  node, $event)` directly with the in-scope widget.
- `getWidgetBounding` accepts a `ResolvedSelection`; returns `undefined`
  for unresolved entries.
- `useAppModeWidgetResizing.onPointerDown` and `updateInputConfig` accept
  a widget instance, matching the existing `removeSelectedInput` shape.
- Persisted `selectedInputs` shape unchanged.
- Unresolved entries continue to render as a removable "unknown widget"
  pill in the sidebar so users can clean up dangling selections.

Slot-rename → entityId-drift bug investigated and confirmed not present:
the `renaming-input` handler in `SubgraphNode.ts` mutates `widget.label`
and `input.label`, never `widget.name`, so `entityId` is stable across
renames. Documented as an invariant in `useResolvedSelectedInputs`.

Amp-Thread-ID: https://ampcode.com/threads/T-019e2260-c2ba-70b4-9962-1638be4bf645
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
DrJKL
2026-05-13 13:07:33 -07:00
parent 302693bcd9
commit 8273c97b2f
8 changed files with 211 additions and 156 deletions

View File

@@ -8,6 +8,9 @@ import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
import DraggableList from '@/components/common/DraggableList.vue'
import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
import type { ResolvedSelection } from '@/components/builder/useResolvedSelectedInputs'
import type { WidgetEntityId } from '@/world/entityIds'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
@@ -27,8 +30,6 @@ import { DOMWidgetImpl } from '@/scripts/domWidget'
import { renameWidget } from '@/utils/widgetUtil'
import { useAppMode } from '@/composables/useAppMode'
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
import type { WidgetEntityId } from '@/world/entityIds'
import { findWidgetByEntityId } from '@/world/widgetLookup'
import { cn } from '@comfyorg/tailwind-utils'
type BoundStyle = { top: string; left: string; width: string; height: string }
@@ -47,27 +48,8 @@ const hoveringSelectable = ref(false)
workflowStore.activeWorkflow?.changeTracker?.reset()
const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([entityId]) => {
const found =
typeof entityId === 'string'
? findWidgetByEntityId(app.rootGraph, entityId as WidgetEntityId)
: undefined
if (!found) {
return {
entityId: entityId as WidgetEntityId,
subLabel: t('linearMode.builder.unknownWidget')
}
}
const [node, widget] = found
return {
entityId: entityId as WidgetEntityId,
label: widget.label,
subLabel: node.title,
canRename: true
}
})
)
const resolvedInputs = useResolvedSelectedInputs()
const outputsWithState = computed<[NodeId, string][]>(() =>
appModeStore.selectedOutputs.map((nodeId) => [
nodeId,
@@ -75,13 +57,6 @@ const outputsWithState = computed<[NodeId, string][]>(() =>
])
)
function inlineRenameInput(entityId: WidgetEntityId, newLabel: string) {
const found = findWidgetByEntityId(app.rootGraph, entityId)
if (!found) return
const [node, widget] = found
renameWidget(widget, node, newLabel)
}
function getHovered(
e: MouseEvent
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
@@ -116,11 +91,10 @@ function getNodeBounding(nodeId: NodeId) {
}
}
function getWidgetBounding(entityId: WidgetEntityId) {
function getWidgetBounding(entry: ResolvedSelection): BoundStyle | undefined {
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
const found = findWidgetByEntityId(app.rootGraph, entityId)
if (!found) return
const [node, widget] = found
if (entry.status !== 'resolved') return undefined
const { node, widget } = entry
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
const marginX = margin ?? BaseWidget.margin
@@ -136,6 +110,11 @@ function getWidgetBounding(entityId: WidgetEntityId) {
}
}
function removeSelectedEntityId(entityId: WidgetEntityId): void {
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
if (index !== -1) appModeStore.selectedInputs.splice(index, 1)
}
function handleDown(e: MouseEvent) {
const [node] = getHovered(e) ?? []
if (!node || e.button > 0) canvasInteractions.forwardEventToCanvas(e)
@@ -191,15 +170,13 @@ const renderedOutputs = computed(() => {
})
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
() =>
appModeStore.selectedInputs.flatMap(([entityId]) => {
if (typeof entityId !== 'string') return []
return [
[entityId, getWidgetBounding(entityId as WidgetEntityId)] as [
resolvedInputs.value.map(
(entry) =>
[entry.entityId, getWidgetBounding(entry)] as [
string,
MaybeRef<BoundStyle> | undefined
]
]
})
)
)
</script>
<template>
@@ -243,21 +220,28 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<IoItem
v-for="{ entityId, label, subLabel, canRename } in inputsWithState"
:key="entityId"
:class="
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
"
:title="label ?? entityId"
:sub-title="subLabel"
:can-rename="canRename"
:remove="
() =>
remove(appModeStore.selectedInputs, ([id]) => id === entityId)
"
@rename="inlineRenameInput(entityId, $event)"
/>
<template v-for="entry in resolvedInputs" :key="entry.entityId">
<IoItem
v-if="entry.status === 'resolved'"
:class="
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
"
:title="entry.widget.label ?? entry.entityId"
:sub-title="entry.node.title"
can-rename
:remove="() => appModeStore.removeSelectedInput(entry.widget)"
@rename="renameWidget(entry.widget, entry.node, $event)"
/>
<IoItem
v-else
:class="
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
"
:title="entry.entityId"
:sub-title="t('linearMode.builder.unknownWidget')"
:remove="() => removeSelectedEntityId(entry.entityId)"
/>
</template>
</DraggableList>
</PropertiesAccordionItem>
<div

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { computed, provide, shallowRef, triggerRef } from 'vue'
import { computed, provide } from 'vue'
import { useAppModeWidgetResizing } from '@/components/builder/useAppModeWidgetResizing'
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
@@ -25,12 +25,9 @@ import { parseImageWidgetValue } from '@/utils/imageUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
import { promptRenameWidget } from '@/utils/widgetUtil'
import type { WidgetEntityId } from '@/world/entityIds'
import { findWidgetByEntityId } from '@/world/widgetLookup'
interface WidgetEntry {
key: string
entityId: WidgetEntityId
persistedHeight: number | undefined
nodeData: ReturnType<typeof nodeToNodeData> & {
widgets: NonNullable<ReturnType<typeof nodeToNodeData>['widgets']>
@@ -48,38 +45,24 @@ const executionErrorStore = useExecutionErrorStore()
const appModeStore = useAppModeStore()
const maskEditor = useMaskEditor()
const { onPointerDown } = useAppModeWidgetResizing((entityId, config) =>
appModeStore.updateInputConfig(entityId, config)
const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
appModeStore.updateInputConfig(widget, config)
)
provide(HideLayoutFieldKey, true)
provide(OverlayAppendToKey, 'body')
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
useEventListener(
app.rootGraph.events,
'configured',
() => (graphNodes.value = app.rootGraph.nodes)
)
useEventListener(app.rootGraph.events, 'node:slot-label:changed', () =>
triggerRef(graphNodes)
)
const resolvedInputs = useResolvedSelectedInputs()
const mappedSelections = computed((): WidgetEntry[] => {
void graphNodes.value
const nodeDataByNode = new Map<
LGraphNode,
ReturnType<typeof nodeToNodeData>
>()
return appModeStore.selectedInputs.flatMap(([entityId, , config]) => {
if (typeof entityId !== 'string') return []
const found = findWidgetByEntityId(
app.rootGraph,
entityId as WidgetEntityId
)
if (!found) return []
const [node, widget] = found
return resolvedInputs.value.flatMap((entry) => {
if (entry.status !== 'resolved') return []
const { entityId, node, widget, config } = entry
if (node.mode !== LGraphEventMode.ALWAYS) return []
if (!nodeDataByNode.has(node)) {
@@ -99,7 +82,6 @@ const mappedSelections = computed((): WidgetEntry[] => {
return [
{
key: entityId,
entityId: entityId as WidgetEntityId,
persistedHeight: config?.height,
nodeData: {
...fullNodeData,
@@ -166,13 +148,7 @@ defineExpose({ handleDragDrop })
</script>
<template>
<div
v-for="{
key,
entityId,
persistedHeight,
nodeData,
action
} in mappedSelections"
v-for="{ key, persistedHeight, nodeData, action } in mappedSelections"
:key
:class="
cn(
@@ -249,7 +225,7 @@ defineExpose({ handleDragDrop })
)
"
:inert="builderMode || undefined"
@pointerdown.capture="(e) => onPointerDown(entityId, e)"
@pointerdown.capture="(e) => onPointerDown(action.widget, e)"
>
<DropZone
:on-drag-over="nodeData.onDragOver"

View File

@@ -1,14 +1,14 @@
import { describe, expect, it, vi } from 'vitest'
import { effectScope } from 'vue'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
import type { WidgetEntityId } from '@/world/entityIds'
import { useAppModeWidgetResizing } from './useAppModeWidgetResizing'
const ENTITY_PROMPT = 'g:1:prompt' as WidgetEntityId
const ENTITY_OTHER = 'g:2:other' as WidgetEntityId
const ENTITY_IMAGE = 'g:1:image' as WidgetEntityId
const WIDGET_PROMPT = { name: 'prompt' } as IBaseWidget
const WIDGET_OTHER = { name: 'other' } as IBaseWidget
const WIDGET_IMAGE = { name: 'image' } as IBaseWidget
function setHeight(el: HTMLElement, height: number) {
Object.defineProperty(el, 'offsetHeight', {
@@ -32,13 +32,13 @@ function wrapWithTextarea(initialHeight = 100): {
describe('useAppModeWidgetResizing', () => {
function setup() {
const onResize =
vi.fn<(entityId: WidgetEntityId, config: InputWidgetConfig) => void>()
vi.fn<(widget: IBaseWidget, config: InputWidgetConfig) => void>()
const { onPointerDown } = useAppModeWidgetResizing(onResize)
function bind(wrapper: HTMLElement, entityId: WidgetEntityId) {
function bind(wrapper: HTMLElement, widget: IBaseWidget) {
wrapper.addEventListener(
'pointerdown',
(e) => onPointerDown(entityId, e as PointerEvent),
(e) => onPointerDown(widget, e as PointerEvent),
{ capture: true }
)
}
@@ -49,19 +49,19 @@ describe('useAppModeWidgetResizing', () => {
it('persists height when textarea is resized via drag', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, ENTITY_PROMPT)
bind(wrapper, WIDGET_PROMPT)
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(textarea, 250)
window.dispatchEvent(new PointerEvent('pointerup'))
expect(onResize).toHaveBeenCalledWith(ENTITY_PROMPT, { height: 250 })
expect(onResize).toHaveBeenCalledWith(WIDGET_PROMPT, { height: 250 })
})
it('does not persist when no height change occurs (e.g. a click)', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, ENTITY_PROMPT)
bind(wrapper, WIDGET_PROMPT)
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
window.dispatchEvent(new PointerEvent('pointerup'))
@@ -72,7 +72,7 @@ describe('useAppModeWidgetResizing', () => {
it('persists once per drag gesture; stray pointerup is a no-op', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, ENTITY_PROMPT)
bind(wrapper, WIDGET_PROMPT)
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(textarea, 250)
@@ -88,7 +88,7 @@ describe('useAppModeWidgetResizing', () => {
const button = document.createElement('button')
wrapper.appendChild(button)
document.body.appendChild(wrapper)
bind(wrapper, ENTITY_PROMPT)
bind(wrapper, WIDGET_PROMPT)
button.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
window.dispatchEvent(new PointerEvent('pointerup'))
@@ -106,21 +106,21 @@ describe('useAppModeWidgetResizing', () => {
wrapper.appendChild(indicator)
document.body.appendChild(wrapper)
setHeight(indicator, 100)
bind(wrapper, ENTITY_IMAGE)
bind(wrapper, WIDGET_IMAGE)
inner.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(indicator, 250)
window.dispatchEvent(new PointerEvent('pointerup'))
expect(onResize).toHaveBeenCalledWith(ENTITY_IMAGE, { height: 250 })
expect(onResize).toHaveBeenCalledWith(WIDGET_IMAGE, { height: 250 })
})
it('drops a stale gesture when a new pointerdown starts before pointerup arrives', () => {
const { bind, onResize } = setup()
const first = wrapWithTextarea()
const second = wrapWithTextarea()
bind(first.wrapper, ENTITY_PROMPT)
bind(second.wrapper, ENTITY_OTHER)
bind(first.wrapper, WIDGET_PROMPT)
bind(second.wrapper, WIDGET_OTHER)
first.textarea.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true })
@@ -134,25 +134,25 @@ describe('useAppModeWidgetResizing', () => {
window.dispatchEvent(new PointerEvent('pointerup'))
expect(onResize).toHaveBeenCalledTimes(1)
expect(onResize).toHaveBeenCalledWith(ENTITY_OTHER, { height: 300 })
expect(onResize).toHaveBeenCalledWith(WIDGET_OTHER, { height: 300 })
})
it('treats pointercancel as the end of a gesture and persists the new height', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, ENTITY_PROMPT)
bind(wrapper, WIDGET_PROMPT)
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(textarea, 250)
window.dispatchEvent(new PointerEvent('pointercancel'))
expect(onResize).toHaveBeenCalledWith(ENTITY_PROMPT, { height: 250 })
expect(onResize).toHaveBeenCalledWith(WIDGET_PROMPT, { height: 250 })
})
it('after pointercancel, a subsequent stray pointerup is a no-op', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, ENTITY_PROMPT)
bind(wrapper, WIDGET_PROMPT)
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(textarea, 250)
@@ -161,12 +161,12 @@ describe('useAppModeWidgetResizing', () => {
window.dispatchEvent(new PointerEvent('pointerup'))
expect(onResize).toHaveBeenCalledTimes(1)
expect(onResize).toHaveBeenCalledWith(ENTITY_PROMPT, { height: 250 })
expect(onResize).toHaveBeenCalledWith(WIDGET_PROMPT, { height: 250 })
})
it('removes global listeners when the owning scope is disposed mid-gesture', () => {
const onResize =
vi.fn<(entityId: WidgetEntityId, config: InputWidgetConfig) => void>()
vi.fn<(widget: IBaseWidget, config: InputWidgetConfig) => void>()
const scope = effectScope()
const { onPointerDown } = scope.run(() =>
useAppModeWidgetResizing(onResize)
@@ -174,7 +174,7 @@ describe('useAppModeWidgetResizing', () => {
const { wrapper, textarea } = wrapWithTextarea()
wrapper.addEventListener(
'pointerdown',
(e) => onPointerDown(ENTITY_PROMPT, e as PointerEvent),
(e) => onPointerDown(WIDGET_PROMPT, e as PointerEvent),
{ capture: true }
)
@@ -199,7 +199,7 @@ describe('useAppModeWidgetResizing', () => {
outerIndicator.appendChild(wrapper)
document.body.appendChild(outerIndicator)
setHeight(outerIndicator, 100)
bind(wrapper, ENTITY_PROMPT)
bind(wrapper, WIDGET_PROMPT)
inner.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(outerIndicator, 250)

View File

@@ -1,12 +1,12 @@
import { onScopeDispose } from 'vue'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
import type { WidgetEntityId } from '@/world/entityIds'
const RESIZABLE_SELECTOR = 'textarea, [data-slot="drop-zone-indicator"]'
export function useAppModeWidgetResizing(
onResize: (entityId: WidgetEntityId, config: InputWidgetConfig) => void
onResize: (widget: IBaseWidget, config: InputWidgetConfig) => void
) {
let pendingHandler: (() => void) | null = null
@@ -19,7 +19,7 @@ export function useAppModeWidgetResizing(
onScopeDispose(clearPendingHandler)
function onPointerDown(entityId: WidgetEntityId, event: PointerEvent) {
function onPointerDown(widget: IBaseWidget, event: PointerEvent) {
const wrapper = event.currentTarget
const target = event.target
if (!(wrapper instanceof HTMLElement) || !(target instanceof HTMLElement))
@@ -36,7 +36,7 @@ export function useAppModeWidgetResizing(
pendingHandler = null
const height = resizable.offsetHeight
if (height === startHeight) return
onResize(entityId, { height })
onResize(widget, { height })
}
pendingHandler = handler
window.addEventListener('pointerup', handler)

View File

@@ -0,0 +1,90 @@
import { useEventListener } from '@vueuse/core'
import { computed, shallowRef, triggerRef } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
import { app } from '@/scripts/app'
import { useAppModeStore } from '@/stores/appModeStore'
import type { WidgetEntityId } from '@/world/entityIds'
import { isWidgetEntityId, parseWidgetEntityId } from '@/world/entityIds'
/**
* A `selectedInputs` entry resolved against the live graph. Resolved entries
* carry the live `node`/`widget` references; unresolved entries (whose target
* node or widget has been removed) are surfaced as `status: 'unknown'` so the
* UI can render a "remove dangling selection" affordance instead of silently
* dropping the row.
*/
export type ResolvedSelection =
| {
status: 'resolved'
entityId: WidgetEntityId
node: LGraphNode
widget: IBaseWidget
displayName: string
config?: InputWidgetConfig
}
| {
status: 'unknown'
entityId: WidgetEntityId
displayName: string
config?: InputWidgetConfig
}
/**
* Resolve `appModeStore.selectedInputs` to live `(node, widget)` pairs.
*
* The persisted shape is `[WidgetEntityId, displayName, InputWidgetConfig?]`;
* this projection layers the live widget/node references on top of each entry
* so handlers (rename, remove, resize, bounding) can act on widget instances
* directly without re-walking the graph.
*
* Reactivity sources:
* - `appModeStore.selectedInputs` (deep-reactive ref)
* - root graph node list (tracked via shallow ref + `configured` event)
* - subgraph slot label changes (tracked via `node:slot-label:changed`)
*
* NOTE: A `WidgetEntityId` encodes `${graphId}:${nodeId}:${widgetName}`. Slot
* label renames mutate `widget.label` but not `widget.name` (see
* `SubgraphNode.ts`'s `renaming-input` handler), so the entityId remains stable
* across renames. If a future code path mutates `widget.name`, persisted
* entries would silently drop here; rewiring would need a new event.
* TODO: revisit if such a path is introduced.
*/
export function useResolvedSelectedInputs() {
const appModeStore = useAppModeStore()
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph?.nodes ?? [])
useEventListener(
() => app.rootGraph?.events,
'configured',
() => (graphNodes.value = app.rootGraph?.nodes ?? [])
)
useEventListener(
() => app.rootGraph?.events,
'node:slot-label:changed',
() => triggerRef(graphNodes)
)
return computed<ResolvedSelection[]>(() => {
void graphNodes.value
const rootGraph = app.rootGraph
if (!rootGraph) return []
return appModeStore.selectedInputs.flatMap(
([entityId, displayName, config]): ResolvedSelection[] => {
if (!isWidgetEntityId(entityId)) return []
const { nodeId, name } = parseWidgetEntityId(entityId)
const node = rootGraph.getNodeById(nodeId)
const widget = node?.widgets?.find((w) => w.name === name)
if (!node || !widget) {
return [{ status: 'unknown', entityId, displayName, config }]
}
return [
{ status: 'resolved', entityId, node, widget, displayName, config }
]
}
)
})
}

View File

@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
import { ComfyWorkflow as ComfyWorkflowClass } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -441,11 +442,13 @@ describe('appModeStore', () => {
describe('updateInputConfig', () => {
const entity = 'g:1:prompt' as WidgetEntityId
const otherEntity = 'g:99:prompt' as WidgetEntityId
const widget = fromAny<IBaseWidget, unknown>({ entityId: entity })
const otherWidget = fromAny<IBaseWidget, unknown>({ entityId: otherEntity })
it('sets config on an existing input', () => {
store.selectedInputs.push([entity, 'prompt'])
store.updateInputConfig(entity, { height: 200 })
store.updateInputConfig(widget, { height: 200 })
expect(store.selectedInputs[0][2]).toEqual({ height: 200 })
})
@@ -453,7 +456,18 @@ describe('appModeStore', () => {
it('is a no-op when entry is not found', () => {
store.selectedInputs.push([entity, 'prompt'])
store.updateInputConfig(otherEntity, { height: 200 })
store.updateInputConfig(otherWidget, { height: 200 })
expect(store.selectedInputs[0][2]).toBeUndefined()
})
it('is a no-op when the widget has no entityId', () => {
store.selectedInputs.push([entity, 'prompt'])
store.updateInputConfig(
fromAny<IBaseWidget, unknown>({ entityId: undefined }),
{ height: 200 }
)
expect(store.selectedInputs[0][2]).toBeUndefined()
})
@@ -463,7 +477,7 @@ describe('appModeStore', () => {
store.selectedInputs.push([entity, 'prompt'])
await nextTick()
store.updateInputConfig(entity, { height: 300 })
store.updateInputConfig(widget, { height: 300 })
await nextTick()
expect(app.rootGraph.extra.linearData).toEqual({

View File

@@ -18,11 +18,31 @@ import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { app } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { resolveNode, resolveNodeWidget } from '@/utils/litegraphUtil'
import type { WidgetEntityId } from '@/world/entityIds'
import { isWidgetEntityId } from '@/world/entityIds'
import { findWidgetByEntityId } from '@/world/widgetLookup'
/**
* Resolve a widget by its canonical {@link WidgetEntityId} within the given
* root graph. Identity comparison is purely structural —
* `widget.entityId === entityId` — so producers and consumers never disagree
* about which widget an id refers to.
*
* This is the single load-time resolution path; render-time consumers should
* project `selectedInputs` through `useResolvedSelectedInputs` instead.
*/
function findWidgetByEntityId(
rootGraph: LGraph,
entityId: WidgetEntityId
): IBaseWidget | undefined {
for (const node of rootGraph.nodes) {
const widget = node.widgets?.find((w) => w.entityId === entityId)
if (widget) return widget
}
return undefined
}
export function nodeTypeValidForApp(type: string) {
return !['Note', 'MarkdownNote'].includes(type)
@@ -76,8 +96,8 @@ export const useAppModeStore = defineStore('appMode', () => {
// Canonical: storedId is already a WidgetEntityId.
if (typeof storedId === 'string' && isWidgetEntityId(storedId)) {
const found = findWidgetByEntityId(rootGraph, storedId)
return found ? buildEntry(storedId, widgetName, config) : null
const widget = findWidgetByEntityId(rootGraph, storedId)
return widget ? buildEntry(storedId, widgetName, config) : null
}
// Legacy NodeLocatorId (`graphId:nodeId`) for promoted widgets.
@@ -229,11 +249,10 @@ export const useAppModeStore = defineStore('appMode', () => {
if (index !== -1) selectedInputs.value.splice(index, 1)
}
function updateInputConfig(
entityId: WidgetEntityId,
config: InputWidgetConfig
) {
const entry = selectedInputs.value.find(([id]) => id === entityId)
function updateInputConfig(widget: IBaseWidget, config: InputWidgetConfig) {
const targetEntityId = widget.entityId
if (!targetEntityId) return
const entry = selectedInputs.value.find(([id]) => id === targetEntityId)
if (!entry) return
entry[2] = { ...entry[2], ...config }
}

View File

@@ -1,28 +0,0 @@
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { WidgetEntityId } from './entityIds'
/**
* Resolve a widget by its canonical {@link WidgetEntityId} within a root graph.
*
* Returns the host node and widget the entity id describes. For promoted
* widgets the host is the SubgraphNode and the widget is its `PromotedWidgetView`;
* for plain widgets the host is the owning node and the widget is its own
* `IBaseWidget` instance.
*
* Identity comparison is purely structural — `widget.entityId === entityId` —
* so producers and consumers can never disagree about which widget an id refers
* to.
*/
export function findWidgetByEntityId(
rootGraph: LGraph,
entityId: WidgetEntityId
): [LGraphNode, IBaseWidget] | undefined {
for (const node of rootGraph.nodes) {
const widget = node.widgets?.find((w) => w.entityId === entityId)
if (widget) return [node, widget]
}
return undefined
}