Skip to content

Commit 341a919

Browse files
jaeone94christian-byrne
authored andcommitted
feat: add node replacement UI to Errors Tab (#9253)
## Summary Adds a node replacement UI to the Errors Tab so users can swap missing nodes with compatible alternatives directly from the error panel, without opening a separate dialog. ## Changes - **What**: New `SwapNodesCard` and `SwapNodeGroupRow` components render swap groups in the Errors Tab; each group shows the missing node type, its instances (with locate buttons), and a Replace button. Added `useMissingNodeScan` composable to scan the graph for missing nodes and populate `executionErrorStore`. Added `removeMissingNodesByType()` to `executionErrorStore` so replaced nodes are pruned from the error list reactively. ## Bug Fixes Found During Implementation ### Bug 1: Replaced nodes render as empty shells until page refresh `replaceWithMapping()` directly mutates `_nodes[idx]`, bypassing the Vue rendering pipeline entirely. Because the replacement node reuses the same ID, `vueNodeData` retains the stale entry from the old placeholder (`hasErrors: true`, empty widgets/inputs). `graph.setDirtyCanvas()` only repaints the LiteGraph canvas and has no effect on Vue. **Fix**: After `replaceWithMapping()`, manually call `nodeGraph.onNodeAdded?.(newNode)` to trigger `handleNodeAdded` in `useGraphNodeManager`, which runs `extractVueNodeData(newNode)` and updates `vueNodeData` correctly. Also added a guard in `handleNodeAdded` to skip `layoutStore.createNode()` when a layout for the same ID already exists, preventing a duplicate `spatialIndex.insert()`. ### Bug 2: Missing node error list overwritten by incomplete server response Two compounding issues: (A) the server's `missing_node_type` error only reports the *first* missing node — the old handler parsed this and called `surfaceMissingNodes([singleNode])`, overwriting the full list collected at load time. (B) `queuePrompt()` calls `clearAllErrors()` before the API request; if the subsequent rescan used the stale `has_errors` flag and found nothing, the missing nodes were permanently lost. **Fix**: Created `useMissingNodeScan.ts` which scans `LiteGraph.registered_node_types` directly (not `has_errors`). The `missing_node_type` catch block in `app.ts` now calls `rescanAndSurfaceMissingNodes(this.rootGraph)` instead of parsing the server's partial response. ## Review Focus - `handleReplaceNode` removes the group from the store only when `replaceNodesInPlace` returns at least one replaced node — should we always clear, or only on full success? - `useMissingNodeScan` re-scans on every execution-error change; confirm no performance concerns for large graphs with many subgraphs. ## Screenshots https://github.com/user-attachments/assets/78310fc4-0424-4920-b369-cef60a123d50 https://github.com/user-attachments/assets/3d2fd5e1-5e85-4c20-86aa-8bf920e86987 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9253-feat-add-node-replacement-UI-to-Errors-Tab-3136d73d365081718d4ddfd628cb4449) by [Unito](https://www.unito.io)
1 parent 370c562 commit 341a919

File tree

14 files changed

+420
-165
lines changed

14 files changed

+420
-165
lines changed
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
<template>
2+
<div class="flex flex-col w-full mb-4">
3+
<!-- Type header row: type name + chevron -->
4+
<div class="flex h-8 items-center w-full">
5+
<p
6+
class="flex-1 min-w-0 text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap text-foreground"
7+
>
8+
{{ `${group.type} (${group.nodeTypes.length})` }}
9+
</p>
10+
11+
<Button
12+
variant="textonly"
13+
size="icon-sm"
14+
:class="
15+
cn(
16+
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
17+
{ 'rotate-180': expanded }
18+
)
19+
"
20+
:aria-label="
21+
expanded
22+
? t('rightSidePanel.missingNodePacks.collapse', 'Collapse')
23+
: t('rightSidePanel.missingNodePacks.expand', 'Expand')
24+
"
25+
@click="toggleExpand"
26+
>
27+
<i
28+
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
29+
/>
30+
</Button>
31+
</div>
32+
33+
<!-- Sub-labels: individual node instances, each with their own Locate button -->
34+
<TransitionCollapse>
35+
<div
36+
v-if="expanded"
37+
class="flex flex-col gap-0.5 pl-2 mb-2 overflow-hidden"
38+
>
39+
<div
40+
v-for="nodeType in group.nodeTypes"
41+
:key="getKey(nodeType)"
42+
class="flex h-7 items-center"
43+
>
44+
<span
45+
v-if="
46+
showNodeIdBadge &&
47+
typeof nodeType !== 'string' &&
48+
nodeType.nodeId != null
49+
"
50+
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 text-xs font-mono text-muted-foreground font-bold mr-1"
51+
>
52+
#{{ nodeType.nodeId }}
53+
</span>
54+
<p class="flex-1 min-w-0 text-xs text-muted-foreground truncate">
55+
{{ getLabel(nodeType) }}
56+
</p>
57+
<Button
58+
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
59+
variant="textonly"
60+
size="icon-sm"
61+
class="size-6 text-muted-foreground hover:text-base-foreground shrink-0 mr-1"
62+
:aria-label="t('rightSidePanel.locateNode', 'Locate Node')"
63+
@click="handleLocateNode(nodeType)"
64+
>
65+
<i class="icon-[lucide--locate] size-3" />
66+
</Button>
67+
</div>
68+
</div>
69+
</TransitionCollapse>
70+
71+
<!-- Description rows: what it is replaced by -->
72+
<div class="flex flex-col text-[13px] mb-2 mt-1 px-1 gap-0.5">
73+
<span class="text-muted-foreground">{{
74+
t('nodeReplacement.willBeReplacedBy', 'This node will be replaced by:')
75+
}}</span>
76+
<span class="font-bold text-foreground">{{
77+
group.newNodeId ?? t('nodeReplacement.unknownNode', 'Unknown')
78+
}}</span>
79+
</div>
80+
81+
<!-- Replace Action Button -->
82+
<div class="flex items-start w-full pt-1 pb-1">
83+
<Button
84+
variant="secondary"
85+
size="md"
86+
class="flex flex-1 w-full"
87+
@click="handleReplaceNode"
88+
>
89+
<i class="icon-[lucide--repeat] size-4 text-foreground shrink-0 mr-1" />
90+
<span class="text-sm text-foreground truncate min-w-0">
91+
{{ t('nodeReplacement.replaceNode', 'Replace Node') }}
92+
</span>
93+
</Button>
94+
</div>
95+
</div>
96+
</template>
97+
98+
<script setup lang="ts">
99+
import { ref } from 'vue'
100+
import { cn } from '@/utils/tailwindUtil'
101+
import { useI18n } from 'vue-i18n'
102+
import Button from '@/components/ui/button/Button.vue'
103+
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
104+
import type { MissingNodeType } from '@/types/comfy'
105+
import type { SwapNodeGroup } from './useErrorGroups'
106+
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
107+
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
108+
109+
const props = defineProps<{
110+
group: SwapNodeGroup
111+
showNodeIdBadge: boolean
112+
}>()
113+
114+
const emit = defineEmits<{
115+
'locate-node': [nodeId: string]
116+
}>()
117+
118+
const { t } = useI18n()
119+
const { replaceNodesInPlace } = useNodeReplacement()
120+
const executionErrorStore = useExecutionErrorStore()
121+
122+
const expanded = ref(false)
123+
124+
function toggleExpand() {
125+
expanded.value = !expanded.value
126+
}
127+
128+
function getKey(nodeType: MissingNodeType): string {
129+
if (typeof nodeType === 'string') return nodeType
130+
return nodeType.nodeId != null ? String(nodeType.nodeId) : nodeType.type
131+
}
132+
133+
function getLabel(nodeType: MissingNodeType): string {
134+
return typeof nodeType === 'string' ? nodeType : nodeType.type
135+
}
136+
137+
function handleLocateNode(nodeType: MissingNodeType) {
138+
if (typeof nodeType === 'string') return
139+
if (nodeType.nodeId != null) {
140+
emit('locate-node', String(nodeType.nodeId))
141+
}
142+
}
143+
144+
function handleReplaceNode() {
145+
const replaced = replaceNodesInPlace(props.group.nodeTypes)
146+
if (replaced.length > 0) {
147+
executionErrorStore.removeMissingNodesByType([props.group.type])
148+
}
149+
}
150+
</script>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<template>
2+
<div class="px-4 pb-2 mt-2">
3+
<!-- Sub-label: guidance message shown above all swap groups -->
4+
<p class="m-0 pb-5 text-sm text-muted-foreground leading-relaxed">
5+
{{
6+
t(
7+
'nodeReplacement.swapNodesGuide',
8+
'The following nodes can be automatically replaced with compatible alternatives.'
9+
)
10+
}}
11+
</p>
12+
<!-- Group Rows -->
13+
<SwapNodeGroupRow
14+
v-for="group in swapNodeGroups"
15+
:key="group.type"
16+
:group="group"
17+
:show-node-id-badge="showNodeIdBadge"
18+
@locate-node="emit('locate-node', $event)"
19+
/>
20+
</div>
21+
</template>
22+
23+
<script setup lang="ts">
24+
import { useI18n } from 'vue-i18n'
25+
import type { SwapNodeGroup } from './useErrorGroups'
26+
import SwapNodeGroupRow from './SwapNodeGroupRow.vue'
27+
28+
const { t } = useI18n()
29+
30+
const { swapNodeGroups, showNodeIdBadge } = defineProps<{
31+
swapNodeGroups: SwapNodeGroup[]
32+
showNodeIdBadge: boolean
33+
}>()
34+
35+
const emit = defineEmits<{
36+
'locate-node': [nodeId: string]
37+
}>()
38+
</script>

src/components/rightSidePanel/errors/TabErrors.vue

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@
2727
:key="group.title"
2828
:collapse="collapseState[group.title] ?? false"
2929
class="border-b border-interface-stroke"
30-
:size="group.type === 'missing_node' ? 'lg' : 'default'"
30+
:size="
31+
group.type === 'missing_node' || group.type === 'swap_nodes'
32+
? 'lg'
33+
: 'default'
34+
"
3135
@update:collapse="collapseState[group.title] = $event"
3236
>
3337
<template #label>
@@ -40,7 +44,9 @@
4044
{{
4145
group.type === 'missing_node'
4246
? `${group.title} (${missingPackGroups.length})`
43-
: group.title
47+
: group.type === 'swap_nodes'
48+
? `${group.title} (${swapNodeGroups.length})`
49+
: group.title
4450
}}
4551
</span>
4652
<span
@@ -69,6 +75,21 @@
6975
: t('rightSidePanel.missingNodePacks.installAll')
7076
}}
7177
</Button>
78+
<Button
79+
v-else-if="group.type === 'swap_nodes'"
80+
v-tooltip.top="
81+
t(
82+
'nodeReplacement.replaceAllWarning',
83+
'Replaces all available nodes in this group.'
84+
)
85+
"
86+
variant="secondary"
87+
size="sm"
88+
class="shrink-0 mr-2 h-8 rounded-lg text-sm"
89+
@click.stop="handleReplaceAll()"
90+
>
91+
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
92+
</Button>
7293
</div>
7394
</template>
7495

