New bottom button and badges (#8603)

- "Enter Subgraph" "Show advanced inputs" and a new "show node Errors"
button now use a combined button design at the bottom of the node.
- A new "Errors" tab is added to the right side panel
- After a failed queue, the label of an invalid widget is now red.
- Badges other than price are now displayed on the bottom of the node.
- Price badge will now truncate from the first space, prioritizing the
sizing of the node title
- An indicator for the node resize handle is now displayed while mousing
over the node.

<img width="669" height="233" alt="image"
src="https://github.com/user-attachments/assets/53b3b59c-830b-474d-8f20-07f557124af7"
/>


![resize](https://github.com/user-attachments/assets/e2473b5b-fe4d-4f1e-b1c3-57c23d2a0349)

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
AustinMroz
2026-02-10 23:29:45 -08:00
committed by GitHub
parent 69062c6da1
commit 28b171168a
25 changed files with 396 additions and 251 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 9">
<path d="M1.82148 8.68376C1.61587 8.68376 1.44996 8.60733 1.34177 8.46284C1.23057 8.31438 1.20157 8.10711 1.26219 7.89434L1.50561 7.03961C1.52502 6.97155 1.51151 6.89831 1.46918 6.8417C1.42684 6.7852 1.3606 6.75194 1.29025 6.75194H0.590376C0.384656 6.75194 0.21875 6.67562 0.110614 6.53113C-0.000591531 6.38256 -0.0295831 6.17529 0.0310774 5.96252L0.867308 3.03952L0.959638 2.71838C1.08375 2.28258 1.53638 1.9284 1.96878 1.9284H2.80622C2.90615 1.9284 2.99406 1.86177 3.02157 1.76508L3.29852 0.79284C3.4225 0.357484 3.87514 0.0033043 4.30753 0.0033043L6.09854 0.000112775L7.40967 0C7.61533 0 7.78124 0.0763259 7.88937 0.220813C8.00058 0.369269 8.02957 0.576538 7.96895 0.78931L7.59405 2.10572C7.4701 2.54096 7.01746 2.89503 6.58507 2.89503L4.79008 2.89844H3.95292C3.8531 2.89844 3.7653 2.96496 3.73762 3.06155L3.03961 5.49964C3.02008 5.56781 3.03359 5.64127 3.07604 5.69787C3.11837 5.75437 3.18461 5.78763 3.2549 5.78763C3.25507 5.78763 4.44105 5.78532 4.44105 5.78532H5.7483C5.95396 5.78532 6.11986 5.86164 6.228 6.00613C6.33921 6.1547 6.3682 6.36197 6.30754 6.57474L5.93263 7.89092C5.80869 8.32628 5.35605 8.68034 4.92366 8.68034L3.12872 8.68376H1.82148Z" fill="#8A8A8A"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -14,11 +14,13 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import TabError from './TabError.vue'
import TabInfo from './info/TabInfo.vue'
import TabGlobalParameters from './parameters/TabGlobalParameters.vue'
import TabNodes from './parameters/TabNodes.vue'
@@ -33,6 +35,7 @@ import {
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
@@ -87,10 +90,25 @@ function closePanel() {
type RightSidePanelTabList = Array<{
label: () => string
value: RightSidePanelTab
icon?: string
}>
//FIXME all errors if nothing selected?
const selectedNodeErrors = computed(() =>
selectedNodes.value
.map((node) => executionStore.getNodeErrors(`${node.id}`))
.filter((nodeError) => !!nodeError)
)
const tabs = computed<RightSidePanelTabList>(() => {
const list: RightSidePanelTabList = []
if (selectedNodeErrors.value.length) {
list.push({
label: () => t('g.error'),
value: 'error',
icon: 'icon-[lucide--octagon-alert] bg-node-stroke-error ml-1'
})
}
list.push({
label: () =>
@@ -271,6 +289,7 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
:value="tab.value"
>
{{ tab.label() }}
<i v-if="tab.icon" :class="cn(tab.icon, 'size-4')" />
</Tab>
</TabList>
</nav>
@@ -288,6 +307,7 @@ function handleProxyWidgetsUpdate(value: ProxyWidgetsProperty) {
:node="selectedSingleNode"
/>
<template v-else>
<TabError v-if="activeTab === 'error'" :errors="selectedNodeErrors" />
<TabSubgraphInputs
v-if="activeTab === 'parameters' && isSingleSubgraphNode"
:node="selectedSingleNode as SubgraphNode"

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import type { NodeError } from '@/schemas/apiSchema'
const { t } = useI18n()
defineProps<{
errors: NodeError[]
}>()
const { copyToClipboard } = useCopyToClipboard()
</script>
<template>
<div class="m-4">
<Button class="w-full" @click="copyToClipboard(JSON.stringify(errors))">
{{ t('g.copy') }}
<i class="icon-[lucide--copy] size-4" />
</Button>
</div>
<div
v-for="(error, index) in errors.flatMap((ne) => ne.errors)"
:key="index"
class="px-2"
>
<h3 class="text-error" v-text="error.message" />
<div class="text-muted-foreground" v-text="error.details" />
</div>
</template>

View File

@@ -36,7 +36,9 @@ export const usePriceBadge = () => {
return badges
}
function isCreditsBadge(badge: LGraphBadge | (() => LGraphBadge)): boolean {
function isCreditsBadge(
badge: Partial<LGraphBadge> | (() => Partial<LGraphBadge>)
): boolean {
const badgeInstance = typeof badge === 'function' ? badge() : badge
return badgeInstance.icon?.image === componentIconSvg
}
@@ -61,6 +63,7 @@ export const usePriceBadge = () => {
}
return {
getCreditsBadge,
isCreditsBadge,
updateSubgraphCredits
}
}

View File

@@ -68,6 +68,7 @@
"icon": "Icon",
"color": "Color",
"error": "Error",
"enterSubgraph": "Enter Subgraph",
"resizeFromBottomRight": "Resize from bottom-right corner",
"resizeFromTopRight": "Resize from top-right corner",
"resizeFromBottomLeft": "Resize from bottom-left corner",

View File

@@ -23,7 +23,7 @@
:class="
cn(
'-translate-x-1/2 w-3',
hasSlotError &&
hasError &&
'before:ring-2 before:ring-error before:ring-offset-0 before:size-4 before:absolute before:rounded-full before:pointer-events-none'
)
"
@@ -40,7 +40,7 @@
:class="
cn(
'truncate text-node-component-slot-text',
hasSlotError && 'text-error font-medium'
hasError && 'text-error font-medium'
)
"
>
@@ -65,19 +65,19 @@ import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { useExecutionStore } from '@/stores/executionStore'
import { cn } from '@/utils/tailwindUtil'
import SlotConnectionDot from './SlotConnectionDot.vue'
interface InputSlotProps {
slotData: INodeSlot
compatible?: boolean
connected?: boolean
dotOnly?: boolean
hasError?: boolean
index: number
nodeType?: string
nodeId?: string
slotData: INodeSlot
index: number
connected?: boolean
compatible?: boolean
dotOnly?: boolean
socketless?: boolean
}
@@ -91,18 +91,6 @@ const hasNoLabel = computed(
)
const dotOnly = computed(() => props.dotOnly || hasNoLabel.value)
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 renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()

View File

@@ -7,11 +7,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
LGraph,
LGraphNode,
LGraphNode as LGLGraphNode,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import NodeHeader from '@/renderer/extensions/vueNodes/components/NodeHeader.vue'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
const mockApp: { rootGraph?: Partial<LGraph> } = vi.hoisted(() => ({}))
@@ -56,7 +56,7 @@ vi.mock('@/i18n', () => ({
}
}))
describe('NodeHeader - Subgraph Functionality', () => {
describe('Vue Node - Subgraph Functionality', () => {
// Helper to setup common mocks
const setupMocks = async (isSubgraph = true, hasGraph = true) => {
if (hasGraph) mockApp.rootGraph = {}
@@ -64,7 +64,7 @@ describe('NodeHeader - Subgraph Functionality', () => {
vi.mocked(getNodeByLocatorId).mockReturnValue({
isSubgraphNode: (): this is SubgraphNode => isSubgraph
} as LGraphNode)
} as LGLGraphNode)
}
beforeEach(() => {
@@ -89,8 +89,8 @@ describe('NodeHeader - Subgraph Functionality', () => {
flags: {}
})
const createWrapper = (props = {}) => {
return mount(NodeHeader, {
const createWrapper = (props: { nodeData: VueNodeData }) => {
return mount(LGraphNode, {
props,
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })],
@@ -106,8 +106,7 @@ describe('NodeHeader - Subgraph Functionality', () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
nodeData: createMockNodeData('test-node-1')
})
await wrapper.vm.$nextTick()
@@ -120,8 +119,7 @@ describe('NodeHeader - Subgraph Functionality', () => {
await setupMocks(false) // isSubgraph = false
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
nodeData: createMockNodeData('test-node-1')
})
await wrapper.vm.$nextTick()
@@ -130,29 +128,11 @@ describe('NodeHeader - Subgraph Functionality', () => {
expect(subgraphButton.exists()).toBe(false)
})
it('should emit enter-subgraph event when button is clicked', async () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
})
await wrapper.vm.$nextTick()
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
await subgraphButton.trigger('click')
expect(wrapper.emitted('enter-subgraph')).toBeTruthy()
expect(wrapper.emitted('enter-subgraph')).toHaveLength(1)
})
it('should handle subgraph context correctly', async () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1', 'subgraph-id'),
readonly: false
nodeData: createMockNodeData('test-node-1', 'subgraph-id')
})
await wrapper.vm.$nextTick()
@@ -167,26 +147,11 @@ describe('NodeHeader - Subgraph Functionality', () => {
expect(subgraphButton.exists()).toBe(true)
})
it('should handle missing graph gracefully', async () => {
await setupMocks(true, false) // isSubgraph = true, hasGraph = false
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
})
await wrapper.vm.$nextTick()
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
expect(subgraphButton.exists()).toBe(false)
})
it('should prevent event propagation on double click', async () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
nodeData: createMockNodeData('test-node-1')
})
await wrapper.vm.$nextTick()

View File

@@ -9,7 +9,7 @@
:data-node-id="nodeData.id"
:class="
cn(
'bg-component-node-background lg-node absolute text-sm',
'group/node bg-node-component-header-surface lg-node absolute text-sm',
'contain-style contain-layout min-w-[225px] min-h-(--node-height) w-(--node-width)',
shapeClass,
'touch-none flex flex-col',
@@ -28,11 +28,9 @@
muted,
'ring-4 ring-primary-500 bg-primary-500/10': isDraggingOver
},
shouldHandleNodePointerEvents && !nodeData.flags?.ghost
? 'pointer-events-auto'
: 'pointer-events-none',
!isCollapsed && ' pb-1'
: 'pointer-events-none'
)
"
:style="[
@@ -40,7 +38,8 @@
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
zIndex: zIndex,
opacity: nodeOpacity,
'--component-node-background': applyLightThemeColor(nodeData.bgcolor)
'--component-node-background': applyLightThemeColor(nodeData.bgcolor),
backgroundColor: applyLightThemeColor(nodeData?.color)
}
]"
v-bind="remainingPointerHandlers"
@@ -71,9 +70,9 @@
<NodeHeader
:node-data="nodeData"
:collapsed="isCollapsed"
:price-badges="badges.pricing"
@collapse="handleCollapse"
@update:title="handleHeaderTitleUpdate"
@enter-subgraph="handleEnterSubgraph"
/>
</div>
@@ -89,7 +88,7 @@
/>
<template v-if="!isCollapsed">
<div class="relative mb-1">
<div class="relative">
<!-- Progress bar for executing state -->
<div
v-if="executing && progress !== undefined"
@@ -105,7 +104,7 @@
</div>
<div
class="flex flex-1 flex-col gap-1 pb-2"
class="flex flex-1 flex-col gap-1 pt-1 pb-3 bg-component-node-background rounded-b-2xl"
:data-testid="`node-body-${nodeData.id}`"
>
<NodeSlots :node-data="nodeData" />
@@ -120,42 +119,75 @@
v-if="shouldShowPreviewImg"
:image-url="latestPreviewUrl"
/>
<!-- Show advanced inputs button for subgraph nodes -->
<div v-if="showAdvancedInputsButton" class="flex justify-center px-3">
<button
:class="
cn(
WidgetInputBaseClass,
'w-full h-7 flex justify-center items-center gap-2 text-sm px-3 outline-0 ring-0 truncate',
'transition-all cursor-pointer hover:bg-accent-background duration-150 active:scale-95'
)
"
@click.stop="showAdvancedState = !showAdvancedState"
>
<template v-if="showAdvancedState">
<i class="icon-[lucide--chevron-up] size-4" />
<span>{{ t('rightSidePanel.hideAdvancedInputsButton') }}</span>
</template>
<template v-else>
<i class="icon-[lucide--settings-2] size-4" />
<span>{{ t('rightSidePanel.showAdvancedInputsButton') }} </span>
</template>
</button>
</div>
<NodeBadges v-bind="badges" :pricing="undefined" />
</div>
</template>
<Button
variant="textonly"
:class="
cn(
'w-full h-12 rounded-b-2xl -mt-5 pt-7 pb-2 -z-1 text-xs',
hasAnyError && 'hover:bg-destructive-background-hover'
)
"
as-child
>
<button
v-if="hasAnyError"
@click.stop="useRightSidePanelStore().openPanel('error')"
>
<span>{{ t('g.error') }}</span>
<i class="icon-[lucide--info] size-4" />
</button>
<button
v-else-if="lgraphNode?.isSubgraphNode()"
data-testid="subgraph-enter-button"
@click.stop="handleEnterSubgraph"
>
<span>{{ t('g.enterSubgraph') }}</span>
<i class="icon-[comfy--workflow] size-4" />
</button>
<button
v-else-if="showAdvancedState || showAdvancedInputsButton"
@click.stop="showAdvancedState = !showAdvancedState"
>
<template v-if="showAdvancedState">
<span>{{ t('rightSidePanel.hideAdvancedInputsButton') }}</span>
<i class="icon-[lucide--chevron-up] size-4" />
</template>
<template v-else>
<span>{{ t('rightSidePanel.showAdvancedInputsButton') }} </span>
<i class="icon-[lucide--settings-2] size-4" />
</template>
</button>
</Button>
<!-- Resize handle (bottom-right only) -->
<div
v-if="!isCollapsed && nodeData.resizable !== false"
role="button"
:aria-label="t('g.resizeFromBottomRight')"
:class="
cn(baseResizeHandleClasses, '-right-1 -bottom-1 cursor-se-resize')
cn(
baseResizeHandleClasses,
'-right-1 -bottom-1 cursor-se-resize group-hover/node:opacity-100'
)
"
@pointerdown.stop="handleResizePointerDown"
/>
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 12 12"
class="w-2/5 h-2/5 top-1 left-1 absolute"
>
<path
d="M11 1L1 11M11 6L6 11"
stroke="var(--color-muted-foreground)"
stroke-width="0.975"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</div>
</template>
@@ -172,6 +204,7 @@ import {
} from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
@@ -189,10 +222,12 @@ import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import NodeBadges from '@/renderer/extensions/vueNodes/components/NodeBadges.vue'
import SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { usePartitionedBadges } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
import { useNodeDrag } from '@/renderer/extensions/vueNodes/layout/useNodeDrag'
@@ -212,7 +247,6 @@ import {
import { cn } from '@/utils/tailwindUtil'
import { useNodeResize } from '../interactions/resize/useNodeResize'
import { WidgetInputBaseClass } from '../widgets/components/layout'
import LivePreview from './LivePreview.vue'
import NodeContent from './NodeContent.vue'
import NodeHeader from './NodeHeader.vue'
@@ -299,6 +333,7 @@ const { position, size, zIndex } = useNodeLayout(() => nodeData.id)
const { pointerHandlers } = useNodePointerInteractions(() => nodeData.id)
const { onPointerdown, ...remainingPointerHandlers } = pointerHandlers
const { startDrag } = useNodeDrag()
const badges = usePartitionedBadges(nodeData)
async function nodeOnPointerdown(event: PointerEvent) {
if (event.altKey && lgraphNode.value) {
@@ -405,7 +440,7 @@ const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
)
const borderClass = computed(() => {
if (hasAnyError.value) return 'border-node-stroke-error'
if (hasAnyError.value) return 'border-node-stroke-error bg-error'
//FIXME need a better way to detecting transparency
if (
!displayHeader.value &&

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import NodeBadge from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
import type { NodeBadgeProps } from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
defineProps<{
hasComfyBadge: boolean
core: NodeBadgeProps[]
extension: NodeBadgeProps[]
}>()
</script>
<template>
<div
v-if="hasComfyBadge || core.length || extension.length"
class="flex h-5 w-full gap-2 px-2 text-muted-foreground"
>
<div
v-if="hasComfyBadge"
class="rounded-full bg-component-node-widget-background size-6 flex justify-center items-center"
>
<i class="icon-[comfy--comfy-c] size-3" />
</div>
<div
v-if="core.length"
class="rounded-full bg-component-node-widget-background h-6 flex justify-center items-center overflow-clip"
>
<template v-for="(badge, index) of core" :key="badge.text">
<div
v-if="index !== 0"
class="border-muted-foreground border-r h-4 mr-0.5 pr-0.5"
/>
<NodeBadge
bg-color="transparent"
v-bind="badge"
class="h-6 first:pl-2 last:pr-2"
/>
</template>
</div>
<div
v-if="extension.length"
class="rounded-full bg-component-node-widget-background h-6 flex justify-center items-center overflow-clip"
>
<template v-for="(badge, index) of extension" :key="badge.text">
<div
v-if="index !== 0"
class="border-muted-foreground border-r h-4 mr-0.5 pr-0.5"
/>
<NodeBadge
bg-color="transparent"
v-bind="badge"
class="h-6 first:pl-2 last:pr-2"
/>
</template>
</div>
</div>
</template>

View File

@@ -7,20 +7,16 @@
:class="
cn(
'lg-node-header text-sm py-2 pl-2 pr-3 w-full min-w-0',
'text-node-component-header bg-node-component-header-surface',
'text-node-component-header',
headerShapeClass
)
"
:style="{
backgroundColor: applyLightThemeColor(nodeData?.color),
opacity: useSettingStore().get('Comfy.Node.Opacity') ?? 1
}"
:data-testid="`node-header-${nodeData?.id || ''}`"
@dblclick="handleDoubleClick"
>
<div class="flex items-center justify-between gap-2.5 min-w-0">
<!-- Collapse/Expand Button -->
<div class="relative grow-1 flex items-center gap-2.5 min-w-0 flex-1">
<div class="relative flex items-center gap-2.5 min-w-0 shrink-1 mr-auto">
<div class="flex shrink-0 items-center px-0.5">
<Button
size="icon-sm"
@@ -41,17 +37,13 @@
/>
</Button>
</div>
<div v-if="isSubgraphNode" class="icon-[comfy--workflow] size-4" />
<div v-if="isApiNode" class="icon-[lucide--component] size-4" />
<!-- Node Title -->
<div
v-tooltip.top="tooltipConfig"
class="flex min-w-0 flex-1 items-center gap-2"
data-testid="node-title"
>
<div class="truncate min-w-0 flex-1">
<div class="truncate flex-1">
<EditableText
:model-value="displayTitle"
:is-editing="isEditing"
@@ -63,56 +55,46 @@
</div>
</div>
<div class="flex shrink-0 items-center justify-between gap-2">
<NodeBadge
v-for="badge of nodeBadges"
:key="badge.text"
v-bind="badge"
/>
<NodeBadge v-if="statusBadge" v-bind="statusBadge" />
<i-comfy:pin
v-if="isPinned"
class="size-5"
data-testid="node-pin-indicator"
/>
<Button
v-if="isSubgraphNode"
v-tooltip.top="enterSubgraphTooltipConfig"
variant="textonly"
size="sm"
data-testid="subgraph-enter-button"
class="text-node-component-header h-5 px-0.5"
@click.stop="handleEnterSubgraph"
@dblclick.stop
<template v-for="badge in priceBadges ?? []" :key="badge.required">
<span
:class="
cn(
'flex h-5 bg-component-node-widget-background p-1 items-center text-xs shrink-0',
badge.rest ? 'rounded-l-full pr-1' : 'rounded-full'
)
"
>
<span>{{ $t('g.edit') }}</span>
<i class="icon-[lucide--scaling] size-5" />
</Button>
</div>
<i class="h-full icon-[lucide--component] bg-amber-400" />
<span class="truncate" v-text="badge.required" />
</span>
<span
v-if="badge.rest"
class="truncate -ml-2.5 grow-1 basis-0 bg-component-node-widget-background rounded-r-full max-w-max min-w-0"
>
<span class="pr-2" v-text="badge.rest" />
</span>
</template>
<NodeBadge v-if="statusBadge" v-bind="statusBadge" />
<i
v-if="isPinned"
class="size-5 icon-[comfy--pin]"
data-testid="node-pin-indicator"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onErrorCaptured, ref, toValue, watch } from 'vue'
import { computed, onErrorCaptured, ref, watch } from 'vue'
import EditableText from '@/components/common/EditableText.vue'
import Button from '@/components/ui/button/Button.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useNodePricing } from '@/composables/node/useNodePricing'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import { LGraphEventMode, RenderShape } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import NodeBadge from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
import { app } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
getLocatorIdFromNodeData,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
@@ -121,6 +103,7 @@ import type { NodeBadgeProps } from './NodeBadge.vue'
interface NodeHeaderProps {
nodeData?: VueNodeData
collapsed?: boolean
priceBadges?: { required: string; rest?: string }[]
}
const { nodeData, collapsed } = defineProps<NodeHeaderProps>()
@@ -128,7 +111,6 @@ const { nodeData, collapsed } = defineProps<NodeHeaderProps>()
const emit = defineEmits<{
collapse: []
'update:title': [newTitle: string]
'enter-subgraph': []
}>()
// Error boundary implementation
@@ -156,10 +138,6 @@ const tooltipConfig = computed(() => {
return createTooltipConfig(description)
})
const enterSubgraphTooltipConfig = computed(() => {
return createTooltipConfig(st('enterSubgraph', 'Enter Subgraph'))
})
const resolveTitle = (info: VueNodeData | undefined) => {
const untitledLabel = st('g.untitled', 'Untitled')
return resolveNodeDisplayName(info ?? null, {
@@ -185,71 +163,7 @@ const statusBadge = computed((): NodeBadgeProps | undefined =>
: undefined
)
// Use per-node pricing revision to re-compute badges only when this node's pricing updates
const {
getRelevantWidgetNames,
hasDynamicPricing,
getInputGroupPrefixes,
getInputNames,
getNodeRevisionRef
} = useNodePricing()
// Cache pricing metadata (won't change during node lifetime)
const isDynamicPricing = computed(() =>
nodeData?.apiNode ? hasDynamicPricing(nodeData.type) : false
)
const relevantPricingWidgets = computed(() =>
nodeData?.apiNode ? getRelevantWidgetNames(nodeData.type) : []
)
const inputGroupPrefixes = computed(() =>
nodeData?.apiNode ? getInputGroupPrefixes(nodeData.type) : []
)
const relevantInputNames = computed(() =>
nodeData?.apiNode ? getInputNames(nodeData.type) : []
)
const nodeBadges = computed<NodeBadgeProps[]>(() => {
// For ALL API nodes: access per-node revision ref to detect when async pricing evaluation completes
// This is needed even for static pricing because JSONata 2.x evaluation is async
if (nodeData?.apiNode && nodeData?.id != null) {
// Access per-node revision ref to establish dependency (each node has its own ref)
void getNodeRevisionRef(nodeData.id).value
// For dynamic pricing, also track widget values and input connections
if (isDynamicPricing.value) {
// Access only the widget values that affect pricing (from widgetValueStore)
const relevantNames = relevantPricingWidgets.value
const widgetStore = useWidgetValueStore()
if (relevantNames.length > 0 && nodeData?.id != null) {
for (const name of relevantNames) {
// Access value from store to create reactive dependency
void widgetStore.getWidget(nodeData.id, name)?.value
}
}
// Access input connections for regular inputs
const inputNames = relevantInputNames.value
if (inputNames.length > 0) {
nodeData?.inputs?.forEach((inp) => {
if (inp.name && inputNames.includes(inp.name)) {
void inp.link // Access link to create reactive dependency
}
})
}
// Access input connections for input_groups (e.g., autogrow inputs)
const groupPrefixes = inputGroupPrefixes.value
if (groupPrefixes.length > 0) {
nodeData?.inputs?.forEach((inp) => {
if (
groupPrefixes.some((prefix) => inp.name?.startsWith(prefix + '.'))
) {
void inp.link // Access link to create reactive dependency
}
})
}
}
}
return [...(nodeData?.badges ?? [])].map(toValue)
})
const isPinned = computed(() => Boolean(nodeData?.flags?.pinned))
const isApiNode = computed(() => Boolean(nodeData?.apiNode))
const headerShapeClass = computed(() => {
if (collapsed) {
@@ -272,22 +186,6 @@ const headerShapeClass = computed(() => {
}
})
// Subgraph detection
const isSubgraphNode = computed(() => {
if (!nodeData?.id) return false
// Get the underlying LiteGraph node
const graph = app.rootGraph
if (!graph) return false
const locatorId = getLocatorIdFromNodeData(nodeData)
const litegraphNode = getNodeByLocatorId(graph, locatorId)
// Use the official type guard method
return litegraphNode?.isSubgraphNode() ?? false
})
// Watch for external changes to the node title or type
watch(
() => [nodeData?.title, nodeData?.type] as const,
@@ -320,8 +218,4 @@ const handleTitleEdit = (newTitle: string) => {
const handleTitleCancel = () => {
isEditing.value = false
}
const handleEnterSubgraph = () => {
emit('enter-subgraph')
}
</script>

View File

@@ -47,6 +47,7 @@
boundingRect: [0, 0, 0, 0]
}"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:has-error="widget.hasError"
:index="widget.slotMetadata.index"
:socketless="widget.simplified.spec?.socketless"
dot-only
@@ -60,7 +61,12 @@
:widget="widget.simplified"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:node-type="nodeType"
class="col-span-2"
:class="
cn(
'col-span-2',
widget.hasError && 'text-node-stroke-error font-bold'
)
"
@update:model-value="widget.updateHandler"
/>
</div>
@@ -95,6 +101,7 @@ import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useExecutionStore } from '@/stores/executionStore'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
@@ -109,6 +116,7 @@ const { nodeData } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
const { bringNodeToFront } = useNodeZIndex()
const executionStore = useExecutionStore()
function handleWidgetPointerEvent(event: PointerEvent) {
if (shouldHandleNodePointerEvents.value) return
@@ -146,21 +154,23 @@ const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
const widgetValueStore = useWidgetValueStore()
interface ProcessedWidget {
name: string
type: string
vueComponent: Component
simplified: SimplifiedWidget
value: WidgetValue
updateHandler: (value: WidgetValue) => void
tooltipConfig: TooltipOptions
slotMetadata?: WidgetSlotMetadata
hidden: boolean
advanced: boolean
hasLayoutSize: boolean
hasError: boolean
hidden: boolean
name: string
simplified: SimplifiedWidget
tooltipConfig: TooltipOptions
type: string
updateHandler: (value: WidgetValue) => void
value: WidgetValue
vueComponent: Component
slotMetadata?: WidgetSlotMetadata
}
const processedWidgets = computed((): ProcessedWidget[] => {
if (!nodeData?.widgets) return []
const nodeErrors = executionStore.lastNodeErrors?.[nodeData.id ?? '']
const nodeId = nodeData.id
const { widgets } = nodeData
@@ -220,6 +230,13 @@ const processedWidgets = computed((): ProcessedWidget[] => {
const tooltipConfig = createTooltipConfig(tooltipText)
result.push({
advanced: widget.options?.advanced ?? false,
hasLayoutSize: widget.hasLayoutSize ?? false,
hasError:
nodeErrors?.errors?.some(
(error) => error.extra_info?.input_name === widget.name
) ?? false,
hidden: widget.options?.hidden ?? false,
name: widget.name,
type: widget.type,
vueComponent,
@@ -227,10 +244,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
value,
updateHandler,
tooltipConfig,
slotMetadata,
hidden: widget.options?.hidden ?? false,
advanced: widget.options?.advanced ?? false,
hasLayoutSize: widget.hasLayoutSize ?? false
slotMetadata
})
}

View File

@@ -0,0 +1,135 @@
import { trim } from 'es-toolkit'
import { computed, toValue } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useNodePricing } from '@/composables/node/useNodePricing'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { NodeBadgeProps } from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeBadgeMode } from '@/types/nodeSource'
function splitAroundFirstSpace(text: string): [string, string | undefined] {
const index = text.indexOf(' ')
if (index === -1) return [text, undefined]
return [text.slice(0, index), text.slice(index + 1)]
}
export function usePartitionedBadges(nodeData: VueNodeData) {
// Use per-node pricing revision to re-compute badges only when this node's pricing updates
const {
getRelevantWidgetNames,
hasDynamicPricing,
getInputGroupPrefixes,
getInputNames,
getNodeRevisionRef
} = useNodePricing()
const { isCreditsBadge } = usePriceBadge()
const settingStore = useSettingStore()
// Cache pricing metadata (won't change during node lifetime)
const isDynamicPricing = computed(() =>
nodeData?.apiNode ? hasDynamicPricing(nodeData.type) : false
)
const relevantPricingWidgets = computed(() =>
nodeData?.apiNode ? getRelevantWidgetNames(nodeData.type) : []
)
const inputGroupPrefixes = computed(() =>
nodeData?.apiNode ? getInputGroupPrefixes(nodeData.type) : []
)
const relevantInputNames = computed(() =>
nodeData?.apiNode ? getInputNames(nodeData.type) : []
)
const unpartitionedBadges = computed<NodeBadgeProps[]>(() => {
// For ALL API nodes: access per-node revision ref to detect when async pricing evaluation completes
// This is needed even for static pricing because JSONata 2.x evaluation is async
if (nodeData?.apiNode && nodeData?.id != null) {
// Access per-node revision ref to establish dependency (each node has its own ref)
void getNodeRevisionRef(nodeData.id).value
// For dynamic pricing, also track widget values and input connections
if (isDynamicPricing.value) {
// Access only the widget values that affect pricing (from widgetValueStore)
const relevantNames = relevantPricingWidgets.value
const widgetStore = useWidgetValueStore()
if (relevantNames.length > 0 && nodeData?.id != null) {
for (const name of relevantNames) {
// Access value from store to create reactive dependency
void widgetStore.getWidget(nodeData.id, name)?.value
}
}
// Access input connections for regular inputs
const inputNames = relevantInputNames.value
if (inputNames.length > 0) {
nodeData?.inputs?.forEach((inp) => {
if (inp.name && inputNames.includes(inp.name)) {
void inp.link // Access link to create reactive dependency
}
})
}
// Access input connections for input_groups (e.g., autogrow inputs)
const groupPrefixes = inputGroupPrefixes.value
if (groupPrefixes.length > 0) {
nodeData?.inputs?.forEach((inp) => {
if (
groupPrefixes.some((prefix) => inp.name?.startsWith(prefix + '.'))
) {
void inp.link // Access link to create reactive dependency
}
})
}
}
}
return [...(nodeData?.badges ?? [])].map(toValue)
})
const nodeDef = useNodeDefStore().nodeDefsByName[nodeData.type]
return computed(() => {
const displaySource = settingStore.get(
'Comfy.NodeBadge.NodeSourceBadgeMode'
)
const isCoreNode =
nodeDef?.isCoreNode && displaySource === NodeBadgeMode.ShowAll
const core: NodeBadgeProps[] = []
const extension: NodeBadgeProps[] = []
const pricing: { required: string; rest?: string }[] = []
if (
settingStore.get('Comfy.NodeBadge.NodeLifeCycleBadgeMode') !==
NodeBadgeMode.None
) {
const lifecycleText = nodeDef?.nodeLifeCycleBadgeText ?? ''
const trimmed = trim(lifecycleText, ['[', ']'])
if (trimmed) core.push({ text: trimmed })
}
if (
settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') !== NodeBadgeMode.None
)
core.push({ text: `#${nodeData.id}` })
const sourceText = nodeDef?.nodeSource?.badgeText
if (
!nodeDef?.isCoreNode &&
displaySource !== NodeBadgeMode.None &&
sourceText
)
core.push({ text: sourceText })
for (const badge of unpartitionedBadges.value.slice(1)) {
if (!badge.text) continue
if (isCreditsBadge(badge)) {
const [required, rest] = splitAroundFirstSpace(badge.text)
pricing.push({ required, rest })
continue
}
extension.push(badge)
}
return {
hasComfyBadge: isCoreNode && pricing.length === 0,
core,
extension,
pricing
}
})
}

View File

@@ -4,6 +4,7 @@ import { computed, ref, watch } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
export type RightSidePanelTab =
| 'error'
| 'parameters'
| 'nodes'
| 'settings'