[fix] add InputSlot and dot error state (#5813)
## Summary Error states were not getting propagated down to the InputSlots from the API Repsonse I created a provider and injected error state. It seemed like a way better idea than prop drilling or building a composable that only two nodes (`InputSlot` and `OutputSlot`) would need. ## Changes The follow are now error code red when an input node has errors: 1. There's a error round border around the dot. 2. The dot is error colored. 3. The input text is error colored. This treatment was okay after feedback from design. ## Screenshots - Error State <img width="749" height="616" alt="Screenshot 2025-09-26 at 9 02 58 PM" src="https://github.com/user-attachments/assets/55c7edc9-081b-4a9d-9753-120465959b5d" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5813-fix-add-InputSlot-and-dot-error-state-27b6d73d36508151a955e485f00a2d05) by [Unito](https://www.unito.io) --------- Co-authored-by: github-actions <github-actions@github.com>
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
@@ -104,7 +104,6 @@ import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
|
|||||||
import { usePaste } from '@/composables/usePaste'
|
import { usePaste } from '@/composables/usePaste'
|
||||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||||
import { i18n, t } from '@/i18n'
|
import { i18n, t } from '@/i18n'
|
||||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
|
||||||
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
|
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
|
||||||
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
|
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
@@ -293,45 +292,36 @@ watch(
|
|||||||
{ deep: true }
|
{ deep: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update node slot errors
|
// Update node slot errors for LiteGraph nodes
|
||||||
|
// (Vue nodes read from store directly)
|
||||||
watch(
|
watch(
|
||||||
() => executionStore.lastNodeErrors,
|
() => executionStore.lastNodeErrors,
|
||||||
(lastNodeErrors) => {
|
(lastNodeErrors) => {
|
||||||
const removeSlotError = (node: LGraphNode) => {
|
if (!comfyApp.graph) return
|
||||||
|
|
||||||
|
for (const node of comfyApp.graph.nodes) {
|
||||||
|
// Clear existing errors
|
||||||
for (const slot of node.inputs) {
|
for (const slot of node.inputs) {
|
||||||
delete slot.hasErrors
|
delete slot.hasErrors
|
||||||
}
|
}
|
||||||
for (const slot of node.outputs) {
|
for (const slot of node.outputs) {
|
||||||
delete slot.hasErrors
|
delete slot.hasErrors
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (const node of comfyApp.graph.nodes) {
|
|
||||||
removeSlotError(node)
|
|
||||||
const nodeErrors = lastNodeErrors?.[node.id]
|
const nodeErrors = lastNodeErrors?.[node.id]
|
||||||
if (!nodeErrors) continue
|
if (!nodeErrors) continue
|
||||||
|
|
||||||
const validErrors = nodeErrors.errors.filter(
|
const validErrors = nodeErrors.errors.filter(
|
||||||
(error) => error.extra_info?.input_name !== undefined
|
(error) => error.extra_info?.input_name !== undefined
|
||||||
)
|
)
|
||||||
const slotErrorsChanged =
|
|
||||||
validErrors.length > 0 &&
|
|
||||||
validErrors.some((error) => {
|
|
||||||
const inputName = error.extra_info!.input_name!
|
|
||||||
const inputIndex = node.findInputSlot(inputName)
|
|
||||||
if (inputIndex !== -1) {
|
|
||||||
node.inputs[inputIndex].hasErrors = true
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
// Trigger Vue node data update if slot errors changed
|
validErrors.forEach((error) => {
|
||||||
if (slotErrorsChanged && comfyApp.graph.onTrigger) {
|
const inputName = error.extra_info!.input_name!
|
||||||
comfyApp.graph.onTrigger('node:slot-errors:changed', {
|
const inputIndex = node.findInputSlot(inputName)
|
||||||
nodeId: node.id
|
if (inputIndex !== -1) {
|
||||||
})
|
node.inputs[inputIndex].hasErrors = true
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
comfyApp.canvas.draw(true, true)
|
comfyApp.canvas.draw(true, true)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<SlotConnectionDot
|
<SlotConnectionDot
|
||||||
ref="connectionDotRef"
|
ref="connectionDotRef"
|
||||||
:color="slotColor"
|
:color="slotColor"
|
||||||
class="-translate-x-1/2"
|
:class="cn('-translate-x-1/2', errorClassesDot)"
|
||||||
v-on="readonly ? {} : { pointerdown: onPointerDown }"
|
v-on="readonly ? {} : { pointerdown: onPointerDown }"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -13,7 +13,9 @@
|
|||||||
<div class="relative">
|
<div class="relative">
|
||||||
<span
|
<span
|
||||||
v-if="!dotOnly"
|
v-if="!dotOnly"
|
||||||
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200 lod-toggle"
|
:class="
|
||||||
|
cn('whitespace-nowrap text-sm font-normal lod-toggle', labelClasses)
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
|
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
|
||||||
</span>
|
</span>
|
||||||
@@ -39,6 +41,7 @@ import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
|
|||||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||||
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||||
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
|
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
|
||||||
|
import { useExecutionStore } from '@/stores/executionStore'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
import LODFallback from './LODFallback.vue'
|
import LODFallback from './LODFallback.vue'
|
||||||
@@ -57,7 +60,30 @@ interface InputSlotProps {
|
|||||||
|
|
||||||
const props = defineProps<InputSlotProps>()
|
const props = defineProps<InputSlotProps>()
|
||||||
|
|
||||||
// Error boundary implementation
|
const executionStore = useExecutionStore()
|
||||||
|
|
||||||
|
const hasSlotError = computed(() => {
|
||||||
|
const nodeErrors = executionStore.lastNodeErrors?.[props.nodeId ?? '']
|
||||||
|
if (!nodeErrors) return false
|
||||||
|
|
||||||
|
const slotName = props.slotData.name
|
||||||
|
return nodeErrors.errors.some(
|
||||||
|
(error) => error.extra_info?.input_name === slotName
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const errorClassesDot = computed(() => {
|
||||||
|
return hasSlotError.value
|
||||||
|
? 'ring-2 ring-error dark-theme:ring-error ring-offset-0 rounded-full'
|
||||||
|
: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const labelClasses = computed(() =>
|
||||||
|
hasSlotError.value
|
||||||
|
? 'text-error dark-theme:text-error font-medium'
|
||||||
|
: 'dark-theme:text-slate-200 text-stone-200'
|
||||||
|
)
|
||||||
|
|
||||||
const renderError = ref<string | null>(null)
|
const renderError = ref<string | null>(null)
|
||||||
const { toastErrorHandler } = useErrorHandling()
|
const { toastErrorHandler } = useErrorHandling()
|
||||||
|
|
||||||
@@ -81,8 +107,12 @@ onErrorCaptured((error) => {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get slot color based on type
|
const slotColor = computed(() => {
|
||||||
const slotColor = computed(() => getSlotColor(props.slotData.type))
|
if (hasSlotError.value) {
|
||||||
|
return 'var(--color-error)'
|
||||||
|
}
|
||||||
|
return getSlotColor(props.slotData.type)
|
||||||
|
})
|
||||||
|
|
||||||
const slotWrapperClass = computed(() =>
|
const slotWrapperClass = computed(() =>
|
||||||
cn(
|
cn(
|
||||||
@@ -103,8 +133,6 @@ const connectionDotRef = ref<ComponentPublicInstance<{
|
|||||||
}> | null>(null)
|
}> | null>(null)
|
||||||
const slotElRef = ref<HTMLElement | null>(null)
|
const slotElRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
// Watch for when the child component's ref becomes available
|
|
||||||
// Vue automatically unwraps the Ref when exposing it
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
const el = connectionDotRef.value?.slotElRef
|
const el = connectionDotRef.value?.slotElRef
|
||||||
slotElRef.value = el || null
|
slotElRef.value = el || null
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
cn(
|
cn(
|
||||||
'bg-white dark-theme:bg-charcoal-800',
|
'bg-white dark-theme:bg-charcoal-800',
|
||||||
'lg-node absolute rounded-2xl',
|
'lg-node absolute rounded-2xl',
|
||||||
'border border-solid border-sand-100 dark-theme:border-charcoal-600',
|
'border-2 border-solid border-sand-100 dark-theme:border-charcoal-600',
|
||||||
// hover (only when node should handle events)
|
// hover (only when node should handle events)
|
||||||
shouldHandleNodePointerEvents &&
|
shouldHandleNodePointerEvents &&
|
||||||
'hover:ring-7 ring-gray-500/50 dark-theme:ring-gray-500/20',
|
'hover:ring-7 ring-gray-500/50 dark-theme:ring-gray-500/20',
|
||||||
@@ -101,7 +101,11 @@
|
|||||||
>
|
>
|
||||||
<!-- Slots only rendered at full detail -->
|
<!-- Slots only rendered at full detail -->
|
||||||
<NodeSlots
|
<NodeSlots
|
||||||
v-memo="[nodeData.inputs?.length, nodeData.outputs?.length]"
|
v-memo="[
|
||||||
|
nodeData.inputs?.length,
|
||||||
|
nodeData.outputs?.length,
|
||||||
|
executionStore.lastNodeErrors
|
||||||
|
]"
|
||||||
:node-data="nodeData"
|
:node-data="nodeData"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
/>
|
/>
|
||||||
@@ -215,16 +219,12 @@ const hasExecutionError = computed(
|
|||||||
() => executionStore.lastExecutionErrorNodeId === nodeData.id
|
() => executionStore.lastExecutionErrorNodeId === nodeData.id
|
||||||
)
|
)
|
||||||
|
|
||||||
// Computed error states for styling
|
|
||||||
const hasAnyError = computed((): boolean => {
|
const hasAnyError = computed((): boolean => {
|
||||||
return !!(
|
return !!(
|
||||||
hasExecutionError.value ||
|
hasExecutionError.value ||
|
||||||
nodeData.hasErrors ||
|
nodeData.hasErrors ||
|
||||||
error ||
|
error ||
|
||||||
// Type assertions needed because VueNodeData.inputs/outputs are typed as unknown[]
|
(executionStore.lastNodeErrors?.[nodeData.id]?.errors.length ?? 0) > 0
|
||||||
// but at runtime they contain INodeInputSlot/INodeOutputSlot objects
|
|
||||||
nodeData.inputs?.some((slot) => slot?.hasErrors) ||
|
|
||||||
nodeData.outputs?.some((slot) => slot?.hasErrors)
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -316,26 +316,19 @@ const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const borderClass = computed(() => {
|
const borderClass = computed(() => {
|
||||||
if (hasAnyError.value) {
|
return (
|
||||||
return 'border-error dark-theme:border-error'
|
(hasAnyError.value && 'border-error dark-theme:border-error') ||
|
||||||
}
|
(executing.value && 'border-blue-500')
|
||||||
if (executing.value) {
|
)
|
||||||
return 'border-blue-500'
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const outlineClass = computed(() => {
|
const outlineClass = computed(() => {
|
||||||
if (!isSelected.value) {
|
return (
|
||||||
return undefined
|
isSelected.value &&
|
||||||
}
|
((hasAnyError.value && 'outline-error dark-theme:outline-error') ||
|
||||||
if (hasAnyError.value) {
|
(executing.value && 'outline-blue-500 dark-theme:outline-blue-500') ||
|
||||||
return 'outline-error dark-theme:outline-error'
|
'outline-black dark-theme:outline-white')
|
||||||
}
|
)
|
||||||
if (executing.value) {
|
|
||||||
return 'outline-blue-500 dark-theme:outline-blue-500'
|
|
||||||
}
|
|
||||||
return 'outline-black dark-theme:outline-white'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Event handlers
|
// Event handlers
|
||||||
|
|||||||