@@ -82,8 +103,16 @@
82103
@open-manager-info="handleOpenManagerInfo"
83104
/>
84105

106+
<!-- Swap Nodes -->
107+
<SwapNodesCard
108+
v-else-if="group.type === 'swap_nodes'"
109+
:swap-node-groups="swapNodeGroups"
110+
:show-node-id-badge="showNodeIdBadge"
111+
@locate-node="handleLocateMissingNode"
112+
/>
113+
85114
<!-- Execution Errors -->
86-
<div v-else class="px-4 space-y-3">
115+
<div v-else-if="group.type === 'execution'" class="px-4 space-y-3">
87116
<ErrorNodeCard
88117
v-for="card in group.cards"
89118
:key="card.id"
@@ -150,11 +179,14 @@ import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
150179
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
151180
import ErrorNodeCard from './ErrorNodeCard.vue'
152181
import MissingNodeCard from './MissingNodeCard.vue'
182+
import SwapNodesCard from './SwapNodesCard.vue'
153183
import Button from '@/components/ui/button/Button.vue'
154184
import DotSpinner from '@/components/common/DotSpinner.vue'
155185
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
156186
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
157187
import { useErrorGroups } from './useErrorGroups'
188+
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
189+
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
158190
159191
const { t } = useI18n()
160192
const { copyToClipboard } = useCopyToClipboard()
@@ -167,6 +199,8 @@ const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
167199
const { missingNodePacks } = useMissingNodes()
168200
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
169201
usePackInstall(() => missingNodePacks.value)
202+
const { replaceNodesInPlace } = useNodeReplacement()
203+
const executionErrorStore = useExecutionErrorStore()
170204
171205
const searchQuery = ref('')
172206
@@ -183,7 +217,8 @@ const {
183217
isSingleNodeSelected,
184218
errorNodeCache,
185219
missingNodeCache,
186-
missingPackGroups
220+
missingPackGroups,
221+
swapNodeGroups
187222
} = useErrorGroups(searchQuery, t)
188223
189224
/**
@@ -229,6 +264,14 @@ function handleOpenManagerInfo(packId: string) {
229264
}
230265
}
231266
267+
function handleReplaceAll() {
268+
const allNodeTypes = swapNodeGroups.value.flatMap((g) => g.nodeTypes)
269+
const replaced = replaceNodesInPlace(allNodeTypes)
270+
if (replaced.length > 0) {
271+
executionErrorStore.removeMissingNodesByType(replaced)
272+
}
273+
}
274+
232275
function handleEnterSubgraph(nodeId: string) {
233276
enterSubgraph(nodeId, errorNodeCache.value)
234277
}

src/components/rightSidePanel/errors/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ export type ErrorGroup =
2222
priority: number
2323
}
2424
| { type: 'missing_node'; title: string; priority: number }
25+
| { type: 'swap_nodes'; title: string; priority: number }

src/components/rightSidePanel/errors/useErrorGroups.ts

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ export interface MissingPackGroup {
4242
isResolving: boolean
4343
}
4444

45+
export interface SwapNodeGroup {
46+
type: string
47+
newNodeId: string | undefined
48+
nodeTypes: MissingNodeType[]
49+
}
50+
4551
interface GroupEntry {
4652
type: 'execution'
4753
priority: number
@@ -444,6 +450,8 @@ export function useErrorGroups(
444450
const resolvingKeys = new Set<string | null>()
445451

446452
for (const nodeType of nodeTypes) {
453+
if (typeof nodeType !== 'string' && nodeType.isReplaceable) continue
454+
447455
let packId: string | null
448456

449457
if (typeof nodeType === 'string') {
@@ -495,18 +503,53 @@ export function useErrorGroups(
495503
}))
496504
})
497505

506+
const swapNodeGroups = computed<SwapNodeGroup[]>(() => {
507+
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
508+
const map = new Map<string, SwapNodeGroup>()
509+
510+
for (const nodeType of nodeTypes) {
511+
if (typeof nodeType === 'string' || !nodeType.isReplaceable) continue
512+
513+
const typeName = nodeType.type
514+
const existing = map.get(typeName)
515+
if (existing) {
516+
existing.nodeTypes.push(nodeType)
517+
} else {
518+
map.set(typeName, {
519+
type: typeName,
520+
newNodeId: nodeType.replacement?.new_node_id,
521+
nodeTypes: [nodeType]
522+
})
523+
}
524+
}
525+
526+
return Array.from(map.values()).sort((a, b) => a.type.localeCompare(b.type))
527+
})
528+
498529
/** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
499530
function buildMissingNodeGroups(): ErrorGroup[] {
500531
const error = executionErrorStore.missingNodesError
501532
if (!error) return []
502533

503-
return [
504-
{
534+
const groups: ErrorGroup[] = []
535+
536+
if (swapNodeGroups.value.length > 0) {
537+
groups.push({
538+
type: 'swap_nodes' as const,
539+
title: st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
540+
priority: 0
541+
})
542+
}
543+
544+
if (missingPackGroups.value.length > 0) {
545+
groups.push({
505546
type: 'missing_node' as const,
506547
title: error.message,
507-
priority: 0
508-
}
509-
]
548+
priority: 1
549+
})
550+
}
551+
552+
return groups.sort((a, b) => a.priority - b.priority)
510553
}
511554

512555
const allErrorGroups = computed<ErrorGroup[]>(() => {
@@ -564,6 +607,7 @@ export function useErrorGroups(
564607
errorNodeCache,
565608
missingNodeCache,
566609
groupedErrorMessages,
567-
missingPackGroups
610+
missingPackGroups,
611+
swapNodeGroups
568612
}
569613
}

0 commit comments

Comments
 (0)